@v3finance/core-sdk 0.2.0 → 0.2.2

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +6 -2
  3. package/dist/cjs/core/gated.d.ts +9 -3
  4. package/dist/cjs/core/gated.d.ts.map +1 -1
  5. package/dist/cjs/core/gated.js +9 -3
  6. package/dist/cjs/core/gated.js.map +1 -1
  7. package/dist/cjs/core/http.d.ts.map +1 -1
  8. package/dist/cjs/core/http.js +17 -3
  9. package/dist/cjs/core/http.js.map +1 -1
  10. package/dist/cjs/generated/operations.js +1 -1
  11. package/dist/cjs/generated/types.d.ts +30 -14
  12. package/dist/cjs/generated/types.d.ts.map +1 -1
  13. package/dist/cjs/resources/addresses.d.ts +4 -3
  14. package/dist/cjs/resources/addresses.d.ts.map +1 -1
  15. package/dist/cjs/resources/addresses.js +4 -3
  16. package/dist/cjs/resources/addresses.js.map +1 -1
  17. package/dist/cjs/resources/approvals.d.ts +22 -0
  18. package/dist/cjs/resources/approvals.d.ts.map +1 -1
  19. package/dist/cjs/resources/approvals.js +22 -0
  20. package/dist/cjs/resources/approvals.js.map +1 -1
  21. package/dist/cjs/resources/transfers.d.ts +81 -17
  22. package/dist/cjs/resources/transfers.d.ts.map +1 -1
  23. package/dist/cjs/resources/transfers.js +81 -17
  24. package/dist/cjs/resources/transfers.js.map +1 -1
  25. package/dist/cjs/resources/types.d.ts +0 -4
  26. package/dist/cjs/resources/types.d.ts.map +1 -1
  27. package/dist/cjs/version.d.ts +1 -1
  28. package/dist/cjs/version.js +1 -1
  29. package/dist/esm/core/gated.d.ts +9 -3
  30. package/dist/esm/core/gated.d.ts.map +1 -1
  31. package/dist/esm/core/gated.js +9 -3
  32. package/dist/esm/core/gated.js.map +1 -1
  33. package/dist/esm/core/http.d.ts.map +1 -1
  34. package/dist/esm/core/http.js +17 -3
  35. package/dist/esm/core/http.js.map +1 -1
  36. package/dist/esm/generated/operations.js +1 -1
  37. package/dist/esm/generated/types.d.ts +30 -14
  38. package/dist/esm/generated/types.d.ts.map +1 -1
  39. package/dist/esm/resources/addresses.d.ts +4 -3
  40. package/dist/esm/resources/addresses.d.ts.map +1 -1
  41. package/dist/esm/resources/addresses.js +4 -3
  42. package/dist/esm/resources/addresses.js.map +1 -1
  43. package/dist/esm/resources/approvals.d.ts +22 -0
  44. package/dist/esm/resources/approvals.d.ts.map +1 -1
  45. package/dist/esm/resources/approvals.js +22 -0
  46. package/dist/esm/resources/approvals.js.map +1 -1
  47. package/dist/esm/resources/transfers.d.ts +81 -17
  48. package/dist/esm/resources/transfers.d.ts.map +1 -1
  49. package/dist/esm/resources/transfers.js +81 -17
  50. package/dist/esm/resources/transfers.js.map +1 -1
  51. package/dist/esm/resources/types.d.ts +0 -4
  52. package/dist/esm/resources/types.d.ts.map +1 -1
  53. package/dist/esm/version.d.ts +1 -1
  54. package/dist/esm/version.js +1 -1
  55. package/docs/concepts.md +7 -4
  56. package/docs/errors.md +54 -16
  57. package/package.json +1 -1
  58. package/src/core/gated.ts +9 -3
  59. package/src/core/http.ts +17 -3
  60. package/src/generated/operations.ts +1 -1
  61. package/src/generated/types.ts +31 -14
  62. package/src/resources/addresses.ts +4 -3
  63. package/src/resources/approvals.ts +22 -0
  64. package/src/resources/transfers.ts +81 -17
  65. package/src/resources/types.ts +0 -4
  66. package/src/version.ts +1 -1
@@ -11,17 +11,45 @@ export interface VoteInput {
11
11
  * The result of sending is `Gated`, and that is the main thing to understand
12
12
  * here. The same `{transaction}` body comes back in three situations:
13
13
  *
14
- * applied (201) — executed, the money left;
14
+ * applied (201) — executed, the money left (rarely the status is still
15
+ * `pending` with a `txHash`: it went out, but its
16
+ * accounting was not recorded, and an administrator's
17
+ * reconciliation finishes it — do not send it again);
15
18
  * pending (202) — awaiting signatures, funds RESERVED but not sent;
16
- * replayed (200) — a repeat; the transfer already existed.
19
+ * replayed (200) — a transfer with this `idempotencyKey` already existed,
20
+ * and this is that transfer as it stands NOW.
17
21
  *
18
22
  * They cannot be told apart by the body. Showing "sent" on `pending` tells
19
23
  * the person money has gone when it is frozen.
24
+ *
25
+ * `replayed` does not mean "sent" either. It says only that the request was
26
+ * seen before; what became of it is in `data.transaction.status`:
27
+ *
28
+ * confirmed, submitted — it left;
29
+ * awaiting_approval — waiting for signatures, funds reserved;
30
+ * pending — still executing, or its outcome is unknown
31
+ * (the first answer was 502
32
+ * `TRANSFER_OUTCOME_UNKNOWN`) until an
33
+ * administrator reconciles it;
34
+ * failed, rejected,
35
+ * cancelled — it did not leave.
20
36
  */
21
37
  export declare class TransfersResource {
22
38
  #private;
23
39
  constructor(http: HttpClient, vaultCode: string);
24
- /** Sends a transfer. The timeout is long: the path calls the wallet service. */
40
+ /**
41
+ * Sends a transfer. The timeout is long: the path calls the wallet service.
42
+ *
43
+ * On `outcome: 'replayed'` read `data.transaction.status` before telling
44
+ * anyone anything — a replay of an attempt that failed, or that is still
45
+ * waiting for signatures, comes back the same way as one that went out.
46
+ *
47
+ * A thrown 502 `TRANSFER_OUTCOME_UNKNOWN` is not a failure: the signing
48
+ * service gave no definite answer, the transaction may have left, and the
49
+ * transfer stays `pending` with its funds reserved (`details.transactionId`).
50
+ * Retry it only with the SAME key — that returns the transfer without
51
+ * executing it again; a new key can send the money twice.
52
+ */
25
53
  send(input: CreateTransferBody, options?: {
26
54
  signal?: AbortSignal;
27
55
  timeoutMs?: number;
@@ -29,7 +57,9 @@ export declare class TransfersResource {
29
57
  /**
30
58
  * One transfer in full. Note the shape: this is the transaction itself, not
31
59
  * the `{ transaction }` wrapper that `send()` returns — and it carries more,
32
- * including `amlLevel`, `approvers`, `usdRate` and `failureReason`.
60
+ * including `amlLevel`, `approvers`, `usdRate` and `failureReason` — the
61
+ * last only on `failed`, `rejected` and `cancelled`, `null` on any other
62
+ * status.
33
63
  */
34
64
  get(transferId: string): Promise<TransactionDetail>;
35
65
  /**
@@ -45,11 +75,30 @@ export declare class TransfersResource {
45
75
  * awaiting_approval — counted, more signatures still needed;
46
76
  * rejected — a `reject` is a veto, and the reserve is released;
47
77
  * confirmed/failed — this vote completed the threshold, so execution ran
48
- * INSIDE this request (gas, then broadcast, ~10s).
78
+ * INSIDE this request (gas, then broadcast, ~10s);
79
+ * pending — with no `executionError`: the outcome is NOT
80
+ * established by this answer. The signing service
81
+ * gave no definite answer (the transaction may have
82
+ * left; an administrator's reconciliation settles
83
+ * it), another call is executing the transfer, or
84
+ * the execution broke off. Re-read the transfer:
85
+ * `confirmed`/`failed` is the outcome, `pending` for
86
+ * over 10 minutes by `updatedAt` is waiting for
87
+ * reconciliation. Never send it again.
49
88
  *
50
89
  * `failed` means the money is back: execution broke after approval and the
51
90
  * reserve was released, with the reason in `executionError`. Treating the
52
- * call as "approved, done" hides both the success and that failure.
91
+ * call as "approved, done" hides both the success and that failure. The
92
+ * exception is a `failed` transfer that has a `txHash`: it went out after
93
+ * reconciliation had already returned the reserve, and an administrator has
94
+ * to correct the ledger. `executionError` beside `rejected` or `cancelled`
95
+ * means the transfer was no longer waiting for signatures when approved,
96
+ * and nothing was executed.
97
+ *
98
+ * Repeating the same decision while the transfer is still waiting finishes
99
+ * what an earlier call left undone (it recorded the vote and broke off) — a
100
+ * veto, or execution once the threshold is met — instead of 409
101
+ * `ALREADY_VOTED`.
53
102
  */
54
103
  vote(transferId: string, input: VoteInput): Promise<VoteResult>;
55
104
  /**
@@ -68,19 +117,34 @@ export declare class TransfersResource {
68
117
  */
69
118
  cancel(transferId: string): Promise<CancelResult>;
70
119
  /**
71
- * Settles a transfer stuck in `pending` — the case where the worker died
72
- * mid-execution, so cancelling does not apply. Administrators only.
120
+ * Settles a transfer nothing else can close. Administrators only.
121
+ *
122
+ * pending — execution died, or its outcome is unknown (the
123
+ * send answered 502 `TRANSFER_OUTCOME_UNKNOWN`);
124
+ * awaiting_approval — with no live approval request to vote on;
125
+ * failed, rejected,
126
+ * cancelled — closed, but the reserve is still held: it is
127
+ * returned, and the status stays.
128
+ *
129
+ * A pending transfer is decided by the HASH: send `txHash` and it is driven
130
+ * to `confirmed`; send none and the reserve is returned (`failed`). Sending
131
+ * none is YOUR statement that nothing was broadcast — the server does not
132
+ * check it, so look on the chain first: returning the reserve of a
133
+ * transaction that did go out creates money. If the transfer already holds
134
+ * a hash from the signing service, only that hash, sent explicitly, settles
135
+ * it — 409 `TRANSFER_BROADCAST` without one, `TXHASH_MISMATCH` with another,
136
+ * the saved hash in `details.txHash`. The other two cases never executed
137
+ * and take no hash.
73
138
  *
74
- * The decision is made by the HASH: send `txHash` and the transfer is
75
- * driven to `confirmed`, because the signing service did put it on the
76
- * chain; send none and the reserve is returned, because there was no
77
- * broadcast. A FRESH pending transfer is refused on purpose — while
78
- * execution may still be in flight, intervening risks a SECOND broadcast,
79
- * which costs more than waiting.
139
+ * A FRESH transfer is refused on purpose — while execution may still be in
140
+ * flight, intervening risks a SECOND broadcast, which costs more than
141
+ * waiting.
80
142
  *
81
- * Pass `fee` along with the hash if you know it: this is the only moment
82
- * it can be recorded on a transfer settled this way, and it stays null
83
- * otherwise. The answer says which way it went — `confirmed` or `failed`.
143
+ * Pass `fee` along with the hash if you know it: this is the only moment it
144
+ * can be recorded on a transfer settled this way; otherwise it stays as it
145
+ * is, null unless the signing service reported one. The answer says which
146
+ * way it went — `confirmed`, `failed`, or the unchanged `rejected` /
147
+ * `cancelled` of a closed transfer.
84
148
  */
85
149
  reconcile(transferId: string, input?: ReconcileTransferBody): Promise<ReconcileTransferResult>;
86
150
  }
@@ -1 +1 @@
1
- {"version":3,"file":"transfers.d.ts","sourceRoot":"","sources":["../../../src/resources/transfers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAO,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAA;AACtD,OAAO,EAAW,KAAK,KAAK,EAAE,MAAM,kBAAkB,CAAA;AACtD,OAAO,KAAK,EACV,YAAY,EACZ,uBAAuB,EACvB,kBAAkB,EAClB,qBAAqB,EACrB,uBAAuB,EACvB,iBAAiB,EACjB,UAAU,EACX,MAAM,uBAAuB,CAAA;AAE9B,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,SAAS,GAAG,QAAQ,CAAA;IAC9B,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC7B;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,iBAAiB;;IAI5B,YAAY,IAAI,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAG9C;IAED,gFAAgF;IAC1E,IAAI,CACR,KAAK,EAAE,kBAAkB,EACzB,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAO,GACzD,OAAO,CAAC,KAAK,CAAC,uBAAuB,CAAC,CAAC,CAUzC;IAED;;;;OAIG;IACH,GAAG,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAElD;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,CAM9D;IAED;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAEhD;IAED;;;;;;;;;;;;;;OAcG;IACH,SAAS,CACP,UAAU,EAAE,MAAM,EAClB,KAAK,GAAE,qBAA0B,GAChC,OAAO,CAAC,uBAAuB,CAAC,CAMlC;CACF"}
1
+ {"version":3,"file":"transfers.d.ts","sourceRoot":"","sources":["../../../src/resources/transfers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAO,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAA;AACtD,OAAO,EAAW,KAAK,KAAK,EAAE,MAAM,kBAAkB,CAAA;AACtD,OAAO,KAAK,EACV,YAAY,EACZ,uBAAuB,EACvB,kBAAkB,EAClB,qBAAqB,EACrB,uBAAuB,EACvB,iBAAiB,EACjB,UAAU,EACX,MAAM,uBAAuB,CAAA;AAE9B,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,SAAS,GAAG,QAAQ,CAAA;IAC9B,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qBAAa,iBAAiB;;IAI5B,YAAY,IAAI,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAG9C;IAED;;;;;;;;;;;;OAYG;IACG,IAAI,CACR,KAAK,EAAE,kBAAkB,EACzB,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAO,GACzD,OAAO,CAAC,KAAK,CAAC,uBAAuB,CAAC,CAAC,CAUzC;IAED;;;;;;OAMG;IACH,GAAG,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAElD;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAqCG;IACH,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,CAM9D;IAED;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAEhD;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,SAAS,CACP,UAAU,EAAE,MAAM,EAClB,KAAK,GAAE,qBAA0B,GAChC,OAAO,CAAC,uBAAuB,CAAC,CAMlC;CACF"}
@@ -6,12 +6,28 @@ import { toGated } from '../core/gated.js';
6
6
  * The result of sending is `Gated`, and that is the main thing to understand
7
7
  * here. The same `{transaction}` body comes back in three situations:
8
8
  *
9
- * applied (201) — executed, the money left;
9
+ * applied (201) — executed, the money left (rarely the status is still
10
+ * `pending` with a `txHash`: it went out, but its
11
+ * accounting was not recorded, and an administrator's
12
+ * reconciliation finishes it — do not send it again);
10
13
  * pending (202) — awaiting signatures, funds RESERVED but not sent;
11
- * replayed (200) — a repeat; the transfer already existed.
14
+ * replayed (200) — a transfer with this `idempotencyKey` already existed,
15
+ * and this is that transfer as it stands NOW.
12
16
  *
13
17
  * They cannot be told apart by the body. Showing "sent" on `pending` tells
14
18
  * the person money has gone when it is frozen.
19
+ *
20
+ * `replayed` does not mean "sent" either. It says only that the request was
21
+ * seen before; what became of it is in `data.transaction.status`:
22
+ *
23
+ * confirmed, submitted — it left;
24
+ * awaiting_approval — waiting for signatures, funds reserved;
25
+ * pending — still executing, or its outcome is unknown
26
+ * (the first answer was 502
27
+ * `TRANSFER_OUTCOME_UNKNOWN`) until an
28
+ * administrator reconciles it;
29
+ * failed, rejected,
30
+ * cancelled — it did not leave.
15
31
  */
16
32
  export class TransfersResource {
17
33
  #http;
@@ -20,7 +36,19 @@ export class TransfersResource {
20
36
  this.#http = http;
21
37
  this.#base = `/api/v1/vaults/${seg(vaultCode)}/transfers`;
22
38
  }
23
- /** Sends a transfer. The timeout is long: the path calls the wallet service. */
39
+ /**
40
+ * Sends a transfer. The timeout is long: the path calls the wallet service.
41
+ *
42
+ * On `outcome: 'replayed'` read `data.transaction.status` before telling
43
+ * anyone anything — a replay of an attempt that failed, or that is still
44
+ * waiting for signatures, comes back the same way as one that went out.
45
+ *
46
+ * A thrown 502 `TRANSFER_OUTCOME_UNKNOWN` is not a failure: the signing
47
+ * service gave no definite answer, the transaction may have left, and the
48
+ * transfer stays `pending` with its funds reserved (`details.transactionId`).
49
+ * Retry it only with the SAME key — that returns the transfer without
50
+ * executing it again; a new key can send the money twice.
51
+ */
24
52
  async send(input, options = {}) {
25
53
  const res = await this.#http.requestRaw('POST', this.#base, {
26
54
  body: input,
@@ -35,7 +63,9 @@ export class TransfersResource {
35
63
  /**
36
64
  * One transfer in full. Note the shape: this is the transaction itself, not
37
65
  * the `{ transaction }` wrapper that `send()` returns — and it carries more,
38
- * including `amlLevel`, `approvers`, `usdRate` and `failureReason`.
66
+ * including `amlLevel`, `approvers`, `usdRate` and `failureReason` — the
67
+ * last only on `failed`, `rejected` and `cancelled`, `null` on any other
68
+ * status.
39
69
  */
40
70
  get(transferId) {
41
71
  return this.#http.request('GET', `${this.#base}/${seg(transferId)}`);
@@ -53,11 +83,30 @@ export class TransfersResource {
53
83
  * awaiting_approval — counted, more signatures still needed;
54
84
  * rejected — a `reject` is a veto, and the reserve is released;
55
85
  * confirmed/failed — this vote completed the threshold, so execution ran
56
- * INSIDE this request (gas, then broadcast, ~10s).
86
+ * INSIDE this request (gas, then broadcast, ~10s);
87
+ * pending — with no `executionError`: the outcome is NOT
88
+ * established by this answer. The signing service
89
+ * gave no definite answer (the transaction may have
90
+ * left; an administrator's reconciliation settles
91
+ * it), another call is executing the transfer, or
92
+ * the execution broke off. Re-read the transfer:
93
+ * `confirmed`/`failed` is the outcome, `pending` for
94
+ * over 10 minutes by `updatedAt` is waiting for
95
+ * reconciliation. Never send it again.
57
96
  *
58
97
  * `failed` means the money is back: execution broke after approval and the
59
98
  * reserve was released, with the reason in `executionError`. Treating the
60
- * call as "approved, done" hides both the success and that failure.
99
+ * call as "approved, done" hides both the success and that failure. The
100
+ * exception is a `failed` transfer that has a `txHash`: it went out after
101
+ * reconciliation had already returned the reserve, and an administrator has
102
+ * to correct the ledger. `executionError` beside `rejected` or `cancelled`
103
+ * means the transfer was no longer waiting for signatures when approved,
104
+ * and nothing was executed.
105
+ *
106
+ * Repeating the same decision while the transfer is still waiting finishes
107
+ * what an earlier call left undone (it recorded the vote and broke off) — a
108
+ * veto, or execution once the threshold is met — instead of 409
109
+ * `ALREADY_VOTED`.
61
110
  */
62
111
  vote(transferId, input) {
63
112
  return this.#http.request('POST', `${this.#base}/${seg(transferId)}/approvals`, { body: input });
@@ -80,19 +129,34 @@ export class TransfersResource {
80
129
  return this.#http.request('POST', `${this.#base}/${seg(transferId)}/cancel`);
81
130
  }
82
131
  /**
83
- * Settles a transfer stuck in `pending` — the case where the worker died
84
- * mid-execution, so cancelling does not apply. Administrators only.
132
+ * Settles a transfer nothing else can close. Administrators only.
133
+ *
134
+ * pending — execution died, or its outcome is unknown (the
135
+ * send answered 502 `TRANSFER_OUTCOME_UNKNOWN`);
136
+ * awaiting_approval — with no live approval request to vote on;
137
+ * failed, rejected,
138
+ * cancelled — closed, but the reserve is still held: it is
139
+ * returned, and the status stays.
140
+ *
141
+ * A pending transfer is decided by the HASH: send `txHash` and it is driven
142
+ * to `confirmed`; send none and the reserve is returned (`failed`). Sending
143
+ * none is YOUR statement that nothing was broadcast — the server does not
144
+ * check it, so look on the chain first: returning the reserve of a
145
+ * transaction that did go out creates money. If the transfer already holds
146
+ * a hash from the signing service, only that hash, sent explicitly, settles
147
+ * it — 409 `TRANSFER_BROADCAST` without one, `TXHASH_MISMATCH` with another,
148
+ * the saved hash in `details.txHash`. The other two cases never executed
149
+ * and take no hash.
85
150
  *
86
- * The decision is made by the HASH: send `txHash` and the transfer is
87
- * driven to `confirmed`, because the signing service did put it on the
88
- * chain; send none and the reserve is returned, because there was no
89
- * broadcast. A FRESH pending transfer is refused on purpose — while
90
- * execution may still be in flight, intervening risks a SECOND broadcast,
91
- * which costs more than waiting.
151
+ * A FRESH transfer is refused on purpose — while execution may still be in
152
+ * flight, intervening risks a SECOND broadcast, which costs more than
153
+ * waiting.
92
154
  *
93
- * Pass `fee` along with the hash if you know it: this is the only moment
94
- * it can be recorded on a transfer settled this way, and it stays null
95
- * otherwise. The answer says which way it went — `confirmed` or `failed`.
155
+ * Pass `fee` along with the hash if you know it: this is the only moment it
156
+ * can be recorded on a transfer settled this way; otherwise it stays as it
157
+ * is, null unless the signing service reported one. The answer says which
158
+ * way it went — `confirmed`, `failed`, or the unchanged `rejected` /
159
+ * `cancelled` of a closed transfer.
96
160
  */
97
161
  reconcile(transferId, input = {}) {
98
162
  return this.#http.request('POST', `${this.#base}/${seg(transferId)}/reconcile`, { body: input });
@@ -1 +1 @@
1
- {"version":3,"file":"transfers.js","sourceRoot":"","sources":["../../../src/resources/transfers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAmB,MAAM,iBAAiB,CAAA;AACtD,OAAO,EAAE,OAAO,EAAc,MAAM,kBAAkB,CAAA;AAgBtD;;;;;;;;;;;;GAYG;AACH,MAAM,OAAO,iBAAiB;IACnB,KAAK,CAAY;IACjB,KAAK,CAAQ;IAEtB,YAAY,IAAgB,EAAE,SAAiB;QAC7C,IAAI,CAAC,KAAK,GAAG,IAAI,CAAA;QACjB,IAAI,CAAC,KAAK,GAAG,kBAAkB,GAAG,CAAC,SAAS,CAAC,YAAY,CAAA;IAC3D,CAAC;IAED,gFAAgF;IAChF,KAAK,CAAC,IAAI,CACR,KAAyB,EACzB,OAAO,GAAiD,EAAE;QAE1D,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,UAAU,CAA0B,MAAM,EAAE,IAAI,CAAC,KAAK,EAAE;YACnF,IAAI,EAAE,KAAK;YACX,GAAG,CAAC,KAAK,CAAC,cAAc,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,cAAc,EAAE,KAAK,CAAC,cAAc,EAAE,CAAC;YACvF,GAAG,CAAC,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC;YACnE,GAAG,CAAC,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC;SAC7E,CAAC,CAAA;QACF,OAAO,OAAO,CAAmD,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,IAAI,EAAE;YACrF,WAAW,EAAE,IAAI;SAClB,CAAC,CAAA;IACJ,CAAC;IAED;;;;OAIG;IACH,GAAG,CAAC,UAAkB;QACpB,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CAAoB,KAAK,EAAE,GAAG,IAAI,CAAC,KAAK,IAAI,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC,CAAA;IACzF,CAAC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,IAAI,CAAC,UAAkB,EAAE,KAAgB;QACvC,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CACvB,MAAM,EACN,GAAG,IAAI,CAAC,KAAK,IAAI,GAAG,CAAC,UAAU,CAAC,YAAY,EAC5C,EAAE,IAAI,EAAE,KAAK,EAAE,CAChB,CAAA;IACH,CAAC;IAED;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,UAAkB;QACvB,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CAAe,MAAM,EAAE,GAAG,IAAI,CAAC,KAAK,IAAI,GAAG,CAAC,UAAU,CAAC,SAAS,CAAC,CAAA;IAC5F,CAAC;IAED;;;;;;;;;;;;;;OAcG;IACH,SAAS,CACP,UAAkB,EAClB,KAAK,GAA0B,EAAE;QAEjC,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CACvB,MAAM,EACN,GAAG,IAAI,CAAC,KAAK,IAAI,GAAG,CAAC,UAAU,CAAC,YAAY,EAC5C,EAAE,IAAI,EAAE,KAAK,EAAE,CAChB,CAAA;IACH,CAAC;CACF"}
1
+ {"version":3,"file":"transfers.js","sourceRoot":"","sources":["../../../src/resources/transfers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAmB,MAAM,iBAAiB,CAAA;AACtD,OAAO,EAAE,OAAO,EAAc,MAAM,kBAAkB,CAAA;AAgBtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,OAAO,iBAAiB;IACnB,KAAK,CAAY;IACjB,KAAK,CAAQ;IAEtB,YAAY,IAAgB,EAAE,SAAiB;QAC7C,IAAI,CAAC,KAAK,GAAG,IAAI,CAAA;QACjB,IAAI,CAAC,KAAK,GAAG,kBAAkB,GAAG,CAAC,SAAS,CAAC,YAAY,CAAA;IAC3D,CAAC;IAED;;;;;;;;;;;;OAYG;IACH,KAAK,CAAC,IAAI,CACR,KAAyB,EACzB,OAAO,GAAiD,EAAE;QAE1D,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,UAAU,CAA0B,MAAM,EAAE,IAAI,CAAC,KAAK,EAAE;YACnF,IAAI,EAAE,KAAK;YACX,GAAG,CAAC,KAAK,CAAC,cAAc,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,cAAc,EAAE,KAAK,CAAC,cAAc,EAAE,CAAC;YACvF,GAAG,CAAC,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC;YACnE,GAAG,CAAC,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC;SAC7E,CAAC,CAAA;QACF,OAAO,OAAO,CAAmD,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,IAAI,EAAE;YACrF,WAAW,EAAE,IAAI;SAClB,CAAC,CAAA;IACJ,CAAC;IAED;;;;;;OAMG;IACH,GAAG,CAAC,UAAkB;QACpB,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CAAoB,KAAK,EAAE,GAAG,IAAI,CAAC,KAAK,IAAI,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC,CAAA;IACzF,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAqCG;IACH,IAAI,CAAC,UAAkB,EAAE,KAAgB;QACvC,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CACvB,MAAM,EACN,GAAG,IAAI,CAAC,KAAK,IAAI,GAAG,CAAC,UAAU,CAAC,YAAY,EAC5C,EAAE,IAAI,EAAE,KAAK,EAAE,CAChB,CAAA;IACH,CAAC;IAED;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,UAAkB;QACvB,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CAAe,MAAM,EAAE,GAAG,IAAI,CAAC,KAAK,IAAI,GAAG,CAAC,UAAU,CAAC,SAAS,CAAC,CAAA;IAC5F,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,SAAS,CACP,UAAkB,EAClB,KAAK,GAA0B,EAAE;QAEjC,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CACvB,MAAM,EACN,GAAG,IAAI,CAAC,KAAK,IAAI,GAAG,CAAC,UAAU,CAAC,YAAY,EAC5C,EAAE,IAAI,EAAE,KAAK,EAAE,CAChB,CAAA;IACH,CAAC;CACF"}
@@ -34,10 +34,6 @@ import type { AddressDetail as GeneratedAddressDetail, AddressListItem, CreateTr
34
34
  * the two must not be merged, because "no account" and "empty account"
35
35
  * deserve different wording. Amounts are raw smallest units; put them through
36
36
  * the helpers in `units.ts` rather than `Number`.
37
- *
38
- * One field is thinner than the type admits: `sharing` is optional because
39
- * `PATCH /addresses/{id}` reuses this schema WITHOUT it (the document says so
40
- * itself, on the property). Listings and the detail endpoint do send it.
41
37
  */
42
38
  export type Address = AddressListItem;
43
39
  /** A single address with its sharing and access details. */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/resources/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EACV,aAAa,IAAI,sBAAsB,EACvC,eAAe,EACf,uBAAuB,EACvB,WAAW,IAAI,oBAAoB,EACnC,UAAU,EACX,MAAM,uBAAuB,CAAA;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,MAAM,OAAO,GAAG,eAAe,CAAA;AAErC,4DAA4D;AAC5D,MAAM,MAAM,aAAa,GAAG,sBAAsB,CAAA;AAElD,wEAAwE;AACxE,MAAM,MAAM,WAAW,GAAG,uBAAuB,CAAC,aAAa,CAAC,CAAA;AAEhE,+CAA+C;AAC/C,MAAM,MAAM,WAAW,GAAG,oBAAoB,CAAA;AAE9C,4DAA4D;AAC5D,MAAM,MAAM,UAAU,GAAG,UAAU,CAAA;AAEnC;;;;;;;GAOG;AACH,MAAM,MAAM,iBAAiB,GACzB,mBAAmB,GACnB,SAAS,GACT,WAAW,GACX,WAAW,GACX,QAAQ,GACR,WAAW,GACX,UAAU,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/resources/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EACV,aAAa,IAAI,sBAAsB,EACvC,eAAe,EACf,uBAAuB,EACvB,WAAW,IAAI,oBAAoB,EACnC,UAAU,EACX,MAAM,uBAAuB,CAAA;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,MAAM,OAAO,GAAG,eAAe,CAAA;AAErC,4DAA4D;AAC5D,MAAM,MAAM,aAAa,GAAG,sBAAsB,CAAA;AAElD,wEAAwE;AACxE,MAAM,MAAM,WAAW,GAAG,uBAAuB,CAAC,aAAa,CAAC,CAAA;AAEhE,+CAA+C;AAC/C,MAAM,MAAM,WAAW,GAAG,oBAAoB,CAAA;AAE9C,4DAA4D;AAC5D,MAAM,MAAM,UAAU,GAAG,UAAU,CAAA;AAEnC;;;;;;;GAOG;AACH,MAAM,MAAM,iBAAiB,GACzB,mBAAmB,GACnB,SAAS,GACT,WAAW,GACX,WAAW,GACX,QAAQ,GACR,WAAW,GACX,UAAU,CAAA"}
@@ -1,5 +1,5 @@
1
1
  /** SDK version, sent in the `user-agent` header. */
2
- export declare const SDK_VERSION = "0.2.0";
2
+ export declare const SDK_VERSION = "0.2.2";
3
3
  /** Default API base URL. */
4
4
  export declare const DEFAULT_BASE_URL = "https://policy-engine-rest.ezig.workers.dev";
5
5
  //# sourceMappingURL=version.d.ts.map
@@ -1,5 +1,5 @@
1
1
  /** SDK version, sent in the `user-agent` header. */
2
- export const SDK_VERSION = '0.2.0';
2
+ export const SDK_VERSION = '0.2.2';
3
3
  /** Default API base URL. */
4
4
  export const DEFAULT_BASE_URL = 'https://policy-engine-rest.ezig.workers.dev';
5
5
  //# sourceMappingURL=version.js.map
package/docs/concepts.md CHANGED
@@ -57,7 +57,7 @@ The API expresses this with the HTTP status, because **the response bodies are i
57
57
  | --- | --- |
58
58
  | 201 | applied — it happened |
59
59
  | 202 | pending — funds are RESERVED, nothing has moved, there is a request to sign |
60
- | 200 | replayed — the same idempotency key; it already happened earlier |
60
+ | 200 | replayed — the same idempotency key; the transfer already existed, in whatever state it is now |
61
61
 
62
62
  The SDK turns that into `Gated`:
63
63
 
@@ -65,13 +65,16 @@ The SDK turns that into `Gated`:
65
65
  switch (result.outcome) {
66
66
  case 'applied': return toast('Transfer sent')
67
67
  case 'pending': return toast('Awaiting signatures — funds are reserved')
68
- case 'replayed': return toast('This transfer was already sent')
68
+ case 'replayed': return toast(describeStatus(result.data.transaction.status))
69
69
  }
70
70
  ```
71
71
 
72
72
  `replayed` is kept apart from `applied` on purpose: a replay means the previous attempt reached the
73
- server and only the answer was lost, so the person should be told "already sent" rather than being
74
- told "sent" a second time.
73
+ server and only the answer was lost — not that it succeeded. The body is that transfer as it stands
74
+ now, so `replayed` must not be shown as "sent": `data.transaction.status` says whether it went out
75
+ (`confirmed`, or `submitted`), is waiting for signatures (`awaiting_approval`), is still executing
76
+ or waiting for an administrator to reconcile it (`pending`) or did not go out (`failed`, `rejected`,
77
+ `cancelled`).
75
78
 
76
79
  **Only `transfers.send` can return it.** `idempotencyKey` exists on exactly one request body in the
77
80
  whole API — the transfer — so it is the only place where a 200 means "this already happened". On
package/docs/errors.md CHANGED
@@ -87,7 +87,10 @@ that status so that one condition has one name whichever side produced it.
87
87
  502 and 504 are the exception, and they are the two statuses to **branch on by
88
88
  `status` rather than by `code`**: the API has no envelope for them — its own
89
89
  derivation would call both `INTERNAL_ERROR` — they arrive from the edge as HTML,
90
- and the SDK is the only side naming them.
90
+ and the SDK is the only side naming them. A 502 that carries a code of its own
91
+ came from the API and is not this case: on `POST /transfers` it can be
92
+ `TRANSFER_OUTCOME_UNKNOWN`, which must not be treated as a failure. Check `code`
93
+ before `status`.
91
94
 
92
95
  One asymmetry left on the server side, worth knowing only so you read it
93
96
  correctly: the API's envelope would derive `UNAUTHORIZED` for a 401 whose body
@@ -102,6 +105,7 @@ the authentication middleware with `UNAUTHENTICATED`.
102
105
  |---|---|---|---|
103
106
  | `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
107
  | `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. |
108
+ | `INVALID_DESTINATION_ADDRESS` | 400 | `toAddress` is not a valid address on the sending address's network: TRON — base58check (34 characters, `T…`, checksum); EVM — `0x` + 40 hex, with the EIP-55 checksum when mixed-case. The string is checked as sent, so leading or trailing whitespace fails too. Checked before AML, the policy and the reserve — nothing was created or reserved. `details` is `{field: "toAddress", network}`. A network of a type the API does not know is not checked here. | Fix client-side: an error on the recipient field. Not a retry. |
105
109
  | `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
110
  | `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
111
  | `ADDRESS_NOT_FOUND` | 404 | No such address id. | Refresh the address list; the address was deleted or the id is stale. |
@@ -109,10 +113,13 @@ the authentication middleware with `UNAUTHENTICATED`.
109
113
  | `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
114
  | `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
115
  | `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). |
116
+ | `IDEMPOTENCY_KEY_REUSED` | 409 | The `idempotencyKey` already belongs to a different transfer: another sending address, recipient, amount, asset or network — their names are in `details.fields` (the amount is compared by value, `1.5` equals `1.50`); or it was used by another person or in another vault, and then there is no `details`. Nothing was created or moved. | Not a replay — a key reused for a new attempt. A new transfer needs a new key; a retry of the same transfer needs the old key with the same body. |
112
117
  | `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
118
  | `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
119
  | `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`. |
120
+ | `WALLET_SERVICE_ERROR` | 400 | The signing service refused the request (answered 4xx). A definite refusal: the transfer is `failed` and the reserve is returned. Every other failure of the signing service is `TRANSFER_OUTCOME_UNKNOWN`, not this. | A retry will fail identically. `message` gives only the service's status (`Wallet Service returned 400`): its own body is not echoed to clients, it goes to the worker log. Show that the signing service refused and report it; do not try to explain the refusal from `message`. A new attempt needs a new key. |
121
+ | `TRANSFER_OUTCOME_UNKNOWN` | 502 | The signing service gave no definite answer — none within 60 s, a broken connection, a 5xx or 524, or a 2xx without a transaction id — so the transaction may or may not have reached the network. The transfer stays `pending` with the funds reserved; `details` is `{transactionId, status: "pending"}`. An administrator's reconciliation settles it. | **Neither a refusal nor a success.** Show that the outcome is being established. **Never retry with a new key** — that can send the money twice. A retry with the same key answers 200 (`replayed`) with the transfer as it stands and does not execute it again. Follow the transfer by `details.transactionId`. |
122
+ | `TRANSFER_BROADCAST_UNRECORDED` | 502 | The transaction went to the network (`details.txHash`), but by then reconciliation had already returned its reserve, so the send is not recorded in the ledger. | **The money left — do not retry.** An administrator has to post a correcting entry; report the hash. |
116
123
  | `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
124
 
118
125
  ### Voting on a transfer
@@ -124,24 +131,33 @@ the authentication middleware with `UNAUTHENTICATED`.
124
131
  | `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
132
  | `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
133
  | `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. |
134
+ | `ALREADY_VOTED` | 409 | You already voted. One vote per person. Repeating the **same** decision is not refused when it can finish what an earlier call left undone (it recorded the vote and broke off): a repeated reject completes the veto, a repeated approve executes the transfer once the threshold is met. A different decision over a recorded vote is always this 409. | Refresh; show your recorded vote. |
135
+ | `REQUEST_INCOMPLETE` | 409 | The approval request behind the transfer was not created completely — signature steps it refers to are missing — so it can never be approved. The `approve` that got this 409 **is recorded**. | Do not offer approve again. The request is closed by its initiator or an admin (cancel; anyone else gets 403 `NOT_INITIATOR`), or by a veto from an approver who has not voted yet; the reserve is returned. Then create the transfer anew. |
128
136
  | `NOT_INITIATOR` | 403 | Only the initiator or an admin can cancel. | Hide the cancel control. |
129
137
 
130
138
  ### Reconciling a stuck transfer
131
139
 
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`.
140
+ `POST /transfers/{id}/reconcile`, admin only. For a transfer nothing else can close: `pending` because execution died or its outcome is unknown (`TRANSFER_OUTCOME_UNKNOWN`); `awaiting_approval` with no live (`PENDING`) approval request; and a closed transfer — `failed`, `rejected` or `cancelled` — whose reserve is still held, where the reserve is returned, the status stays, and the answer carries that status.
141
+
142
+ A `pending` transfer is decided by the hash: with `txHash` it is driven to `confirmed`; without one the reserve is returned (`failed`) — that is the reconciler's statement that nothing was broadcast, not something the server checks. So for a `pending` transfer with no hash, look on the chain from the sending address first: returning the reserve of a transaction that did go out creates money. A `pending` transfer that already has a hash saved from the signing service is settled only by that hash, sent explicitly. The other two cases never executed: they take no hash, and the reserve is returned.
133
143
 
134
144
  | Code | Status | When | What to do |
135
145
  |---|---|---|---|
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. |
146
+ | `TRANSFER_STILL_EXECUTING` | 409 | The transfer changed less than 10 minutes ago (`updatedAt`). An unknown outcome counts as a change: the clock starts when execution stopped waiting for the signing service. | **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). |
147
+ | `TRANSFER_NOT_STUCK` | 409 | Nothing to reconcile: the transfer is in none of the states above (an `awaiting_approval` one still has a live request, a closed one holds no reserve), or another path changed it while you were reconciling. | Re-read it. It resolved itself, or it is closed by a vote or a cancel; nothing to do here. |
148
+ | `RESERVE_ALREADY_CONSUMED` | 409 | Without `txHash`: a `confirm` posting has already spent the reserve — the transaction went to the network. With `txHash`: the reserve was returned in parallel, for instance by a reconciliation without a hash. | Without a hash: **never retry as a release.** The money left. Finish the transfer by supplying its `txHash` instead. With a hash: re-read the transfer. |
149
+ | `TRANSFER_BROADCAST` | 409 | No `txHash` sent, but the `pending` transfer has a hash saved from the signing service — it went to the network. `details.txHash` is that hash. Nothing moved. | Reconcile again with that `txHash`. Never as a release. |
150
+ | `TXHASH_MISMATCH` | 409 | The `txHash` differs from the hash saved on the transfer, which is in `details.txHash`. Nothing moved. | Check the hash. The saved one came from the signing service. |
151
+ | `TXHASH_IN_USE` | 409 | The `txHash` is already recorded on another transfer. Nothing moved. | Almost certainly the wrong hash. Check it. |
152
+ | `TRANSFER_NOT_EXECUTED` | 409 | `txHash` sent for a transfer that never executed — `awaiting_approval`, or closed with its reserve still held. | Reconcile without `txHash`; the reserve is returned. |
153
+ | `RESERVE_MISSING` | 409 | `txHash` sent for a transfer whose funds were never reserved, so it cannot have executed. | Reconcile without `txHash`; it closes as `failed` with no money moved. |
154
+ | `RESERVE_RELEASED` | 409 | `txHash` sent, but the reserve had already been returned: on a `pending` transfer the returned funds are already spent and the send cannot be charged to them; a closed transfer is not confirmed by reconciliation at all. Nothing moved. | An administrator has to post a correcting entry. Escalate. |
139
155
 
140
156
  ### Not error codes, though they look like them
141
157
 
142
158
  Two sets of uppercase strings travel inside **successful** bodies and will mislead anyone grepping for codes:
143
159
 
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.
160
+ - **`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 was not executed — usually `failed` with the reserve returned; the transfer itself says which. An unknown outcome is **not** reported here: there is no `TRANSFER_OUTCOME_UNKNOWN` in a vote answer. The transfer route answers `status: "pending"` with `executionError: null`, the request route `APPROVED` with `finalized: false` and `applyError: null` — the outcome is not established by that answer; read it from the transfer.
145
161
  - **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
162
 
147
163
  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:
@@ -181,6 +197,16 @@ Two sets of uppercase strings travel inside **successful** bodies and will misle
181
197
 
182
198
  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
199
 
200
+ Creating an address **into a folder** (`groupId`) can succeed without the folder, and that is not an error either. The right to the folder is checked before anything is created — a refusal there is `GROUP_CREATE_FORBIDDEN` above — but the address is put into the folder only after the signing service and monitoring, and the right is checked again at that moment. If that fails, the address still exists and the answer is still **201**, with `warnings: [{code, message}]` in the body:
201
+
202
+ | Code | Meaning |
203
+ |---|---|
204
+ | `FOLDER_NOT_FOUND` | The folder was deleted. |
205
+ | `FOLDER_FORBIDDEN` | The address's creator may no longer add addresses to the folder. |
206
+ | `FOLDER_WRITE_FAILED` | Writing the membership failed. |
207
+
208
+ The same three arrive in `applyWarnings` of a **200** vote that applied an approved `create_address`: there the folder is checked when the request is applied, not when it was filed, and the request's history gets a `folder_skipped` event. Nothing is thrown in either case, so read the field. The address is fine; do not show it inside the folder.
209
+
184
210
  ## Grants and sharing
185
211
 
186
212
  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.
@@ -258,7 +284,8 @@ In the single-subject form the per-subject code is returned as a normal error
258
284
  | `NOT_FOUND` | 404 | No such request in this vault. | Refresh the list. |
259
285
  | `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
286
  | `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. |
287
+ | `ALREADY_VOTED` | 409 | One vote per person, already cast. Repeating the **same** decision on a `PENDING` request is not refused when it can finish what an earlier call left undone: a repeated reject completes the veto, a repeated approve completes the approval once the threshold is met. | Refresh; show your vote. |
288
+ | `REQUEST_INCOMPLETE` | 409 | The request was not created completely — signature steps its approvers refer to are missing — so it can never be approved. The `approve` that got this 409 **is recorded**. | Do not offer approve again. It is closed by its initiator or an admin (cancel; anyone else gets 403 `NOT_INITIATOR`), or by a veto from an approver who has not voted yet. Then create the action anew. |
262
289
  | `NOT_INITIATOR` | 403 | Only the initiator or an admin can cancel. | Hide the cancel control. |
263
290
  | `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
291
  | `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. |
@@ -385,7 +412,17 @@ A risk threshold in a policy is expressed as a **level**, not a numeric score, a
385
412
 
386
413
  `/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
414
 
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.
415
+ One of the library's codes is named here anyway, because the API itself refers to it: `FAILED_TO_GET_SESSION`. The library raises it when it fails to read or renew a session — a sign-out or a revocation deleting the session in the middle of a renewal is one way to get there. The API retries the session lookup once on it; on the API's own paths a failure that survives the retry arrives as `INTERNAL_ERROR`, and only `/api/auth/*` can hand you `FAILED_TO_GET_SESSION` itself, in the library's flat form. Treat it by `status`, like the rest of that namespace.
416
+
417
+ Three refusals the API puts **in front of** the library are its own: they arrive in the API's envelope, and their codes can be branched on.
418
+
419
+ | Code | Status | When | What to do |
420
+ |---|---|---|---|
421
+ | `ADMIN_CANNOT_APPROVE_WEB_LOGIN` | 403 | `POST /api/auth/device/approve` by a platform administrator. Approving a web login gives the holder of the code a new session of the approving user, and a platform administrator's session is not handed to the web version at all. | Terminal. Approve from an account that is not a platform administrator. |
422
+ | `WEB_SESSION_CANNOT_APPROVE` | 403 | The same approval sent with a session that was itself obtained through a web login. Only a session signed in another way may approve, so one leaked web token cannot keep minting new ones. | Approve from the mobile app. |
423
+ | `WEB_SESSION_CANNOT_MANAGE_SESSIONS` | 403 | A web-login session called `list-sessions`, `revoke-session`, `revoke-sessions` or `revoke-other-sessions`. Signing out (`sign-out`) is not affected. | Hide session management in a web client; it is done from the mobile app. |
424
+
425
+ Rate limiting outside `/api/auth/*` is not implemented in the API itself. A 429 there comes from the platform in front of it, usually without a JSON body, which means `code: "RATE_LIMITED"` and no `details`. Inside `/api/auth/*` a 429 can also come from the library's own limiter, as a bare `{message}`; and requesting a web-login code (`/api/auth/device/code`) is limited by the API itself, with a 429 in the API's envelope and `code: "RATE_LIMITED"`. Whichever answered, back off — for `X-Retry-After` seconds when the header is there — and retry.
389
426
 
390
427
  ## Easily confused
391
428
 
@@ -393,7 +430,7 @@ Rate limiting is not implemented in the API itself. A 429 therefore comes from t
393
430
 
394
431
  **`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
432
 
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.
433
+ **`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. (Reconciling **with** a `txHash` can get the same code for the opposite reason — the reserve was returned in parallel; then re-read the transfer.)
397
434
 
398
435
  **`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
436
 
@@ -438,19 +475,20 @@ try {
438
475
  } else throw e
439
476
  }
440
477
 
441
- if (result.outcome === 'replayed') showAlreadySent(result.data)
442
- else if (result.outcome === 'pending') showAwaitingApproval(result.data)
443
- else showSent(result.data)
478
+ if (result.outcome === 'pending') showAwaitingApproval(result.data)
479
+ else if (result.outcome === 'applied') showSent(result.data)
480
+ else showByStatus(result.data.transaction.status) // replayed: NOT necessarily sent — see below
444
481
  ```
445
482
 
446
483
  Rules that follow from how the server handles the key:
447
484
 
448
485
  - **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.
486
+ - **`replayed` does not mean "sent".** It means "a transfer with this key already exists": the earlier attempt did reach the server and only its answer was lost. `data` is that transfer as it stands **now**, and what happened to it is in `data.transaction.status` — `confirmed` (or `submitted`): it left; `awaiting_approval`: it is waiting for signatures, funds reserved; `pending`: it is still executing, or its outcome is unknown and waits for an administrator's reconciliation; `failed`, `rejected` or `cancelled`: it did not leave. Tell the person that, not "sent".
487
+ - **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. The same key with any other parameters is not a replay at all: 409 `IDEMPOTENCY_KEY_REUSED`.
451
488
  - **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
489
  - **`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
490
  - **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.
491
+ - **A thrown `TRANSFER_OUTCOME_UNKNOWN` is not.** It is a 502 that carries the same uncertainty as a timeout: the transaction may have left. Only the same key, which returns the transfer and does not execute it again; the outcome is set by reconciliation. `TRANSFER_BROADCAST_UNRECORDED` means it did leave — no retry at all.
454
492
  - **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
493
 
456
494
  ## Declared but never returned
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v3finance/core-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
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": {
package/src/core/gated.ts CHANGED
@@ -15,8 +15,10 @@
15
15
  * frozen.
16
16
  *
17
17
  * `replayed` is kept separate from `applied` on purpose. A replay means the
18
- * previous attempt DID reach the server and only the answer was lost, so the
19
- * person should be told "already sent", not "sent" a second time.
18
+ * previous attempt DID reach the server and only the answer was lost — not
19
+ * that it succeeded. The body is that operation as it stands now: a replayed
20
+ * transfer may have gone out, be waiting for signatures, be still executing
21
+ * or have failed, and only its own status says which.
20
22
  */
21
23
 
22
24
  export type GatedOutcome = 'applied' | 'pending' | 'replayed'
@@ -74,7 +76,11 @@ export function toGated<TApplied, TPending>(
74
76
  }
75
77
  }
76
78
 
77
- /** Narrowing: the operation ran, now or earlier, and money moved. */
79
+ /**
80
+ * Narrowing: `applied` or `replayed`. For `applied` the operation ran now.
81
+ * For `replayed` it only existed already — whether money moved is in the
82
+ * body's own status, not in this check.
83
+ */
78
84
  export function isApplied<A, P>(
79
85
  r: Gated<A, P>,
80
86
  ): r is Extract<Gated<A, P>, { outcome: 'applied' | 'replayed' }> {
package/src/core/http.ts CHANGED
@@ -157,13 +157,27 @@ export class HttpClient {
157
157
  constructor(options: ClientOptions & { baseUrl: string }) {
158
158
  this.baseUrl = options.baseUrl.replace(/\/+$/, '')
159
159
  this.#options = options
160
- const f = options.fetch ?? globalThis.fetch
161
- if (typeof f !== 'function') {
160
+ const custom = options.fetch
161
+ if (typeof (custom ?? globalThis.fetch) !== 'function') {
162
162
  throw new Error(
163
163
  '@v3finance/core-sdk: fetch is unavailable in this runtime — pass one to the client options.',
164
164
  )
165
165
  }
166
- this.#fetch = f
166
+ // NEVER store the function and call it as `this.#fetch(…)`. A private
167
+ // field call passes the client as the receiver, and a browser's `fetch`
168
+ // checks its receiver: anything but the window is
169
+ // `TypeError: Illegal invocation`, thrown before a single byte leaves.
170
+ // Node and React Native do not check, which is how every request from a
171
+ // web page failed while the whole test suite passed — and since a
172
+ // `TypeError` out of `fetch` is what a dropped connection looks like, the
173
+ // failure surfaced as a "network error" on a perfectly good network.
174
+ //
175
+ // The wrapper calls with no receiver at all. Without a custom fetch the
176
+ // global one is looked up per call rather than captured here, so whatever
177
+ // patches it later (instrumentation, a mock server) is still honoured.
178
+ this.#fetch = custom
179
+ ? (input, init) => custom(input, init)
180
+ : (input, init) => globalThis.fetch(input, init)
167
181
  this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
168
182
  }
169
183