@ickb/sdk 1000.0.82 → 9001.0.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.
Files changed (157) hide show
  1. package/README.md +84 -32
  2. package/dist/constants.d.ts +9 -22
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +62 -76
  5. package/dist/constants.js.map +1 -1
  6. package/dist/conversion/estimate.d.ts +33 -0
  7. package/dist/conversion/estimate.d.ts.map +1 -0
  8. package/dist/conversion/estimate.js +117 -0
  9. package/dist/conversion/estimate.js.map +1 -0
  10. package/dist/conversion/maturity.d.ts +15 -0
  11. package/dist/conversion/maturity.d.ts.map +1 -0
  12. package/dist/conversion/maturity.js +113 -0
  13. package/dist/conversion/maturity.js.map +1 -0
  14. package/dist/conversion/plans.d.ts +20 -0
  15. package/dist/conversion/plans.d.ts.map +1 -0
  16. package/dist/conversion/plans.js +106 -0
  17. package/dist/conversion/plans.js.map +1 -0
  18. package/dist/conversion/projection.d.ts +21 -0
  19. package/dist/conversion/projection.d.ts.map +1 -0
  20. package/dist/conversion/projection.js +94 -0
  21. package/dist/conversion/projection.js.map +1 -0
  22. package/dist/conversion/types.d.ts +264 -0
  23. package/dist/conversion/types.d.ts.map +1 -0
  24. package/dist/conversion/types.js +2 -0
  25. package/dist/conversion/types.js.map +1 -0
  26. package/dist/conversion/withdrawal_ring.d.ts +34 -0
  27. package/dist/conversion/withdrawal_ring.d.ts.map +1 -0
  28. package/dist/conversion/withdrawal_ring.js +87 -0
  29. package/dist/conversion/withdrawal_ring.js.map +1 -0
  30. package/dist/dao.d.ts +100 -0
  31. package/dist/dao.d.ts.map +1 -0
  32. package/dist/dao.js +125 -0
  33. package/dist/dao.js.map +1 -0
  34. package/dist/index.d.ts +49 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +20 -3
  37. package/dist/index.js.map +1 -1
  38. package/dist/logic.d.ts +81 -0
  39. package/dist/logic.d.ts.map +1 -0
  40. package/dist/logic.js +173 -0
  41. package/dist/logic.js.map +1 -0
  42. package/dist/order/cells.d.ts +168 -0
  43. package/dist/order/cells.d.ts.map +1 -0
  44. package/dist/order/cells.js +309 -0
  45. package/dist/order/cells.js.map +1 -0
  46. package/dist/order/conversion.d.ts +37 -0
  47. package/dist/order/conversion.d.ts.map +1 -0
  48. package/dist/order/conversion.js +120 -0
  49. package/dist/order/conversion.js.map +1 -0
  50. package/dist/order/fee.d.ts +9 -0
  51. package/dist/order/fee.d.ts.map +1 -0
  52. package/dist/order/fee.js +24 -0
  53. package/dist/order/fee.js.map +1 -0
  54. package/dist/order/fill.d.ts +36 -0
  55. package/dist/order/fill.d.ts.map +1 -0
  56. package/dist/order/fill.js +53 -0
  57. package/dist/order/fill.js.map +1 -0
  58. package/dist/order/info.d.ts +62 -0
  59. package/dist/order/info.d.ts.map +1 -0
  60. package/dist/order/info.js +102 -0
  61. package/dist/order/info.js.map +1 -0
  62. package/dist/order/master.d.ts +49 -0
  63. package/dist/order/master.d.ts.map +1 -0
  64. package/dist/order/master.js +27 -0
  65. package/dist/order/master.js.map +1 -0
  66. package/dist/order/matcher.d.ts +43 -0
  67. package/dist/order/matcher.d.ts.map +1 -0
  68. package/dist/order/matcher.js +124 -0
  69. package/dist/order/matcher.js.map +1 -0
  70. package/dist/order/order.d.ts +63 -0
  71. package/dist/order/order.d.ts.map +1 -0
  72. package/dist/order/order.js +234 -0
  73. package/dist/order/order.js.map +1 -0
  74. package/dist/order/order_data.d.ts +51 -0
  75. package/dist/order/order_data.d.ts.map +1 -0
  76. package/dist/order/order_data.js +67 -0
  77. package/dist/order/order_data.js.map +1 -0
  78. package/dist/order/ratio.d.ts +40 -0
  79. package/dist/order/ratio.d.ts.map +1 -0
  80. package/dist/order/ratio.js +74 -0
  81. package/dist/order/ratio.js.map +1 -0
  82. package/dist/order/relative.d.ts +43 -0
  83. package/dist/order/relative.d.ts.map +1 -0
  84. package/dist/order/relative.js +57 -0
  85. package/dist/order/relative.js.map +1 -0
  86. package/dist/owned_owner.d.ts +108 -0
  87. package/dist/owned_owner.d.ts.map +1 -0
  88. package/dist/owned_owner.js +260 -0
  89. package/dist/owned_owner.js.map +1 -0
  90. package/dist/sdk.d.ts +113 -41
  91. package/dist/sdk.d.ts.map +1 -1
  92. package/dist/sdk.js +410 -209
  93. package/dist/sdk.js.map +1 -1
  94. package/dist/send/sign_and_send_transaction.d.ts +61 -0
  95. package/dist/send/sign_and_send_transaction.d.ts.map +1 -0
  96. package/dist/send/sign_and_send_transaction.js +201 -0
  97. package/dist/send/sign_and_send_transaction.js.map +1 -0
  98. package/dist/send/wait_transaction.d.ts +42 -0
  99. package/dist/send/wait_transaction.d.ts.map +1 -0
  100. package/dist/send/wait_transaction.js +172 -0
  101. package/dist/send/wait_transaction.js.map +1 -0
  102. package/dist/udt.d.ts +108 -0
  103. package/dist/udt.d.ts.map +1 -0
  104. package/dist/udt.js +180 -0
  105. package/dist/udt.js.map +1 -0
  106. package/dist/utils/chain.d.ts +3 -0
  107. package/dist/utils/chain.d.ts.map +1 -0
  108. package/dist/utils/chain.js +2 -0
  109. package/dist/utils/chain.js.map +1 -0
  110. package/dist/utils/codec.d.ts +19 -0
  111. package/dist/utils/codec.d.ts.map +1 -0
  112. package/dist/utils/codec.js +69 -0
  113. package/dist/utils/codec.js.map +1 -0
  114. package/dist/utils/utils.d.ts +99 -0
  115. package/dist/utils/utils.d.ts.map +1 -0
  116. package/dist/utils/utils.js +130 -0
  117. package/dist/utils/utils.js.map +1 -0
  118. package/dist/utils/verified_client.d.ts +19 -0
  119. package/dist/utils/verified_client.d.ts.map +1 -0
  120. package/dist/utils/verified_client.js +92 -0
  121. package/dist/utils/verified_client.js.map +1 -0
  122. package/package.json +19 -26
  123. package/src/constants.ts +65 -181
  124. package/src/conversion/estimate.ts +155 -0
  125. package/src/conversion/maturity.ts +158 -0
  126. package/src/conversion/plans.ts +144 -0
  127. package/src/conversion/projection.ts +136 -0
  128. package/src/conversion/types.ts +302 -0
  129. package/src/conversion/withdrawal_ring.ts +122 -0
  130. package/src/dao.ts +196 -0
  131. package/src/index.ts +89 -3
  132. package/src/logic.ts +283 -0
  133. package/src/order/cells.ts +383 -0
  134. package/src/order/conversion.ts +162 -0
  135. package/src/order/fee.ts +30 -0
  136. package/src/order/fill.ts +83 -0
  137. package/src/order/info.ts +181 -0
  138. package/src/order/master.ts +42 -0
  139. package/src/order/matcher.ts +191 -0
  140. package/src/order/order.ts +288 -0
  141. package/src/order/order_data.ts +131 -0
  142. package/src/order/ratio.ts +127 -0
  143. package/src/order/relative.ts +109 -0
  144. package/src/owned_owner.ts +364 -0
  145. package/src/sdk.ts +542 -440
  146. package/src/send/sign_and_send_transaction.ts +256 -0
  147. package/src/send/wait_transaction.ts +227 -0
  148. package/src/udt.ts +249 -0
  149. package/src/utils/chain.ts +2 -0
  150. package/src/utils/codec.ts +78 -0
  151. package/src/utils/utils.ts +208 -0
  152. package/src/utils/verified_client.ts +136 -0
  153. package/dist/codec.d.ts +0 -3
  154. package/dist/codec.d.ts.map +0 -1
  155. package/dist/codec.js +0 -58
  156. package/dist/codec.js.map +0 -1
  157. package/src/codec.ts +0 -138
@@ -0,0 +1,256 @@
1
+ import { ccc } from "@ckb-ccc/core";
2
+ import { assertFresh, type BroadcastDeadline } from "../dao.ts";
3
+ import { jsonRpcRequestor, unique } from "../utils/utils.ts";
4
+ import { verifiedClient } from "../utils/verified_client.ts";
5
+
6
+ /** Error from a broadcast whose outcome is unknown; the local transaction hash is retained. */
7
+ export class TransactionBroadcastError extends Error {
8
+ /** Chain transaction hash computed from the signed transaction before broadcast. */
9
+ public readonly txHash: ccc.Hex;
10
+
11
+ /** Creates a hash-preserving broadcast error. */
12
+ constructor(txHash: ccc.Hex, options: ErrorOptions) {
13
+ super(`Transaction ${txHash} broadcast outcome is unresolved`, options);
14
+ this.name = "TransactionBroadcastError";
15
+ this.txHash = txHash;
16
+ }
17
+ }
18
+
19
+ /** Error from a send refused because the chain reached the transaction's broadcast deadline. */
20
+ export class TransactionExpiredError extends Error {
21
+ /** The deadline and the tip epoch that reached it. */
22
+ public readonly epochs: { broadcastBefore: ccc.Epoch; tip: ccc.Epoch };
23
+
24
+ /** Creates the error for a deadline the tip has reached; nothing was sent. */
25
+ constructor(
26
+ epochs: { broadcastBefore: ccc.Epoch; tip: ccc.Epoch },
27
+ options?: ErrorOptions,
28
+ ) {
29
+ super("Transaction expired: the chain reached its broadcast deadline", options);
30
+ this.name = "TransactionExpiredError";
31
+ this.epochs = epochs;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Verifies the inputs against the chain, signs locally, records chain identity before
37
+ * RPC, then broadcasts without CCC's cache wrapper.
38
+ *
39
+ * @remarks Nothing is signed unless every cell attached to an input equals the output
40
+ * its creating transaction made, a bare `OutPoint` being valued from that output, and the
41
+ * signer kept the ordered inputs, outputs and outputs data.
42
+ * Nothing is sent when the chain would take more fee than `plannedFee`, the fee completion
43
+ * priced, `tx.estimateFee(feeRate)` at its rate. That fee is measured from verified values,
44
+ * a phase-2 DAO input proven in its request block with `get_transaction_proof`, so the
45
+ * client must be JSON-RPC. When `broadcastBefore` is given, the send is refused with
46
+ * `TransactionExpiredError` once a fresh tip reaches it.
47
+ * `recordTxHash` runs before the send RPC, for a caller that must record the hash before a
48
+ * send that may never answer; every later failure carries it as `TransactionBroadcastError`. A node already holding this exact transaction
49
+ * is an acceptance; any other send failure throws `TransactionBroadcastError`. Why each
50
+ * check exists: SDK README, Signing and broadcast.
51
+ */
52
+ export async function signAndSendTransaction(
53
+ signer: ccc.Signer,
54
+ tx: ccc.TransactionLike,
55
+ plannedFee: ccc.Num,
56
+ recordTxHash?: (txHash: ccc.Hex) => void,
57
+ broadcastBefore?: BroadcastDeadline,
58
+ ): Promise<ccc.Hex> {
59
+ // Transaction.from reuses Transaction instances, which a signer may mutate in place.
60
+ const requested = ccc.Transaction.from(tx).clone();
61
+ const requestedBody = economicBody(requested);
62
+ // The checks read through a verified client, whatever client the signer holds.
63
+ const client = verifiedClient(signer.client);
64
+ const inputs = await verifiedInputs(client, requested.inputs);
65
+ const signed = await signer.signTransaction(tx);
66
+ if (economicBody(signed) !== requestedBody) {
67
+ throw new Error("Signer altered the transaction inputs, outputs or outputs data");
68
+ }
69
+ const txHash = signed.hash();
70
+ const fee = await verifiedFee(client, signed, inputs);
71
+ // A plain JavaScript caller of the earlier signature passes undefined or its recordTxHash
72
+ // here: numFrom throws on undefined, and on a function either throws or reads 0n. A bare
73
+ // comparison of a bigint with either is false and would skip the check.
74
+ const planned = ccc.numFrom(plannedFee);
75
+ if (fee > planned) {
76
+ throw new Error(
77
+ `Transaction ${txHash} pays ${String(fee)} shannons of fee, more than the ${String(planned)} planned`,
78
+ );
79
+ }
80
+ if (broadcastBefore !== undefined) {
81
+ const tip = await client.getTipHeader();
82
+ assertFresh(tip, broadcastBefore.tipMaxAgeMs);
83
+ const { epoch } = tip;
84
+ if (broadcastBefore.epoch.compare(epoch) <= 0) {
85
+ throw new TransactionExpiredError({
86
+ broadcastBefore: broadcastBefore.epoch,
87
+ tip: epoch,
88
+ });
89
+ }
90
+ }
91
+ recordTxHash?.(txHash);
92
+
93
+ // The signature binds the bytes, so the node can only have accepted this exact
94
+ // transaction; its returned hash adds nothing and the local one stays the identity.
95
+ try {
96
+ await signer.client.sendTransactionNoCache(signed);
97
+ } catch (cause) {
98
+ // A duplicate submission of this exact transaction is already accepted.
99
+ if (
100
+ cause instanceof ccc.ErrorClientDuplicatedTransaction &&
101
+ cause.txHash === txHash
102
+ ) {
103
+ return txHash;
104
+ }
105
+ throw new TransactionBroadcastError(txHash, { cause });
106
+ }
107
+ return txHash;
108
+ }
109
+
110
+ // The default lock signs each input as its OutPoint only, never the cell behind it, so
111
+ // the capacity, lock, type and data attached to an input are a node's claim: a node
112
+ // understating a plain cell's capacity or a UDT amount would turn the difference into
113
+ // fee or a burn on a transaction that passes every other check. The verified client reads
114
+ // the cell from its creating transaction, whose hash certifies its body.
115
+ async function verifiedInputs(
116
+ client: ccc.Client,
117
+ inputs: readonly ccc.CellInput[],
118
+ ): Promise<ccc.Cell[]> {
119
+ return Promise.all(
120
+ inputs.map(async (input) => {
121
+ const outPoint = input.previousOutput.toHex();
122
+ const cell = await client.getCell(input.previousOutput);
123
+ if (cell === undefined) {
124
+ throw new Error(`Input ${outPoint} is not an output of a known transaction`);
125
+ }
126
+ if (
127
+ (input.cellOutput !== undefined && !cell.cellOutput.eq(input.cellOutput)) ||
128
+ (input.outputData !== undefined && input.outputData !== cell.outputData)
129
+ ) {
130
+ throw new Error(
131
+ `Input ${outPoint} differs from the cell its transaction created`,
132
+ );
133
+ }
134
+ return cell;
135
+ }),
136
+ );
137
+ }
138
+
139
+ // The fee the chain will take: the verified inputs' capacity, plus the interest a phase-2
140
+ // DAO input collects, less the outputs. Completion priced that interest through the
141
+ // signer's client at whatever header the node named, so a lower rate there shortened the
142
+ // change; the miner would keep the difference under any fee-rate ceiling. A node names
143
+ // which block created a cell; the chain values it at headers the transaction names in its
144
+ // deps, the deposit header at the number the cell's data records and the request header
145
+ // at the block that committed the request, which the node must prove
146
+ // (assertCommittedIn), once per request transaction. The deposit header is then the one
147
+ // the cell's data numbers and the request header the real request block, so dao.c's own
148
+ // rules settle the rest on chain.
149
+ async function verifiedFee(
150
+ client: ccc.Client,
151
+ signed: ccc.Transaction,
152
+ inputs: readonly ccc.Cell[],
153
+ ): Promise<ccc.Num> {
154
+ const deps = new Set(signed.headerDeps);
155
+ const proven = new Set<string>();
156
+ let capacity = ccc.Zero;
157
+ for (const cell of inputs) {
158
+ capacity += cell.cellOutput.capacity;
159
+ const { depositHeader, withdrawHeader } = await cell.getNervosDaoInfo(client);
160
+ if (withdrawHeader === undefined) {
161
+ continue;
162
+ }
163
+ if (!deps.has(depositHeader.hash) || !deps.has(withdrawHeader.hash)) {
164
+ throw new Error(
165
+ `Input ${cell.outPoint.toHex()} is valued at a header the transaction does not name`,
166
+ );
167
+ }
168
+ const { txHash } = cell.outPoint;
169
+ if (!proven.has(`${txHash}${withdrawHeader.hash}`)) {
170
+ await assertCommittedIn(client, txHash, withdrawHeader);
171
+ proven.add(`${txHash}${withdrawHeader.hash}`);
172
+ }
173
+ capacity += ccc.calcDaoProfit(cell.capacityFree, depositHeader, withdrawHeader);
174
+ }
175
+ return capacity - signed.getOutputsCapacity();
176
+ }
177
+
178
+ /**
179
+ * Refuses unless the node proves `txHash` is among the transactions of `header`'s block.
180
+ * An outpoint names its transaction, not the block that committed it, so a phase-2 input
181
+ * valued at the request header the node names could otherwise be valued at another
182
+ * request block of the same transaction. The proof is a Merkle path from the transaction
183
+ * hash to the header's transactions root, which the header's own hash binds.
184
+ */
185
+ async function assertCommittedIn(
186
+ client: ccc.Client,
187
+ txHash: ccc.Hex,
188
+ header: ccc.ClientBlockHeader,
189
+ ): Promise<void> {
190
+ const requestor = jsonRpcRequestor(client);
191
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, no-restricted-syntax -- the node's answer, checked field by field in isCommittedIn.
192
+ const proof = (await requestor.request("get_transaction_proof", [
193
+ [txHash],
194
+ header.hash,
195
+ ])) as TransactionProof;
196
+ if (!isCommittedIn(txHash, header, proof)) {
197
+ throw new Error(`Transaction ${txHash} is not proven in block ${header.hash}`);
198
+ }
199
+ }
200
+
201
+ /** The node's `get_transaction_proof` answer for one transaction, the fields checked. */
202
+ export interface TransactionProof {
203
+ witnesses_root: ccc.Hex;
204
+ proof: { indices: readonly ccc.Hex[]; lemmas: readonly ccc.Hex[] };
205
+ }
206
+
207
+ /**
208
+ * Whether `proof` is a path from `txHash` to `header`'s transactions root. CKB builds that
209
+ * root over two subtrees, `blake2b(raw transactions root || witnesses root)`, each a
210
+ * complete binary Merkle tree whose node at position `i` has its parent at `(i - 1) / 2`
211
+ * and is a left child when `i` is odd; the index is the leaf's position and the lemmas are
212
+ * its siblings bottom up.
213
+ */
214
+ export function isCommittedIn(
215
+ txHash: ccc.Hex,
216
+ header: ccc.ClientBlockHeader,
217
+ { witnesses_root: witnessesRoot, proof }: TransactionProof,
218
+ ): boolean {
219
+ const [index, ...extra] = proof.indices;
220
+ if (index === undefined || extra.length > 0) {
221
+ return false;
222
+ }
223
+ let node = txHash;
224
+ let position = Number(index);
225
+ for (const lemma of proof.lemmas) {
226
+ if (position === 0) {
227
+ return false;
228
+ }
229
+ node = position % 2 === 1 ? ccc.hashCkb(node, lemma) : ccc.hashCkb(lemma, node);
230
+ position = (position - 1) >> 1;
231
+ }
232
+ return position === 0 && ccc.hashCkb(node, witnessesRoot) === header.transactionsRoot;
233
+ }
234
+
235
+ // Hashing a body-only transaction reuses CCC serialization to freeze the ordered
236
+ // inputs, outputs and outputs data, while ignoring signer-owned witnesses and
237
+ // the cell/header deps a connector may still have to prepare.
238
+ function economicBody(tx: ccc.TransactionLike): ccc.Hex {
239
+ const { inputs, outputs, outputsData } = ccc.Transaction.from(tx);
240
+ return ccc.Transaction.from({ inputs, outputs, outputsData }).hash();
241
+ }
242
+
243
+ /**
244
+ * Returns the primary lock plus all signer address locks, deduplicated by script hash.
245
+ */
246
+ export async function signerAccountLocks(
247
+ signer: ccc.Signer,
248
+ primaryLock: ccc.Script,
249
+ ): Promise<ccc.Script[]> {
250
+ return [
251
+ ...unique([
252
+ primaryLock,
253
+ ...(await signer.getAddressObjs()).map(({ script }) => script),
254
+ ]),
255
+ ];
256
+ }
@@ -0,0 +1,227 @@
1
+ import { ccc } from "@ckb-ccc/core";
2
+ import { jsonRpcRequestor } from "../utils/utils.ts";
3
+
4
+ const MAX_TIMEOUT_MS = 2_147_483_647;
5
+ const DEFAULT_TIMEOUT_MS = 60_000;
6
+ const DEFAULT_INTERVAL_MS = 2_000;
7
+
8
+ /** Error reported when CKB gives a terminal non-committed transaction status. */
9
+ export class TransactionWaitError extends Error {
10
+ /** Hash of the transaction that reached a terminal status. */
11
+ public readonly txHash: ccc.Hex;
12
+ /** Terminal status reported by the node. */
13
+ public readonly status: string;
14
+ /** Optional rejection reason reported by the node. */
15
+ public readonly reason: string | undefined;
16
+
17
+ /** Creates an error from a transaction hash and terminal node status. */
18
+ constructor(
19
+ txHash: ccc.Hex,
20
+ options: ErrorOptions & { status: string; reason?: string },
21
+ ) {
22
+ const { status, reason } = options;
23
+ const detail = reason === undefined ? status : `${status}: ${reason}`;
24
+ super(`Transaction ${txHash} ended with status ${detail}`, options);
25
+ this.name = "TransactionWaitError";
26
+ this.txHash = txHash;
27
+ this.status = status;
28
+ this.reason = reason;
29
+ }
30
+ }
31
+
32
+ /** One bounded observation window for an already-broadcast transaction. */
33
+ export interface WaitTransactionOptions {
34
+ /** Absolute budget in milliseconds for the whole wait. Defaults to 60000. */
35
+ timeout?: number;
36
+ /** Delay in milliseconds between polls. Defaults to 2000. */
37
+ interval?: number;
38
+ /** Stops waiting, including on in-flight operations; their transports may still finish. */
39
+ signal?: AbortSignal;
40
+ }
41
+
42
+ /**
43
+ * Observes one already-broadcast transaction for a single bounded window and resolves
44
+ * once the node reports it committed.
45
+ *
46
+ * @remarks Stack waits at depth zero. Actors rebuild next turn; the app can reopen a
47
+ * window on the same hash without signing again. Callers needing deeper confirmation
48
+ * must observe it separately.
49
+ * The status is polled with raw `get_transaction` verbosity 1, since CCC's typed response
50
+ * discards a null transaction body and its rejection status; the client must be JSON-RPC,
51
+ * the connector's composed client included. The body is never read: no caller uses it,
52
+ * and the same node's word on it would verify nothing.
53
+ * Timeout and abort also apply while awaiting client
54
+ * operations, but the in-flight client operation receives no abort signal and may
55
+ * finish after this function rejects. Nothing here mutates the client cache: later attempts rebuild
56
+ * from exact committed reads.
57
+ */
58
+ export async function waitTransaction(
59
+ client: ccc.Client,
60
+ txHashLike: ccc.HexLike,
61
+ options: WaitTransactionOptions = {},
62
+ ): Promise<void> {
63
+ const {
64
+ timeout = DEFAULT_TIMEOUT_MS,
65
+ interval = DEFAULT_INTERVAL_MS,
66
+ signal,
67
+ } = options;
68
+ validateWaitOptions(timeout, interval);
69
+ // Normalized before the window opens so a malformed hash cannot leave a timer
70
+ // or abort listener installed outside the try/finally that closes them.
71
+ const txHash = ccc.hexFrom(txHashLike);
72
+ signal?.throwIfAborted();
73
+
74
+ const budget = openWaitWindow(timeout, signal);
75
+ try {
76
+ for (;;) {
77
+ if (await isCommitted(client, txHash, budget)) {
78
+ // An abort synchronized into the gap between the last read and this
79
+ // return must not resolve the wait successfully.
80
+ budget.throwIfStopped();
81
+ return;
82
+ }
83
+ if (Date.now() + interval >= budget.deadline) {
84
+ throw budget.timeoutError;
85
+ }
86
+ await pollingSleep(interval, budget);
87
+ }
88
+ } finally {
89
+ budget.close();
90
+ }
91
+ }
92
+
93
+ interface WaitWindow {
94
+ deadline: number;
95
+ timeoutError: ccc.ErrorClientWaitTransactionTimeout;
96
+ stopped: Promise<never>;
97
+ throwIfStopped: () => void;
98
+ close: () => void;
99
+ }
100
+
101
+ function validateWaitOptions(timeout: number, interval: number): void {
102
+ assertCount(interval, "interval");
103
+ assertCount(timeout, "timeout");
104
+ // A single unarmed timer owns the whole window, so the budget must fit one.
105
+ if (timeout > MAX_TIMEOUT_MS) {
106
+ throw new RangeError(`timeout must not exceed ${String(MAX_TIMEOUT_MS)} ms`);
107
+ }
108
+ }
109
+
110
+ function assertCount(value: number, name: string): void {
111
+ if (!Number.isSafeInteger(value) || value < 0) {
112
+ throw new RangeError(`${name} must be a non-negative safe integer`);
113
+ }
114
+ }
115
+
116
+ function openWaitWindow(timeout: number, signal: AbortSignal | undefined): WaitWindow {
117
+ const stop = Promise.withResolvers<never>();
118
+ const timeoutError = new ccc.ErrorClientWaitTransactionTimeout(timeout);
119
+ const timer = setTimeout(() => {
120
+ stop.reject(timeoutError);
121
+ }, timeout);
122
+ const onAbort = (): void => {
123
+ stop.reject(abortReason(signal));
124
+ };
125
+ signal?.addEventListener("abort", onAbort, { once: true });
126
+ // Only the operation currently racing this window consumes its rejection.
127
+ void stop.promise.catch(ignoreSettlement);
128
+ return {
129
+ deadline: Date.now() + timeout,
130
+ timeoutError,
131
+ stopped: stop.promise,
132
+ throwIfStopped: (): void => {
133
+ // Only a promise currently racing the window observes its rejection, so
134
+ // an abort landing outside a race has to be read from the signal itself.
135
+ if (signal?.aborted === true) {
136
+ throw abortReason(signal);
137
+ }
138
+ },
139
+ close: (): void => {
140
+ clearTimeout(timer);
141
+ signal?.removeEventListener("abort", onAbort);
142
+ },
143
+ };
144
+ }
145
+
146
+ function ignoreSettlement(): void {
147
+ // Deliberately empty: the racing caller owns the outcome.
148
+ }
149
+
150
+ function abortReason(signal: AbortSignal | undefined): Error {
151
+ return signal?.reason instanceof Error
152
+ ? signal.reason
153
+ : new Error("Transaction wait aborted", { cause: signal?.reason });
154
+ }
155
+
156
+ async function within<T>(operation: () => Promise<T>, budget: WaitWindow): Promise<T> {
157
+ if (Date.now() >= budget.deadline) {
158
+ throw budget.timeoutError;
159
+ }
160
+ const running = operation();
161
+ // The operation is not cancelled, so a settlement arriving after the window
162
+ // closed must stay handled rather than surface as an unhandled one.
163
+ void running.catch(ignoreSettlement);
164
+ const result = await Promise.race([running, budget.stopped]);
165
+ // An operation that settles in the same turn as an abort can win the race, so
166
+ // cancellation is decided before the result is accepted.
167
+ budget.throwIfStopped();
168
+ // An operation that blocks the event loop past the deadline starves the
169
+ // window timer, so its overdue result must not win the race.
170
+ if (Date.now() >= budget.deadline) {
171
+ throw budget.timeoutError;
172
+ }
173
+ return result;
174
+ }
175
+
176
+ async function pollingSleep(interval: number, budget: WaitWindow): Promise<void> {
177
+ let timer: ReturnType<typeof setTimeout> | undefined;
178
+ const sleeping = new Promise<void>((resolve) => {
179
+ timer = setTimeout(resolve, interval);
180
+ });
181
+ try {
182
+ await Promise.race([sleeping, budget.stopped]);
183
+ } finally {
184
+ clearTimeout(timer);
185
+ }
186
+ }
187
+
188
+ async function isCommitted(
189
+ client: ccc.Client,
190
+ txHash: ccc.Hex,
191
+ budget: WaitWindow,
192
+ ): Promise<boolean> {
193
+ const requestor = jsonRpcRequestor(client);
194
+ // Raw verbosity-1 polling keeps a status-only rejection that CCC's typed transaction
195
+ // response discards (its body is null, so CCC returns nothing).
196
+ const { status, reason } = rawTransactionStatus(
197
+ await within(
198
+ async () => requestor.request("get_transaction", [txHash, "0x1"]),
199
+ budget,
200
+ ),
201
+ );
202
+ if (status === "rejected") {
203
+ throw new TransactionWaitError(txHash, { status, reason });
204
+ }
205
+ return status === "committed";
206
+ }
207
+
208
+ /** A node's raw `get_transaction` status record (verbosity 1), the fields read here. */
209
+ function rawTransactionStatus(response: unknown): {
210
+ status: string | undefined;
211
+ reason: string | undefined;
212
+ } {
213
+ const record =
214
+ typeof response === "object" &&
215
+ response !== null &&
216
+ "tx_status" in response &&
217
+ typeof response.tx_status === "object" &&
218
+ response.tx_status !== null
219
+ ? response.tx_status
220
+ : {};
221
+ return {
222
+ status:
223
+ "status" in record && typeof record.status === "string" ? record.status : undefined,
224
+ reason:
225
+ "reason" in record && typeof record.reason === "string" ? record.reason : undefined,
226
+ };
227
+ }