@chironbuilder/ootle-sdk 0.1.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/dist/accountApi.d.ts +33 -0
  4. package/dist/accountApi.d.ts.map +1 -0
  5. package/dist/accountApi.js +1 -0
  6. package/dist/adapters.d.ts +13 -0
  7. package/dist/adapters.d.ts.map +1 -0
  8. package/dist/adapters.js +68 -0
  9. package/dist/componentAddress.d.ts +27 -0
  10. package/dist/componentAddress.d.ts.map +1 -0
  11. package/dist/componentAddress.js +68 -0
  12. package/dist/confidential.d.ts +50 -0
  13. package/dist/confidential.d.ts.map +1 -0
  14. package/dist/confidential.js +86 -0
  15. package/dist/derivation.d.ts +7 -0
  16. package/dist/derivation.d.ts.map +1 -0
  17. package/dist/derivation.js +47 -0
  18. package/dist/domainHash.d.ts +11 -0
  19. package/dist/domainHash.d.ts.map +1 -0
  20. package/dist/domainHash.js +44 -0
  21. package/dist/htlc.d.ts +26 -0
  22. package/dist/htlc.d.ts.map +1 -0
  23. package/dist/htlc.js +69 -0
  24. package/dist/index.d.ts +21 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +14 -0
  27. package/dist/ootleNetwork.d.ts +4 -0
  28. package/dist/ootleNetwork.d.ts.map +1 -0
  29. package/dist/ootleNetwork.js +11 -0
  30. package/dist/ownershipProof.d.ts +4 -0
  31. package/dist/ownershipProof.d.ts.map +1 -0
  32. package/dist/ownershipProof.js +53 -0
  33. package/dist/storage.d.ts +61 -0
  34. package/dist/storage.d.ts.map +1 -0
  35. package/dist/storage.js +110 -0
  36. package/dist/timeout.d.ts +2 -0
  37. package/dist/timeout.d.ts.map +1 -0
  38. package/dist/timeout.js +19 -0
  39. package/dist/vault.d.ts +12 -0
  40. package/dist/vault.d.ts.map +1 -0
  41. package/dist/vault.js +44 -0
  42. package/dist/wallet.d.ts +733 -0
  43. package/dist/wallet.d.ts.map +1 -0
  44. package/dist/wallet.js +1964 -0
  45. package/package.json +46 -0
  46. package/vendor/ootle-patched/README.md +67 -0
  47. package/vendor/ootle-patched/dist/index.d.ts +2129 -0
  48. package/vendor/ootle-patched/dist/index.js +1365 -0
  49. package/vendor/ootle-patched/package.json +23 -0
  50. package/vendor/ootle-wasm-patched/README.md +128 -0
  51. package/vendor/ootle-wasm-patched/ootle_wasm.d.ts +454 -0
  52. package/vendor/ootle-wasm-patched/ootle_wasm.js +9 -0
  53. package/vendor/ootle-wasm-patched/ootle_wasm_bg.js +1485 -0
  54. package/vendor/ootle-wasm-patched/ootle_wasm_bg.wasm +0 -0
  55. package/vendor/ootle-wasm-patched/ootle_wasm_bg.wasm.d.ts +83 -0
  56. package/vendor/ootle-wasm-patched/package.json +27 -0
package/dist/wallet.js ADDED
@@ -0,0 +1,1964 @@
1
+ import { OotleWallet, StealthTransfer, TransactionBuilder, WalletStealthAuthorizer, WasmStealthCrypto, XTR_FAUCET_CLAIM_RESOURCE_ADDRESS, XTR_FAUCET_COMPONENT_ADDRESS, XTR_FAUCET_VAULT_ADDRESS, amountLiteral, createOutput, decryptOwnedUtxo, defaultIndexerUrl, getVaultIdsForAccount, resolveMaxEpoch, resolveTransaction, resourceAddressLiteral, sealTransaction, sendTransaction, signTransaction, stealthUtxoSubstateId, submitTransaction, } from "@tari-project/ootle";
2
+ import { IndexerProvider } from "@tari-project/ootle-indexer";
3
+ import { SecretKeyWallet } from "@tari-project/ootle-secret-key-wallet";
4
+ import { buildScriptPathWitness, buildStealthTransferStatement, createConfidentialWithdrawProofLiteral, createStealthOutputWitness, parseOotleAddress, publicKeyFromSecretKey, schnorrSign, stealthDhSecret, validateStealthTransfer, } from "@tari-project/ootle-wasm";
5
+ import { componentAddressFromWalletAddress, deriveAccountComponentAddress } from "./componentAddress";
6
+ import { scanTransactionsForOwnedOutputs, sumConfidentialCommitments } from "./confidential";
7
+ import { deriveAccountKeys } from "./derivation";
8
+ import { htlcConditions } from "./htlc";
9
+ import { buildOwnershipProofMessage, buildWalletOwnershipMessage } from "./ownershipProof";
10
+ import { toOotleNetwork } from "./ootleNetwork";
11
+ import { addPendingShield, addShieldedOutput, getKnownVersions, getPrivatePaymentScanCursor, listPendingShields, listShieldedOutputs, localAccountId, markShieldedOutputSpent, removePendingShield, serialized, setKnownVersions, setPrivatePaymentScanCursor, } from "./storage";
12
+ import { withTimeout } from "./timeout";
13
+ import { fromHex, toHex } from "./vault";
14
+ /**
15
+ * Batch-reads each resource's on-chain `divisibility` and `metadata.SYMBOL`/`metadata.name`.
16
+ * Decimal precision and display name are real chain data, never a client-side convention: XTR is 6
17
+ * and a typical DemoToken defaults to 8, and assuming one for the other misprices amounts by two
18
+ * orders of magnitude. A resource whose substate is missing or isn't a `Resource` degrades to
19
+ * `divisibility: 0` and null names rather than throwing -- one unreadable resource must not take
20
+ * down a whole balance listing.
21
+ */
22
+ async function fetchResourceMetadata(provider, resourceIds) {
23
+ const divisibilityByResource = new Map();
24
+ const symbolByResource = new Map();
25
+ const nameByResource = new Map();
26
+ if (resourceIds.length === 0)
27
+ return { divisibilityByResource, symbolByResource, nameByResource };
28
+ const { substates } = await withTimeout(provider.fetchSubstates(resourceIds), 15_000, "reading token decimal precision");
29
+ for (const id of resourceIds) {
30
+ const value = substates[id]?.substate;
31
+ const resource = value && "Resource" in value ? value.Resource : undefined;
32
+ divisibilityByResource.set(id, typeof resource?.divisibility === "number" ? resource.divisibility : 0);
33
+ const metadata = resource?.metadata;
34
+ symbolByResource.set(id, typeof metadata?.SYMBOL === "string" ? metadata.SYMBOL : null);
35
+ nameByResource.set(id, typeof metadata?.name === "string" ? metadata.name : null);
36
+ }
37
+ return { divisibilityByResource, symbolByResource, nameByResource };
38
+ }
39
+ /**
40
+ * A stealth output's **minimum value promise**: a public claim, committed into the output's own
41
+ * range proof, that it is worth at least this much.
42
+ *
43
+ * A confidential output normally carries a range proof for `0 <= v < 2^64`, hiding `v` completely.
44
+ * Set a promise `m` and the proof instead attests `m <= v < 2^64`, with `m` stored in the clear on
45
+ * the output (`UnspentOutput.minimum_value_promise`). Anyone reading the output on-chain learns
46
+ * "worth at least `m`" and nothing more precise -- which is exactly a proof of funds: a permanent,
47
+ * non-interactive, publicly verifiable artifact that needs no cooperation from this wallet to check
48
+ * (fetch the `utxo_{resource}_{commitment}` substate, read the field, confirm it is still unspent).
49
+ *
50
+ * Two things follow that callers must get right, which is why this is validated rather than passed
51
+ * straight through:
52
+ *
53
+ * 1. **A promise above the real value is unprovable.** The bulletproof asserting `m <= v` cannot be
54
+ * generated when `m > v`, so this fails here with a message that says so, rather than deeper in
55
+ * the wasm with something opaque -- or, worse, producing a proof the network then rejects after
56
+ * the fee half of the transaction has already been committed.
57
+ * 2. **It is a permanent, irreversible privacy disclosure.** Shielding is how value stops being
58
+ * publicly visible; a promise puts a floor back on public view, for the life of the output, for
59
+ * everyone -- not just whoever the proof was made for. That is the deliberate trade being made,
60
+ * and it is why every path reaching this asks the user first.
61
+ */
62
+ // Exported (unlike this file's other module-level guards) so the rule can be exercised directly --
63
+ // it is the one place standing between a caller and an unprovable range proof, and a regression here
64
+ // would surface as an opaque wasm failure after the fee half of a transaction had already committed.
65
+ export function assertValidMinimumValuePromise(promise, amount) {
66
+ if (promise < 0n) {
67
+ throw new Error(`minimumValuePromise cannot be negative (got ${promise}).`);
68
+ }
69
+ if (promise > amount) {
70
+ throw new Error(`minimumValuePromise (${promise}) cannot exceed the output's own amount (${amount}) -- the range proof asserting "at least ${promise}" is impossible for an output actually worth ${amount}.`);
71
+ }
72
+ }
73
+ /**
74
+ * `Output.memo`/`OutputInit.memo` is typed `object` in `@tari-project/ootle` (untyped there), but
75
+ * is actually the tagged `Memo` union from `@tari-project/ootle-ts-bindings` -- confirmed by
76
+ * reading that package's generated `Memo.d.ts` directly rather than assuming the Rust SDK docs'
77
+ * `.with_memo_message(...)` name maps 1:1 onto this TS binding. `{ Message: text }` is the plain
78
+ * free-text variant; the other variants (`U256`, `Bytes`, `PayRefAndBytes`) aren't used by this
79
+ * wallet's UI, which only ever offers a plain text note.
80
+ */
81
+ function toMemo(memo) {
82
+ return memo ? { Message: memo } : undefined;
83
+ }
84
+ /**
85
+ * Inverse of `toMemo()`, for a *received* output: `DecryptedData.memo` (confidential.ts's
86
+ * `ScannedStealthOutput.memo`, `claimPrivatePayment()`'s decrypt result) is the raw JSON-encoded
87
+ * `Memo` union string, not plain text -- confirmed against the SDK's own `DecryptedData` doc
88
+ * comment, not assumed. This wallet only ever creates `Message` memos, but a payment from a
89
+ * different sender/tool could use any variant -- render something readable for those instead of
90
+ * leaking raw JSON or silently dropping the memo. Returns `undefined` for no memo, an unparseable
91
+ * value, or an empty `Message`.
92
+ */
93
+ function fromMemo(memoJson) {
94
+ if (!memoJson)
95
+ return undefined;
96
+ try {
97
+ const memo = JSON.parse(memoJson);
98
+ if ("Message" in memo)
99
+ return memo.Message || undefined;
100
+ if ("SenderAddress" in memo)
101
+ return `From: ${memo.SenderAddress}`;
102
+ const [kind, value] = Object.entries(memo)[0];
103
+ return `[${kind} memo: ${value}]`;
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ /**
110
+ * One derived Ootle account: a signer (owner + view keypair) plus the network connection needed
111
+ * to read its state and submit transactions, entirely independent of the wallet daemon.
112
+ */
113
+ export class OotleAccount {
114
+ index;
115
+ network;
116
+ signer;
117
+ provider = null;
118
+ // Held only for signOwnershipProof(), which needs stealthDhSecret() directly -- SecretKeyWallet
119
+ // keeps its own copy of this exact value in a private field already (for addStealthSignature's
120
+ // transaction-signing path), so this adds no new exposure, just a second reference to the same
121
+ // in-memory secret for a second, narrowly-scoped purpose.
122
+ ownerSecret;
123
+ constructor(index, network, signer, ownerSecret) {
124
+ this.index = index;
125
+ this.network = network;
126
+ this.signer = signer;
127
+ this.ownerSecret = ownerSecret;
128
+ }
129
+ /** `entropy` is the 16-byte CipherSeed entropy (see cipherSeed.ts), not a raw 32-byte seed. */
130
+ static fromSeed(entropy, index, networkName) {
131
+ const network = toOotleNetwork(networkName);
132
+ const { ownerSecret, viewSecret } = deriveAccountKeys(entropy, index);
133
+ const signer = SecretKeyWallet.fromSecretKey(ownerSecret, network, viewSecret);
134
+ return new OotleAccount(index, network, signer, ownerSecret);
135
+ }
136
+ async getProvider() {
137
+ if (!this.provider) {
138
+ // Nothing has run yet at this point, so any failure here is by definition a connectivity
139
+ // problem (unlike execute()'s later errors, which are meaningful on-chain rejections this
140
+ // class deliberately leaves unwrapped) — mirrors the same "unreachable" framing
141
+ // DaemonAccount.connectClient() uses for the equivalent first-contact step.
142
+ const url = defaultIndexerUrl(this.network);
143
+ try {
144
+ this.provider = await withTimeout(IndexerProvider.connect({ url, network: this.network }), 15_000, "connecting to the Tari indexer");
145
+ }
146
+ catch (e) {
147
+ const details = e instanceof Error ? e.message : String(e);
148
+ throw new Error(`Could not reach the Tari indexer at ${url}. (${details})`);
149
+ }
150
+ }
151
+ return this.provider;
152
+ }
153
+ /**
154
+ * The bech32m "otl_..." wallet address, for display / receiving funds. Confirmed (empirically,
155
+ * against `generateOotleAddress`) to be what `Signer.getAddress()` actually returns for a
156
+ * `SecretKeyWallet` — despite the base `Signer` interface's JSDoc calling it "the component
157
+ * address", it is NOT the on-chain account component address. See `getComponentAddress()`.
158
+ */
159
+ async getWalletAddress() {
160
+ return this.signer.getAddress();
161
+ }
162
+ async getPublicKey() {
163
+ return this.signer.getPublicKey();
164
+ }
165
+ /**
166
+ * The account's on-chain component address — what instructions use as the `account` argument
167
+ * (deposit/withdraw/pay_fee/etc.) and what `getVaultIdsForAccount` needs to find balances.
168
+ *
169
+ * Computed client-side via `deriveAccountComponentAddress` (`componentAddress.ts`), which
170
+ * reproduces Ootle's domain-separated Blake2b hash of (ACCOUNT_TEMPLATE_ADDRESS, owner_public_key)
171
+ * byte-for-byte — verified against all three committed golden vectors from
172
+ * `tari-ootle`'s `crates/ootle_sdk_core/fixtures/address_derive/`.
173
+ */
174
+ async getComponentAddress() {
175
+ const publicKey = await this.getPublicKey();
176
+ return deriveAccountComponentAddress(publicKey);
177
+ }
178
+ async getBalances() {
179
+ const provider = await this.getProvider();
180
+ const account = await this.getComponentAddress();
181
+ let vaultIds;
182
+ try {
183
+ vaultIds = await withTimeout(getVaultIdsForAccount(provider, account), 15_000, "looking up this account's vaults");
184
+ }
185
+ catch (e) {
186
+ // A real timeout here (the indexer connection above already succeeded, so this specifically
187
+ // means it's up but slow/overloaded) must not look identical to "this account genuinely has
188
+ // no vaults yet" — silently mapping both to an empty balance list would show a brand-new
189
+ // account and a degraded-indexer account the exact same way, with no indication anything's
190
+ // actually wrong in the latter case.
191
+ if (e instanceof Error && e.message.startsWith("Timed out"))
192
+ throw e;
193
+ return []; // account not yet on-chain (never funded)
194
+ }
195
+ // Fold in this account's own known-good stealth outputs (from shield()/unshield(), or from
196
+ // redeeming someone else's shared commitment via the "Advanced" unshield flow) up front, not
197
+ // gated behind a vault existing -- see ShieldedOutputRecord's doc comment for why this local
198
+ // ledger is the only lead to them at all: they're freestanding `utxo_{resource}_{commitment}`
199
+ // substates, never entries in a vault's own `Confidential.commitments` map, so nothing below
200
+ // this point would otherwise see them. A resource whose *only* balance came from redeeming a
201
+ // shared commitment has no on-chain vault at all (`vaultIds.length === 0` for it), so this
202
+ // can't be computed only after confirming a vault exists.
203
+ // A local record only says this account once *held* the output. It says nothing about whether
204
+ // the output has since been spent — by another wallet on the same seed, by another device, or
205
+ // by a spend this ledger never saw — and `scanForPrivatePayments` happily re-claims outputs
206
+ // that were already consumed. Counting those inflates the private balance with money that
207
+ // cannot be spent, and the failure only surfaces later as an unresolvable input mid-transfer.
208
+ //
209
+ // The chain is the authority: a stealth output is a substate, and a consumed one is simply
210
+ // gone. Confirmed against the live Esmeralda indexer that `getSubstate` returns the substate
211
+ // for a live output and errors (HTTP 500) for a consumed one, and that batching them through
212
+ // `fetchSubstates` fails the *whole* call as soon as one id is consumed — so each is checked
213
+ // on its own, in parallel.
214
+ //
215
+ // Deliberately not persisted as `spent`: the indexer's error is indistinguishable from a real
216
+ // server fault, and marking the ledger on an ambiguous signal is a one-way door that would
217
+ // hide genuine funds. Excluding it from this reading is self-healing — if the output really is
218
+ // live, the next balance read counts it again. The vault lookup above already proved the
219
+ // indexer is reachable in this same call, which is what makes a per-id failure here meaningful
220
+ // rather than just noise.
221
+ const unspentRecords = (await listShieldedOutputs(localAccountId(this.index))).filter((r) => !r.spent);
222
+ const liveness = await Promise.all(unspentRecords.map(async (record) => {
223
+ try {
224
+ await withTimeout(provider.getSubstate(stealthUtxoSubstateId(record.resourceAddress, fromHex(record.commitment))), 15_000, "checking a shielded output");
225
+ return true;
226
+ }
227
+ catch {
228
+ return false;
229
+ }
230
+ }));
231
+ const shieldedByResource = new Map();
232
+ unspentRecords.forEach((record, i) => {
233
+ if (!liveness[i])
234
+ return;
235
+ shieldedByResource.set(record.resourceAddress, (shieldedByResource.get(record.resourceAddress) ?? 0n) + BigInt(record.amount));
236
+ });
237
+ if (vaultIds.length === 0 && shieldedByResource.size === 0)
238
+ return [];
239
+ const parsed = [];
240
+ if (vaultIds.length > 0) {
241
+ const { substates } = await withTimeout(provider.fetchSubstates(vaultIds), 15_000, "fetching vault balances");
242
+ for (const id of vaultIds) {
243
+ const substate = substates[id];
244
+ const value = substate?.substate;
245
+ if (!value || !("Vault" in value))
246
+ continue;
247
+ const container = value.Vault.resource_container;
248
+ const [kind, data] = Object.entries(container)[0];
249
+ if (kind === "NonFungible") {
250
+ const tokenIds = (data.token_ids ?? []).map(stringifyNonFungibleId);
251
+ parsed.push({ resourceAddress: data.address, kind, amount: BigInt(tokenIds.length), nonFungibleTokenIds: tokenIds });
252
+ continue;
253
+ }
254
+ const rawAmount = (data.amount ?? data.revealed_amount ?? 0);
255
+ const commitments = kind === "Confidential" ? data.commitments : undefined;
256
+ parsed.push({ resourceAddress: data.address, kind, amount: BigInt(rawAmount), commitments });
257
+ }
258
+ }
259
+ if (parsed.length === 0 && shieldedByResource.size === 0)
260
+ return [];
261
+ // Decrypt each Confidential vault's hidden commitments with this account's own view key —
262
+ // pure local decryption of data already fetched above, no new network calls. One crypto
263
+ // provider + one view-secret fetch is reused across every confidential vault in this
264
+ // account rather than re-deriving per vault.
265
+ const confidentialByIndex = new Map();
266
+ const hasConfidential = parsed.some((p) => p.commitments && Object.keys(p.commitments).length > 0);
267
+ if (hasConfidential) {
268
+ const crypto = new WasmStealthCrypto(this.network);
269
+ const viewSecret = await this.signer.getViewSecret();
270
+ for (const [i, p] of parsed.entries()) {
271
+ const commitments = p.commitments;
272
+ if (!commitments || Object.keys(commitments).length === 0)
273
+ continue;
274
+ confidentialByIndex.set(i, await sumConfidentialCommitments(crypto, viewSecret, commitments));
275
+ }
276
+ }
277
+ // Batch-fetch each distinct resource's own substate for its real `divisibility` and metadata
278
+ // symbol/name — decimal precision and display name are both on-chain data, not a client-side
279
+ // guess (confirmed empirically: XTR is 6, a typical DemoToken defaults to 8, and assuming one
280
+ // divisibility for both silently misprices trades by orders of magnitude).
281
+ // Union with shielded-only resources (computed up front, before the vault check above) so a
282
+ // resource with no vault at all still gets its real divisibility/symbol/name looked up.
283
+ const resourceIds = [...new Set([...parsed.map((p) => p.resourceAddress), ...shieldedByResource.keys()])];
284
+ const { divisibilityByResource, symbolByResource, nameByResource } = await fetchResourceMetadata(provider, resourceIds);
285
+ const balances = parsed.map((p, i) => ({
286
+ resourceAddress: p.resourceAddress,
287
+ kind: p.kind,
288
+ amount: p.amount,
289
+ confidentialAmount: (confidentialByIndex.get(i)?.total ?? 0n) + (shieldedByResource.get(p.resourceAddress) ?? 0n),
290
+ confidentialDecryptFailures: confidentialByIndex.get(i)?.failedCount ?? 0,
291
+ divisibility: divisibilityByResource.get(p.resourceAddress) ?? 0,
292
+ symbol: symbolByResource.get(p.resourceAddress) ?? null,
293
+ name: nameByResource.get(p.resourceAddress) ?? null,
294
+ nonFungibleTokenIds: p.nonFungibleTokenIds ?? null,
295
+ }));
296
+ // Resources whose only balance is a shielded output with no on-chain vault at all (e.g.
297
+ // redeemed via the "Advanced" unshield flow from someone else's shared commitment) never
298
+ // appear in `parsed` above -- synthesize an entry for each so they aren't silently dropped.
299
+ balances.push(...synthesizeShieldedOnlyBalances(new Set(parsed.map((p) => p.resourceAddress)), shieldedByResource, divisibilityByResource, symbolByResource, nameByResource));
300
+ return balances;
301
+ }
302
+ /**
303
+ * Builds, signs (with this account's key) and submits/dry-runs a transaction. `opts.inputs` lets
304
+ * a caller pre-pin substates it already knows are needed; beyond that, this indexer requires
305
+ * *every* substate an instruction touches to be listed as an input — even ones referenced only
306
+ * by address in a `CallMethod` on an already-existing component (confirmed empirically, first
307
+ * with the faucet, and again with this account's own existing component when calling into it for
308
+ * a `pay_fee`/`deposit`/`withdraw` on a second use) — and there's no want-derivation pass in this
309
+ * TS SDK to discover that dependency graph up front the way the Rust `ootle_sdk_core` can.
310
+ *
311
+ * Rather than hand-tracing each call's full component/vault graph, this retries on the specific
312
+ * rejection the engine itself gives for a missing input — "At instruction #N: <address> not
313
+ * found" — extracting that address, resolving its current version, adding it to the pinned
314
+ * inputs, and resubmitting. Each retry can surface a *different* missing address (a component's
315
+ * vaults are only discoverable after the component itself is known), so this loops until either
316
+ * success or the same address reappears (nothing new left to resolve). The version-race retry
317
+ * `claimTestnetXtr()` needs for a heavily-contended shared substate is folded in too.
318
+ */
319
+ async execute(instructions, opts = {}) {
320
+ const provider = await this.getProvider();
321
+ const account = await this.getComponentAddress();
322
+ const maxFee = opts.maxFee ?? 5000n;
323
+ // Resolved once, outside the retry loop below: every transaction now carries a mandatory
324
+ // `max_epoch`, and a fresh chain-tip lookup on every retry would buy nothing (the loop's
325
+ // whole retry budget runs in well under an epoch in practice) at the cost of an extra
326
+ // round trip per attempt.
327
+ const maxEpoch = await resolveMaxEpoch(provider);
328
+ // A first-time transaction into brand-new resources needs one retry per previously-unknown
329
+ // substate it discovers (each pool, each pool's own internal vaults, and any new vault the
330
+ // account itself needs created to hold a token it's never held before) — confirmed empirically
331
+ // that a single-hop swap into a new resource needed more than the original budget of 12, and a
332
+ // routed (multi-hop) swap touches roughly double the substates a direct one does (two pools
333
+ // instead of one, up to two new account vaults instead of one for the intermediate *and* final
334
+ // tokens) — hit exactly this exhausting 20 on a real 2-hop swap.
335
+ const maxRetries = opts.maxRetries ?? 30;
336
+ const seenAddresses = new Set();
337
+ let inputs = await applyKnownVersions(opts.inputs ? [...opts.inputs] : []);
338
+ for (let attempt = 0;; attempt++) {
339
+ const builder = TransactionBuilder.new(this.network, maxEpoch)
340
+ .withInstructions(instructions)
341
+ .feeTransactionPayFromComponent(account, maxFee);
342
+ if (inputs.length)
343
+ builder.withInputs(inputs);
344
+ const unsignedTx = builder.buildUnsignedTransaction();
345
+ try {
346
+ if (opts.dryRun)
347
+ return await withTimeout(this.submitDryRun(provider, unsignedTx), 30_000, "submitting the transaction");
348
+ const result = await withTimeout(this.submitReal(provider, unsignedTx), 60_000, "submitting the transaction");
349
+ await recordKnownVersions(result);
350
+ return result;
351
+ }
352
+ catch (e) {
353
+ if (!(e instanceof Error) || attempt >= maxRetries)
354
+ throw e;
355
+ // No amount of retrying fixes an empty fee vault — surface this immediately instead of
356
+ // burning the retry budget re-discovering the same "insufficient balance" outcome.
357
+ if (e.message.includes("InsufficientFeesPaid") || /insufficient/i.test(e.message)) {
358
+ throw new Error(`This account doesn't have enough XTR to pay the transaction fee. Claim testnet XTR first. (${e.message})`);
359
+ }
360
+ if (e.message.includes("Lock failure")) {
361
+ // A "Lock failure: Substate X:N is DOWN" names the *exact* version that was just
362
+ // consumed — the next version is deterministically N+1, computable locally with no
363
+ // network round trip. This matters: re-querying the indexer here (the previous
364
+ // approach) can hand back the very same stale N again, and worse, this loop's *own*
365
+ // prior attempt can have advanced the real version further still if it reached and paid
366
+ // the fee phase before its main instructions failed (`AcceptFeeRejectRest` still commits
367
+ // the fee) — so trusting a fresh `resolveInputs()` call to have caught up is exactly the
368
+ // race that was producing an apparently-stuck version across many retries.
369
+ const staleVersion = extractStaleLockVersion(e.message);
370
+ if (staleVersion) {
371
+ // The known-versions cache would otherwise keep reasserting the very version that was
372
+ // just rejected over every future resolve for this substate (see
373
+ // `forgetKnownVersions`'s doc comment) -- the local N+1 computed below is correct for
374
+ // *this* attempt, but leaving the stale entry in place would win out again on some
375
+ // later, unrelated transaction that resolves this same substate from scratch.
376
+ await forgetKnownVersions([staleVersion.substateId]);
377
+ inputs = inputs.map((input) => input.substate_id === staleVersion.substateId ? { ...input, version: staleVersion.version + 1 } : input);
378
+ continue;
379
+ }
380
+ const resolved = await resolveInputsWithRetry(provider, inputs.map(({ substate_id }) => ({ substate_id, version: null })));
381
+ // Deliberately NOT `applyKnownVersions(resolved)`: the remembered version is exactly
382
+ // what just failed, and it is `>` whatever the indexer reports, so re-applying it
383
+ // overwrites the freshly resolved (correct) version with the stale one and the next
384
+ // attempt fails identically. Persisted, so one bad write wedges every later transaction
385
+ // across reloads. Forget those entries and trust the chain.
386
+ await forgetKnownVersions(inputs.map((i) => i.substate_id));
387
+ inputs = resolved;
388
+ continue;
389
+ }
390
+ const missing = extractMissingSubstateAddress(e.message);
391
+ if (!missing || seenAddresses.has(missing))
392
+ throw e;
393
+ seenAddresses.add(missing);
394
+ // `resolveInputsWithRetry` already rides out the ordinary case (a producer that hasn't
395
+ // finalized on the indexer yet). If it's *still* failing after that budget, the substate
396
+ // is most likely genuinely absent rather than merely slow -- the common way that happens
397
+ // is a recipient account that has never been funded, since an Ootle account component
398
+ // only exists on chain once something has been deposited into it. Surface that plainly
399
+ // instead of the indexer's raw 404, which names neither the cause nor the fix.
400
+ let resolved;
401
+ try {
402
+ const [firstResolved] = await resolveInputsWithRetry(provider, [{ substate_id: missing, version: null }]);
403
+ resolved = firstResolved;
404
+ }
405
+ catch (resolveError) {
406
+ if (resolveError instanceof Error && resolveError.message.startsWith("Timed out"))
407
+ throw resolveError;
408
+ throw new Error(`${missing} does not exist on the Ootle network yet. An account is created the first ` +
409
+ `time it receives funds, so a brand-new account cannot be paid until it has been ` +
410
+ `funded once. (${resolveError instanceof Error ? resolveError.message.slice(0, 120) : String(resolveError)})`);
411
+ }
412
+ inputs = await applyKnownVersions([...inputs, resolved]);
413
+ }
414
+ }
415
+ }
416
+ /**
417
+ * A working replacement for the SDK's exported `sendDryRun()`, which is broken against this
418
+ * indexer: it builds the identical signed/sealed envelope but POSTs it to the regular
419
+ * `transactions` endpoint, which rejects dry-run envelopes with "Dry-run transactions must be
420
+ * submitted to the /transactions/dry-run endpoint" (confirmed empirically). This reproduces
421
+ * `sendDryRun`'s own pipeline — set `dry_run`, `resolveTransaction`, `signTransaction`,
422
+ * `sealTransaction` (all exported, same functions it uses internally) — but posts the sealed
423
+ * envelope to the correct path via the indexer client's transport directly. Dry-run responses
424
+ * come back synchronously with the full result already attached (no `transactions/{id}/result`
425
+ * polling needed, unlike a real submission).
426
+ *
427
+ * The response shape (`{transaction_id, result: ExecuteResult}`) is the indexer's own
428
+ * `SubmitTransactionDryRunResponse` (`applications/tari_indexer/src/rest_api/handlers/
429
+ * transactions.rs`'s `submit_transaction_dry_run` handler) — confirmed directly against that
430
+ * Rust source since `@tari-project/ootle-ts-bindings` doesn't export a matching type for it
431
+ * (only the real-submission response, which no longer carries a `result` field at all as of
432
+ * the 0.39.0 protocol upgrade).
433
+ */
434
+ async submitDryRun(provider, unsignedTx) {
435
+ const resolved = await resolveTransaction(provider, { ...unsignedTx, dry_run: true });
436
+ const signed = await signTransaction([this.signer], resolved);
437
+ const envelope = sealTransaction(signed);
438
+ const res = await fetch(`${defaultIndexerUrl(this.network)}/transactions/dry-run`, {
439
+ method: "POST",
440
+ headers: { "Content-Type": "application/json" },
441
+ // `TransactionEnvelope` (ootle-ts-bindings) is just `string` — the request body is the
442
+ // `{ transaction }` wrapper (`IndexerSubmitTransactionRequest`), not the bare envelope string.
443
+ body: JSON.stringify({ transaction: envelope }),
444
+ });
445
+ const text = await res.text();
446
+ let body;
447
+ try {
448
+ body = text ? JSON.parse(text) : undefined;
449
+ }
450
+ catch {
451
+ // Not JSON — fall through and report the raw text below.
452
+ }
453
+ if (!res.ok)
454
+ throw new Error(`HTTP ${res.status}: ${body?.error?.message ?? text ?? res.statusText}`);
455
+ if (body?.error)
456
+ throw new Error(`${body.error.code}: ${body.error.message}`);
457
+ const response = body;
458
+ // A rejected dry-run comes back as a normal HTTP 200 with the rejection embedded in the
459
+ // result (there's nothing to poll — dry runs never reach consensus). Throwing here, in the
460
+ // same "Transaction {id} was rejected: {reason}" shape `sendTransaction`'s own polling throws
461
+ // in, means `execute()`'s retry loop only needs one error-handling path for both. Two failure
462
+ // variants matter: a clean `Reject`, and `AcceptFeeRejectRest` (the fee phase succeeded — it
463
+ // only touches the account, which by definition already resolved — but the *main* instructions
464
+ // aborted, confirmed empirically hitting this on a first read of the DEX pool: the fee-only
465
+ // success masked the "pool component not found" this same regex needs to see to retry).
466
+ const outcome = response.result.finalize.result;
467
+ if (typeof outcome === "object" && outcome !== null) {
468
+ if ("Reject" in outcome) {
469
+ throw new Error(`Transaction ${response.transaction_id} was rejected: ${JSON.stringify(outcome.Reject)}`);
470
+ }
471
+ if ("AcceptFeeRejectRest" in outcome) {
472
+ throw new Error(`Transaction ${response.transaction_id} accepted the fee but rejected the rest: ${JSON.stringify(outcome.AcceptFeeRejectRest)}`);
473
+ }
474
+ }
475
+ return response;
476
+ }
477
+ /**
478
+ * A working replacement for the SDK's exported `sendTransaction()`. That function still submits
479
+ * correctly, but its own polling (`ct()`/`st()` internally) only surfaces a generic
480
+ * `abort_details` summary on rejection — not the full structured `RejectReason`/`SubstateDiff`
481
+ * this class's auto-resolve retry (see `execute()`) needs to find a missing substate's address
482
+ * in. Confirmed empirically: a real submission that hit "SubstateNotFound: vault_... not found"
483
+ * came back from `sendTransaction()` with no address in the thrown message at all, so the retry
484
+ * loop had nothing to extract and gave up on the first attempt — the exact failure this method
485
+ * exists to fix. Submission itself (`provider.submitTransaction`) is unchanged and already
486
+ * proven correct; only the result-polling and error-message construction are reimplemented, in
487
+ * the same detailed shape `submitDryRun()` above already produces.
488
+ */
489
+ async submitReal(provider, unsignedTx) {
490
+ const resolved = await resolveTransaction(provider, unsignedTx);
491
+ // Both of these parse the transaction inside the wasm as an untagged enum
492
+ // (`ootle_wasm/core/src/transaction.rs`), so a single bad field anywhere collapses into "data
493
+ // did not match any variant of untagged enum TransactionInput" with nothing naming the
494
+ // culprit. `signTransaction` parses `{transaction, signatures}` -- so a malformed *signature*
495
+ // fails it exactly like a malformed transaction does -- and `sealTransaction` parses it again.
496
+ // The shape is the only evidence there is and it is gone by the time the error surfaces, so it
497
+ // is attached here rather than guessed at afterwards.
498
+ let signed;
499
+ try {
500
+ signed = await signTransaction([this.signer], resolved);
501
+ }
502
+ catch (e) {
503
+ throw new Error(`${e instanceof Error ? e.message : String(e)} — at signTransaction; resolved tx: ${describeResolvedTx(resolved)}`, { cause: e });
504
+ }
505
+ let envelope;
506
+ try {
507
+ envelope = sealTransaction(signed);
508
+ }
509
+ catch (e) {
510
+ throw new Error(`${e instanceof Error ? e.message : String(e)} — at sealTransaction; signed: ${describeSignedTx(signed)}`, { cause: e });
511
+ }
512
+ const { transaction_id } = await provider.submitTransaction(envelope);
513
+ return pollTransactionResult(provider, transaction_id);
514
+ }
515
+ /**
516
+ * Transfers `amount` (raw, resource-native units) of `resourceAddress` from this account to
517
+ * `recipientAddress` — a plain `withdraw` off this account's own vault, handed straight to
518
+ * `deposit` on the recipient's account component via a workspace bucket, in one transaction.
519
+ * Works for any resource this account holds, including XTR; `execute()`'s auto-resolve retry
520
+ * (see its own doc comment) discovers and pins whichever vaults/components aren't already
521
+ * known, exactly as it does for every other instruction this class builds by hand.
522
+ *
523
+ * `recipientAddress` accepts either form (see `WalletAccountApi.send`'s doc comment): the
524
+ * recipient's bech32m wallet address, or a bare `component_...` address. `CallMethod.call` is a
525
+ * `SubstateId`, so it must end up being the latter either way — handing an "otl_…" straight
526
+ * through is not a friendly error: `SubstateId::from_str` splits on the first underscore, does
527
+ * not recognise the `otl` prefix, and the instruction then fails to deserialize, surfacing from
528
+ * the wasm as "data did not match any variant of untagged enum TransactionInput", naming neither
529
+ * the field nor the reason.
530
+ *
531
+ * `deposit` is a method call on an *existing* component, and `CreateAccount` is create-or-conflict
532
+ * (against an account that already exists it rejects with "Substate <component>:0 is already UP
533
+ * and conflicts with an existing output" -- Tari's own walletd reads that exact string as "this
534
+ * already exists" in its faucet handler), so a brand-new recipient's account is created only when
535
+ * it is genuinely missing, and only when a wallet address was given -- a bare component address
536
+ * never reveals the owner key `CreateAccount` needs, so that form can only pay an account that
537
+ * already exists.
538
+ */
539
+ async send(recipientAddress, resourceAddress, amount, maxFee = 5000n) {
540
+ const account = await this.getComponentAddress();
541
+ const isWalletAddress = recipientAddress.startsWith("otl");
542
+ const recipient = isWalletAddress ? componentAddressFromWalletAddress(recipientAddress) : recipientAddress;
543
+ const instructions = [];
544
+ if (isWalletAddress) {
545
+ const provider = await this.getProvider();
546
+ let recipientExists = true;
547
+ try {
548
+ await withTimeout(provider.getSubstate(recipient), 15_000, "checking the recipient's account");
549
+ }
550
+ catch (e) {
551
+ // A timeout means the indexer is degraded, not that the account is missing — creating one
552
+ // on that assumption is how a working transfer turns into an "already UP" rejection.
553
+ if (e instanceof Error && e.message.startsWith("Timed out"))
554
+ throw e;
555
+ recipientExists = false;
556
+ }
557
+ if (!recipientExists) {
558
+ const { owner_key } = parseOotleAddress(recipientAddress);
559
+ instructions.push({
560
+ CreateAccount: { owner_public_key: toHex(owner_key), owner_rule: null, access_rules: null, bucket_workspace_id: null },
561
+ });
562
+ }
563
+ }
564
+ instructions.push({ CallMethod: { call: { Address: account }, method: "withdraw", args: [resourceAddressLiteral(resourceAddress), amountLiteral(amount)] } }, { PutLastInstructionOutputOnWorkspace: { key: 0 } }, { CallMethod: { call: { Address: recipient }, method: "deposit", args: [{ Workspace: { id: 0, offset: null } }] } });
565
+ return this.execute(instructions, { maxFee });
566
+ }
567
+ /**
568
+ * Moves `amount` of this account's own REVEALED balance into a Confidential-type vault for the
569
+ * same resource -- the "Confidential" `ResourceType`'s equivalent of `shield()`, a different
570
+ * privacy mechanism from Stealth (vault-based, ElGamal-encrypted to a resource view key, rather
571
+ * than freestanding one-time-key UTXOs -- see the resource-types write-up in the integration
572
+ * docs). Only meaningful for a resource actually created as `ResourceType::Confidential`; calling
573
+ * this against a Fungible or Stealth resource's vault fails on-chain ("resource type mismatch" or
574
+ * similar), not client-side -- this class has no way to know a resource's type up front without
575
+ * an extra substate fetch, so the failure is left to the engine.
576
+ *
577
+ * Builds a `ConfidentialWithdrawProof` client-side (no wallet-daemon round trip) via the vendored
578
+ * `createConfidentialWithdrawProofLiteral` -- see that WASM export's own doc comment for why the
579
+ * `tari_bor` encoding happens in Rust rather than being hand-rolled here: the official
580
+ * `@tari-project/ootle` SDK's own `feeTransactionPayFromComponentConfidential` is unimplemented
581
+ * for exactly this reason ("a ConfidentialWithdrawProof Literal must be tari_bor-CBOR-encoded,
582
+ * which the TS SDK does not yet support").
583
+ *
584
+ * The new confidential output is addressed to this account's own owner/view keys -- the same
585
+ * one-time-output-witness construction `createStealthOutputWitness` already builds for Stealth
586
+ * outputs, reused here since `ConfidentialOutputStatement.output` and a `StealthOutputWitness`'s
587
+ * `witness` share the exact same shape (mask, sender_public_nonce, encrypted_data). Only the
588
+ * `witness` half of that result is used -- the `auth`/`tag` halves are Stealth-specific
589
+ * (`SpendAuthorization`/`UtxoTag`); a Confidential output has neither, ownership being purely
590
+ * "which vault holds it," not a spend-authorization key.
591
+ *
592
+ * Two instructions, mirroring `send()`'s withdraw-then-deposit shape: `withdraw_confidential`
593
+ * (drawing `amount` from the vault's revealed side, per the proof's `input_revealed_amount`)
594
+ * produces a bucket holding the one new confidential output, which `deposit` puts back in the
595
+ * same vault.
596
+ */
597
+ async depositConfidential(resourceAddress, amount, maxFee = 50000n) {
598
+ if (amount <= 0n)
599
+ throw new Error(`depositConfidential: amount must be greater than zero, got ${amount}`);
600
+ const account = await this.getComponentAddress();
601
+ const ownerPublicKey = await this.getPublicKey();
602
+ const viewSecret = await this.signer.getViewSecret();
603
+ const viewPublicKey = publicKeyFromSecretKey(viewSecret);
604
+ const witnessJson = createStealthOutputWitness(this.network, ownerPublicKey, viewPublicKey, amount, resourceAddress, undefined, // resource_view_key -- irrelevant here; that grants a THIRD PARTY decrypt access, not needed to address an output to ourselves
605
+ undefined, // memo_json
606
+ undefined, // pay_to_json -- irrelevant: only `witness` below is used, never this call's `auth`/`tag`
607
+ 0n);
608
+ const { witness } = JSON.parse(witnessJson);
609
+ const outputJson = JSON.stringify(witness);
610
+ const proofBytes = createConfidentialWithdrawProofLiteral("[]", // no confidential inputs spent -- drawing purely from the vault's revealed side
611
+ amount, outputJson, 0n, undefined, 0n);
612
+ const proofLiteral = { Literal: toHex(proofBytes) };
613
+ const instructions = [
614
+ {
615
+ CallMethod: {
616
+ call: { Address: account },
617
+ method: "withdraw_confidential",
618
+ args: [resourceAddressLiteral(resourceAddress), proofLiteral],
619
+ },
620
+ },
621
+ { PutLastInstructionOutputOnWorkspace: { key: 0 } },
622
+ { CallMethod: { call: { Address: account }, method: "deposit", args: [{ Workspace: { id: 0, offset: null } }] } },
623
+ ];
624
+ return this.execute(instructions, { maxFee });
625
+ }
626
+ /**
627
+ * Claims free testnet XTR from the network's builtin faucet (esmeralda/igor only — there is no
628
+ * such faucet on mainnet). Mirrors both `build_faucet_claim_with_wants` in tari-ootle's
629
+ * `crates/ootle_sdk_core/src/faucet.rs` and (confirmed against its actual pinned-input list)
630
+ * `handle_create_free_test_coins` in `applications/tari_walletd/src/handlers/accounts.rs` — the
631
+ * handler backing the `accounts.create_free_test_coins` walletd RPC that tari-dex's swap-ui
632
+ * calls. Self-funding, so it works even for a brand-new account that doesn't exist on-chain yet:
633
+ * the fee phase itself creates the account, funds it from the faucet
634
+ * (`XTR_FAUCET_COMPONENT_ADDRESS.take(account)`), then pays its own fee out of what it just
635
+ * received.
636
+ *
637
+ * Unlike every other component this class calls, none of the faucet's substates (component,
638
+ * vault, claim resource) get auto-resolved from the instructions by this indexer — each has to
639
+ * be pinned as an explicit transaction input (confirmed empirically: submitting with only the
640
+ * claim resource pinned rejected with "component_...0000 not found" at the `take` call).
641
+ * `provider.resolveInputs()` fills in each one's current version.
642
+ *
643
+ * The faucet vault is a single shared substate every claimant on the testnet contends for, so
644
+ * the version pinned by `resolveInputs()` routinely goes stale between resolution and consensus
645
+ * (confirmed empirically: "Lock failure: Substate vault_...:8 is DOWN" — someone else's claim
646
+ * landed first). This isn't a bug to fix, just contention to ride out: catch that specific
647
+ * rejection and retry with freshly-resolved versions and a new transaction id.
648
+ *
649
+ * Retries back off (`retryDelayMs * (attempt + 1)`, so 300ms, 600ms, 900ms, ...) rather than
650
+ * resubmitting back-to-back: confirmed empirically that hammering `resolveInputs()` immediately
651
+ * after a rejection can keep handing back the same already-stale version every time (the
652
+ * indexer's own view of a shared, heavily-contended substate can lag behind consensus by more
653
+ * than one round trip takes) — a short, growing pause gives that view time to catch up instead
654
+ * of burning the whole retry budget re-observing the same stale state.
655
+ */
656
+ async claimTestnetXtr(maxFee = 5000n, retries = 10, retryDelayMs = 300) {
657
+ const provider = await this.getProvider();
658
+ const publicKeyHex = bytesToHex(await this.getPublicKey());
659
+ const account = await this.getComponentAddress();
660
+ const maxEpoch = await resolveMaxEpoch(provider);
661
+ for (let attempt = 0;; attempt++) {
662
+ const inputs = await withTimeout(provider.resolveInputs([
663
+ { substate_id: XTR_FAUCET_COMPONENT_ADDRESS, version: null },
664
+ { substate_id: XTR_FAUCET_VAULT_ADDRESS, version: null },
665
+ { substate_id: XTR_FAUCET_CLAIM_RESOURCE_ADDRESS, version: null },
666
+ ]), 15_000, "resolving the faucet's current state");
667
+ const unsignedTx = TransactionBuilder.new(this.network, maxEpoch)
668
+ .withFeeInstructionsBuilder((b) => b
669
+ .createAccount(publicKeyHex)
670
+ .saveVar("faucet_account")
671
+ .callMethod({ componentAddress: XTR_FAUCET_COMPONENT_ADDRESS, methodName: "take" }, [{ Workspace: "faucet_account" }]))
672
+ .feeTransactionPayFromComponent(account, maxFee)
673
+ .withInputs(inputs)
674
+ .buildUnsignedTransaction();
675
+ try {
676
+ const result = await withTimeout(sendTransaction(provider, this.signer, unsignedTx), 30_000, "submitting the claim");
677
+ // This is typically the first transaction for a brand-new account — recording its
678
+ // resulting versions (the newly-created fee vault included) closes the exact gap that
679
+ // otherwise bites the *next* transaction (see `applyKnownVersions`'s doc comment).
680
+ await recordKnownVersions(result);
681
+ return result;
682
+ }
683
+ catch (e) {
684
+ // "Failed to decode transaction: unexpected type null at position N: expected u64" is a
685
+ // serde failure at the indexer, and the byte offset alone names nothing. The transaction
686
+ // is the only evidence and it is gone once this throws, so attach the field shapes — the
687
+ // u64-typed fields first, since one of them arriving null is what the message describes.
688
+ if (e instanceof Error && /Failed to decode transaction/i.test(e.message)) {
689
+ const t = unsignedTx;
690
+ const shape = [
691
+ `network=${typeof t.network}:${JSON.stringify(t.network)}`,
692
+ `min_epoch=${JSON.stringify(t.min_epoch)}`,
693
+ `max_epoch=${typeof t.max_epoch}:${JSON.stringify(t.max_epoch)}`,
694
+ `nonce=${typeof t.nonce}:${JSON.stringify(t.nonce)}`,
695
+ `dry_run=${JSON.stringify(t.dry_run)}`,
696
+ `is_seal_signer_authorized=${JSON.stringify(t.is_seal_signer_authorized)}`,
697
+ `blobs=${Array.isArray(t.blobs) ? `array[${t.blobs.length}]` : typeof t.blobs}`,
698
+ `inputs=${JSON.stringify(t.inputs)}`,
699
+ `fee_instructions=${JSON.stringify(t.fee_instructions)?.slice(0, 400)}`,
700
+ ].join(" ");
701
+ throw new Error(`${e.message} — tx: ${shape}`);
702
+ }
703
+ // The claim's fee phase calls `createAccount` for *this* account, and `CreateAccount` is
704
+ // create-or-conflict: once the account exists the instruction rejects with "is already UP
705
+ // and conflicts with an existing output". Tari's own walletd reads that exact string as
706
+ // "faucet already claimed" (`applications/tari_walletd/src/handlers/accounts.rs`), so it
707
+ // is reported that way here rather than as a raw consensus rejection — retrying cannot
708
+ // help, and the account is fine.
709
+ if (e instanceof Error && e.message.includes("is already UP and conflicts with an existing output")) {
710
+ throw new Error("This account has already claimed from the faucet — the faucet only pays out once per account.");
711
+ }
712
+ const isStaleVersionRace = e instanceof Error && e.message.includes("Lock failure");
713
+ if (!isStaleVersionRace || attempt >= retries)
714
+ throw e;
715
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs * (attempt + 1)));
716
+ }
717
+ }
718
+ }
719
+ /**
720
+ * Shields (moves from revealed to private/confidential) `amount` of `resourceAddress`, held as
721
+ * a new stealth output owned by this same account. Uses an entirely separate submission
722
+ * pipeline from `execute()`: the SDK's `StealthTransfer` builder produces its own
723
+ * `TransactionEnvelope` directly (via `WalletStealthAuthorizer`/`AuthorizedTransfer`), not
724
+ * through `TransactionBuilder`/`resolveTransaction`/`signTransaction`/`sealTransaction`.
725
+ *
726
+ * The one genuinely new failure mode this adds beyond `send()`: if the extension's service
727
+ * worker is killed *after* the transaction finalizes on-chain but *before* the resulting
728
+ * commitment is written to storage, the shield succeeds financially but the wallet loses its
729
+ * only lead back to that output — worse than a failed `send()`, whose destination is always a
730
+ * known component address. `pendingShieldTransactionIds` (written before polling, cleared only
731
+ * after the commitment record is stored) narrows that window to "killed between finalization
732
+ * and one storage write" and gives `recoverPendingShields()` (background/index.ts) something
733
+ * to reconcile on next launch.
734
+ *
735
+ * `maxFee` defaults far higher than `send()`'s 5000 -- confirmed live that 5000 gets
736
+ * "AcceptFeeRejectRest" with `ExecutionFailure: "Insufficient fees to fund native
737
+ * verification"` (the stealth balance proof / range proof verification costs real compute
738
+ * points beyond the engine's free grace allowance). Unused fee is refunded on success, so
739
+ * erring high just wastes nothing; erring low burns the small fee-intent cost with nothing to
740
+ * show for it, since only the fee half of the transaction gets committed.
741
+ */
742
+ async shield(resourceAddress, amount, maxFee = 50000n, memo, minimumValuePromise = 0n) {
743
+ assertValidMinimumValuePromise(minimumValuePromise, amount);
744
+ const accountId = localAccountId(this.index);
745
+ const provider = await this.getProvider();
746
+ const account = await this.getComponentAddress();
747
+ // Output.destination decodes as a bech32m wallet address (owner_key + view_key), NOT the
748
+ // on-chain component address -- passing `account` here throws "Bech32 decode error" (confirmed
749
+ // live). See getWalletAddress()'s doc comment for why these two addresses are easy to conflate.
750
+ const walletAddress = await this.getWalletAddress();
751
+ const spec = await new StealthTransfer(provider, resourceAddress)
752
+ // StealthTransfer.prepare() auto-adds the revealed account's own substate and any vault
753
+ // addresses embedded in its on-chain state, but never the resource's own substate -- fine
754
+ // for XTR (resource_0101...0101 is engine-special-cased and needs no lock), but any other
755
+ // resource's instructions fail with "SubstateNotFound: resource_... not found" at
756
+ // instruction #1 without it explicitly pinned here. Confirmed directly against prepare()'s
757
+ // source (node_modules/@tari-project/ootle/dist/index.js) -- not assumed from the docs.
758
+ .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
759
+ .spendRevealedInput(account, amount)
760
+ .toStealthOutput(createOutput({ destination: walletAddress, amount, resourceAddress, memo: toMemo(memo), minimumValuePromise }))
761
+ .payFeeFromRevealed(maxFee)
762
+ .prepare();
763
+ const ownCommitment = extractOutputCommitment(spec, 0);
764
+ const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
765
+ // No stealth inputs to unblind for a shield (revealed-only source), so no viewSecret needed.
766
+ // `fromSpec`'s own default crypto is `new WasmStealthCrypto()` -- Network.LocalNet, NOT this
767
+ // account's real network -- which produced an "Invalid transaction signature" server-side
768
+ // rejection (confirmed live) since the balance proof it computes is network-domain-separated.
769
+ // Must match the network StealthTransfer itself used when it built the outputs statement.
770
+ const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
771
+ const envelope = await authorized.seal();
772
+ const transactionId = await submitTransaction(provider, envelope);
773
+ await addPendingShield({ transactionId, accountId, resourceAddress, amount: amount.toString(), ownCommitment, memo });
774
+ try {
775
+ const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the shield transaction");
776
+ await recordKnownVersions(response);
777
+ await recordKnownShieldedOutput(accountId, resourceAddress, ownCommitment, amount, transactionId, memo);
778
+ }
779
+ finally {
780
+ await removePendingShield(transactionId);
781
+ }
782
+ // The commitment and its substate id are returned, not just the transaction id, because they are
783
+ // the only durable handle on the output this created -- and with a non-zero
784
+ // `minimumValuePromise` that substate *is* the proof-of-funds artifact, which a caller cannot
785
+ // reconstruct from a transaction id alone. Returned unconditionally (a zero promise included)
786
+ // rather than only for the proof case, so callers get one shape to handle.
787
+ return { transactionId, commitment: ownCommitment, substateId: stealthUtxoSubstateId(resourceAddress, fromHex(ownCommitment)), minimumValuePromise: minimumValuePromise.toString() };
788
+ }
789
+ /**
790
+ * Proves this account currently controls the stealth output at `substateId` -- e.g. a
791
+ * `minimumValuePromise` proof-of-funds output this account created earlier -- by Schnorr-signing
792
+ * `challenge` with the output's one-time spend key. Spends nothing and reveals nothing about the
793
+ * output beyond what `tari_getSubstate` already shows anyone.
794
+ *
795
+ * `challenge` is signed under a domain tag (`ownershipProof.ts`) disjoint from real transaction
796
+ * signing, so the resulting signature can never be replayed as spend authorization for this or
797
+ * any other output -- this is the entire reason a caller cannot just ask for a raw signature over
798
+ * an arbitrary message.
799
+ *
800
+ * Throws if the substate isn't a live, key-authorized stealth output, or if it isn't actually
801
+ * addressed to this account (checked by re-deriving the expected one-time public key and
802
+ * comparing it to the substate's own `auth.Key` -- the same check `scan_stealth_output` does
803
+ * receiver-side, just run here against a specific caller-supplied id instead of a scan).
804
+ */
805
+ async signOwnershipProof(resourceAddress, substateId, challenge) {
806
+ const provider = await this.getProvider();
807
+ const response = await provider.getSubstate(substateId);
808
+ const substate = response.substate;
809
+ const utxo = substate.Utxo;
810
+ if (!utxo?.output) {
811
+ throw new Error("This output doesn't exist, isn't a stealth output, or has already been spent.");
812
+ }
813
+ const expectedAuthKeyHex = utxo.output.auth.Key;
814
+ if (!expectedAuthKeyHex) {
815
+ throw new Error("This output isn't key-authorized (it's spent by a script condition, not a signature) -- there's no key to prove ownership of.");
816
+ }
817
+ const senderPublicNonce = fromHex(utxo.output.output.public_nonce);
818
+ const oneTimeSecret = stealthDhSecret(this.network, this.ownerSecret, senderPublicNonce);
819
+ try {
820
+ const oneTimePublicKey = publicKeyFromSecretKey(oneTimeSecret);
821
+ if (toHex(oneTimePublicKey) !== expectedAuthKeyHex) {
822
+ throw new Error("This account doesn't control that output.");
823
+ }
824
+ const message = buildOwnershipProofMessage(this.network, resourceAddress, substateId, challenge);
825
+ const sig = schnorrSign(oneTimeSecret, message);
826
+ return { publicKey: toHex(oneTimePublicKey), publicNonce: toHex(sig.public_nonce), signature: toHex(sig.signature) };
827
+ }
828
+ finally {
829
+ oneTimeSecret.fill(0); // a derived per-output secret, not the account's own key -- zeroed anyway, on principle
830
+ }
831
+ }
832
+ /**
833
+ * Proves this account holds a specific `otl_…` wallet address by Schnorr-signing `challenge`
834
+ * with the account's own persistent owner key -- not a per-output derived one, so unlike
835
+ * `signOwnershipProof` this needs no substate lookup and isn't tied to any particular output.
836
+ *
837
+ * Signs under a domain tag (`ownershipProof.ts`) disjoint from real transaction signing, for the
838
+ * same reason `signOwnershipProof` does: this exact key also signs real transactions
839
+ * (`SecretKeyWallet.signTransaction`), so a raw signature over caller-supplied bytes would risk
840
+ * being replayable as spend authorization.
841
+ *
842
+ * A verifier checks the result against the owner key decoded from the wallet address *they*
843
+ * already have in mind (`parseOotleAddress`) -- never against a value this call's caller reports
844
+ * about themselves.
845
+ */
846
+ async signWalletOwnership(challenge) {
847
+ const walletAddress = await this.getWalletAddress();
848
+ const message = buildWalletOwnershipMessage(this.network, walletAddress, challenge);
849
+ const sig = schnorrSign(this.ownerSecret, message);
850
+ return { walletAddress, publicNonce: toHex(sig.public_nonce), signature: toHex(sig.signature) };
851
+ }
852
+ /**
853
+ * Unshields (moves from private/confidential back to revealed) `revealedOutAmount` of this
854
+ * account's shielded balance for `resourceAddress` — see `ShieldedOutputRecord`'s doc comment
855
+ * for why a known local record is the only way to spend one (no client-side scan-by-view-key
856
+ * API exists for stealth UTXOs). Which specific output(s) to spend is decided internally by
857
+ * `resolveUnshieldPlan`'s coin selection (largest-first, spending more than one in the same
858
+ * transaction via repeated `spendStealthInput()` calls if a single output isn't enough) — the
859
+ * caller only supplies an amount, not a commitment.
860
+ *
861
+ * Two `StealthTransfer` builder requirements, confirmed directly against its `validate()`/
862
+ * `emitInstructions()` source (not assumed from the docs alone), shape this method:
863
+ *
864
+ * 1. `toRevealedOutput`/`payFeeFromRevealed` both require a revealed source account already
865
+ * registered via `spendRevealedInput`, which itself requires an amount `> 0` — there is no
866
+ * way to register just the account without withdrawing something. This method withdraws a
867
+ * trivial `1n`-unit "dust" amount for that sole purpose and folds it straight back into the
868
+ * revealed output (`revealedOutAmount + 1n`), so it costs nothing beyond the tx fee.
869
+ * 2. The builder always requires at least one stealth output — a "pure" 100%-revealed spend
870
+ * with zero private remainder cannot be constructed in one step. This method always creates
871
+ * a new stealth output for the selected inputs' total minus `revealedOutAmount` back to this
872
+ * same account, so `resolveUnshieldPlan` guarantees that remainder is always `> 0`.
873
+ *
874
+ * Shares `shield()`'s mid-flight crash safety: the pending-shield ledger entry carries
875
+ * `spentCommitments` so `recoverPendingShields()` can both record the new change output *and*
876
+ * mark the spent ones, even if the service worker dies between finalization and the storage
877
+ * writes.
878
+ */
879
+ async unshield(resourceAddress, revealedOutAmount, maxFee = 100000n, memo) {
880
+ const accountId = localAccountId(this.index);
881
+ const records = await listShieldedOutputs(accountId);
882
+ const { commitments, remainder } = resolveUnshieldPlan(records, resourceAddress, revealedOutAmount);
883
+ const dust = 1n;
884
+ const provider = await this.getProvider();
885
+ const account = await this.getComponentAddress();
886
+ // See shield()'s comment: Output.destination needs the bech32m wallet address, not the
887
+ // on-chain component address.
888
+ const walletAddress = await this.getWalletAddress();
889
+ // See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
890
+ // never adds it on its own.
891
+ let builder = new StealthTransfer(provider, resourceAddress)
892
+ .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
893
+ .spendRevealedInput(account, dust);
894
+ for (const commitment of commitments) {
895
+ builder = builder.spendStealthInput(account, fromHex(commitment));
896
+ }
897
+ const spec = await builder
898
+ .toStealthOutput(createOutput({ destination: walletAddress, amount: remainder, resourceAddress, memo: toMemo(memo) }))
899
+ .toRevealedOutput(revealedOutAmount + dust)
900
+ .payFeeFromRevealed(maxFee)
901
+ .prepare();
902
+ const ownCommitment = extractOutputCommitment(spec, 0);
903
+ const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
904
+ const viewSecret = await this.signer.getViewSecret();
905
+ // See shield()'s comment: must pass this account's real network, not fromSpec's LocalNet default.
906
+ const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { viewSecret, crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
907
+ const envelope = await authorized.seal();
908
+ const transactionId = await submitTransaction(provider, envelope);
909
+ await addPendingShield({
910
+ transactionId,
911
+ accountId,
912
+ resourceAddress,
913
+ amount: remainder.toString(),
914
+ spentCommitments: commitments,
915
+ ownCommitment,
916
+ memo,
917
+ });
918
+ try {
919
+ const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the unshield transaction");
920
+ await recordKnownVersions(response);
921
+ await recordKnownShieldedOutput(accountId, resourceAddress, ownCommitment, remainder, transactionId, memo);
922
+ for (const commitment of commitments) {
923
+ await markShieldedOutputSpent(accountId, commitment);
924
+ }
925
+ }
926
+ finally {
927
+ await removePendingShield(transactionId);
928
+ }
929
+ return { transactionId };
930
+ }
931
+ /**
932
+ * Withdraws `amount` of a Stealth-typed resource (e.g. XTR) from this account's revealed
933
+ * balance and feeds it, as a `Bucket` left on the workspace under `workspaceVarName`, into
934
+ * `followUpInstructions` — all in one signed transaction.
935
+ *
936
+ * This is the *only* way to move Stealth-typed funds into an arbitrary contract call. A plain
937
+ * `CallMethod withdraw` on a Stealth vault is not a standalone-valid instruction: confirmed
938
+ * empirically (isolated to a bare `withdraw` immediately followed by `deposit`, zero other
939
+ * instructions) that `account.execute()` fails it client-side, before ever reaching the
940
+ * network, with a generic `JSON deserialization failed: ... untagged enum TransactionInput`
941
+ * error. Moving Stealth funds anywhere always requires the native `StealthTransfer`
942
+ * instruction, signed via `WalletStealthAuthorizer` — which `execute()`/`TransactionBuilder`
943
+ * never invokes (see this file's own `execute()` doc comment, and the
944
+ * `isStealthTransferInstruction` guard + comment in `background/index.ts`). This method uses
945
+ * the *same* `StealthTransfer` builder + `WalletStealthAuthorizer` pipeline `shield()`/
946
+ * `unshield()` already use, extended with `toRevealedOutputAsBucket`/`andThen` (see
947
+ * `vendor/ootle-patched/README.md`) to leave the revealed output on the workspace instead of
948
+ * auto-depositing it back to this account, and to append the caller's own instructions after
949
+ * it in the same transaction.
950
+ *
951
+ * @param relatedComponents Every *other* component `followUpInstructions` touches (e.g. a
952
+ * DAO contract being called). The `StealthTransfer` builder auto-registers this account's
953
+ * own vaults as tx inputs, but has no way to know what the caller's own follow-up
954
+ * instructions reference — the engine rejects a `CallMethod` touching an unregistered
955
+ * substate with `SubstateNotFound`. Each is registered by address, plus every vault its own
956
+ * state references (the same vault-discovery this account's own address already gets).
957
+ */
958
+ async withdrawStealthAndExecute(resourceAddress, amount, workspaceVarName, followUpInstructions, relatedComponents = [], maxFee = 100000n) {
959
+ if (amount <= 0n)
960
+ throw new Error(`withdrawStealthAndExecute amount must be > 0, got ${amount}`);
961
+ const provider = await this.getProvider();
962
+ const account = await this.getComponentAddress();
963
+ const walletAddress = await this.getWalletAddress();
964
+ // A zero-stealth-output StealthTransferStatement (pure revealed-in, revealed-out-as-bucket)
965
+ // isn't a shape the bundled ootle-wasm@0.37.0 signer can parse -- confirmed live: it throws
966
+ // the same generic "did not match any variant of untagged enum TransactionInput" error inside
967
+ // signTransaction's own WASM call, regardless of how balance_proof is represented (present,
968
+ // null, or omitted -- all three tried). Every other stealth-transfer path in this file
969
+ // (shield/unshield/sendPrivately) always includes >=1 real stealth output for exactly this
970
+ // reason. Rather than fight an apparent WASM-binary limitation, this keeps a tiny (1 µ-unit)
971
+ // stealth output back to this account -- the same proven-working shape -- alongside the real
972
+ // amount as a revealed bucket.
973
+ const dust = 1n;
974
+ let builder = new StealthTransfer(provider, resourceAddress)
975
+ .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
976
+ .spendRevealedInput(account, amount + dust)
977
+ .toStealthOutput(createOutput({ destination: walletAddress, amount: dust, resourceAddress }))
978
+ .toRevealedOutputAsBucket(amount, workspaceVarName)
979
+ .andThen(followUpInstructions);
980
+ for (const component of relatedComponents) {
981
+ builder = builder.withBuilder((b) => b.addInput({ substate_id: component, version: null }));
982
+ for (const vaultId of await getVaultIdsForAccount(provider, component)) {
983
+ builder = builder.withBuilder((b) => b.addInput({ substate_id: vaultId, version: null }));
984
+ }
985
+ }
986
+ const spec = await builder.payFeeFromRevealed(maxFee).prepare();
987
+ const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
988
+ const viewSecret = await this.signer.getViewSecret();
989
+ const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { viewSecret, crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
990
+ const envelope = await authorized.seal();
991
+ const transactionId = await submitTransaction(provider, envelope);
992
+ const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the transaction");
993
+ await recordKnownVersions(response);
994
+ return { transactionId };
995
+ }
996
+ /**
997
+ * Funds an HTLC (hashed timelock contract): creates a stealth output for `amount` of
998
+ * `resourceAddress`, gated not by a normal one-time stealth key but by a two-leaf TIP-0006
999
+ * condition tree (`htlcConditions` in `./htlc.ts`) — a claim path admissible only to
1000
+ * `claimantWalletAddress` (revealing the SHA-256 preimage of `hashLockHex`, before
1001
+ * `refundEpoch`), and a refund path admissible only to this account (at/after `refundEpoch`).
1002
+ * `htlcConditions`'s own doc comment covers the exact leaf shapes.
1003
+ *
1004
+ * This account never needs (and must never be given) the actual preimage — only its hash. The
1005
+ * caller resolves `refundEpoch` from a desired wall-clock deadline via the provider/network's
1006
+ * current epoch, the same as any other epoch-relative on-chain deadline.
1007
+ *
1008
+ * Returns the full `conditions` tree alongside the transaction id and this output's own
1009
+ * commitment: only the tree's **root** is committed on-chain, so the claimant needs the exact
1010
+ * leaves — out of band, via whatever swap protocol coordinates the two sides — to later reveal
1011
+ * the claim leaf.
1012
+ *
1013
+ * Also returns `outputMask` (hex): this account funded the output, so it's the one place the
1014
+ * blinding mask is ever available to it without decryption — `encrypted_data` is encrypted for
1015
+ * `claimantWalletAddress`'s view key, not this account's, so if the claim path is never taken
1016
+ * and this account later calls `htlcRefund`, it cannot decrypt the output the normal way (the
1017
+ * way `htlcClaim`'s claimant can, since the output is addressed to *them*). Retaining this mask
1018
+ * now — alongside the known `amount` — is what makes an undecryptable refund possible at all.
1019
+ * `htlcRefund`'s own doc comment covers the rest.
1020
+ *
1021
+ * `destination` is set to the claimant's own wallet address (not this account's) so the
1022
+ * claimant can independently decrypt and verify the funded amount themselves, the same as any
1023
+ * other stealth payment addressed to them — spend authority is governed entirely separately, by
1024
+ * `payTo`.
1025
+ */
1026
+ async htlcFund(resourceAddress, amount, claimantWalletAddress, hashLockHex, refundEpoch, maxFee = 50000n) {
1027
+ const provider = await this.getProvider();
1028
+ const account = await this.getComponentAddress();
1029
+ const walletAddress = await this.getWalletAddress();
1030
+ const claimant = parseOotleAddress(claimantWalletAddress);
1031
+ const refunder = parseOotleAddress(walletAddress);
1032
+ const conditions = htlcConditions({
1033
+ hashLockHex,
1034
+ refundEpoch,
1035
+ claimantPublicKeyHex: toHex(claimant.owner_key),
1036
+ refunderPublicKeyHex: toHex(refunder.owner_key),
1037
+ });
1038
+ // See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
1039
+ // never adds it on its own.
1040
+ const spec = await new StealthTransfer(provider, resourceAddress)
1041
+ .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
1042
+ .spendRevealedInput(account, amount)
1043
+ .toStealthOutput(createOutput({ destination: claimantWalletAddress, amount, resourceAddress, payTo: { Conditions: conditions } }))
1044
+ .payFeeFromRevealed(maxFee)
1045
+ .prepare();
1046
+ const ownCommitment = extractOutputCommitment(spec, 0);
1047
+ const outputMask = spec.outputMask.toHex();
1048
+ const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
1049
+ // No stealth inputs to unblind (revealed-only source), so no viewSecret needed -- same as shield().
1050
+ const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
1051
+ const envelope = await authorized.seal();
1052
+ const transactionId = await submitTransaction(provider, envelope);
1053
+ const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the HTLC funding transaction");
1054
+ await recordKnownVersions(response);
1055
+ return { transactionId, conditions, ownCommitment, outputMask };
1056
+ }
1057
+ /**
1058
+ * Claims a funded HTLC (the counterpart to `htlcFund`): reveals the claim leaf's SHA-256
1059
+ * preimage to spend the script-path-locked output, moving `amount` into a brand-new, normal
1060
+ * (freely key-spendable) stealth output owned by this account.
1061
+ *
1062
+ * This account must be the intended claimant — `htlcFund` addressed the output to this
1063
+ * account's own wallet address, so it can unblind it the normal way: fetch the `utxo_...`
1064
+ * substate and decrypt with this account's own view secret, exactly like `claimPrivatePayment`
1065
+ * does for any other incoming stealth payment. `conditions` must be the *exact* two-leaf tree
1066
+ * `htlcFund` returned (or was handed out of band) — the wrong tree produces a script-path
1067
+ * witness whose condition root won't match the output's committed `SpendAuthorization::Script`
1068
+ * root, and `buildStealthTransferStatement`/the network both reject the spend.
1069
+ *
1070
+ * Fees are paid from this account's own existing revealed balance via `pay_fee` (same as any
1071
+ * other transaction this wallet submits), not from the claimed HTLC funds — a transaction's fee
1072
+ * instructions execute in a separate phase *before* its main instructions, so the claim's own
1073
+ * `StealthTransfer` instruction (a main instruction) can't fund its own fee even in principle.
1074
+ *
1075
+ * Bypasses `StealthTransfer`/`WalletStealthAuthorizer` entirely — that builder pipeline only
1076
+ * ever produces key-path stealth inputs (a one-time DH signature per input), which doesn't
1077
+ * apply here: this spend's authorization is the revealed script-path leaf itself (its
1078
+ * `AccessRule` atom, satisfied by this account's normal transaction signature), not a stealth
1079
+ * spend key. `buildStealthTransferStatement` (tari-project/tari-ootle#2431) produces the
1080
+ * complete statement — inputs/outputs/balance proof/covenant claims — in one call from the
1081
+ * unblinded mask+value and the two witnesses below, so there is nothing left for the builder
1082
+ * pipeline to add.
1083
+ */
1084
+ async htlcClaim(resourceAddress, commitmentHex, conditions, preimageHex, maxFee = 50000n) {
1085
+ if (!/^[0-9a-f]{64}$/i.test(preimageHex)) {
1086
+ throw new Error(`htlcClaim: preimageHex must be exactly 64 hex characters (32 bytes), got ${JSON.stringify(preimageHex)}`);
1087
+ }
1088
+ const provider = await this.getProvider();
1089
+ const account = await this.getComponentAddress();
1090
+ const walletAddress = await this.getWalletAddress();
1091
+ const commitment = fromHex(commitmentHex);
1092
+ const substateId = stealthUtxoSubstateId(resourceAddress, commitment);
1093
+ const substate = await provider.getSubstate(substateId);
1094
+ const viewSecret = await this.signer.getViewSecret();
1095
+ const decrypted = await decryptOwnedUtxo(new WasmStealthCrypto(this.network), viewSecret, substate, substateId);
1096
+ if (!decrypted) {
1097
+ throw new Error("This HTLC output doesn't belong to your account, or wasn't found on-chain — it may already be claimed or refunded.");
1098
+ }
1099
+ const claimLeaf = conditions[0];
1100
+ if (!claimLeaf)
1101
+ throw new Error("htlcClaim: conditions must be the two-leaf [claim, refund] tree htlcFund returned");
1102
+ const statementJson = buildHtlcSpendStatement({
1103
+ network: this.network,
1104
+ conditions,
1105
+ leaf: claimLeaf,
1106
+ data: fromHex(preimageHex),
1107
+ mask: decrypted.mask.toHex(),
1108
+ value: decrypted.value,
1109
+ destinationWalletAddress: walletAddress,
1110
+ resourceAddress,
1111
+ });
1112
+ const transactionId = await submitHtlcSpend({
1113
+ provider,
1114
+ signer: this.signer,
1115
+ network: this.network,
1116
+ account,
1117
+ resourceAddress,
1118
+ substateId,
1119
+ statementJson,
1120
+ maxFee,
1121
+ timeoutLabel: "submitting the HTLC claim transaction",
1122
+ });
1123
+ return { transactionId };
1124
+ }
1125
+ /**
1126
+ * Refunds an HTLC this account itself funded (via `htlcFund`), once `refundEpoch` has passed:
1127
+ * reveals the refund leaf to spend the output back into a brand-new, normal (freely
1128
+ * key-spendable) stealth output owned by this account.
1129
+ *
1130
+ * Unlike `htlcClaim`, this account **cannot** decrypt the output on-chain — `htlcFund` addressed
1131
+ * it to the claimant's wallet, so `encrypted_data` is encrypted for the claimant's view key, not
1132
+ * this account's. `amount` and `outputMaskHex` must be exactly what `htlcFund` returned (or
1133
+ * otherwise already known — this account chose `amount` itself when funding, and `outputMaskHex`
1134
+ * is `htlcFund`'s own return value for exactly this purpose): reconstructing the commitment from
1135
+ * a wrong mask or amount fails cleanly (the resulting input just doesn't match the on-chain
1136
+ * commitment, so the network rejects the spend) rather than silently, but there is no recovery
1137
+ * path if this account discarded the mask — see `htlcFund`'s doc comment.
1138
+ *
1139
+ * Otherwise identical to `htlcClaim`: same fee-from-own-balance reasoning, same
1140
+ * `buildStealthTransferStatement`-in-one-call approach, same bypass of
1141
+ * `StealthTransfer`/`WalletStealthAuthorizer`.
1142
+ */
1143
+ async htlcRefund(resourceAddress, commitmentHex, conditions, amount, outputMaskHex, maxFee = 50000n) {
1144
+ const provider = await this.getProvider();
1145
+ const account = await this.getComponentAddress();
1146
+ const walletAddress = await this.getWalletAddress();
1147
+ const commitment = fromHex(commitmentHex);
1148
+ const substateId = stealthUtxoSubstateId(resourceAddress, commitment);
1149
+ const refundLeaf = conditions[1];
1150
+ if (!refundLeaf)
1151
+ throw new Error("htlcRefund: conditions must be the two-leaf [claim, refund] tree htlcFund returned");
1152
+ const statementJson = buildHtlcSpendStatement({
1153
+ network: this.network,
1154
+ conditions,
1155
+ leaf: refundLeaf,
1156
+ data: new Uint8Array(0), // The refund leaf has no HashLock atom -- no witness data needed.
1157
+ mask: outputMaskHex,
1158
+ value: amount,
1159
+ destinationWalletAddress: walletAddress,
1160
+ resourceAddress,
1161
+ });
1162
+ const transactionId = await submitHtlcSpend({
1163
+ provider,
1164
+ signer: this.signer,
1165
+ network: this.network,
1166
+ account,
1167
+ resourceAddress,
1168
+ substateId,
1169
+ statementJson,
1170
+ maxFee,
1171
+ timeoutLabel: "submitting the HTLC refund transaction",
1172
+ });
1173
+ return { transactionId };
1174
+ }
1175
+ /**
1176
+ * Sends `amount` of this account's shielded balance for `resourceAddress` directly to
1177
+ * `recipientWalletAddress` — a private-to-private transfer, unlike `shield()` (which always
1178
+ * targets this same account's own wallet address). The recipient never appears on-chain in the
1179
+ * clear; only they (holding the matching view key) can discover the payment, and only by being
1180
+ * told the resulting commitment out of band — same "no scan API" caveat as everywhere else
1181
+ * stealth outputs are spent. Which specific output(s) to spend is decided internally by
1182
+ * `resolveSendPrivatelyPlan`'s coin selection (largest-first, spending more than one in the same
1183
+ * transaction via repeated `spendStealthInput()` calls if a single output isn't enough) — the
1184
+ * caller only supplies an amount, not a commitment.
1185
+ *
1186
+ * Unlike `unshield()`, sending the *entire* selected total with no remainder is possible in one
1187
+ * step here: the builder's "at least one stealth output" requirement is trivially satisfied by
1188
+ * the recipient's own output, with no need for a same-account dust/change output to satisfy it.
1189
+ * Change (if `amount` is less than the selected inputs' total) is a second stealth output back
1190
+ * to this account.
1191
+ *
1192
+ * The fee-paying "dust" trick from `unshield()` still applies for the same reason: pay_fee and
1193
+ * (when there's change) the *concept* of "this account received a new output" both need a
1194
+ * revealed source account registered via `spendRevealedInput`, which requires amount `> 0`.
1195
+ *
1196
+ * Crash-safety: this account's own change commitment (if any) is read directly from the
1197
+ * locally-built outputs statement, *not* inferred from the finalized transaction's
1198
+ * `up_substates` — those also contain the recipient's brand-new stealth output, and naively
1199
+ * grabbing "the first new stealth output" would risk recording the recipient's payment as our
1200
+ * own. See `PendingShield.ownCommitment`'s doc comment.
1201
+ */
1202
+ async sendPrivately(resourceAddress, recipientWalletAddress, amount, maxFee = 100000n, memo, minimumValuePromise = 0n) {
1203
+ assertValidMinimumValuePromise(minimumValuePromise, amount);
1204
+ const accountId = localAccountId(this.index);
1205
+ const records = await listShieldedOutputs(accountId);
1206
+ const { commitments, changeAmount } = resolveSendPrivatelyPlan(records, resourceAddress, amount);
1207
+ const dust = 1n;
1208
+ const provider = await this.getProvider();
1209
+ const account = await this.getComponentAddress();
1210
+ const ownWalletAddress = await this.getWalletAddress();
1211
+ // See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
1212
+ // never adds it on its own.
1213
+ let builder = new StealthTransfer(provider, resourceAddress)
1214
+ .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
1215
+ .spendRevealedInput(account, dust);
1216
+ for (const commitment of commitments) {
1217
+ builder = builder.spendStealthInput(account, fromHex(commitment));
1218
+ }
1219
+ // The promise rides on the recipient's output only. Putting one on the change output would
1220
+ // publish a floor on this account's own remaining private balance -- an unrelated disclosure the
1221
+ // caller never asked for, and one the recipient has no interest in.
1222
+ builder = builder.toStealthOutput(createOutput({ destination: recipientWalletAddress, amount, resourceAddress, memo: toMemo(memo), minimumValuePromise }));
1223
+ if (changeAmount > 0n) {
1224
+ builder = builder.toStealthOutput(createOutput({ destination: ownWalletAddress, amount: changeAmount, resourceAddress }));
1225
+ }
1226
+ const spec = await builder.toRevealedOutput(dust).payFeeFromRevealed(maxFee).prepare();
1227
+ // Output 0 is the recipient's; output 1 (only present when there's change) is ours. The
1228
+ // recipient has no way to discover their new output on their own (no scan-by-view-key API
1229
+ // exists) -- this commitment must be handed back to the caller so it can be shared with them
1230
+ // out of band; without it, the payment is invisible to them even though it succeeded on-chain.
1231
+ const recipientCommitment = extractOutputCommitment(spec, 0);
1232
+ const ownCommitment = changeAmount > 0n ? extractOutputCommitment(spec, 1) : undefined;
1233
+ const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
1234
+ const viewSecret = await this.signer.getViewSecret();
1235
+ const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { viewSecret, crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
1236
+ const envelope = await authorized.seal();
1237
+ const transactionId = await submitTransaction(provider, envelope);
1238
+ await addPendingShield({
1239
+ transactionId,
1240
+ accountId,
1241
+ resourceAddress,
1242
+ amount: changeAmount.toString(),
1243
+ spentCommitments: commitments,
1244
+ ownCommitment,
1245
+ });
1246
+ try {
1247
+ const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the private send");
1248
+ await recordKnownVersions(response);
1249
+ if (ownCommitment)
1250
+ await recordKnownShieldedOutput(accountId, resourceAddress, ownCommitment, changeAmount, transactionId);
1251
+ for (const commitment of commitments) {
1252
+ await markShieldedOutputSpent(accountId, commitment);
1253
+ }
1254
+ }
1255
+ finally {
1256
+ await removePendingShield(transactionId);
1257
+ }
1258
+ return {
1259
+ transactionId,
1260
+ recipientCommitment,
1261
+ recipientSubstateId: stealthUtxoSubstateId(resourceAddress, fromHex(recipientCommitment)),
1262
+ minimumValuePromise: minimumValuePromise.toString(),
1263
+ };
1264
+ }
1265
+ /**
1266
+ * The recipient-side counterpart to `sendPrivately()`'s commitment hand-off: given a commitment
1267
+ * shared out of band, fetches the corresponding `utxo_{resource}_{commitment}` substate and
1268
+ * tries to decrypt it with this account's own view secret. `decryptOwnedUtxo` swallows the
1269
+ * "not ours" throw and returns `null` instead, so a wrong/foreign commitment fails cleanly here
1270
+ * rather than looking like a network error. Success both *proves* ownership and recovers the
1271
+ * amount in one step (no need to be told the amount separately) -- recorded exactly like a
1272
+ * self-shield's output, so it then just shows up in this account's private balance and
1273
+ * shielded-outputs picker.
1274
+ */
1275
+ async claimPrivatePayment(resourceAddress, commitmentHex) {
1276
+ const accountId = localAccountId(this.index);
1277
+ const existing = await listShieldedOutputs(accountId);
1278
+ if (existing.some((r) => r.resourceAddress === resourceAddress && r.commitment === commitmentHex)) {
1279
+ throw new Error("You've already claimed this payment.");
1280
+ }
1281
+ const provider = await this.getProvider();
1282
+ const substateId = stealthUtxoSubstateId(resourceAddress, fromHex(commitmentHex));
1283
+ const substate = await provider.getSubstate(substateId);
1284
+ const viewSecret = await this.signer.getViewSecret();
1285
+ const decrypted = await decryptOwnedUtxo(new WasmStealthCrypto(this.network), viewSecret, substate, substateId);
1286
+ if (!decrypted) {
1287
+ throw new Error("This commitment doesn't belong to your account, or wasn't found on-chain.");
1288
+ }
1289
+ const memo = fromMemo(decrypted.memo);
1290
+ // No originating transaction id is available from a bare commitment -- the substate id is
1291
+ // itself a stable, deterministic reference back to this exact output, so it fills that slot.
1292
+ await recordKnownShieldedOutput(accountId, resourceAddress, commitmentHex, decrypted.value, substateId, memo);
1293
+ return { amount: decrypted.value, memo };
1294
+ }
1295
+ /**
1296
+ * This account's unspent stealth outputs, newest first, optionally filtered to one resource.
1297
+ *
1298
+ * This local ledger *is* the account's private position -- see `ShieldedOutputRecord`'s doc
1299
+ * comment for why it has to be: a stealth output is a freestanding
1300
+ * `utxo_{resource}_{commitment}` substate, not an entry in any vault, and there is no
1301
+ * scan-by-view-key API that could rediscover one from the chain alone given only its commitment.
1302
+ * Anything not in here is, as far as this wallet is concerned, not spendable.
1303
+ */
1304
+ async listUnspentShieldedOutputs(resourceAddress) {
1305
+ return selectUnspentShieldedOutputs(await listShieldedOutputs(localAccountId(this.index)), resourceAddress);
1306
+ }
1307
+ /**
1308
+ * Per-resource totals over `listUnspentShieldedOutputs()`, with each resource's real on-chain
1309
+ * `divisibility`/symbol/name folded in (the same lookup `getBalances()` does, via the shared
1310
+ * `fetchResourceMetadata` helper -- a private balance has to be renderable on its own, without a
1311
+ * caller cross-referencing `getBalances()` for the decimals).
1312
+ *
1313
+ * Distinct from `getBalances()`'s `confidentialAmount` in two ways worth being precise about:
1314
+ * it counts only these freestanding stealth outputs (not a Confidential *vault*'s own
1315
+ * commitments map, which `getBalances()` also decrypts and adds in), and it reports a resource
1316
+ * whose only holding is private, which `getBalances()` only surfaces via its
1317
+ * `synthesizeShieldedOnlyBalances` path. For "what can I spend privately right now", this is the
1318
+ * authoritative number, because it is exactly what `resolveUnshieldPlan`/
1319
+ * `resolveSendPrivatelyPlan` select from.
1320
+ *
1321
+ * One network round trip (the resource metadata batch); the amounts themselves are local.
1322
+ */
1323
+ async getPrivateBalances() {
1324
+ const holdings = summarizePrivateHoldings(await this.listUnspentShieldedOutputs());
1325
+ if (holdings.length === 0)
1326
+ return [];
1327
+ const provider = await this.getProvider();
1328
+ const { divisibilityByResource, symbolByResource, nameByResource } = await fetchResourceMetadata(provider, holdings.map((h) => h.resourceAddress));
1329
+ return holdings.map((h) => ({
1330
+ ...h,
1331
+ divisibility: divisibilityByResource.get(h.resourceAddress) ?? 0,
1332
+ symbol: symbolByResource.get(h.resourceAddress) ?? null,
1333
+ name: nameByResource.get(h.resourceAddress) ?? null,
1334
+ }));
1335
+ }
1336
+ /**
1337
+ * Automatic counterpart to `claimPrivatePayment()`: instead of requiring the recipient be told a
1338
+ * commitment out of band, walks the indexer's recent-transaction history looking for
1339
+ * `StealthTransfer` outputs this account can decrypt with its own view key (see
1340
+ * `scanTransactionsForOwnedOutputs`'s doc comment for why the data needed to do this is present
1341
+ * even in the *pruned* form `listRecentTransactions` returns).
1342
+ *
1343
+ * Cursor-based (`privatePaymentScanCursors[accountId]`, the newest transaction id seen last time)
1344
+ * so this can run cheaply and opportunistically on every `popup-get-status` — see
1345
+ * `buildStatus()`'s `recoverPendingShields()` call for the same pattern. Walks backward in pages
1346
+ * of `pageSize` (oldest-paging via `last_id`, per the indexer's own "recent" convention) up to
1347
+ * `maxPages`, stopping early once it reaches the transaction id it left off at last time. A very
1348
+ * active chain producing more than `maxPages * pageSize` new transactions between scans will
1349
+ * only be caught up partway; the next scan resumes from the same cursor and continues.
1350
+ */
1351
+ async scanForPrivatePayments(maxPages, pageSize = 50) {
1352
+ const accountId = localAccountId(this.index);
1353
+ const provider = await this.getProvider();
1354
+ const viewSecret = await this.signer.getViewSecret();
1355
+ const crypto = new WasmStealthCrypto(this.network);
1356
+ const known = await listShieldedOutputs(accountId);
1357
+ const knownCommitments = new Set(known.map((r) => r.commitment));
1358
+ const previousCursor = await getPrivatePaymentScanCursor(accountId);
1359
+ let newestSeen = null;
1360
+ let lastId = null;
1361
+ const found = [];
1362
+ // Whether this pass actually walked all the way back to `previousCursor` (or there was none to
1363
+ // reach -- the very first scan ever). Only true in that case is it safe to advance the cursor:
1364
+ // if `maxPages` runs out first, advancing anyway would silently and PERMANENTLY skip the
1365
+ // unscanned gap between here and the old cursor -- the next scan would start from the new
1366
+ // (already-advanced) cursor and never revisit it. Leaving the cursor where it was instead means
1367
+ // the next opportunistic scan just re-tries the same catch-up (redundant work, not data loss);
1368
+ // see this method's own doc comment ("the next scan resumes from the same cursor").
1369
+ // "No cursor" does not mean "we have the whole history" — after a restore it means the
1370
+ // opposite, that nothing is known yet. Starting `true` let the cursor be advanced to the
1371
+ // newest transaction after only a few pages, writing off every stealth output older than that
1372
+ // window: the next scan starts from the new cursor and never looks back, so those outputs
1373
+ // become unrecoverable. Only reaching the old cursor, or running out of history, counts.
1374
+ const isFirstScan = previousCursor === null;
1375
+ const pageBudget = maxPages ?? (isFirstScan ? 400 : 3);
1376
+ let reachedCursor = false;
1377
+ pages: for (let page = 0; page < pageBudget; page++) {
1378
+ const { transactions } = await provider.listRecentTransactions({ limit: pageSize, last_id: lastId, source: null });
1379
+ if (transactions.length === 0) {
1380
+ reachedCursor = true; // the indexer's whole history fit before ever finding previousCursor
1381
+ break;
1382
+ }
1383
+ if (page === 0)
1384
+ newestSeen = transactions[0].transaction_id;
1385
+ for (const entry of transactions) {
1386
+ if (entry.transaction_id === previousCursor) {
1387
+ reachedCursor = true;
1388
+ break pages;
1389
+ }
1390
+ const newlyFound = await scanTransactionsForOwnedOutputs(crypto, viewSecret, [entry], knownCommitments);
1391
+ for (const raw of newlyFound) {
1392
+ // scanTransactionsForOwnedOutputs' `memo` is the raw JSON-encoded Memo union (see
1393
+ // ScannedStealthOutput's doc comment) -- decode once here so both the persisted record
1394
+ // and this method's own return value carry plain text, not JSON, memo consumers never
1395
+ // need to know about `fromMemo()` themselves.
1396
+ const output = { ...raw, memo: fromMemo(raw.memo) };
1397
+ await recordKnownShieldedOutput(accountId, output.resourceAddress, output.commitment, output.amount, output.transactionId, output.memo);
1398
+ knownCommitments.add(output.commitment);
1399
+ found.push(output);
1400
+ }
1401
+ }
1402
+ lastId = transactions[transactions.length - 1].transaction_id;
1403
+ }
1404
+ if (newestSeen && reachedCursor)
1405
+ await setPrivatePaymentScanCursor(accountId, newestSeen);
1406
+ return { claimed: found.length, found };
1407
+ }
1408
+ }
1409
+ /**
1410
+ * Reads the commitment of the output at `outputIndex` (in the order `toStealthOutput()` was
1411
+ * called) directly from the outputs statement `StealthTransfer.prepare()` already built —
1412
+ * generated client-side, so it's known *before* submission, not inferred afterward by scanning
1413
+ * the finalized transaction's `up_substates`. That scan-based approach is unambiguous when a
1414
+ * transfer only ever creates one new stealth output belonging to this account (shield, unshield),
1415
+ * but breaks down the moment a transfer also creates a *different* account's new stealth output
1416
+ * in the same transaction (a private send's recipient output) — grabbing "the first new stealth
1417
+ * output" from `up_substates` would then risk attributing someone else's payment to us.
1418
+ */
1419
+ /**
1420
+ * Builds a complete `StealthTransferStatement` (as its final compact JSON string) that spends one
1421
+ * script-path-locked HTLC UTXO into one brand-new, normal (key-spendable) stealth output — the
1422
+ * shared core of `htlcClaim`/`htlcRefund`, which differ only in which leaf they reveal and how
1423
+ * they come by the input's `mask`/`value` (on-chain decryption for a claim; retained from funding
1424
+ * time for a refund, since the funder can't decrypt an output addressed to the claimant).
1425
+ *
1426
+ * Every JSON fragment here is spliced from the raw strings `buildScriptPathWitness`/
1427
+ * `createStealthOutputWitness` themselves return, never `JSON.parse`d and rebuilt — both carry
1428
+ * u64 fields (amounts, epochs) that can exceed `Number.MAX_SAFE_INTEGER`, and a parse/restringify
1429
+ * round-trip through a JS number would silently corrupt them. `value` is spliced in from `params`
1430
+ * via template-literal interpolation of a `bigint`, which stringifies to its exact decimal digits
1431
+ * with no such risk.
1432
+ */
1433
+ // Exported (unlike this file's other module-level helpers) specifically so its wasm-assembly
1434
+ // logic can be exercised directly in tests without needing a full account + fake provider --
1435
+ // see htlc.test.ts's real-crypto round-trip test.
1436
+ export function buildHtlcSpendStatement(params) {
1437
+ const witnessResultJson = buildScriptPathWitness(JSON.stringify(params.conditions), JSON.stringify(params.leaf), params.data);
1438
+ // `witnessResultJson` is exactly `{"witness":...,"condition_root":"..."}` -- strip the outer
1439
+ // braces and splice the inner `"witness":...,"condition_root":...` content directly into the
1440
+ // input entry below, rather than parsing and re-serializing the object.
1441
+ const witnessInner = witnessResultJson.slice(1, -1);
1442
+ const inputEntry = `{"mask_and_value":{"value":${params.value},"mask":"${params.mask}"},${witnessInner}}`;
1443
+ const destination = parseOotleAddress(params.destinationWalletAddress);
1444
+ const outputWitnessJson = createStealthOutputWitness(params.network, destination.owner_key, destination.view_key, params.value, params.resourceAddress, null, null, null, // pay_to_json: null -- default StealthPublicKey, a normal one-time key-spendable output.
1445
+ 0n);
1446
+ const statementJson = buildStealthTransferStatement(`[${inputEntry}]`, 0n, `[${outputWitnessJson}]`, 0n);
1447
+ // Fail fast locally rather than spend a network round trip on a malformed statement -- the same
1448
+ // check tari-ootle's own `build_stealth_transfer_statement` unit tests run before trusting its
1449
+ // output (crates/ootle_wasm/core/src/stealth/transfer.rs).
1450
+ validateStealthTransfer(statementJson, null);
1451
+ return statementJson;
1452
+ }
1453
+ /**
1454
+ * Submits a hand-built HTLC claim/refund transaction: one `StealthTransfer` instruction carrying
1455
+ * `statementJson` verbatim, spending `substateId`, fee paid from `account`'s own revealed balance.
1456
+ *
1457
+ * Deliberately bypasses `TransactionBuilder`'s usual `WalletStealthAuthorizer` companion — that
1458
+ * pipeline only ever produces key-path stealth-input signatures, which don't apply to a
1459
+ * script-path spend (whose authorization is the revealed leaf's own `AccessRule`, satisfied by
1460
+ * this account's ordinary transaction signature) — `signTransaction`/`sealTransaction` alone are
1461
+ * both necessary and sufficient here, the same pipeline any plain `CallMethod`-only transaction
1462
+ * uses.
1463
+ */
1464
+ async function submitHtlcSpend(params) {
1465
+ const maxEpoch = await resolveMaxEpoch(params.provider);
1466
+ const builder = TransactionBuilder.new(params.network, maxEpoch)
1467
+ .addInput({ substate_id: params.substateId, version: null })
1468
+ .addInput({ substate_id: params.account, version: null })
1469
+ .feeTransactionPayFromComponent(params.account, params.maxFee)
1470
+ .addInstruction(
1471
+ // `statement` is spliced as a raw JSON fragment (see buildHtlcSpendStatement), the same
1472
+ // technique `@tari-project/ootle`'s own `statementAsWire` uses internally for the piecemeal
1473
+ // StealthTransfer builder path -- this cast mirrors that one exactly.
1474
+ {
1475
+ StealthTransfer: {
1476
+ resource_address_ref: { Address: params.resourceAddress },
1477
+ statement: { __ootleRawJson: params.statementJson },
1478
+ revealed_input_bucket: null,
1479
+ },
1480
+ });
1481
+ for (const vaultId of await getVaultIdsForAccount(params.provider, params.account)) {
1482
+ builder.addInput({ substate_id: vaultId, version: null });
1483
+ }
1484
+ const unsignedTx = await resolveTransaction(params.provider, builder.buildUnsignedTransaction());
1485
+ const signed = await signTransaction([params.signer], unsignedTx);
1486
+ const envelope = await sealTransaction(signed);
1487
+ const transactionId = await submitTransaction(params.provider, envelope);
1488
+ const response = await withTimeout(pollTransactionResult(params.provider, transactionId), 60_000, params.timeoutLabel);
1489
+ await recordKnownVersions(response);
1490
+ return transactionId;
1491
+ }
1492
+ function extractOutputCommitment(spec, outputIndex) {
1493
+ const parsed = spec.statement.outputsStatement.parsed();
1494
+ const commitment = parsed.outputs?.[outputIndex]?.output?.commitment;
1495
+ if (typeof commitment !== "string" || commitment.length === 0) {
1496
+ throw new Error(`Failed to read output ${outputIndex}'s commitment from the locally-built outputs statement.`);
1497
+ }
1498
+ return commitment;
1499
+ }
1500
+ /**
1501
+ * Synthesizes a `TokenBalance` entry for each resource whose only balance is a shielded output
1502
+ * with no on-chain vault at all (e.g. redeemed via the "Advanced" unshield flow from someone
1503
+ * else's shared commitment) -- these never appear in `getBalances()`'s vault-derived `parsed`
1504
+ * list, since they're freestanding `utxo_{resource}_{commitment}` substates, not vault entries.
1505
+ * Skips any resource already covered by `parsedResources` (that one gets its shielded amount
1506
+ * folded into its existing entry instead, in `getBalances()` itself).
1507
+ */
1508
+ export function synthesizeShieldedOnlyBalances(parsedResources, shieldedByResource, divisibilityByResource, symbolByResource, nameByResource) {
1509
+ const balances = [];
1510
+ for (const [resourceAddress, amount] of shieldedByResource) {
1511
+ if (parsedResources.has(resourceAddress))
1512
+ continue;
1513
+ balances.push({
1514
+ resourceAddress,
1515
+ kind: "Stealth",
1516
+ amount: 0n,
1517
+ confidentialAmount: amount,
1518
+ confidentialDecryptFailures: 0,
1519
+ divisibility: divisibilityByResource.get(resourceAddress) ?? 0,
1520
+ symbol: symbolByResource.get(resourceAddress) ?? null,
1521
+ name: nameByResource.get(resourceAddress) ?? null,
1522
+ nonFungibleTokenIds: null,
1523
+ });
1524
+ }
1525
+ return balances;
1526
+ }
1527
+ /**
1528
+ * A display string for one `NonFungibleId` (the tagged union `{U256} | {String} | {Uint32} |
1529
+ * {Uint64}`, or occasionally a bare primitive) -- just the value, since the variant tag is rarely
1530
+ * interesting to a user looking at a list of tokens they hold. Falls back to the raw JSON for a
1531
+ * shape this doesn't recognise (a future `NonFungibleId` variant) rather than throwing partway
1532
+ * through a balance list.
1533
+ */
1534
+ function stringifyNonFungibleId(id) {
1535
+ if (typeof id === "string" || typeof id === "number" || typeof id === "bigint")
1536
+ return String(id);
1537
+ if (id && typeof id === "object") {
1538
+ if ("U256" in id)
1539
+ return String(id.U256);
1540
+ if ("String" in id)
1541
+ return String(id.String);
1542
+ if ("Uint32" in id)
1543
+ return String(id.Uint32);
1544
+ if ("Uint64" in id)
1545
+ return String(id.Uint64);
1546
+ }
1547
+ return JSON.stringify(id);
1548
+ }
1549
+ /**
1550
+ * Coin selection for spending shielded outputs: picks this resource's unspent records
1551
+ * largest-first until their sum covers `targetAmount`, so a spend needing more than any single
1552
+ * output holds is satisfied by combining several in one transaction (the builder's
1553
+ * `spendStealthInput()` can be called once per input UTXO) rather than requiring the caller to
1554
+ * pick one record themselves. Largest-first minimizes the number of inputs spent (each one adds
1555
+ * real signature/proof-verification fee cost), rather than needlessly consolidating dust.
1556
+ *
1557
+ * Returns the unselected remainder too (still sorted largest-first) so callers that need "at
1558
+ * least one more unit available" (see `resolveUnshieldPlan`) can pull in exactly one more output
1559
+ * without re-deriving the candidate list.
1560
+ */
1561
+ /**
1562
+ * The unspent subset of a shielded-output ledger, newest first, optionally narrowed to one
1563
+ * resource. Newest-first is a display order, deliberately *not* the spend order: coin selection is
1564
+ * `selectShieldedUtxosForAmount`'s largest-first job, and the two must not be conflated.
1565
+ *
1566
+ * Split out of `OotleAccount.listUnspentShieldedOutputs` (which is just this over the account's own
1567
+ * stored records) so the filtering rule -- what counts as spendable, and what a dApp with view
1568
+ * access is allowed to see -- is testable on its own.
1569
+ */
1570
+ export function selectUnspentShieldedOutputs(records, resourceAddress) {
1571
+ return records
1572
+ .filter((r) => !r.spent && (resourceAddress === undefined || r.resourceAddress === resourceAddress))
1573
+ .sort((a, b) => b.createdAt - a.createdAt);
1574
+ }
1575
+ /**
1576
+ * Totals a set of already-unspent shielded outputs per resource. `outputCount` is carried
1577
+ * alongside the sum rather than left to be recomputed, because it is not cosmetic: each output is
1578
+ * spent whole, so the count is what bounds whether a given amount can actually be covered (see
1579
+ * `resolveUnshieldPlan`) -- a balance of 100 across one output and across fifty are very different
1580
+ * things to a caller planning a spend.
1581
+ *
1582
+ * Insertion-ordered by first appearance (Map iteration order), so a caller's rendering doesn't
1583
+ * reshuffle between calls for no reason.
1584
+ */
1585
+ export function summarizePrivateHoldings(records) {
1586
+ const totals = new Map();
1587
+ for (const record of records) {
1588
+ const entry = totals.get(record.resourceAddress) ?? { amount: 0n, outputCount: 0 };
1589
+ entry.amount += BigInt(record.amount);
1590
+ entry.outputCount += 1;
1591
+ totals.set(record.resourceAddress, entry);
1592
+ }
1593
+ return [...totals].map(([resourceAddress, entry]) => ({ resourceAddress, ...entry }));
1594
+ }
1595
+ export function selectShieldedUtxosForAmount(records, resourceAddress, targetAmount) {
1596
+ const candidates = records
1597
+ .filter((r) => r.resourceAddress === resourceAddress && !r.spent)
1598
+ .sort((a, b) => {
1599
+ const diff = BigInt(b.amount) - BigInt(a.amount);
1600
+ return diff > 0n ? 1 : diff < 0n ? -1 : 0;
1601
+ });
1602
+ const selected = [];
1603
+ let total = 0n;
1604
+ let i = 0;
1605
+ for (; i < candidates.length; i++) {
1606
+ if (total >= targetAmount)
1607
+ break;
1608
+ selected.push(candidates[i]);
1609
+ total += BigInt(candidates[i].amount);
1610
+ }
1611
+ return { selected, total, unselected: candidates.slice(i) };
1612
+ }
1613
+ /**
1614
+ * Pure planning for `unshield()`: selects enough of the caller's unspent records for this
1615
+ * resource (via `selectShieldedUtxosForAmount`) to cover `revealedOutAmount`, and checks the
1616
+ * total leaves at least 1 unit as private remainder (see `unshield()`'s doc comment for why the
1617
+ * upper bound is strict, not `<=`). Extracted so this decision logic is unit-testable without a
1618
+ * live provider/WASM crypto, same reasoning as `pollTransactionResult`'s extraction.
1619
+ *
1620
+ * If the minimal selection's total lands *exactly* on `revealedOutAmount` (zero remainder), and
1621
+ * another unspent record for this resource exists, one more (the smallest available) is pulled
1622
+ * in purely to create a nonzero remainder — spending an extra UTXO is preferable to failing a
1623
+ * reveal that the account's total private balance could otherwise satisfy.
1624
+ */
1625
+ export function resolveUnshieldPlan(records, resourceAddress, revealedOutAmount) {
1626
+ if (revealedOutAmount <= 0n)
1627
+ throw new Error("The amount to reveal must be greater than zero.");
1628
+ const { selected, total, unselected } = selectShieldedUtxosForAmount(records, resourceAddress, revealedOutAmount);
1629
+ if (total < revealedOutAmount) {
1630
+ throw new Error(`Amount exceeds your private balance (you have ${total}).`);
1631
+ }
1632
+ let finalSelected = selected;
1633
+ let finalTotal = total;
1634
+ if (finalTotal === revealedOutAmount) {
1635
+ const extra = unselected[unselected.length - 1]; // smallest remaining unspent record, if any
1636
+ if (!extra) {
1637
+ throw new Error("Can't unshield your full private balance in one transaction -- at least 1 unit must remain private as change.");
1638
+ }
1639
+ finalSelected = [...finalSelected, extra];
1640
+ finalTotal += BigInt(extra.amount);
1641
+ }
1642
+ return { commitments: finalSelected.map((r) => r.commitment), remainder: finalTotal - revealedOutAmount };
1643
+ }
1644
+ /**
1645
+ * Pure planning for `sendPrivately()`: selects enough of the caller's unspent records for this
1646
+ * resource to cover `amount` — unlike `resolveUnshieldPlan`, sending the *entire* selected total
1647
+ * with zero change is allowed, since the recipient's own stealth output already satisfies the
1648
+ * builder's "at least one stealth output" requirement (no same-account dust output is needed to
1649
+ * satisfy it, unlike unshield's revealed destination). Extracted for the same unit-testability
1650
+ * reasons as `resolveUnshieldPlan`.
1651
+ */
1652
+ export function resolveSendPrivatelyPlan(records, resourceAddress, amount) {
1653
+ if (amount <= 0n)
1654
+ throw new Error("The amount to send must be greater than zero.");
1655
+ const { selected, total } = selectShieldedUtxosForAmount(records, resourceAddress, amount);
1656
+ if (total < amount) {
1657
+ throw new Error(`Amount exceeds your private balance (you have ${total}).`);
1658
+ }
1659
+ return { commitments: selected.map((r) => r.commitment), changeAmount: total - amount };
1660
+ }
1661
+ /**
1662
+ * Reconciles shields whose `pollTransactionResult` never got to finish (the extension's service
1663
+ * worker was killed between submission and the storage write) — see `PendingShield`'s doc
1664
+ * comment. Checks each pending shield's current status *once* (not a blocking poll-to-finalize
1665
+ * loop — if it's still genuinely pending, this leaves it for the next check rather than stalling
1666
+ * whatever triggered recovery, typically `popup-get-status`) and either completes the storage
1667
+ * write (finalized successfully), drops it (rejected — no stealth output was ever created, so
1668
+ * there's nothing to recover), or leaves it alone (still pending). One provider is reused across
1669
+ * every pending shield, whichever local account they belong to — this is a read-only connection,
1670
+ * not account/signing-specific.
1671
+ */
1672
+ export async function recoverPendingShields(provider) {
1673
+ const pending = await listPendingShields();
1674
+ for (const p of pending) {
1675
+ try {
1676
+ const response = await provider.getTransactionResult(p.transactionId);
1677
+ const result = response.result;
1678
+ if (result === "Pending")
1679
+ continue; // still in flight -- leave it for next time
1680
+ if ("Rejected" in result) {
1681
+ await removePendingShield(p.transactionId);
1682
+ continue;
1683
+ }
1684
+ const outcome = result.Finalized.execution_result?.finalize.result;
1685
+ const succeeded = outcome && typeof outcome === "object" && "Accept" in outcome;
1686
+ if (succeeded) {
1687
+ await recordKnownVersions(response);
1688
+ if (p.ownCommitment) {
1689
+ await recordKnownShieldedOutput(p.accountId, p.resourceAddress, p.ownCommitment, BigInt(p.amount), p.transactionId, p.memo);
1690
+ }
1691
+ if (p.spentCommitments) {
1692
+ for (const commitment of p.spentCommitments) {
1693
+ await markShieldedOutputSpent(p.accountId, commitment);
1694
+ }
1695
+ }
1696
+ }
1697
+ // Reject / AcceptFeeRejectRest: no stealth output was created either way, nothing to
1698
+ // recover -- just stop waiting on it.
1699
+ await removePendingShield(p.transactionId);
1700
+ }
1701
+ catch {
1702
+ // A transient network/indexer error checking one pending shield must not stop the rest
1703
+ // from being reconciled -- leave this one in the list, it'll be retried next time.
1704
+ }
1705
+ }
1706
+ }
1707
+ /**
1708
+ * Persists a `ShieldedOutputRecord` for a commitment already known locally (see
1709
+ * `extractOutputCommitment`'s doc comment for why this is preferred over scanning the finalized
1710
+ * transaction's `up_substates`) — the only lead back to it, since there is no client-side
1711
+ * scan/list API for stealth UTXOs (see confidential.ts's module doc).
1712
+ */
1713
+ async function recordKnownShieldedOutput(accountId, resourceAddress, commitment, amount, transactionId, memo) {
1714
+ await addShieldedOutput({
1715
+ accountId,
1716
+ resourceAddress,
1717
+ commitment,
1718
+ amount: amount.toString(),
1719
+ transactionId,
1720
+ createdAt: Date.now(),
1721
+ spent: false,
1722
+ memo,
1723
+ });
1724
+ }
1725
+ // The indexer's own view of a substate an account keeps touching (above all its own fee vault,
1726
+ // referenced by nearly every transaction) can lag behind the version our *own* just-confirmed
1727
+ // transaction produced — confirmed empirically: even a brand-new account, on its second-ever
1728
+ // transaction, got "Lock failure: vault:1 is DOWN" from `resolveInputs()` handing back version 1
1729
+ // when the true current version (from our own prior transaction's `up_substates`) was already
1730
+ // higher, and this reproduced identically across multiple fresh accounts and reloads. A stateful
1731
+ // wallet daemon avoids this because it tracks its own substate versions locally instead of
1732
+ // re-asking a remote indexer for state it just changed itself. This is that same idea — persisted
1733
+ // (not just an in-memory Map) specifically because an in-memory-only cache loses exactly the
1734
+ // knowledge that matters most across a reload, which is the scenario this was written to fix.
1735
+ let knownVersionsPromise = null;
1736
+ async function loadKnownVersions() {
1737
+ if (!knownVersionsPromise) {
1738
+ knownVersionsPromise = getKnownVersions().then((raw) => new Map(Object.entries(raw)));
1739
+ }
1740
+ return knownVersionsPromise;
1741
+ }
1742
+ /** Prefers our own record of a substate's version over whatever the indexer just reported, when
1743
+ * we have a newer one — see the block comment above for why. */
1744
+ async function applyKnownVersions(inputs) {
1745
+ const known = await loadKnownVersions();
1746
+ return inputs.map((input) => {
1747
+ const version = known.get(input.substate_id);
1748
+ return version !== undefined && (input.version === null || version > input.version) ? { ...input, version } : input;
1749
+ });
1750
+ }
1751
+ /** Remembers the post-transaction version of every substate a *confirmed* (never a dry-run —
1752
+ * those don't reflect real state) transaction touched, so the next transaction that references
1753
+ * one of them doesn't have to trust the indexer's possibly-stale view of it. */
1754
+ async function recordKnownVersions(response) {
1755
+ const result = response.result;
1756
+ if (result === "Pending" || "Rejected" in result)
1757
+ return;
1758
+ const outcome = result.Finalized.execution_result?.finalize.result;
1759
+ const upSubstates = outcome && typeof outcome === "object" && "Accept" in outcome
1760
+ ? outcome.Accept.up_substates
1761
+ : outcome && typeof outcome === "object" && "AcceptFeeRejectRest" in outcome
1762
+ ? outcome.AcceptFeeRejectRest[0].up_substates
1763
+ : undefined;
1764
+ if (!upSubstates || upSubstates.length === 0)
1765
+ return;
1766
+ const updates = new Map();
1767
+ for (const [id, substate] of upSubstates)
1768
+ updates.set(id, substate.version);
1769
+ // Serialized through the same write queue as every other storage mutation (see storage.ts's
1770
+ // `serialized`) so two concurrent confirmations can't interleave their read-modify-write on
1771
+ // this key and lose an update, and so this write can't race a wallet wipe to leave stale version
1772
+ // numbers behind.
1773
+ await serialized(async () => {
1774
+ const known = await loadKnownVersions();
1775
+ for (const [id, version] of updates)
1776
+ known.set(id, version);
1777
+ await setKnownVersions(Object.fromEntries(known));
1778
+ });
1779
+ }
1780
+ /**
1781
+ * Forgets what we thought we knew about these substates' versions, after the chain has told us a
1782
+ * remembered one is unusable. Keeping it would make `applyKnownVersions` reassert it over every
1783
+ * future resolve, turning one bad write into a permanently stuck wallet.
1784
+ */
1785
+ export async function forgetKnownVersions(substateIds) {
1786
+ if (substateIds.length === 0)
1787
+ return;
1788
+ const known = await loadKnownVersions();
1789
+ let changed = false;
1790
+ for (const id of substateIds)
1791
+ if (known.delete(id))
1792
+ changed = true;
1793
+ if (changed)
1794
+ await setKnownVersions(Object.fromEntries(known));
1795
+ }
1796
+ /** Drops the in-memory known-versions cache. Call this on wallet reset so a freshly-created wallet
1797
+ * can't inherit the previous wallet's cached version numbers — the persisted side of that wipe is
1798
+ * the caller's own responsibility (a serialized clear of whatever `KeyValueStore` backs `storage.ts`). */
1799
+ export function resetKnownVersions() {
1800
+ knownVersionsPromise = null;
1801
+ }
1802
+ /**
1803
+ * Polls a submitted transaction's result to finalization (or rejection/timeout). Extracted from
1804
+ * `OotleAccount.submitReal()` so `shield()`/`unshield()` — which submit via a completely
1805
+ * different pipeline (`StealthTransfer`/`WalletStealthAuthorizer`/`submitTransaction`, not
1806
+ * `TransactionBuilder`) — share the exact same hardened Reject/AcceptFeeRejectRest/timeout
1807
+ * handling instead of a second, potentially-drifting copy of it.
1808
+ */
1809
+ export async function pollTransactionResult(provider, transactionId, timeoutMs = 60_000) {
1810
+ const deadline = Date.now() + timeoutMs;
1811
+ for (;;) {
1812
+ const response = await provider.getTransactionResult(transactionId);
1813
+ const result = response.result;
1814
+ if (result === "Pending") {
1815
+ if (Date.now() > deadline)
1816
+ throw new Error(`Timed out waiting for transaction ${transactionId} to finalize.`);
1817
+ await new Promise((resolve) => setTimeout(resolve, 500));
1818
+ continue;
1819
+ }
1820
+ if ("Rejected" in result) {
1821
+ throw new Error(`Transaction ${transactionId} was rejected: ${result.Rejected.details}`);
1822
+ }
1823
+ const outcome = result.Finalized.execution_result?.finalize.result;
1824
+ if (outcome && typeof outcome === "object") {
1825
+ if ("Reject" in outcome) {
1826
+ throw new Error(`Transaction ${transactionId} was rejected: ${JSON.stringify(outcome.Reject)}`);
1827
+ }
1828
+ if ("AcceptFeeRejectRest" in outcome) {
1829
+ throw new Error(`Transaction ${transactionId} accepted the fee but rejected the rest: ${JSON.stringify(outcome.AcceptFeeRejectRest)}`);
1830
+ }
1831
+ }
1832
+ return response;
1833
+ }
1834
+ }
1835
+ function bytesToHex(bytes) {
1836
+ let out = "";
1837
+ for (const byte of bytes)
1838
+ out += byte.toString(16).padStart(2, "0");
1839
+ return out;
1840
+ }
1841
+ // `resolveInputs()` throws "Failed to find input \"<id>\": ... Verify the substate id is correct
1842
+ // (typo? wrong network?) or wait for the producing transaction to finalize." — the indexer client's
1843
+ // own wording for a plain 404 on `substatesGet`. Confirmed empirically this fires on a substate that
1844
+ // exists moments later: the indexer's committed view lags the version this account's *own* prior
1845
+ // transaction (or a template it just called into, e.g. a newly-created pool) produced by more than
1846
+ // one round trip, the same race `claimTestnetXtr()`'s "Lock failure" backoff exists for. Unlike
1847
+ // "wrong network"/"typo'd address", a genuinely-missing substate never starts existing no matter how
1848
+ // long this waits — but there is no way to distinguish the two from the message alone, so this
1849
+ // retries a bounded number of times and lets a persistent 404 surface as a real error afterward.
1850
+ const RESOLVE_INPUTS_NOT_YET_FINALIZED_PATTERN = /Failed to find input/i;
1851
+ export async function resolveInputsWithRetry(provider, requirements, retries = 5, retryDelayMs = 500) {
1852
+ for (let attempt = 0;; attempt++) {
1853
+ try {
1854
+ return await withTimeout(provider.resolveInputs(requirements), 15_000, "resolving inputs");
1855
+ }
1856
+ catch (e) {
1857
+ const isNotYetFinalized = e instanceof Error && RESOLVE_INPUTS_NOT_YET_FINALIZED_PATTERN.test(e.message);
1858
+ if (!isNotYetFinalized || attempt >= retries)
1859
+ throw e;
1860
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs * (attempt + 1)));
1861
+ }
1862
+ }
1863
+ }
1864
+ /**
1865
+ * Checks whether `substateId` already exists on-chain, tolerating a couple of retries in case it
1866
+ * just hasn't finalized on the indexer yet (a much smaller budget than `resolveInputsWithRetry`'s
1867
+ * default — this is used to *decide* whether to create something, not to wait out a known producer,
1868
+ * so a persistent 404 should read as "doesn't exist" quickly rather than stall the caller). Only
1869
+ * `resolveInputsWithRetry`'s specific "not yet finalized" 404 is treated as "missing" — any other
1870
+ * failure (network error, indexer down) propagates rather than being silently read as nonexistence,
1871
+ * since that misread would make `send()` prepend a `CreateAccount` for an account that actually
1872
+ * exists, which the engine rejects outright ("is already UP and conflicts with an existing output").
1873
+ */
1874
+ export async function substateExists(provider, substateId) {
1875
+ try {
1876
+ await resolveInputsWithRetry(provider, [{ substate_id: substateId, version: null }], 2, 300);
1877
+ return true;
1878
+ }
1879
+ catch (e) {
1880
+ if (e instanceof Error && RESOLVE_INPUTS_NOT_YET_FINALIZED_PATTERN.test(e.message))
1881
+ return false;
1882
+ throw e;
1883
+ }
1884
+ }
1885
+ // Matches the address this engine names in a "not found" rejection. Confirmed two distinct
1886
+ // phrasings for what is structurally the same SubstateNotFound rejection, depending on *how* the
1887
+ // missing reference was hit: a plain call-target miss reads "At instruction #1: component_...
1888
+ // not found" (address before the phrase), while a template-internal reference (e.g. a confidential
1889
+ // vault's withdraw() needing an extra substate the instructions never named directly) reads
1890
+ // "...Template referenced substate but it was not found: resource_..." (address after). A
1891
+ // cross-template call (e.g. the DEX router calling into a pool component internally) reads
1892
+ // "Substate 'component_...' not found or is not a transaction input" — address quoted, so the
1893
+ // optional `['"]?` matters: without it, the quote character sits between the address and "not
1894
+ // found" and the whitespace-only pattern silently fails to match. Any of the `<kind>_<hex>`
1895
+ // address forms this SDK uses (component/resource/vault/transaction_receipt/...), always hex
1896
+ // after the last `_`.
1897
+ const MISSING_SUBSTATE_PATTERNS = [
1898
+ /\b([a-z_]+_[0-9a-f]{16,})['"]?\s+not found\b/i,
1899
+ /not found:\s*([a-z_]+_[0-9a-f]{16,})\b/i,
1900
+ ];
1901
+ export function extractMissingSubstateAddress(message) {
1902
+ for (const pattern of MISSING_SUBSTATE_PATTERNS) {
1903
+ const match = pattern.exec(message);
1904
+ if (match)
1905
+ return match[1];
1906
+ }
1907
+ return null;
1908
+ }
1909
+ // Matches "Lock failure: Substate <id>:<version> is DOWN" — the exact substate and version that
1910
+ // was just consumed, letting the caller compute the next version (version + 1) deterministically
1911
+ // instead of re-querying a possibly-still-lagging indexer.
1912
+ const STALE_LOCK_PATTERN = /Substate ([a-z_]+_[0-9a-f]{16,}):(\d+) is (?:not found or )?DOWN/i;
1913
+ export function extractStaleLockVersion(message) {
1914
+ const match = STALE_LOCK_PATTERN.exec(message);
1915
+ return match ? { substateId: match[1], version: Number(match[2]) } : null;
1916
+ }
1917
+ /**
1918
+ * A compact description of a resolved transaction, for attaching to a wasm parse failure.
1919
+ *
1920
+ * Deliberately structural rather than a full dump: field names and the shape of `inputs` are what
1921
+ * identify a mismatch against `UnsignedTransactionV1`, and a whole transaction (range proofs,
1922
+ * blobs) is far too big to carry in an error message.
1923
+ */
1924
+ function describeResolvedTx(resolved) {
1925
+ try {
1926
+ const tx = resolved;
1927
+ const keys = Object.keys(tx).join(",");
1928
+ const inputs = Array.isArray(tx.inputs) ? tx.inputs : [];
1929
+ const sample = inputs.slice(0, 3).map((i) => JSON.stringify(i)).join(" ");
1930
+ const missing = ["network", "fee_instructions", "instructions", "inputs", "max_epoch", "is_seal_signer_authorized", "dry_run"]
1931
+ .filter((k) => !(k in tx));
1932
+ return [
1933
+ `keys=[${keys}]`,
1934
+ missing.length ? `MISSING=[${missing.join(",")}]` : null,
1935
+ `inputs=${inputs.length}${sample ? ` ${sample}` : ""}`,
1936
+ ].filter(Boolean).join(" ");
1937
+ }
1938
+ catch {
1939
+ return "unavailable";
1940
+ }
1941
+ }
1942
+ /**
1943
+ * The signed transaction's shape, for a `sealTransaction` parse failure.
1944
+ *
1945
+ * `signTransaction` wraps the transaction as `{transaction, signatures}` and the signatures are
1946
+ * generated inside it, so they are the half of the payload the caller never sees — and a malformed
1947
+ * one fails the parse identically to a malformed transaction.
1948
+ */
1949
+ function describeSignedTx(signed) {
1950
+ try {
1951
+ const s = signed;
1952
+ const sigs = Array.isArray(s.signatures) ? s.signatures : [];
1953
+ const shape = sigs.slice(0, 2).map((sig) => {
1954
+ const inner = sig?.signature;
1955
+ return `{public_key:${typeof sig?.public_key} signature:{public_nonce:${typeof inner?.public_nonce},signature:${typeof inner?.signature}}}`;
1956
+ }).join(" ");
1957
+ const tx = s.transaction;
1958
+ return `keys=[${Object.keys(s).join(",")}] signatures=${sigs.length}${shape ? ` ${shape}` : ""}` +
1959
+ (tx ? ` tx=${describeResolvedTx(tx)}` : "");
1960
+ }
1961
+ catch {
1962
+ return "unavailable";
1963
+ }
1964
+ }