@v3finance/core-sdk 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +397 -7
- package/README.md +215 -72
- package/dist/cjs/client.d.ts +67 -22
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +38 -2
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/core/errors.d.ts +30 -10
- package/dist/cjs/core/errors.d.ts.map +1 -1
- package/dist/cjs/core/errors.js +47 -15
- package/dist/cjs/core/errors.js.map +1 -1
- package/dist/cjs/core/gated.d.ts +21 -3
- package/dist/cjs/core/gated.d.ts.map +1 -1
- package/dist/cjs/core/gated.js +12 -3
- package/dist/cjs/core/gated.js.map +1 -1
- package/dist/cjs/core/http.d.ts +12 -8
- package/dist/cjs/core/http.d.ts.map +1 -1
- package/dist/cjs/core/http.js +130 -40
- package/dist/cjs/core/http.js.map +1 -1
- package/dist/cjs/core/pagination.d.ts +28 -1
- package/dist/cjs/core/pagination.d.ts.map +1 -1
- package/dist/cjs/core/pagination.js +36 -1
- package/dist/cjs/core/pagination.js.map +1 -1
- package/dist/cjs/generated/operations.js +1 -1
- package/dist/cjs/generated/types.d.ts +948 -172
- package/dist/cjs/generated/types.d.ts.map +1 -1
- package/dist/cjs/index.d.ts +32 -8
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +12 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/resources/addresses.d.ts +22 -8
- package/dist/cjs/resources/addresses.d.ts.map +1 -1
- package/dist/cjs/resources/addresses.js +7 -1
- package/dist/cjs/resources/addresses.js.map +1 -1
- package/dist/cjs/resources/admin.d.ts +150 -25
- package/dist/cjs/resources/admin.d.ts.map +1 -1
- package/dist/cjs/resources/admin.js +94 -23
- package/dist/cjs/resources/admin.js.map +1 -1
- package/dist/cjs/resources/approvals.d.ts +46 -5
- package/dist/cjs/resources/approvals.d.ts.map +1 -1
- package/dist/cjs/resources/approvals.js +27 -4
- package/dist/cjs/resources/approvals.js.map +1 -1
- package/dist/cjs/resources/balances.d.ts +152 -16
- package/dist/cjs/resources/balances.d.ts.map +1 -1
- package/dist/cjs/resources/balances.js +9 -5
- package/dist/cjs/resources/balances.js.map +1 -1
- package/dist/cjs/resources/categories.d.ts +93 -21
- package/dist/cjs/resources/categories.d.ts.map +1 -1
- package/dist/cjs/resources/categories.js +65 -18
- package/dist/cjs/resources/categories.js.map +1 -1
- package/dist/cjs/resources/counterparties.d.ts +26 -3
- package/dist/cjs/resources/counterparties.d.ts.map +1 -1
- package/dist/cjs/resources/counterparties.js +17 -0
- package/dist/cjs/resources/counterparties.js.map +1 -1
- package/dist/cjs/resources/folders.d.ts +51 -10
- package/dist/cjs/resources/folders.d.ts.map +1 -1
- package/dist/cjs/resources/folders.js +36 -5
- package/dist/cjs/resources/folders.js.map +1 -1
- package/dist/cjs/resources/history.d.ts +20 -2
- package/dist/cjs/resources/history.d.ts.map +1 -1
- package/dist/cjs/resources/history.js.map +1 -1
- package/dist/cjs/resources/me.d.ts +27 -4
- package/dist/cjs/resources/me.d.ts.map +1 -1
- package/dist/cjs/resources/me.js +22 -1
- package/dist/cjs/resources/me.js.map +1 -1
- package/dist/cjs/resources/members.d.ts +19 -4
- package/dist/cjs/resources/members.d.ts.map +1 -1
- package/dist/cjs/resources/members.js +15 -6
- package/dist/cjs/resources/members.js.map +1 -1
- package/dist/cjs/resources/operations.d.ts +64 -8
- package/dist/cjs/resources/operations.d.ts.map +1 -1
- package/dist/cjs/resources/operations.js +26 -2
- package/dist/cjs/resources/operations.js.map +1 -1
- package/dist/cjs/resources/registries.d.ts +87 -9
- package/dist/cjs/resources/registries.d.ts.map +1 -1
- package/dist/cjs/resources/registries.js +54 -11
- package/dist/cjs/resources/registries.js.map +1 -1
- package/dist/cjs/resources/reports.d.ts +300 -41
- package/dist/cjs/resources/reports.d.ts.map +1 -1
- package/dist/cjs/resources/reports.js +135 -7
- package/dist/cjs/resources/reports.js.map +1 -1
- package/dist/cjs/resources/sharing.d.ts +61 -10
- package/dist/cjs/resources/sharing.d.ts.map +1 -1
- package/dist/cjs/resources/sharing.js +51 -4
- package/dist/cjs/resources/sharing.js.map +1 -1
- package/dist/cjs/resources/transfers.d.ts +26 -4
- package/dist/cjs/resources/transfers.d.ts.map +1 -1
- package/dist/cjs/resources/transfers.js +23 -1
- package/dist/cjs/resources/transfers.js.map +1 -1
- package/dist/cjs/resources/types.d.ts +20 -0
- package/dist/cjs/resources/types.d.ts.map +1 -1
- package/dist/cjs/units.d.ts.map +1 -1
- package/dist/cjs/units.js +18 -2
- package/dist/cjs/units.js.map +1 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +67 -22
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +38 -2
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/core/errors.d.ts +30 -10
- package/dist/esm/core/errors.d.ts.map +1 -1
- package/dist/esm/core/errors.js +47 -15
- package/dist/esm/core/errors.js.map +1 -1
- package/dist/esm/core/gated.d.ts +21 -3
- package/dist/esm/core/gated.d.ts.map +1 -1
- package/dist/esm/core/gated.js +12 -3
- package/dist/esm/core/gated.js.map +1 -1
- package/dist/esm/core/http.d.ts +12 -8
- package/dist/esm/core/http.d.ts.map +1 -1
- package/dist/esm/core/http.js +131 -41
- package/dist/esm/core/http.js.map +1 -1
- package/dist/esm/core/pagination.d.ts +28 -1
- package/dist/esm/core/pagination.d.ts.map +1 -1
- package/dist/esm/core/pagination.js +36 -1
- package/dist/esm/core/pagination.js.map +1 -1
- package/dist/esm/generated/operations.js +1 -1
- package/dist/esm/generated/types.d.ts +948 -172
- package/dist/esm/generated/types.d.ts.map +1 -1
- package/dist/esm/index.d.ts +32 -8
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +17 -5
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/resources/addresses.d.ts +22 -8
- package/dist/esm/resources/addresses.d.ts.map +1 -1
- package/dist/esm/resources/addresses.js +7 -1
- package/dist/esm/resources/addresses.js.map +1 -1
- package/dist/esm/resources/admin.d.ts +150 -25
- package/dist/esm/resources/admin.d.ts.map +1 -1
- package/dist/esm/resources/admin.js +94 -23
- package/dist/esm/resources/admin.js.map +1 -1
- package/dist/esm/resources/approvals.d.ts +46 -5
- package/dist/esm/resources/approvals.d.ts.map +1 -1
- package/dist/esm/resources/approvals.js +27 -4
- package/dist/esm/resources/approvals.js.map +1 -1
- package/dist/esm/resources/balances.d.ts +152 -16
- package/dist/esm/resources/balances.d.ts.map +1 -1
- package/dist/esm/resources/balances.js +9 -5
- package/dist/esm/resources/balances.js.map +1 -1
- package/dist/esm/resources/categories.d.ts +93 -21
- package/dist/esm/resources/categories.d.ts.map +1 -1
- package/dist/esm/resources/categories.js +65 -18
- package/dist/esm/resources/categories.js.map +1 -1
- package/dist/esm/resources/counterparties.d.ts +26 -3
- package/dist/esm/resources/counterparties.d.ts.map +1 -1
- package/dist/esm/resources/counterparties.js +17 -0
- package/dist/esm/resources/counterparties.js.map +1 -1
- package/dist/esm/resources/folders.d.ts +51 -10
- package/dist/esm/resources/folders.d.ts.map +1 -1
- package/dist/esm/resources/folders.js +36 -5
- package/dist/esm/resources/folders.js.map +1 -1
- package/dist/esm/resources/history.d.ts +20 -2
- package/dist/esm/resources/history.d.ts.map +1 -1
- package/dist/esm/resources/history.js.map +1 -1
- package/dist/esm/resources/me.d.ts +27 -4
- package/dist/esm/resources/me.d.ts.map +1 -1
- package/dist/esm/resources/me.js +22 -1
- package/dist/esm/resources/me.js.map +1 -1
- package/dist/esm/resources/members.d.ts +19 -4
- package/dist/esm/resources/members.d.ts.map +1 -1
- package/dist/esm/resources/members.js +15 -6
- package/dist/esm/resources/members.js.map +1 -1
- package/dist/esm/resources/operations.d.ts +64 -8
- package/dist/esm/resources/operations.d.ts.map +1 -1
- package/dist/esm/resources/operations.js +26 -2
- package/dist/esm/resources/operations.js.map +1 -1
- package/dist/esm/resources/registries.d.ts +87 -9
- package/dist/esm/resources/registries.d.ts.map +1 -1
- package/dist/esm/resources/registries.js +54 -11
- package/dist/esm/resources/registries.js.map +1 -1
- package/dist/esm/resources/reports.d.ts +300 -41
- package/dist/esm/resources/reports.d.ts.map +1 -1
- package/dist/esm/resources/reports.js +135 -7
- package/dist/esm/resources/reports.js.map +1 -1
- package/dist/esm/resources/sharing.d.ts +61 -10
- package/dist/esm/resources/sharing.d.ts.map +1 -1
- package/dist/esm/resources/sharing.js +51 -4
- package/dist/esm/resources/sharing.js.map +1 -1
- package/dist/esm/resources/transfers.d.ts +26 -4
- package/dist/esm/resources/transfers.d.ts.map +1 -1
- package/dist/esm/resources/transfers.js +23 -1
- package/dist/esm/resources/transfers.js.map +1 -1
- package/dist/esm/resources/types.d.ts +20 -0
- package/dist/esm/resources/types.d.ts.map +1 -1
- package/dist/esm/units.d.ts.map +1 -1
- package/dist/esm/units.js +18 -2
- package/dist/esm/units.js.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/docs/concepts.md +191 -0
- package/docs/errors.md +471 -0
- package/package.json +7 -6
- package/src/client.ts +89 -25
- package/src/core/errors.ts +49 -15
- package/src/core/gated.ts +22 -4
- package/src/core/http.ts +156 -46
- package/src/core/pagination.ts +38 -1
- package/src/generated/operations.ts +1 -1
- package/src/generated/types.ts +983 -173
- package/src/index.ts +64 -7
- package/src/resources/addresses.ts +23 -8
- package/src/resources/admin.ts +186 -33
- package/src/resources/approvals.ts +55 -9
- package/src/resources/balances.ts +186 -14
- package/src/resources/categories.ts +125 -30
- package/src/resources/counterparties.ts +23 -6
- package/src/resources/folders.ts +74 -18
- package/src/resources/history.ts +27 -2
- package/src/resources/me.ts +30 -5
- package/src/resources/members.ts +26 -7
- package/src/resources/operations.ts +73 -8
- package/src/resources/registries.ts +90 -11
- package/src/resources/reports.ts +348 -67
- package/src/resources/sharing.ts +71 -18
- package/src/resources/transfers.ts +33 -5
- package/src/resources/types.ts +20 -0
- package/src/units.ts +19 -2
- package/src/version.ts +1 -1
package/docs/errors.md
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# Errors
|
|
2
|
+
|
|
3
|
+
Every failure thrown by this SDK is a `CoreError`.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { CoreError, isCoreError, hasCode } from '@v3finance/core-sdk'
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
await client.vault('acme').transfers.send({ ... })
|
|
10
|
+
} catch (e) {
|
|
11
|
+
if (!isCoreError(e)) throw e
|
|
12
|
+
if (hasCode(e, 'INSUFFICIENT_BALANCE')) return showTopUp()
|
|
13
|
+
showMessage(e.message)
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**`code` is always a non-empty string.** It comes from one of three places: the server sent it, it was derived from the HTTP status, or — when no answer arrived to derive anything from — it is one of the four the SDK names itself, in the table below. Branch on `code`; display `message`. Never branch on `message` — its wording changes without notice, and parts of it are in Russian.
|
|
18
|
+
|
|
19
|
+
Those four are listed nowhere else in this document because the API does not use them for anything, so one `switch` over `code` can cover both halves without a collision.
|
|
20
|
+
|
|
21
|
+
**`kind` says where the failure came from, and each kind calls for a different reaction:**
|
|
22
|
+
|
|
23
|
+
| `kind` | `code` | `status` | Meaning | Reaction |
|
|
24
|
+
|---|---|---|---|---|
|
|
25
|
+
| `http` | from the body, otherwise from the status | the status | The server answered `>= 400`. `code` and `status` are meaningful. | Branch on `code` (tables below). |
|
|
26
|
+
| `network` | `NETWORK_ERROR` | `null` | The request never completed: no connection, DNS, socket closed. | Retry. On money paths read [Timeouts on money paths](#timeouts-on-money-paths) first — the request may still have arrived. |
|
|
27
|
+
| `timeout` | `TIMEOUT` | `null` | No answer within the timeout (60s by default). | Same as `network`, and on money paths the same caveat applies with more force. |
|
|
28
|
+
| `aborted` | `ABORTED` | `null` | The caller aborted it via `signal`. | Not a failure. Do not surface it. On money paths treat it like `timeout`: the request may already have left. |
|
|
29
|
+
| `parse` | `PARSE_ERROR` | `null` | A response arrived on a 2xx status but was not readable as JSON. | A contract mismatch or a proxy in the way. Report it; retrying rarely helps. |
|
|
30
|
+
|
|
31
|
+
**`details`** is what the handler attached, plus the two root keys the envelope moves inside it (see below) — so it can be populated even where the handler attached nothing. Its shape depends on `code` — the tables name it where it is useful. It is `undefined` far more often than not.
|
|
32
|
+
|
|
33
|
+
**`endpoint`** is `"METHOD /path"`, for logs only. Never put it in a user-facing message.
|
|
34
|
+
|
|
35
|
+
## Two error envelopes
|
|
36
|
+
|
|
37
|
+
Most of the API answers with `{"error":{"code","message","details?"}}`. Reporting endpoints (`/reports/*`, `/report-snapshots/*`, `/report-exports/*`, `/report-templates/*`) have their own, older envelope with `REPORT_*` codes and a `details` object that is usually `{}`. The two shapes differ on the wire; `CoreError` reads both, so you never see the difference. Two further shapes exist and are also handled: a flat `{code,message}` from the auth namespace, and a non-JSON body.
|
|
38
|
+
|
|
39
|
+
A non-JSON error body almost always means the edge or a proxy answered rather than the API: both of the API's own catch-alls are JSON now — an unmatched path answers `{"error":{"code":"NOT_FOUND","message":"Not found"}}` and an unhandled exception `{"error":{"code":"INTERNAL_ERROR","message":"Internal error"}}`. There is one exception, and it is the API: a body that is malformed or empty while the request carries `Content-Type: application/json` is rejected by the framework's validator with a **400 `text/plain`** `Malformed JSON in request body`. That refusal is raised as an exception carrying its own finished response, so the envelope layer — which only ever rewrites JSON bodies — never sees it. It reaches you as `code: "BAD_REQUEST"` with that sentence as `message`.
|
|
40
|
+
|
|
41
|
+
No resource method of this package can produce it: they build their bodies themselves and serialise with `JSON.stringify`. The only route to it from here is the low-level `request()` with a `body` that `JSON.stringify` turns into nothing — a function, a symbol — because an empty body still goes out under a JSON content type. Worth knowing so it is not mistaken for an infrastructure fault; not worth writing a branch for.
|
|
42
|
+
|
|
43
|
+
What you do have to know is that the envelope layer rebuilds the body, so only `code`, `message` and `details` ever reach you under their own names. Two machine-readable keys that handlers set beside `error` are not lost, though — the layer carries them **inside `details`**:
|
|
44
|
+
|
|
45
|
+
- `field` → `details.field` — the offending field key on a `CATEGORY_FIELD_*` error, and `null` when the handler decided the error is not about one particular field. The key is still inside `message` as well, but read `details` and stop parsing text.
|
|
46
|
+
- `stepIndex` → `details.stepIndex` — which step of a composite chain was denied, counted from 0. `message` keeps its `"Step 2 (counterparty_create): …"` prefix for display, but take the number from `details`.
|
|
47
|
+
|
|
48
|
+
The forwarded set is closed, and that is the part worth remembering: every other root key a handler sets is still dropped, which is what stops a future `{error: e.message, ...e}` from handing a client a database-driver text or an internal URL. A new key does not appear by itself — it has to be added to the API's list first.
|
|
49
|
+
|
|
50
|
+
## Validation errors have no code of their own
|
|
51
|
+
|
|
52
|
+
Request-schema failures (a missing field, a bad enum, a malformed cursor) come back in the ordinary envelope, with no code of their own:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{ "error": { "code": "BAD_REQUEST", "message": "amount: Required; asset: Invalid enum value" } }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The validation hook inside the API does build a flat `{"error": "amount: Required; …"}`, and older notes (this file among them) described that as the wire format. It is not: the envelope layer repackages every body whose `error` is a string, so the flat form never leaves the API. Do not write a reader for it.
|
|
59
|
+
|
|
60
|
+
`code` is derived from the status — the hook sets none — which is why it is `BAD_REQUEST` and not something domain-specific. `message` is a readable `path: problem` list joined by `; ` and is the only information available; `details` is absent. Show the message, or map it to your own form state by parsing the paths.
|
|
61
|
+
|
|
62
|
+
This is why `BAD_REQUEST` is not a domain error. It means "the body or query did not match the schema", and it covers, among others: an unknown report type (`type=transaction-activity` instead of `transaction_activity` — the names are snake_case everywhere), an `idempotencyKey` shorter than 8 characters, and an out-of-range `limit`.
|
|
63
|
+
|
|
64
|
+
## Status-derived codes
|
|
65
|
+
|
|
66
|
+
When a body carried no code of its own — a proxy's HTML 502, a platform 429, an
|
|
67
|
+
empty body — the SDK derives one from the status, using the API's own name for
|
|
68
|
+
that status so that one condition has one name whichever side produced it.
|
|
69
|
+
|
|
70
|
+
| Status | Code |
|
|
71
|
+
|---|---|
|
|
72
|
+
| 400 | `BAD_REQUEST` |
|
|
73
|
+
| 401 | `UNAUTHENTICATED` |
|
|
74
|
+
| 403 | `FORBIDDEN` |
|
|
75
|
+
| 404 | `NOT_FOUND` |
|
|
76
|
+
| 409 | `CONFLICT` |
|
|
77
|
+
| 413 | `PAYLOAD_TOO_LARGE` |
|
|
78
|
+
| 422 | `UNPROCESSABLE_ENTITY` |
|
|
79
|
+
| 429 | `RATE_LIMITED` |
|
|
80
|
+
| 501 | `NOT_IMPLEMENTED` |
|
|
81
|
+
| 503 | `SERVICE_UNAVAILABLE` |
|
|
82
|
+
| 502 | `BAD_GATEWAY` — the SDK's own name |
|
|
83
|
+
| 504 | `GATEWAY_TIMEOUT` — the SDK's own name |
|
|
84
|
+
| other `>= 500` | `INTERNAL_ERROR` |
|
|
85
|
+
| other `4xx` | `REQUEST_FAILED` |
|
|
86
|
+
|
|
87
|
+
502 and 504 are the exception, and they are the two statuses to **branch on by
|
|
88
|
+
`status` rather than by `code`**: the API has no envelope for them — its own
|
|
89
|
+
derivation would call both `INTERNAL_ERROR` — they arrive from the edge as HTML,
|
|
90
|
+
and the SDK is the only side naming them.
|
|
91
|
+
|
|
92
|
+
One asymmetry left on the server side, worth knowing only so you read it
|
|
93
|
+
correctly: the API's envelope would derive `UNAUTHORIZED` for a 401 whose body
|
|
94
|
+
somehow carried no code. No path produces that today — every real 401 comes from
|
|
95
|
+
the authentication middleware with `UNAUTHENTICATED`.
|
|
96
|
+
|
|
97
|
+
## Money and transfers
|
|
98
|
+
|
|
99
|
+
`POST /transfers`, and the vote / cancel / reconcile endpoints behind it.
|
|
100
|
+
|
|
101
|
+
| Code | Status | When | What to do |
|
|
102
|
+
|---|---|---|---|
|
|
103
|
+
| `INSUFFICIENT_BALANCE` | 400 | The atomic reserve failed: the address does not have that much of that token available. A concurrent transfer may have taken it. | Show the shortfall. Refresh balances before offering a retry — the amount that fits may have changed. Do not retry blind. |
|
|
104
|
+
| `INVALID_AMOUNT` | 400 | Amount is not a positive decimal string, or has more decimals than the token allows. | Fix client-side. A user-facing validation message, not a retry. |
|
|
105
|
+
| `TOKEN_NOT_FOUND` | 400 | The asset is not in the registry, or is disabled (kill-switched during an incident). | Refresh the token registry. If it is still missing, the asset is unavailable — say so; do not retry. |
|
|
106
|
+
| `TOKEN_NETWORK_MISMATCH` | 400 | The token lives on a different network than the sending address. | A client bug or a stale form. Re-resolve the token for the address's network. |
|
|
107
|
+
| `ADDRESS_NOT_FOUND` | 404 | No such address id. | Refresh the address list; the address was deleted or the id is stale. |
|
|
108
|
+
| `ADDRESS_NOT_IN_VAULT` | 400 | The address exists but belongs to another vault. | A client bug: the vault scope and the address id disagree. |
|
|
109
|
+
| `ADDRESS_TRANSFER_FORBIDDEN` | 403 | You are neither the owner nor an admin, and you hold no grant with transfer rights on this address. Checked before the policy engine. | Show the address as view-only. The remedy is a `contributor` grant from the owner — direct the user there, not to a retry. |
|
|
110
|
+
| `POLICY_DENIED` | 403 | The policy engine said `block`, or no policy permits the action (default-deny). `details` carries the full decision, including `trace`. | Terminal for this request. Show `message` — it already names the rule that fired. Changing the amount or the destination may make the same request pass; retrying it unchanged will not. |
|
|
111
|
+
| `APPROVAL_UNSATISFIABLE` | 409 | Signatures are required but cannot be gathered: fewer eligible approvers than the threshold. `details` has `{threshold, eligibleApprovers}`. | **Do not retry.** Nobody can approve this. Tell the user to contact an administrator — the policy or the approver group has to change. See [Easily confused](#easily-confused). |
|
|
112
|
+
| `APPROVAL_FAILED` | 409 | The approval request could not be created for some other reason. The reserve has already been returned, so no funds are stuck. | Retry once with a **new** idempotency key. If it repeats, it is a server-side fault — escalate. |
|
|
113
|
+
| `VAULT_HAS_NO_WALLET_SERVICE` | 500 | The vault has no `walletServiceUrl` configured. | Configuration fault, not a user error. Transfers from this vault cannot work at all. Contact an administrator. |
|
|
114
|
+
| `GAS_STATION_FAILED` | 502 | Energy/bandwidth provisioning failed. The transfer is marked `failed` and the reserve is already returned. | Retry with a **new** idempotency key (the old one is bound to the failed row). If it persists, an upstream service is down. |
|
|
115
|
+
| `WALLET_SERVICE_ERROR` | 502, or 400 when the signing service itself answered 4xx | The signing service was unreachable, errored, or returned no transaction id. The transfer is `failed` and the reserve is returned. | Same as above: retry with a new key. On the 400 variant the signing service rejected the request and a retry will fail identically — but `message` now gives only its status (`Wallet Service returned 400`): the service's own body is not echoed to clients, it goes to the worker log. Show that the signing service refused and report the transfer id; do not try to explain the refusal from `message`. |
|
|
116
|
+
| `TRANSFER_NOT_EXECUTABLE` | 404 / 409 / 500 | The transfer is missing, or is not in the status execution requires. | Re-read the transfer and drive the UI from its real status. Do not retry. |
|
|
117
|
+
|
|
118
|
+
### Voting on a transfer
|
|
119
|
+
|
|
120
|
+
`POST /transfers/{id}/approvals` and `POST /transfers/{id}/cancel`.
|
|
121
|
+
|
|
122
|
+
| Code | Status | When | What to do |
|
|
123
|
+
|---|---|---|---|
|
|
124
|
+
| `TRANSFER_NOT_FOUND` | 404 | No such transfer, or an `awaiting_approval` transfer has no approval request — which is a data inconsistency, not a normal state. | Refresh the list. If a transfer you can see keeps producing this, report it. |
|
|
125
|
+
| `NOT_AWAITING_APPROVAL` | 409 | Voting is over: someone else's vote already resolved it, or it was cancelled. | Not an error worth an alert. Refresh the transfer and show its real status. |
|
|
126
|
+
| `NOT_AN_APPROVER` | 403 | You are not in the approver snapshot taken when the request was created. Editing the policy or the group afterwards does not add you. | Hide the approve/reject controls for this item. Terminal. |
|
|
127
|
+
| `ALREADY_VOTED` | 409 | You already voted. One vote per person. | Refresh; show your recorded vote. |
|
|
128
|
+
| `NOT_INITIATOR` | 403 | Only the initiator or an admin can cancel. | Hide the cancel control. |
|
|
129
|
+
|
|
130
|
+
### Reconciling a stuck transfer
|
|
131
|
+
|
|
132
|
+
`POST /transfers/{id}/reconcile`, admin only. For a transfer left in `pending` because the worker died mid-execution — cancel does not reach those, it requires `awaiting_approval`.
|
|
133
|
+
|
|
134
|
+
| Code | Status | When | What to do |
|
|
135
|
+
|---|---|---|---|
|
|
136
|
+
| `TRANSFER_STILL_EXECUTING` | 409 | The transfer entered `pending` less than 10 minutes ago. | **This is "wait", not "no".** Execution may still be in flight; intervening now risks a second broadcast. Back off and offer the action again after the window. See [Easily confused](#easily-confused). |
|
|
137
|
+
| `TRANSFER_NOT_STUCK` | 409 | The transfer is not in `pending` at all, or was closed by another path while you were reconciling. | Re-read it. It resolved itself; nothing to do. |
|
|
138
|
+
| `RESERVE_ALREADY_CONSUMED` | 409 | You asked to return the reserve, but a `confirm` posting has already spent it — the transaction went to the network. | **Never retry as a release.** The money left. Finish the transfer by supplying its `txHash` instead. |
|
|
139
|
+
|
|
140
|
+
### Not error codes, though they look like them
|
|
141
|
+
|
|
142
|
+
Two sets of uppercase strings travel inside **successful** bodies and will mislead anyone grepping for codes:
|
|
143
|
+
|
|
144
|
+
- **`applyError` on a 200 vote response** — `STALE`, `APPLY_FAILED`, `APPLY_FAILED_ROLLED_BACK`, `APPLY_FAILED_PARTIAL`, `TRANSFER_EXECUTION_FAILED`. The vote itself succeeded and the request reached its final status; what failed was applying the approved change. No exception is thrown, so you must read this field. `TRANSFER_EXECUTION_FAILED` in particular means the approval went through and the transfer then failed — the reserve has been returned.
|
|
145
|
+
- **Policy `reason` strings** inside `POLICY_DENIED`'s `details`, and `observations[].code` in report bodies. The first are author-defined — an administrator writes `DAILY_LIMIT_EXCEEDED` or `AMOUNT_EXCEEDED` into a policy by hand, so the set is open and unstable. The second are report findings, not failures. Do not branch on either as if it were an error code.
|
|
146
|
+
|
|
147
|
+
The observation set, unlike the policy reasons, is closed, and it is all nine of these — each with a `severity` of `info` or `warning`, which is the field to drive presentation from:
|
|
148
|
+
|
|
149
|
+
| Code | Severity | Raised when |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `COUNTERPARTY_TURNOVER_INCREASED` | info | USD turnover is up 30% or more on the previous period |
|
|
152
|
+
| `COUNTERPARTY_TURNOVER_DECREASED` | info | USD turnover is down 30% or more |
|
|
153
|
+
| `AVERAGE_PAYMENT_INCREASED` | info | The average operation is up 50% or more |
|
|
154
|
+
| `SINGLE_OPERATION_CONCENTRATION` | warning | One operation is 50% or more of the period's USD turnover |
|
|
155
|
+
| `SINGLE_ADDRESS_CONCENTRATION` | info | One address is 70% or more of it |
|
|
156
|
+
| `UNMATCHED_OPERATIONS_PRESENT` | warning | Operations in the period have no counterparty |
|
|
157
|
+
| `UNCATEGORIZED_OPERATIONS_PRESENT` | info | Operations in the period carry no category |
|
|
158
|
+
| `OPERATIONS_WITHOUT_USD_RATE` | warning | Operations had no rate, so they are missing from the USD totals |
|
|
159
|
+
| `PENDING_OPERATIONS_AGED` | warning | Something has been `pending` or `awaiting_approval` for over 6 hours |
|
|
160
|
+
|
|
161
|
+
The thresholds are server-side configuration, so treat the numbers above as today's values rather than part of the contract. Each observation also carries `drilldownFilters`, naming what the finding was computed over — but only some of those keys are ones the drill-down endpoint actually reads (`statuses` is; the singular `addressId` and `operationKey` are not), so forwarding the object wholesale widens the resulting list instead of narrowing it, and does so without an error.
|
|
162
|
+
|
|
163
|
+
## Addresses
|
|
164
|
+
|
|
165
|
+
`POST /addresses` and the address listing.
|
|
166
|
+
|
|
167
|
+
| Code | Status | When | What to do |
|
|
168
|
+
|---|---|---|---|
|
|
169
|
+
| `CONCURRENCY_CONFLICT` | 409 | A unique derivation index could not be allocated after 3 attempts — too many simultaneous creations. | **Retry.** This is the one conflict here where a plain retry is the correct answer. |
|
|
170
|
+
| `NETWORK_NOT_IN_VAULT` | 400 | The network is not connected to this vault. | Offer only the vault's own networks. Terminal otherwise. |
|
|
171
|
+
| `INVALID_SEGMENT` | 400 | A derivation-path segment is missing, unknown, or disabled. | A client bug or a stale form: re-read the vault's route configuration and rebuild the form. |
|
|
172
|
+
| `GROUP_CREATE_FORBIDDEN` | 403 | You passed a `groupId` for a folder you may not create addresses in. | Create the address outside the folder, or ask the folder's creator. |
|
|
173
|
+
| `FORBIDDEN_SCOPE` | 403 | You passed `?userId=` for somebody other than yourself, and you are not an admin. | A client bug. Drop the parameter. |
|
|
174
|
+
| `MONITORING_FAILED` | 502 | The address was created and then rolled back because it could not be registered for chain monitoring. | Nothing was kept. Retry; if it persists, an upstream service is down. |
|
|
175
|
+
| `WALLET_SERVICE_ERROR` | 502 | The signing service did not return an address. | Retry. Nothing was created. |
|
|
176
|
+
| `VAULT_NOT_FOUND` | 404 | The vault code does not resolve. | A client bug or a deleted vault. |
|
|
177
|
+
| `VAULT_HAS_NO_WALLET_SERVICE` | 500 | No `walletServiceUrl` on the vault. | Configuration fault. Contact an administrator. |
|
|
178
|
+
| `NO_DYNAMIC_ROUTE` | 500 | The vault's derivation scheme has no incrementing segment. | Configuration fault — address creation is impossible in this vault until an admin fixes it. |
|
|
179
|
+
| `TOKEN_NOT_REGISTERED` | 500 | An asset segment points at a token missing from the registry. | Configuration fault. Contact an administrator. |
|
|
180
|
+
| `POLICY_DENIED` | 403 | `block`, or no policy permits `create_address`. `details` holds the decision. | Terminal. Show `message`. |
|
|
181
|
+
|
|
182
|
+
Address creation that requires signatures is **not** an error: the server answers **202** with `{status:"awaiting_approval", approvalRequestId, approvalThreshold}`. Nothing is thrown. (`POLICY_REQUIRES_APPROVAL` exists in the server's type but is never sent — see [Declared but never returned](#declared-but-never-returned).)
|
|
183
|
+
|
|
184
|
+
## Grants and sharing
|
|
185
|
+
|
|
186
|
+
Address shares, folder shares, member-visibility grants. All grant `POST`s are idempotent: repeating a pair changes the rights, and a revoked grant is reactivated. There is no `GRANT_EXISTS` anywhere.
|
|
187
|
+
|
|
188
|
+
| Code | Status | When | What to do |
|
|
189
|
+
|---|---|---|---|
|
|
190
|
+
| `SUBJECT_REQUIRED` | 400 | A **member-visibility** grant with `subjectType:"user"` and no `subjectUserId`, or `"group"` and no `subjectGroupId`. Only that endpoint reports the omission under this code — see below. | A client bug. |
|
|
191
|
+
| `TARGET_REQUIRED` | 400 | A visibility grant with `scope:"group"` and no `targetGroupId`. | A client bug. |
|
|
192
|
+
| `SELF_SHARE` | 400 | You tried to share something with yourself. | Filter yourself out of the recipient picker. |
|
|
193
|
+
| `OWNER_SUBJECT` | 400 | The recipient already owns the object; a grant would add nothing. | Filter the owner out of the picker. |
|
|
194
|
+
| `NOT_MEMBER` | 404 | The user is not a member of this vault. | They must be invited to the vault first. Terminal for a grant. |
|
|
195
|
+
| `NOT_VISIBLE` | 403 | The user **is** a member, but is not visible to you, so you cannot name them as a recipient. | Different remedy from `NOT_MEMBER`: ask an admin for a visibility grant. See [Easily confused](#easily-confused). |
|
|
196
|
+
| `GROUP_NOT_FOUND` | 404 | The subject group is not a policy group of this vault. | Refresh the group list. |
|
|
197
|
+
| `NOT_OWNER` | 403 | Only the address owner (or an admin) may share it or manage its grants. | Hide the share controls. Terminal. |
|
|
198
|
+
| `NOT_MANAGER` | **403 or 404** | 403: you are not the folder's creator. 404: the folder does not exist. The code is the same for both. | Use `status` to tell them apart. 404 → refresh the folder list; 403 → hide the control. |
|
|
199
|
+
| `NOT_FOLDER_MANAGER` | 403 | Only the folder's creator changes its contents. | Terminal. Ask the creator. |
|
|
200
|
+
| `NOT_ADDRESS_OWNER` | 403 | You manage the folder, but the address you are adding is not yours — putting it in a shared folder would widen access to it behind its owner's back. | Terminal, and deliberately so. Only the address's own owner can add it. |
|
|
201
|
+
| `ALREADY_MEMBER` | 409 | The address is already in the folder. | Benign. Treat as success and refresh. |
|
|
202
|
+
| `GROUP_NAME_CONFLICT` | 409 | You already have a folder with that name (names are unique per owner, not per vault). | Ask for a different name. |
|
|
203
|
+
| `ACCESS_VIA_GROUP` | 409 | You tried to remove your own access, but it comes from a user group, not a personal grant. `details` has `{groupId, groupName}`. | Explain it with the group name from `details`: the user must leave the group, or an admin must revoke the group's grant. |
|
|
204
|
+
| `NOT_YOURS` | 403 | You tried to remove someone else's grant via the "remove me" endpoint. | A client bug. Revoking another person's access is the object owner's prerogative. |
|
|
205
|
+
| `FORBIDDEN` | 403 | Generic refusal when removing an address from a folder. | Terminal. |
|
|
206
|
+
|
|
207
|
+
### One omitted subject id, three different answers
|
|
208
|
+
|
|
209
|
+
Naming a `subjectType` and forgetting the matching id is the same client bug everywhere, and each of the three grant endpoints reports it differently. Nothing in the shapes hints at this, so it is worth having in one place:
|
|
210
|
+
|
|
211
|
+
| Endpoint | `subjectType:"user"`, no `subjectUserId` | `subjectType:"group"`, no `subjectGroupId` |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `POST …/visibility-grants` | `SUBJECT_REQUIRED`, **400** | `SUBJECT_REQUIRED`, **400** |
|
|
214
|
+
| `POST …/addresses/{id}/shares` (single subject) | `SUBJECT_REQUIRED`, **403** | `GROUP_NOT_FOUND`, **403** |
|
|
215
|
+
| `POST …/address-folders/{id}/grants` (single subject) | `BAD_REQUEST`, 400 | `BAD_REQUEST`, 400 |
|
|
216
|
+
|
|
217
|
+
Why they differ, since it explains which to expect elsewhere:
|
|
218
|
+
|
|
219
|
+
- the folder endpoint's body schema requires the pairing, so the request is a schema failure and never reaches a handler — hence a validation `BAD_REQUEST` and no domain code;
|
|
220
|
+
- the address-share schema does not, so the request reaches the shared subject validator. For `"group"` that validator looks the group up by an empty id and misses, which is why the mistake surfaces as `GROUP_NOT_FOUND` rather than as a missing subject;
|
|
221
|
+
- the **status** on that endpoint is hard-coded to 403 for every per-subject refusal (see below), so neither of its codes arrives with the status its meaning suggests.
|
|
222
|
+
|
|
223
|
+
In the `subjects[]` batch form of either endpoint the pairing *is* enforced by the schema, so there a missing id is always a 400 `BAD_REQUEST` for the whole request — one bad entry does reject the rest, which is the single exception to the independence the batch form otherwise guarantees.
|
|
224
|
+
|
|
225
|
+
### Batch sharing needs item-by-item handling
|
|
226
|
+
|
|
227
|
+
Sharing one address accepts a `subjects[]` array, and each recipient is processed **independently** — one rejection must not cancel the rest.
|
|
228
|
+
|
|
229
|
+
- If **at least one** grant was created or changed, the status is **201** and the body is `{results:[…]}`. **No exception is thrown.** Walk `results` and read `ok`, `outcome` (`created` / `updated` / `reactivated`) and `error` per item.
|
|
230
|
+
- If **every** item failed, the status is **400** with the same `{results:[…]}` body. There is no `error` key, so the envelope never touches it: you get `code: "BAD_REQUEST"` and the whole body in `details`. Read `details.results` for the real per-subject codes.
|
|
231
|
+
|
|
232
|
+
The API's document describes this as the second of two forms that 400 takes — the ordinary envelope for a body that failed validation, and `ShareBatchAllRejected` for a batch in which every recipient was refused — so there is a type for the cast:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
import { isCoreError, type ShareBatchAllRejected } from '@v3finance/core-sdk'
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
await client.vault('acme').sharing.share(addressId, { subjects, role: 'viewer' })
|
|
239
|
+
} catch (e) {
|
|
240
|
+
if (!isCoreError(e) || e.status !== 400) throw e
|
|
241
|
+
const refused = (e.details as ShareBatchAllRejected | undefined)?.results
|
|
242
|
+
if (!refused) throw e // the other 400: the body did not match the schema
|
|
243
|
+
for (const item of refused) report(item.subjectUserId ?? item.subjectGroupId, item.error.code)
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`details` is `unknown`, so this is an assertion, not a check: the `results` test on the next line is what tells the two 400s apart. A validation failure carries no `details` at all.
|
|
248
|
+
- Inside a `results` item, `error.message` **equals** `error.code`. Never show it to a person; map the code to your own wording.
|
|
249
|
+
|
|
250
|
+
In the single-subject form the per-subject code is returned as a normal error — but when sharing **one address**, the status is hard-coded to **403** regardless of the code, so a `NOT_MEMBER` arrives as 403 there and as 404 from the folder endpoint. Branch on `code`, not `status`.
|
|
251
|
+
|
|
252
|
+
## Approvals and voting
|
|
253
|
+
|
|
254
|
+
`/approval-requests` — the generic engine behind transfers, counterparty changes, address creation and category edits.
|
|
255
|
+
|
|
256
|
+
| Code | Status | When | What to do |
|
|
257
|
+
|---|---|---|---|
|
|
258
|
+
| `NOT_FOUND` | 404 | No such request in this vault. | Refresh the list. |
|
|
259
|
+
| `NOT_PENDING` | 409 | The request is already `APPROVED` / `REJECTED` / `CANCELLED`. Voting is over. | Refresh and show the final status. Expected under concurrency; not worth an alert. |
|
|
260
|
+
| `NOT_APPROVER` | 403 | You are not in the approver snapshot. The snapshot is frozen at creation — later policy or group edits do not add you. | Hide the controls for this request. Terminal. |
|
|
261
|
+
| `ALREADY_VOTED` | 409 | One vote per person, already cast. | Refresh; show your vote. |
|
|
262
|
+
| `NOT_INITIATOR` | 403 | Only the initiator or an admin can cancel. | Hide the cancel control. |
|
|
263
|
+
| `UNSATISFIABLE` | 409 | The request cannot be created: a step has fewer eligible approvers than its threshold, or every step is auto-approved so no request is needed. | **Do not retry.** An administrator has to fix the policy. Same situation as `APPROVAL_UNSATISFIABLE` on a transfer. |
|
|
264
|
+
| `DB_REQUIRED` | 500 | A money-path request was handled without the database binding. Deliberately fail-closed, so no reserve is left locked. | Infrastructure fault. Nothing the user can do; escalate. |
|
|
265
|
+
| `POLICY_DENIED` | 403 | No policy permits one of the steps. `message` is prefixed `Step N (action): …`. | Terminal. The step index is in `details.stepIndex`; the prefix in `message` is for display, not for parsing. |
|
|
266
|
+
| `POLICY_BLOCKED` | 403 | A `block` policy matched one of the steps. | Terminal. Show `message` — it names the policy and the reason. |
|
|
267
|
+
| `CHAIN_FAILED_ROLLED_BACK` | 400 | A composite chain that needed no signatures failed partway and **was** rolled back. | Nothing was kept. Fix the cause from `message` and resubmit. |
|
|
268
|
+
| `CHAIN_FAILED_PARTIAL` | 400 | Same, but the rollback did **not** fully succeed. | **Do not resubmit.** Some steps are applied. Re-read the affected objects and reconcile, or escalate. |
|
|
269
|
+
|
|
270
|
+
## Annotation categories
|
|
271
|
+
|
|
272
|
+
Category schema management (admin) and value assignment (anyone with the right).
|
|
273
|
+
|
|
274
|
+
| Code | Status | When | What to do |
|
|
275
|
+
|---|---|---|---|
|
|
276
|
+
| `CATEGORY_NOT_FOUND` | 404 | No such category in this vault — but only from the paths that **write values**: assigning, editing an assignment, and the apply of an approved `category_assign` / `category_edit`. The value writer is the one place that raises it. | Refresh the category list. |
|
|
277
|
+
| `CATEGORY_ARCHIVED` | 400 | The category is archived and takes no new assignments. | Remove it from the picker. |
|
|
278
|
+
| `CATEGORY_APPLIES_TO` | 400 | The category applies to transfers only, or deposits only, and you used it on the other kind. | Filter the picker by operation type. |
|
|
279
|
+
| `CATEGORY_ALREADY_ASSIGNED` | 409 | This category is already on this operation. | Switch to `PATCH` to edit the existing assignment. |
|
|
280
|
+
| `CATEGORY_FORBIDDEN` | 403 | You lack `view` / `assign` / `edit` on this category — or lack the matching access on the operation's address. | Show the fields as read-only, or hide them. Terminal; the remedy is a category grant or address access. |
|
|
281
|
+
| `ASSIGNMENT_STALE` | 409 | Optimistic lock: the assignment changed since you loaded it. | **Re-read, merge, resubmit.** The canonical refresh-and-retry case. Do not resend the same `expectVersion`. |
|
|
282
|
+
| `CATEGORY_KEY_CONFLICT` | 409 | A category with that key already exists. Keys are immutable once created. | Ask for a different key. |
|
|
283
|
+
| `CATEGORY_FIELD_CONFLICT` | 409 | A field with that key already exists on the category. | Ask for a different key. |
|
|
284
|
+
| `CATEGORY_FIELD_DUPLICATE` | 400 | The request body repeats a field key. | A client bug. |
|
|
285
|
+
| `CATEGORY_FIELD_UNKNOWN` | 400 | You wrote a value for a field the category does not have. | Re-read the category schema; your copy is stale. |
|
|
286
|
+
| `CATEGORY_FIELD_ARCHIVED` | 400 | You wrote to an archived field. | Drop it from the form. |
|
|
287
|
+
| `CATEGORY_FIELD_REQUIRED` | 400 | A required field has no value. | Normal form validation. The field key is in `details.field` (and in `message`). |
|
|
288
|
+
| `CATEGORY_FIELD_TYPE` | 400 | A value does not match the field's type, or a field config is invalid (`scale` outside 0–18, `scale` on a non-numeric field, `options` on a non-select field). | Fix client-side. The field key is in `details.field`, and in `message` too — bind the form error from `details`. |
|
|
289
|
+
| `CATEGORY_FIELD_OPTION` | 400 | A select value is not among the options, or the options list is empty or has duplicates. | Re-read the schema and rebuild the options. |
|
|
290
|
+
| `CATEGORY_FIELD_LINK` | 400 | A `currencyFieldKey` is wrong: on a non-currency field, pointing at itself, at a missing field, or at a field that is not `select`/`text`. | An admin-side configuration fix. |
|
|
291
|
+
| `CATEGORY_APPROVAL_CONFIG` | 400 | `approvalScope` is not `none` but no `approvalThreshold` was given. | A client bug: the two fields must be set together. |
|
|
292
|
+
| `CATEGORY_GRANT_SUBJECT` | 400 / 404 | The grant subject is missing (400) or is not a member / group of this vault (404). | Use `status` to distinguish. 404 → refresh the directory. |
|
|
293
|
+
| `CATEGORY_APPROVER_SUBJECT` | 404 | The named approver user or group does not exist. | Refresh the directory; the id is stale. |
|
|
294
|
+
|
|
295
|
+
The **schema** endpoints under `/operation-categories` — reading a category, patching it, adding or archiving a field, managing grants and approvers — do not use `CATEGORY_NOT_FOUND` for a category that does not resolve. They answer a plain 404, so you get the status-derived `NOT_FOUND`, and the same goes for a missing field. Reaching for `CATEGORY_NOT_FOUND` to drive "this category is gone, reload the schema" therefore only works on the value paths; branch on `status === 404` if you need it on both.
|
|
296
|
+
|
|
297
|
+
Editing a value in a category that requires approval is **not** an error: you get **202** with the created request. The approval condition lives on the category itself, not in the policy engine.
|
|
298
|
+
|
|
299
|
+
Category keys and field keys and types are **immutable** after creation, and anything with history can only be archived. There is no error code for attempting otherwise — the API simply has no endpoint for it.
|
|
300
|
+
|
|
301
|
+
## Counterparties
|
|
302
|
+
|
|
303
|
+
Note the statuses: conflicts in this domain answer **400**, not 409.
|
|
304
|
+
|
|
305
|
+
| Code | Status | When | What to do |
|
|
306
|
+
|---|---|---|---|
|
|
307
|
+
| `NAME_TAKEN` | 400 | A counterparty with that name already exists in the vault. | Ask for a different name. |
|
|
308
|
+
| `ADDRESS_TAKEN` | 400 | One of the addresses already belongs to another counterparty here. `message` names it when it can be resolved. | Show which address collides; the user must remove it from the other counterparty first. |
|
|
309
|
+
| `NETWORK_NOT_REGISTERED` | 400 | An address names a network missing from the registry. `message` lists them. | Terminal for a non-admin: an administrator must register the network. |
|
|
310
|
+
| `VALIDATION` | 400 | A required field (`name`, `address`, `code`) is empty after trimming. | Normal form validation. |
|
|
311
|
+
| `CODE_TAKEN` | 400 | A counterparty **group** with that code already exists in the vault. | Ask for a different code. |
|
|
312
|
+
| `ALREADY_MEMBER` | 400 | The counterparty is already in the group. | Benign. Treat as success and refresh. |
|
|
313
|
+
| `NOT_FOUND` | 404 | The counterparty or group does not exist in this vault. | Refresh. (Arrives as a status-derived `NOT_FOUND` — see [Declared but never returned](#declared-but-never-returned).) |
|
|
314
|
+
|
|
315
|
+
Changing a group's membership is scoped to its own vault, and that is checked at the write, including when it arrives via an approved request. A cross-vault attempt fails as a plain 404, not with a distinct code.
|
|
316
|
+
|
|
317
|
+
Group membership also gates destination whitelists: archiving a counterparty group revokes the whitelist it backs, because membership resolves over non-archived groups only. There is no error for this — transfers simply start being denied with `POLICY_DENIED`.
|
|
318
|
+
|
|
319
|
+
## Operation metadata and relations
|
|
320
|
+
|
|
321
|
+
One domain code lives here — the metadata, link and status-history endpoints have none of their own and refuse with plain statuses.
|
|
322
|
+
|
|
323
|
+
| Code | Status | When | What to do |
|
|
324
|
+
|---|---|---|---|
|
|
325
|
+
| `OPERATION_RELATION_INVALID` | 422 | The relation itself does not make sense: it points an operation at itself, or it carries a `relatedAmountRaw` without the `tokenId` that amount is denominated in, or that `tokenId` is not in the registry. | Fix client-side; `message` says which of the three. A partial amount is meaningless without its token, so send the pair or neither. |
|
|
326
|
+
|
|
327
|
+
What is **not** an invalid relation, though it is easy to read as one: if you may not touch either operation, the answer is a plain 404 (`NOT_FOUND`) naming neither side — deliberately, so the existence of an operation you cannot see is not disclosed. Writing a relation needs the `transfer` right on both, not `view`, because access to an operation and the right to annotate it are separate rights. So a viewer gets that 404 for an operation they can read perfectly well, and the fix is a `contributor` grant, not a different payload.
|
|
328
|
+
|
|
329
|
+
## Reports
|
|
330
|
+
|
|
331
|
+
Reporting endpoints use their own envelope with `REPORT_*` codes. `details` is present but frequently `{}`.
|
|
332
|
+
|
|
333
|
+
| Code | Status | When | What to do |
|
|
334
|
+
|---|---|---|---|
|
|
335
|
+
| `REPORT_INVALID_PERIOD` | 400 | `from >= to`, an unparseable date, an invalid IANA timezone, a bad `at`, or `from`/`to` missing for a type that needs them. | Fix the parameters. `message` says which. |
|
|
336
|
+
| `REPORT_PERIOD_TOO_LARGE` | 400 | The period exceeds 1100 days — or, on a snapshot, the result exceeded ~900 KB. | Narrow the period or the filters, then retry. Same code, two causes; `message` distinguishes them. |
|
|
337
|
+
| `REPORT_INVALID_GROUPING` | 400 | `groupBy=day` over a period longer than 400 days. | Switch to `week` or `month` and retry. |
|
|
338
|
+
| `REPORT_INVALID_PARAMS` | 400 | A parameter the chosen report type requires is missing. `details.param` names it — but only where exactly one parameter is at fault (`counterpartyId`, `counterpartyGroupId`, `tokenId`, `categoryKey`). `address_activity` wants `addressId` **or** `folderId`, and that branch sends `details: {}`, leaving `message` as the only source. | Read `details.param` when it is there and fall back to `message`; do not build the form error from `details.param` alone. |
|
|
339
|
+
| `REPORT_UNSUPPORTED_FORMAT` | 400 | Either an unknown report type, or an export format that does not exist yet (`xlsx`, `pdf`). | For the format case, fall back to `json` or `csv` — it will not start working on retry. |
|
|
340
|
+
| `REPORT_FORBIDDEN_SCOPE` | 403 | You asked for another user's scope, for a snapshot that is not yours, or tried to create a shared template without being an admin. | Drop the parameter, or contact an admin. Terminal. |
|
|
341
|
+
| `REPORT_ADDRESS_NOT_FOUND`, `REPORT_FOLDER_NOT_FOUND`, `REPORT_TOKEN_NOT_FOUND`, `REPORT_COUNTERPARTY_NOT_FOUND`, `REPORT_COUNTERPARTY_GROUP_NOT_FOUND` | 404 | The filter target does not exist, or is outside your scope — the two are not distinguished, on purpose. | Refresh the relevant list. Do not conclude the object was deleted. |
|
|
342
|
+
| `REPORT_SNAPSHOT_NOT_FOUND` | 404 | No such snapshot, or it is not yours. | Refresh the list. |
|
|
343
|
+
| `REPORT_SNAPSHOT_NOT_READY` | 409 | The snapshot exists but its status is not `completed`. | Poll; then export. A wait, not a failure. |
|
|
344
|
+
| `REPORT_EXPORT_NOT_FOUND` | 404 | No such export, it is not yours, or its content is gone. | Request the export again. |
|
|
345
|
+
| `REPORT_DATA_INCONSISTENT` | 500 | **The catch-all** for any unexpected failure during report generation — despite the name, it does not specifically mean the data is inconsistent. | Treat as a server fault: retry once, then report it with the endpoint, the parameters and the time. `message` is the constant `Report generation failed` — the driver text behind it stays in the worker log by design, so there is nothing diagnostic to forward. |
|
|
346
|
+
|
|
347
|
+
The export **download** endpoint streams a file rather than JSON. Errors from it still arrive as a normal envelope; successes do not, so read them with the raw-response path, not as JSON.
|
|
348
|
+
|
|
349
|
+
## Profile, seats and invitations
|
|
350
|
+
|
|
351
|
+
| Code | Status | When | What to do |
|
|
352
|
+
|---|---|---|---|
|
|
353
|
+
| `USERNAME_TAKEN` | 409 | Usernames are unique globally. | Ask for another. |
|
|
354
|
+
| `INVALID_EMAIL` | 400 | The address is malformed, or is itself a seat placeholder address. | Normal form validation. |
|
|
355
|
+
| `EMAIL_TAKEN` | 409 | Another account already uses that address. | Ask for another, or point the user at signing in with it. |
|
|
356
|
+
| `NOT_A_SEAT` | 409 | The account already has a real email. This endpoint binds a first address to a seat account; it is **not** a change-email endpoint, because changing an address without a confirmation loop is account takeover. | Terminal. Changing an existing address needs a separate, confirmed flow. |
|
|
357
|
+
| `INVITATION_NOT_FOUND` | 404 | No pending invitation for that email. | The user needs a new invitation from an admin. |
|
|
358
|
+
| `USER_ALREADY_LINKED` | 400 | The account is already attached to an invitation. | Benign under retries. Treat as already-done. |
|
|
359
|
+
|
|
360
|
+
The public invitation lookup (`GET /invitations/by-token/{token}`) signals a dead link with **410** and no domain code — you get `REQUEST_FAILED`. Both "revoked" and "already accepted" come back that way, distinguishable only by `message`. Branch on `status === 410` and tell the user the link is no longer usable.
|
|
361
|
+
|
|
362
|
+
## AML
|
|
363
|
+
|
|
364
|
+
`GET /aml/score`, and risk as a policy condition.
|
|
365
|
+
|
|
366
|
+
| Code | Status | When | What to do |
|
|
367
|
+
|---|---|---|---|
|
|
368
|
+
| `AML_ADDRESS_UNRECOGNISED` | 400 | The address format matches no supported network (TRON `T`+33, Ethereum `0x`+40). `details.address` echoes it. | Normal validation. Do not call the endpoint for addresses you cannot classify. |
|
|
369
|
+
| `AML_UNAVAILABLE` | 502 | The provider timed out, returned an error, or no API key is configured. `details.providerStatus` carries the upstream status. | **Show "could not check", never a clean result.** The status is always 502 regardless of what the provider returned — read `details.providerStatus` if you need the real one. Retrying immediately rarely helps; a successful answer is cached for 24 h. |
|
|
370
|
+
|
|
371
|
+
Two rules the API enforces and your interface has to mirror:
|
|
372
|
+
|
|
373
|
+
- **`amlLevel: null` means "not checked", not "no risk".** A green badge on an unchecked address misleads someone about money. Render the unknown state distinctly.
|
|
374
|
+
- **The level stored on an operation is a snapshot** taken when the operation was created, not live. A request waiting for signatures never re-checks. If you need the current value, call `/aml/score` separately and label it as current.
|
|
375
|
+
|
|
376
|
+
A risk threshold in a policy is expressed as a **level**, not a numeric score, and unknown counts as a match by default — a provider outage must not silently remove a control.
|
|
377
|
+
|
|
378
|
+
## Authentication and access
|
|
379
|
+
|
|
380
|
+
| Code | Status | When | What to do |
|
|
381
|
+
|---|---|---|---|
|
|
382
|
+
| `UNAUTHENTICATED` | 401 | No session, or the token expired. | Sign out and route to login. The client's `onUnauthorized` hook fires first for every 401. |
|
|
383
|
+
| `FORBIDDEN` | 403 | Admin-only endpoint, or you are not a member of this vault. | Terminal. Hide the surface rather than letting the user hit it. |
|
|
384
|
+
| `BAD_REQUEST` | 400 | Either a schema validation failure (see above), or a vault code missing from the path. | Read `message`. |
|
|
385
|
+
|
|
386
|
+
`/api/auth/*` is the auth library's own namespace. It answers with a flat `{code, message}` that the API's envelope does not rewrite; `CoreError` reads it, so `code` is populated, but the values come from that library and are not part of this API's contract. Do not branch on a fixed list there — branch on `status`, and show `message`.
|
|
387
|
+
|
|
388
|
+
Rate limiting is not implemented in the API itself. A 429 therefore comes from the platform in front of it, usually without a JSON body, which means `code: "RATE_LIMITED"` and no `details`. Back off and retry.
|
|
389
|
+
|
|
390
|
+
## Easily confused
|
|
391
|
+
|
|
392
|
+
**`APPROVAL_UNSATISFIABLE` / `UNSATISFIABLE` is not a transient conflict.** It is a 409, and 409s are usually worth a retry. This one never is: it means the policy demands more signatures than there are people able to give them, so every retry fails identically. The only resolution is an administrator changing the policy or the approver group. Say that, and do not offer a retry button. On a transfer, the reserve has already been returned when you see this, so no funds are stuck.
|
|
393
|
+
|
|
394
|
+
**`TRANSFER_STILL_EXECUTING` means "wait", not "no".** The transfer went into `pending` less than 10 minutes ago and execution may still be in flight. The server refuses on purpose: intervening could cause a **second broadcast**, which is far more expensive than waiting. Do not surface it as a failure, do not loop on it — back off past the window and offer the action again.
|
|
395
|
+
|
|
396
|
+
**`RESERVE_ALREADY_CONSUMED` means the money left.** It appears when a release is attempted over a transfer whose reserve was already spent by a `confirm` posting. Idempotency does not protect you here: confirm and release are different keys that draw on the same balance, so a release layered on a completed confirm would create money out of nothing. The correct next step is always to finish the transfer by its `txHash`, never to retry the release.
|
|
397
|
+
|
|
398
|
+
**`CONCURRENCY_CONFLICT` on address creation is the opposite of the above: retry it.** It means the server could not allocate a unique derivation index because other creations were racing it. Nothing was created, nothing is half-done, and the next attempt will usually succeed. Make this the one 409 in the SDK you retry automatically.
|
|
399
|
+
|
|
400
|
+
**`NOT_VISIBLE` is not `NOT_MEMBER`.** `NOT_MEMBER` (404) means the person is not in the vault — they need an invitation. `NOT_VISIBLE` (403) means they **are** in the vault but you cannot see them, so you cannot name them as a grant recipient. The remedy is a visibility grant from an administrator. Visibility is default-deny — you see only yourself until granted — so `NOT_VISIBLE` is the common failure when sharing is first rolled out, and reporting it as "no such user" sends people hunting for a problem that does not exist.
|
|
401
|
+
|
|
402
|
+
**`NOT_MANAGER` carries two different situations under one code**, 403 (not the folder's creator) and 404 (no such folder). Switch on `status`.
|
|
403
|
+
|
|
404
|
+
**Validation errors carry no code.** A 400 with `code: "BAD_REQUEST"` is almost always a schema failure, and `message` is the only detail you get. Do not write a `case 'BAD_REQUEST'` branch expecting a domain meaning.
|
|
405
|
+
|
|
406
|
+
**`message` is sometimes just the code.** Inside a batch `results` item, `error.message` is set to `error.code`. `CoreError` already guards against this for top-level errors — it substitutes a neutral message when the server's `message` equals its `code` — but inside a `results` array you are reading raw JSON. Map codes to your own wording there.
|
|
407
|
+
|
|
408
|
+
**A 401 does not always carry the same code.** This API's own middleware sends `UNAUTHENTICATED`, and so does the SDK when a 401 arrives without a code of its own — but `/api/auth/*` is served by the authentication library and sends whatever it sends. **Branch on `status === 401`**, which is also what the `onUnauthorized` hook does for you.
|
|
409
|
+
|
|
410
|
+
**`details` is not always there, and its shape varies by code.** It is typed `unknown`. Narrow it before reading. Reporting endpoints send `{}` more often than anything.
|
|
411
|
+
|
|
412
|
+
## Timeouts on money paths
|
|
413
|
+
|
|
414
|
+
The default timeout is 60 seconds, because `POST /transfers` executes inline: it provisions gas, waits for propagation, then broadcasts. A timeout there does **not** mean nothing happened — it means you do not know what happened.
|
|
415
|
+
|
|
416
|
+
**Retry a timed-out transfer only with the same `idempotencyKey`.** A new key is a new transfer, and you will send the money twice.
|
|
417
|
+
|
|
418
|
+
**The key is read from the request BODY, not from a header.** `transfers.send` puts it in the body, which is what protects it. The low-level `request()` helper still accepts an `idempotencyKey` option for source compatibility, but the transport now sends it **nowhere** and the option is deprecated: calling a money endpoint through the low-level client with the key only in the options buys you nothing, and a retry after a timeout sends the money twice.
|
|
419
|
+
|
|
420
|
+
It used to leave as an `idempotency-key` header, which was worse than useless. The API's CORS preflight allows exactly `Content-Type` and `Authorization`, so in a browser the extra header failed the preflight and the request was never sent — a money call that could not reach the server, surfacing as a network error that no retry explains. In Node it was merely ignored.
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
const key = crypto.randomUUID() // one key per intended transfer
|
|
424
|
+
|
|
425
|
+
async function send() {
|
|
426
|
+
return client.vault('acme').transfers.send({
|
|
427
|
+
fromAddressId, toAddress, amount, asset,
|
|
428
|
+
idempotencyKey: key, // in the body
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let result
|
|
433
|
+
try {
|
|
434
|
+
result = await send()
|
|
435
|
+
} catch (e) {
|
|
436
|
+
if (isCoreError(e) && (e.kind === 'timeout' || e.kind === 'network' || e.kind === 'aborted')) {
|
|
437
|
+
result = await send() // SAME key — a replay, not a second transfer
|
|
438
|
+
} else throw e
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (result.outcome === 'replayed') showAlreadySent(result.data)
|
|
442
|
+
else if (result.outcome === 'pending') showAwaitingApproval(result.data)
|
|
443
|
+
else showSent(result.data)
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Rules that follow from how the server handles the key:
|
|
447
|
+
|
|
448
|
+
- **The key is reserved before any external call.** A replay is therefore recognised even if the first attempt died mid-broadcast. The server answers **200** and does not broadcast again; the SDK labels that `outcome: 'replayed'`.
|
|
449
|
+
- **Tell "replayed" apart from "sent".** A replay means the earlier attempt did reach the server and only the answer was lost. Say "already sent", not "sent" a second time.
|
|
450
|
+
- **A new key after a `failed` or `rejected` transfer.** The old key is bound to that row, and reusing it returns the dead transfer instead of sending a new one.
|
|
451
|
+
- **No key sent, and a timeout?** Do **not** retry. Fetch the transfer list or the operation history first and establish whether the transfer exists. Make the SDK's own callers always pass a key so this situation cannot arise.
|
|
452
|
+
- **`network` and `aborted` carry the same uncertainty as `timeout`** on money paths. The request may have been received and executed; only the response was lost. Treat all three identically: same key, or verify first.
|
|
453
|
+
- **A thrown `GAS_STATION_FAILED` or `WALLET_SERVICE_ERROR` is different.** Those are definite: the transfer is `failed` and the reserve has been returned. Retry those with a **new** key.
|
|
454
|
+
- **Nothing a client does can leave funds locked.** Every failure path either returns the reserve or leaves a `pending` transfer that an admin can reconcile. Timeouts are a reporting problem, not a custody one — but a double send is permanent, so the key matters more than the retry.
|
|
455
|
+
|
|
456
|
+
## Declared but never returned
|
|
457
|
+
|
|
458
|
+
These codes exist on the server and never reach a client. Most are simply never constructed; two are constructed but cannot get out — one sits behind a middleware that answers first, the other has its code stripped on the way. Either way, do not write handling for them. If one ever does appear, the server changed.
|
|
459
|
+
|
|
460
|
+
| Code | Where it is declared | What happens instead |
|
|
461
|
+
|---|---|---|
|
|
462
|
+
| `POLICY_REQUIRES_APPROVAL` | Address creation errors | A **202** with `{status:"awaiting_approval", approvalRequestId, approvalThreshold}`. Not an error at all. |
|
|
463
|
+
| `STALE` | Approval-request errors | Only ever arrives as `applyError.code` inside a **200** vote response. Never thrown. |
|
|
464
|
+
| `NOT_FOUND` (counterparty) | Counterparty errors | The handlers return a plain 404, so you still receive `code: "NOT_FOUND"` — derived from the status rather than sent. Behaviour is identical; the declaration is dead. |
|
|
465
|
+
| `ASSIGNMENT_NOT_FOUND` | Category assignment errors | A plain 404 with the status-derived `NOT_FOUND`. |
|
|
466
|
+
| `REPORT_VAULT_NOT_FOUND` | Report errors, and one report handler does build it | Every report endpoint sits behind the vault-membership middleware, which resolves the vault first and answers its own 404 — so the handler's check cannot be the one that fires. You get the status-derived `NOT_FOUND`, with the middleware's `Vault "x" not found` as the message. |
|
|
467
|
+
| `STEP_REF_UNRESOLVED` | Approval-request error codes | It is raised, but only inside the composite-chain runner, when a `$step:N` reference names a step that has no result yet. The runner's `catch` keeps the sentence and drops the code, so what leaves is `CHAIN_FAILED_ROLLED_BACK` or `CHAIN_FAILED_PARTIAL` with that sentence inside `message`. Handle those; `hasCode(e, 'STEP_REF_UNRESOLVED')` is never true. |
|
|
468
|
+
|
|
469
|
+
Two further codes are reachable only through server misconfiguration and are worth knowing so you classify them correctly rather than showing them to a user: `DB_REQUIRED` (500, a money path reached without its database binding — fail-closed, nothing is left locked) and `VAULT_HAS_NO_WALLET_SERVICE` / `NO_DYNAMIC_ROUTE` / `TOKEN_NOT_REGISTERED` (500, vault configuration gaps that make transfers or address creation impossible until an administrator fixes them).
|
|
470
|
+
|
|
471
|
+
**Conditions I could not pin down:** `TRANSFER_NOT_EXECUTABLE` is thrown from several places with three different statuses (404 when the transfer is missing, 409 when it is not `pending`, 500 when its execution context fails to load), so apart from "re-read the transfer and trust its status" there is no single client action that fits all of them. `APPROVAL_FAILED` is the fallback for any approval-request creation failure that is not `UNSATISFIABLE`; which failures reach it is not evident from the code, so "retry once with a new key, then escalate" is the honest guidance rather than a specific cause.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@v3finance/core-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The official TypeScript SDK for the V3 Finance Core API — custodial addresses, transfers,
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The official TypeScript SDK for the V3 Finance Core API — custodial addresses, transfers under policy control, approvals, shared access, labelling and reports",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"dist",
|
|
21
21
|
"src",
|
|
22
|
+
"docs",
|
|
22
23
|
"CHANGELOG.md"
|
|
23
24
|
],
|
|
24
25
|
"main": "./dist/cjs/index.js",
|
|
@@ -48,13 +49,13 @@
|
|
|
48
49
|
"./package.json": "./package.json"
|
|
49
50
|
},
|
|
50
51
|
"scripts": {
|
|
51
|
-
"spec:update": "node scripts/fetch-spec.ts",
|
|
52
|
-
"generate": "node scripts/generate.ts",
|
|
53
|
-
"generate:check": "node scripts/generate.ts --check",
|
|
52
|
+
"spec:update": "node --experimental-strip-types scripts/fetch-spec.ts",
|
|
53
|
+
"generate": "node --experimental-strip-types scripts/generate.ts",
|
|
54
|
+
"generate:check": "node --experimental-strip-types scripts/generate.ts --check",
|
|
54
55
|
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.build.json --noEmit",
|
|
55
56
|
"test": "vitest run",
|
|
56
57
|
"test:watch": "vitest",
|
|
57
|
-
"build": "node scripts/build.ts",
|
|
58
|
+
"build": "node --experimental-strip-types scripts/build.ts",
|
|
58
59
|
"preversion": "node scripts/sync-version.mjs",
|
|
59
60
|
"version": "node scripts/sync-version.mjs && git add src/version.ts",
|
|
60
61
|
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|