@chironbuilder/ootle-sdk 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/wallet.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Mask, OotleWallet, StealthInput, StealthInputsStatement, StealthOutputsStatement, StealthTransfer, StealthTransferStatement, TARI_RESOURCE_ADDRESS, 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, serializeUnsignedTx, signTransaction, stealthUtxoSubstateId, submitTransaction, } from "@tari-project/ootle";
1
+ import { Mask, OotleWallet, StealthInput, StealthInputsStatement, StealthOutputsStatement, StealthTransfer, StealthTransferStatement, TARI_RESOURCE_ADDRESS, TransactionBuilder, WalletStealthAuthorizer, WasmStealthCrypto, XTR_FAUCET_CLAIM_RESOURCE_ADDRESS, XTR_FAUCET_COMPONENT_ADDRESS, XTR_FAUCET_VAULT_ADDRESS, amountLiteral, createOutput, decryptOwnedUtxo, defaultIndexerUrl, getVaultIdsForAccount, resolveMaxEpoch, resolveTransaction, resourceAddressLiteral, generateSealKeypair, sealTransaction, sendTransaction, serializeUnsignedTx, signTransaction, stealthUtxoSubstateId, submitTransaction, } from "@tari-project/ootle";
2
2
  import { IndexerProvider } from "@tari-project/ootle-indexer";
3
3
  import { SecretKeyWallet } from "@tari-project/ootle-secret-key-wallet";
4
4
  import { buildScriptPathWitness, burnClaimStealthSecret, buildStealthTransferStatement, createConfidentialWithdrawProofLiteral, createStealthOutputWitness, parseOotleAddress, publicKeyFromSecretKey, schnorrSign, stealthDhSecret, validateBurnClaimOwnershipProof, validateStealthTransfer, } from "@tari-project/ootle-wasm";
@@ -171,6 +171,15 @@ export class OotleAccount {
171
171
  async getWalletAddress() {
172
172
  return this.signer.getAddress();
173
173
  }
174
+ /**
175
+ * This account's owner public key, synchronously. Named as the receiver of every revealed
176
+ * stealth output this account's `StealthTransfer`s create (tari-ootle#2645): the engine only
177
+ * hands a revealed bucket to a key whose badge is in the transaction's auth scope, and those
178
+ * transfers are always signed with the account key.
179
+ */
180
+ get accountPublicKey() {
181
+ return publicKeyFromSecretKey(this.ownerSecret);
182
+ }
174
183
  async getPublicKey() {
175
184
  return this.signer.getPublicKey();
176
185
  }
@@ -781,7 +790,7 @@ export class OotleAccount {
781
790
  // on-chain component address -- passing `account` here throws "Bech32 decode error" (confirmed
782
791
  // live). See getWalletAddress()'s doc comment for why these two addresses are easy to conflate.
783
792
  const walletAddress = await this.getWalletAddress();
784
- const builder = new StealthTransfer(provider, resourceAddress)
793
+ const builder = new StealthTransfer(provider, resourceAddress).withRevealedReceiver(this.accountPublicKey)
785
794
  // StealthTransfer.prepare() auto-adds the revealed account's own substate and any vault
786
795
  // addresses embedded in its on-chain state, but never the resource's own substate -- fine
787
796
  // for XTR (resource_0101...0101 is engine-special-cased and needs no lock), but any other
@@ -924,9 +933,15 @@ export class OotleAccount {
924
933
  // See shield()'s comment: Output.destination needs the bech32m wallet address, not the
925
934
  // on-chain component address.
926
935
  const walletAddress = await this.getWalletAddress();
936
+ // An account that has only ever held private funds (e.g. a claimed L1 burn) has no component
937
+ // on-chain to withdraw dust from, deposit into, or pay a fee from. Its first unshield creates
938
+ // the account from the revealed funds instead, paying the fee from them too.
939
+ if (!(await substateExists(provider, account))) {
940
+ return await this.unshieldIntoNewAccount(provider, resourceAddress, revealedOutAmount, commitments, remainder, maxFee, memo);
941
+ }
927
942
  // See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
928
943
  // never adds it on its own.
929
- let builder = new StealthTransfer(provider, resourceAddress)
944
+ let builder = new StealthTransfer(provider, resourceAddress).withRevealedReceiver(this.accountPublicKey)
930
945
  .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
931
946
  .spendRevealedInput(account, dust);
932
947
  for (const commitment of commitments) {
@@ -1014,7 +1029,7 @@ export class OotleAccount {
1014
1029
  // stealth output back to this account -- the same proven-working shape -- alongside the real
1015
1030
  // amount as a revealed bucket.
1016
1031
  const dust = 1n;
1017
- let builder = new StealthTransfer(provider, resourceAddress)
1032
+ let builder = new StealthTransfer(provider, resourceAddress).withRevealedReceiver(this.accountPublicKey)
1018
1033
  .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
1019
1034
  .spendRevealedInput(account, amount + dust)
1020
1035
  .toStealthOutput(createOutput({ destination: walletAddress, amount: dust, resourceAddress }))
@@ -1082,11 +1097,8 @@ export class OotleAccount {
1082
1097
  inputs: [{ commitment: commitmentHex, witness: "KeyPath" }],
1083
1098
  revealed_amount: "0",
1084
1099
  });
1085
- const outputsJson = JSON.stringify({
1086
- outputs: [],
1087
- revealed_output_amount: revealedAmount.toString(),
1088
- agg_range_proof: "",
1089
- });
1100
+ // The account key signs this transaction (the authorizer's default), so it takes the revealed funds.
1101
+ const outputsJson = revealOnlyOutputsJson(revealedAmount, this.accountPublicKey);
1090
1102
  const statement = new StealthTransferStatement(new StealthInputsStatement([], 0n, inputsJson), new StealthOutputsStatement(outputsJson));
1091
1103
  const privateFee = await this.resolvePrivateFee(feeType, maxFee);
1092
1104
  const maxEpoch = await resolveMaxEpoch(provider);
@@ -1237,7 +1249,9 @@ export class OotleAccount {
1237
1249
  const feeChange = decrypted.value - maxFee;
1238
1250
  const inputSkeleton = JSON.stringify({ inputs: [{ commitment: feeCommitmentHex, witness: "KeyPath" }], revealed_amount: "0" });
1239
1251
  const feeChangeOutput = createOutput({ destination: walletAddress, amount: feeChange, resourceAddress: feeResourceAddress });
1240
- const { statement: feeOutputsStatement, outputMask: feeOutputMask } = await crypto.generateOutputsStatement([feeChangeOutput], maxFee);
1252
+ const { statement: feeOutputsStatement, outputMask: feeOutputMask } = await crypto.generateOutputsStatement([feeChangeOutput], maxFee,
1253
+ // The revealed fee goes to the fee UTXO's one-time key, which signs for it (see `feeSigner`).
1254
+ utxoSpendKey(substate));
1241
1255
  const feeInputsStatement = new StealthInputsStatement([], 0n, inputSkeleton);
1242
1256
  const feeAggInputMask = await crypto.aggregateInputMasks([decrypted.mask]);
1243
1257
  const feeBalanceProof = await crypto.generateBalanceProofSignature(feeAggInputMask, feeOutputMask, feeInputsStatement.statementJson, feeOutputsStatement.statementJson);
@@ -1551,7 +1565,10 @@ export class OotleAccount {
1551
1565
  // Redeemed resource: 1 stealth input -> 0 stealth outputs, fully revealed into the bucket
1552
1566
  // `followUpInstructions` consumes.
1553
1567
  const redeemedInputsStatement = new StealthInputsStatement([], 0n, inputSkeleton);
1554
- const redeemedOutputsStatement = new StealthOutputsStatement(JSON.stringify({ outputs: [], revealed_output_amount: revealedAmount.toString(), agg_range_proof: "" }));
1568
+ const redeemedOutputsStatement = new StealthOutputsStatement(
1569
+ // This transaction deliberately carries no account signature, so the revealed funds go to the
1570
+ // key that does sign for them: the redeemed UTXO's own one-time spend key.
1571
+ revealOnlyOutputsJson(revealedAmount, utxoSpendKey(substate)));
1555
1572
  const redeemedAggInputMask = await crypto.aggregateInputMasks([redeemed.mask]);
1556
1573
  const redeemedBalanceProof = await crypto.generateBalanceProofSignature(redeemedAggInputMask, Mask.zero(), redeemedInputsStatement.statementJson, redeemedOutputsStatement.statementJson);
1557
1574
  const redeemedStatement = new StealthTransferStatement(redeemedInputsStatement, redeemedOutputsStatement, redeemedBalanceProof);
@@ -1666,7 +1683,7 @@ export class OotleAccount {
1666
1683
  // See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
1667
1684
  // never adds it on its own. Output 0 is the HTLC; output 1 (only with change) is ours.
1668
1685
  const build = () => {
1669
- let b = new StealthTransfer(provider, resourceAddress).withBuilder((bb) => bb.addInput({ substate_id: resourceAddress, version: null }));
1686
+ let b = new StealthTransfer(provider, resourceAddress).withRevealedReceiver(this.accountPublicKey).withBuilder((bb) => bb.addInput({ substate_id: resourceAddress, version: null }));
1670
1687
  if (stealth)
1671
1688
  for (const c of commitments)
1672
1689
  b = b.spendStealthInput(account, fromHex(c));
@@ -2165,7 +2182,7 @@ export class OotleAccount {
2165
2182
  const ownWalletAddress = await this.getWalletAddress();
2166
2183
  // See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
2167
2184
  // never adds it on its own.
2168
- let builder = new StealthTransfer(provider, resourceAddress)
2185
+ let builder = new StealthTransfer(provider, resourceAddress).withRevealedReceiver(this.accountPublicKey)
2169
2186
  .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
2170
2187
  .spendRevealedInput(account, dust);
2171
2188
  for (const commitment of commitments) {
@@ -2275,9 +2292,163 @@ export class OotleAccount {
2275
2292
  async claimBurn(contents, maxFee, memo = "Burnt funds claimed from L1") {
2276
2293
  if (maxFee !== undefined && maxFee <= 0n)
2277
2294
  throw new Error(`claimBurn: maxFee must be > 0, got ${maxFee}`);
2295
+ const { buildClaim, value, provider } = await this.prepareBurnClaim(contents, memo);
2296
+ let fee = maxFee ?? (await this.estimateClaimFee(await buildClaim(claimFeeProbe(value), true)));
2297
+ let attempt = await buildClaim(fee, false);
2298
+ let transactionId = await submitTransaction(provider, attempt.envelope);
2299
+ let response;
2300
+ try {
2301
+ response = await withTimeout(pollTransactionResult(provider, transactionId), 90_000, "claiming the burn");
2302
+ }
2303
+ catch (e) {
2304
+ // A real run can meter a little differently from its dry run. When the rejection names the
2305
+ // fee it wanted, a measured fee is resubmitted once at exactly that; an explicit one is not.
2306
+ const required = maxFee === undefined ? requiredFeeFromRejection(e) : null;
2307
+ if (required === null || required <= fee)
2308
+ throw e;
2309
+ fee = required;
2310
+ attempt = await buildClaim(fee, false);
2311
+ transactionId = await submitTransaction(provider, attempt.envelope);
2312
+ response = await withTimeout(pollTransactionResult(provider, transactionId), 90_000, "claiming the burn");
2313
+ }
2314
+ await recordKnownVersions(response);
2315
+ await recordKnownShieldedOutput(localAccountId(this.index), TARI_RESOURCE_ADDRESS, attempt.ownCommitment, attempt.claimedAmount, transactionId, memo);
2316
+ return { transactionId, claimedAmount: attempt.claimedAmount, commitment: attempt.ownCommitment, fee };
2317
+ }
2318
+ /**
2319
+ * The fee `claimBurn(contents)` would pay, measured by dry-running the claim -- nothing is
2320
+ * submitted. The indexer's dry run checks everything about the claim except the burn's L1
2321
+ * inclusion (the burn output rules, its ownership proof, the claim-key signature), so this also
2322
+ * tells a caller whether the claim is well formed before anything is spent.
2323
+ */
2324
+ async estimateClaimBurnFee(contents, memo = "Burnt funds claimed from L1") {
2325
+ const { buildClaim, value } = await this.prepareBurnClaim(contents, memo);
2326
+ return this.estimateClaimFee(await buildClaim(claimFeeProbe(value), true));
2327
+ }
2328
+ /**
2329
+ * `unshield()` for an account whose component doesn't exist on-chain yet. Everything runs in the
2330
+ * fee phase, the way Tari's own wallet unshields into a new account: the stealth inputs reveal
2331
+ * `amount + fee` (any excess returns as a stealth change output), `TakeFromBucket` splits off the
2332
+ * fee for `PayFeeFromBucket`, and `CreateAccount` creates this account from the rest. The fee is
2333
+ * measured with a dry run first, since a fee revealed from stealth funds is never refunded. With no
2334
+ * change to pay the fee from (unshielding the whole private balance), the fee comes out of the
2335
+ * unshielded amount instead, so the account receives `amount` less the fee.
2336
+ *
2337
+ * The seal key is authorized as a signer and named as the revealed output's receiver
2338
+ * (tari-ootle#2645); each input's one-time key co-signs its key-path spend.
2339
+ */
2340
+ async unshieldIntoNewAccount(provider, resourceAddress, amount, commitments, remainder, maxFee, memo) {
2341
+ if (resourceAddress !== TARI_RESOURCE_ADDRESS) {
2342
+ throw new Error("This account doesn't exist on-chain yet. Unshield some TARI first to create it, then unshield other resources.");
2343
+ }
2344
+ const accountId = localAccountId(this.index);
2345
+ const crypto = new WasmStealthCrypto(this.network);
2346
+ const viewSecret = await this.signer.getViewSecret();
2347
+ const walletAddress = await this.getWalletAddress();
2348
+ const inputs = await Promise.all(commitments.map(async (commitmentHex) => {
2349
+ const substateId = stealthUtxoSubstateId(resourceAddress, fromHex(commitmentHex));
2350
+ const substate = await provider.getSubstate(substateId);
2351
+ const decrypted = await decryptOwnedUtxo(crypto, viewSecret, substate, substateId);
2352
+ if (!decrypted)
2353
+ throw new Error(`unshield: cannot decrypt ${substateId} -- it isn't this account's, or it is already spent.`);
2354
+ const nonce = substate.substate.Utxo.output.output
2355
+ .public_nonce;
2356
+ return { commitmentHex, substateId, mask: decrypted.mask, nonce };
2357
+ }));
2358
+ const inputMask = await crypto.aggregateInputMasks(inputs.map((i) => i.mask));
2359
+ const inputsJson = JSON.stringify({
2360
+ inputs: inputs.map((i) => ({ commitment: i.commitmentHex, witness: "KeyPath" })),
2361
+ revealed_amount: "0",
2362
+ });
2363
+ const sealKeypair = generateSealKeypair();
2364
+ const build = async (fee, dryRun) => {
2365
+ // The fee comes out of the private change first. An account that doesn't exist has nothing
2366
+ // else to pay with, so whatever the change can't cover (all of it, when unshielding the whole
2367
+ // balance) comes out of the unshielded amount: the account is created with `amount - shortfall`.
2368
+ const fromChange = fee < remainder ? fee : remainder;
2369
+ const shortfall = fee - fromChange;
2370
+ const change = remainder - fromChange;
2371
+ if (fee <= 0n || shortfall >= amount) {
2372
+ throw new Error(`unshield: ${amount + remainder} is not enough to cover the ${fee} fee to create the account`);
2373
+ }
2374
+ const revealed = amount + fromChange;
2375
+ let outputsStatement;
2376
+ let outputMask = Mask.zero();
2377
+ if (change > 0n) {
2378
+ const output = createOutput({ destination: walletAddress, amount: change, resourceAddress, memo: toMemo(memo) });
2379
+ ({ statement: outputsStatement, outputMask } = await crypto.generateOutputsStatement([output], revealed, sealKeypair.public_key));
2380
+ }
2381
+ else {
2382
+ outputsStatement = new StealthOutputsStatement(revealOnlyOutputsJson(revealed, sealKeypair.public_key));
2383
+ }
2384
+ const inputsStatement = new StealthInputsStatement([], 0n, inputsJson);
2385
+ const balanceProof = await crypto.generateBalanceProofSignature(inputMask, outputMask, inputsJson, outputsStatement.statementJson);
2386
+ const statement = new StealthTransferStatement(inputsStatement, outputsStatement, balanceProof);
2387
+ await crypto.validateTransfer(statement);
2388
+ const changeCommitment = change > 0n ? outputsStatement.parsed().outputs[0].output.commitment : null;
2389
+ const builder = TransactionBuilder.new(this.network, await resolveMaxEpoch(provider));
2390
+ for (const instruction of [
2391
+ {
2392
+ StealthTransfer: {
2393
+ resource_address_ref: { Address: resourceAddress },
2394
+ statement: { __ootleRawJson: statement.toCompactJson() },
2395
+ revealed_input_bucket: null,
2396
+ },
2397
+ },
2398
+ { PutLastInstructionOutputOnWorkspace: { key: 0 } },
2399
+ { TakeFromBucket: { input_bucket: { id: 0, offset: null }, amount: fee.toString(), output_bucket: 1 } },
2400
+ { PayFeeFromBucket: { bucket: { id: 1, offset: null } } },
2401
+ {
2402
+ CreateAccount: {
2403
+ owner_public_key: toHex(this.accountPublicKey),
2404
+ owner_rule: null,
2405
+ access_rules: null,
2406
+ bucket_workspace_id: { id: 0, offset: null },
2407
+ },
2408
+ },
2409
+ ]) {
2410
+ builder.addFeeInstruction(instruction);
2411
+ }
2412
+ builder.addInput({ substate_id: resourceAddress, version: null, is_write: false });
2413
+ for (const input of inputs)
2414
+ builder.addInput({ substate_id: input.substateId, version: null });
2415
+ const unsignedBody = builder.buildUnsignedTransaction();
2416
+ unsignedBody.is_seal_signer_authorized = true;
2417
+ unsignedBody.dry_run = dryRun;
2418
+ const unsignedTx = await resolveTransaction(provider, unsignedBody);
2419
+ const signers = inputs.map((input) => ({
2420
+ getAddress: async () => walletAddress,
2421
+ getPublicKey: async () => parseOotleAddress(walletAddress).owner_key,
2422
+ signTransaction: async (tx, sealPublicKey) => {
2423
+ const json = serializeUnsignedTx(tx);
2424
+ return [await this.signer.addStealthSignature(json, fromHex(input.nonce), sealPublicKey, { crypto })];
2425
+ },
2426
+ }));
2427
+ const signed = await signTransaction(signers, unsignedTx, sealKeypair);
2428
+ return { envelope: sealTransaction(signed), change, changeCommitment };
2429
+ };
2430
+ // Any fee the balance can pay works as the dry run's probe; it only feeds the metered cost.
2431
+ const payable = remainder + amount - 1n;
2432
+ const probe = maxFee < payable ? maxFee : payable;
2433
+ const fee = await this.estimateClaimFee(await build(probe, true), "unshield");
2434
+ if (fee > maxFee)
2435
+ throw new Error(`unshield: creating the account costs ${fee}, above the ${maxFee} limit`);
2436
+ const attempt = await build(fee, false);
2437
+ const transactionId = await submitTransaction(provider, attempt.envelope);
2438
+ const response = await withTimeout(pollTransactionResult(provider, transactionId), 90_000, "unshielding into a new account");
2439
+ await recordKnownVersions(response);
2440
+ for (const input of inputs)
2441
+ await markShieldedOutputSpent(accountId, input.commitmentHex);
2442
+ if (attempt.changeCommitment) {
2443
+ await recordKnownShieldedOutput(accountId, resourceAddress, attempt.changeCommitment, attempt.change, transactionId, memo);
2444
+ }
2445
+ return { transactionId };
2446
+ }
2447
+ /** Verifies a burn is ours and returns a builder for its claim transaction at a given fee. */
2448
+ async prepareBurnClaim(contents, memo) {
2278
2449
  const proof = contents.claim_proof;
2279
2450
  const burnCommitment = fromHex(proof.commitment);
2280
- const senderOffset = fromHex(proof.sender_offset_public_key);
2451
+ const senderOffset = fromHex(proof.output.sender_offset_public_key);
2281
2452
  const value = BigInt(proof.value);
2282
2453
  const stealthSecret = burnClaimStealthSecret(this.ownerSecret, senderOffset);
2283
2454
  const ownershipValid = validateBurnClaimOwnershipProof(this.network, fromHex(proof.ownership_proof.public_nonce), fromHex(proof.ownership_proof.signature), burnCommitment, value, stealthSecret);
@@ -2292,6 +2463,7 @@ export class OotleAccount {
2292
2463
  }
2293
2464
  const provider = await this.getProvider();
2294
2465
  const walletAddress = await this.getWalletAddress();
2466
+ // The claim's only signer, so it is also the receiver of the revealed fee (tari-ootle#2645).
2295
2467
  const sealKeypair = { secret_key: stealthSecret, public_key: publicKeyFromSecretKey(stealthSecret) };
2296
2468
  // One claim transaction revealing `fee`. The output statement encodes the revealed amount, so
2297
2469
  // every fee needs its own statement and proofs.
@@ -2301,7 +2473,7 @@ export class OotleAccount {
2301
2473
  throw new Error(`claimBurn: the burn (${decrypted.value}) does not cover the claim fee (${fee})`);
2302
2474
  }
2303
2475
  const output = createOutput({ destination: walletAddress, amount: claimedAmount, resourceAddress: TARI_RESOURCE_ADDRESS, memo: toMemo(memo) });
2304
- const { statement: outputsStatement, outputMask } = await crypto.generateOutputsStatement([output], fee);
2476
+ const { statement: outputsStatement, outputMask } = await crypto.generateOutputsStatement([output], fee, sealKeypair.public_key);
2305
2477
  // The single input is the UTXO `ClaimBurn` mints in the same transaction; the instruction
2306
2478
  // itself authorises spending it, so it carries no witness.
2307
2479
  const inputsStatement = await crypto.buildInputsStatement([new StealthInput(burnCommitment)], 0n);
@@ -2322,7 +2494,7 @@ export class OotleAccount {
2322
2494
  })
2323
2495
  .addFeeInstruction({ PutLastInstructionOutputOnWorkspace: { key: 0 } })
2324
2496
  .addFeeInstruction({ PayFeeFromBucket: { bucket: { id: 0, offset: null } } })
2325
- .addInput({ substate_id: TARI_RESOURCE_ADDRESS, version: null });
2497
+ .addInput({ substate_id: TARI_RESOURCE_ADDRESS, version: null, is_write: false });
2326
2498
  // The seal key `s` is the transaction's only signer, so it must be authorized as the main
2327
2499
  // signer; the TS builder leaves that off by default.
2328
2500
  const unsignedBody = builder.buildUnsignedTransaction();
@@ -2332,30 +2504,10 @@ export class OotleAccount {
2332
2504
  const signed = await signTransaction([], unsignedTx, sealKeypair);
2333
2505
  return { envelope: sealTransaction(signed), claimedAmount, ownCommitment };
2334
2506
  };
2335
- let fee = maxFee ?? (await this.estimateClaimFee(await buildClaim(claimFeeProbe(decrypted.value), true)));
2336
- let attempt = await buildClaim(fee, false);
2337
- let transactionId = await submitTransaction(provider, attempt.envelope);
2338
- let response;
2339
- try {
2340
- response = await withTimeout(pollTransactionResult(provider, transactionId), 90_000, "claiming the burn");
2341
- }
2342
- catch (e) {
2343
- // A real run can meter a little differently from its dry run. When the rejection names the
2344
- // fee it wanted, a measured fee is resubmitted once at exactly that; an explicit one is not.
2345
- const required = maxFee === undefined ? requiredFeeFromRejection(e) : null;
2346
- if (required === null || required <= fee)
2347
- throw e;
2348
- fee = required;
2349
- attempt = await buildClaim(fee, false);
2350
- transactionId = await submitTransaction(provider, attempt.envelope);
2351
- response = await withTimeout(pollTransactionResult(provider, transactionId), 90_000, "claiming the burn");
2352
- }
2353
- await recordKnownVersions(response);
2354
- await recordKnownShieldedOutput(localAccountId(this.index), TARI_RESOURCE_ADDRESS, attempt.ownCommitment, attempt.claimedAmount, transactionId, memo);
2355
- return { transactionId, claimedAmount: attempt.claimedAmount, commitment: attempt.ownCommitment, fee };
2507
+ return { buildClaim, value: decrypted.value, provider };
2356
2508
  }
2357
2509
  /** Dry-runs a sealed claim and returns the fee it needs: what it was charged plus the allowance. */
2358
- async estimateClaimFee(dryRun) {
2510
+ async estimateClaimFee(dryRun, what = "claim") {
2359
2511
  const res = await fetch(`${defaultIndexerUrl(this.network)}/transactions/dry-run`, {
2360
2512
  method: "POST",
2361
2513
  headers: { "Content-Type": "application/json" },
@@ -2370,7 +2522,7 @@ export class OotleAccount {
2370
2522
  // Not JSON — reported raw below.
2371
2523
  }
2372
2524
  if (!res.ok || body?.error || !body?.result) {
2373
- throw new Error(`Could not estimate the claim fee: ${body?.error?.message ?? text ?? res.statusText}`);
2525
+ throw new Error(`Could not estimate the ${what} fee: ${body?.error?.message ?? text ?? res.statusText}`);
2374
2526
  }
2375
2527
  const finalize = body.result.finalize;
2376
2528
  const outcome = finalize.result;
@@ -2383,7 +2535,7 @@ export class OotleAccount {
2383
2535
  if (amount !== undefined)
2384
2536
  charged += BigInt(amount);
2385
2537
  if (charged === 0n)
2386
- throw new Error("Could not estimate the claim fee: the dry run reported no cost");
2538
+ throw new Error(`Could not estimate the ${what} fee: the dry run reported no cost`);
2387
2539
  return charged + FEE_ESTIMATE_ALLOWANCE;
2388
2540
  }
2389
2541
  /**
@@ -2648,7 +2800,7 @@ export function buildHtlcSpendStatement(params) {
2648
2800
  const destination = parseOotleAddress(params.destinationWalletAddress);
2649
2801
  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.
2650
2802
  0n);
2651
- const statementJson = buildStealthTransferStatement(`[${inputEntry}]`, 0n, `[${outputWitnessJson}]`, 0n);
2803
+ const statementJson = buildStealthTransferStatement(`[${inputEntry}]`, 0n, `[${outputWitnessJson}]`, 0n, new Uint8Array(0));
2652
2804
  // Fail fast locally rather than spend a network round trip on a malformed statement -- the same
2653
2805
  // check tari-ootle's own `build_stealth_transfer_statement` unit tests run before trusting its
2654
2806
  // output (crates/ootle_wasm/core/src/stealth/transfer.rs).
@@ -3091,6 +3243,14 @@ export async function pollTransactionResult(provider, transactionId, timeoutMs =
3091
3243
  if ("Rejected" in result) {
3092
3244
  throw new Error(`Transaction ${transactionId} was rejected: ${result.Rejected.details}`);
3093
3245
  }
3246
+ // Consensus can abort a transaction whatever its execution said (a lock conflict, an expired
3247
+ // epoch, too little fee); only a `Commit` means it happened.
3248
+ const decision = result.Finalized.final_decision;
3249
+ if (decision !== "Commit") {
3250
+ const reason = typeof decision === "object" && decision && "Abort" in decision ? decision.Abort : JSON.stringify(decision);
3251
+ const details = result.Finalized.abort_details ? `: ${result.Finalized.abort_details}` : "";
3252
+ throw new Error(`Transaction ${transactionId} was aborted (${reason})${details}`);
3253
+ }
3094
3254
  const outcome = result.Finalized.execution_result?.finalize.result;
3095
3255
  if (outcome && typeof outcome === "object") {
3096
3256
  if ("Reject" in outcome) {
@@ -3233,3 +3393,21 @@ function describeSignedTx(signed) {
3233
3393
  return "unavailable";
3234
3394
  }
3235
3395
  }
3396
+ /**
3397
+ * The one-time spend key of a stealth UTXO substate (its `auth` `Key`, or `KeyAndScript.spend_key`).
3398
+ * A key-path spend of the UTXO is signed with this key, so its badge is in the transaction's auth
3399
+ * scope -- which makes it a valid receiver for a revealed output (tari-ootle#2645) in transactions
3400
+ * this account deliberately doesn't sign with its own key.
3401
+ */
3402
+ function utxoSpendKey(substate) {
3403
+ const auth = substate.substate?.Utxo?.output?.auth;
3404
+ const key = auth && "Key" in auth ? auth.Key : auth && "KeyAndScript" in auth ? auth.KeyAndScript.spend_key : undefined;
3405
+ if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key)) {
3406
+ throw new Error("This stealth output has no one-time spend key (it is script-only), so it can't be spent by key.");
3407
+ }
3408
+ return fromHex(key);
3409
+ }
3410
+ /** A hand-built outputs statement that reveals `amount` to `receiver` and creates no stealth outputs. */
3411
+ function revealOnlyOutputsJson(amount, receiver) {
3412
+ return JSON.stringify({ outputs: [], revealed_output: { amount: amount.toString(), receiver: toHex(receiver) }, agg_range_proof: "" });
3413
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chironbuilder/ootle-sdk",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Shared TypeScript client for Tari Ootle (L2): account derivation, balances, transactions, HTLCs, and stealth/confidential transfers — the account core behind Sapient and the Tari L1 web wallet's Ootle support.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,12 +44,16 @@
44
44
  "@tari-project/ootle": "file:./vendor/ootle-patched",
45
45
  "@tari-project/ootle-indexer": "^0.3.0",
46
46
  "@tari-project/ootle-secret-key-wallet": "^0.3.0",
47
- "@tari-project/ootle-ts-bindings": "^1.49.1",
47
+ "@tari-project/ootle-ts-bindings": "^1.55.0",
48
48
  "@tari-project/ootle-wasm": "file:./vendor/ootle-wasm-patched"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/chrome": "^0.0.280",
52
52
  "typescript": "^5.6.0",
53
53
  "vitest": "^4.1.0"
54
+ },
55
+ "overrides": {
56
+ "@tari-project/ootle-wasm": "$@tari-project/ootle-wasm",
57
+ "@tari-project/ootle": "$@tari-project/ootle"
54
58
  }
55
59
  }
@@ -17,6 +17,7 @@ import { IndexerGetTransactionResultResponse } from '@tari-project/ootle-ts-bind
17
17
  import { IndexerSubmitTransactionRequest } from '@tari-project/ootle-ts-bindings';
18
18
  import { IndexerSubmitTransactionResponse } from '@tari-project/ootle-ts-bindings';
19
19
  import { IndexerTransactionFinalizedResult } from '@tari-project/ootle-ts-bindings';
20
+ import { InputDeclaration } from '@tari-project/ootle-ts-bindings';
20
21
  import { Instruction } from '@tari-project/ootle-ts-bindings';
21
22
  import { InstructionArg } from '@tari-project/ootle-ts-bindings';
22
23
  import { ListRecentTransactionsRequest } from '@tari-project/ootle-ts-bindings';
@@ -517,7 +518,7 @@ export declare function fromHexStr(hex: string): Uint8Array;
517
518
  * @returns A complete transfer statement.
518
519
  * @throws {InvalidArgumentError} if `specs` is empty.
519
520
  */
520
- export declare function generateOutputsStatement(crypto: StealthCryptoProvider, specs: Output[], revealed: bigint): Promise<StealthTransferStatement>;
521
+ export declare function generateOutputsStatement(crypto: StealthCryptoProvider, specs: Output[], revealed: bigint, revealedReceiver?: Uint8Array): Promise<StealthTransferStatement>;
521
522
 
522
523
  /**
523
524
  * Generate a fresh seal keypair (a thin wrapper over the WASM `generateKeypair`).
@@ -1195,10 +1196,13 @@ export declare interface StealthCryptoProvider {
1195
1196
  *
1196
1197
  * @param specs - The outputs to create (incl. change).
1197
1198
  * @param revealedOutputAmount - The un-confidential (revealed) µTari output amount.
1199
+ * @param revealedReceiver - The 32-byte public key allowed to take the revealed output
1200
+ * (tari-ootle#2645: the engine requires its badge in the transaction's auth scope). Required
1201
+ * when `revealedOutputAmount > 0`, ignored otherwise.
1198
1202
  * @returns The WASM-produced {@link StealthOutputsStatement} and the aggregated
1199
1203
  * `outputMask` for the balance proof.
1200
1204
  */
1201
- generateOutputsStatement(specs: Output[], revealedOutputAmount: bigint): Promise<{
1205
+ generateOutputsStatement(specs: Output[], revealedOutputAmount: bigint, revealedReceiver?: Uint8Array): Promise<{
1202
1206
  statement: StealthOutputsStatement;
1203
1207
  outputMask: Mask;
1204
1208
  }>;
@@ -1363,11 +1367,23 @@ export declare class StealthTransfer {
1363
1367
  /** Guards against a second {@link prepare} re-emitting instructions into the same builder. */
1364
1368
  private prepared;
1365
1369
  /**
1366
- * Set by {@link toRevealedOutputAsBucket}; when non-null, {@link emitInstructions} leaves the
1367
- * revealed-change bucket on the workspace under this name instead of auto-depositing it back
1368
- * to the revealed source account.
1370
+ * When set, revealed change is captured as a bucket on the workspace under this name
1371
+ * instead of being auto-deposited back to `revealedInput.source` — see
1372
+ * {@link toRevealedOutputAsBucket}.
1369
1373
  */
1370
1374
  private revealedOutputBucketVar;
1375
+ /**
1376
+ * Extra instructions appended after the native `StealthTransfer` instruction (and its
1377
+ * change-bucket handling), in the same transaction — see {@link andThen}. Lets a caller
1378
+ * chain e.g. a custom contract call that consumes the bucket saved by
1379
+ * {@link toRevealedOutputAsBucket}, all signed together by the authorizer in one
1380
+ * transaction instead of needing a separate one.
1381
+ */
1382
+ private followUpInstructions;
1383
+ /**
1384
+ * The public key allowed to take the revealed output — see {@link withRevealedReceiver}.
1385
+ */
1386
+ private revealedReceiver;
1371
1387
  /**
1372
1388
  * @param provider - Read-only chain access (used by {@link prepare} to resolve inputs).
1373
1389
  * @param resourceAddress - The resource being transferred.
@@ -1394,32 +1410,40 @@ export declare class StealthTransfer {
1394
1410
  */
1395
1411
  toRevealedOutput(amount: bigint): this;
1396
1412
  /**
1397
- * Like {@link toRevealedOutput}, but leaves the revealed-change bucket on the workspace under
1398
- * `workspaceVarName` instead of auto-depositing it back to the revealed source account. Pairs
1399
- * with {@link andThen} so a caller can route Stealth-typed funds into its own follow-up
1400
- * instructions in the same signed transaction — a plain `CallMethod withdraw` on a Stealth
1401
- * vault is not a standalone-valid instruction; moving Stealth funds anywhere always requires
1402
- * the native `StealthTransfer` instruction this builder already emits.
1413
+ * Like {@link toRevealedOutput}, but instead of auto-depositing the change back into
1414
+ * `revealedInput.source`, the `StealthTransfer` instruction's bucket output is left on the
1415
+ * workspace under `workspaceVarName` for {@link andThen}'s instructions (or a caller-supplied
1416
+ * `withBuilder` continuation) to consume directly — e.g. passing it straight into a custom
1417
+ * contract call in the same transaction, rather than requiring a second transaction that
1418
+ * withdraws it back out again (which a plain `CallMethod withdraw` on a Stealth-typed vault
1419
+ * cannot do standalone — see this class's own `emitInstructions` doc comment: the account
1420
+ * template's `withdraw` only produces a valid bucket when paired with the native
1421
+ * `StealthTransfer` instruction in the same transaction).
1403
1422
  *
1404
- * Mutually exclusive with {@link toRevealedOutput} in the same builder — call one or the
1405
- * other, not both, since they disagree on where the change bucket ends up.
1423
+ * A real `toStealthOutput` is still required elsewhere on this builder (`validate()` enforces
1424
+ * it): the bundled `ootle-wasm@0.37.0` signer cannot parse a statement with zero stealth
1425
+ * outputs at all — confirmed live, `signTransaction`'s own WASM call throws the generic "did
1426
+ * not match any variant of untagged enum TransactionInput" error regardless of how
1427
+ * `balance_proof` is represented. A small dust self-output (as `OotleAccount.
1428
+ * withdrawStealthAndExecute` in tari-wallet-extension does) satisfies this cheaply.
1406
1429
  *
1407
- * @throws {InvalidArgumentError} if `amount` is not `> 0`, or a different workspace name was
1408
- * already set by an earlier call.
1430
+ * @throws {InvalidArgumentError} if `amount` is not `> 0`.
1409
1431
  */
1410
1432
  toRevealedOutputAsBucket(amount: bigint, workspaceVarName: string): this;
1411
1433
  /**
1412
- * Append raw instructions to run in the same signed transaction, after the native
1413
- * `StealthTransfer` instruction (and the revealed-change bucket save from
1414
- * {@link toRevealedOutputAsBucket}, if any) — typically used to consume that bucket in a
1415
- * custom contract call.
1434
+ * Name the public key allowed to take the revealed output (tari-ootle#2645). The engine
1435
+ * requires that key's badge in the transaction's auth scope before it creates the revealed
1436
+ * bucket, so it must be a key that signs the transaction — normally the account key, which
1437
+ * {@link WalletStealthAuthorizer} signs with by default. Required whenever the transfer has a
1438
+ * revealed output ({@link toRevealedOutput} / {@link toRevealedOutputAsBucket}).
1416
1439
  *
1417
- * Workspace variable ids are a single flat counter across the whole transaction:
1418
- * `StealthTransfer` claims id `0` (and `1`, when there's a revealed input) internally via its
1419
- * own `saveVar` calls before these run, so instructions referencing workspace variables by
1420
- * **name** (`{ Workspace: "name" }`) resolve correctly through the builder's own name → id
1421
- * map — but any instruction constructed with a raw numeric `WorkspaceOffsetId` by hand must
1422
- * account for those already-claimed ids itself.
1440
+ * @throws {InvalidArgumentError} if `publicKey` is not 32 bytes.
1441
+ */
1442
+ withRevealedReceiver(publicKey: Uint8Array): this;
1443
+ /**
1444
+ * Append `instructions` after the native `StealthTransfer` instruction (and its bucket
1445
+ * handling), so they run in the same signed transaction — typically consuming the bucket
1446
+ * {@link toRevealedOutputAsBucket} saved to the workspace. Repeatable; each call appends.
1423
1447
  */
1424
1448
  andThen(instructions: Instruction[]): this;
1425
1449
  /**
@@ -1840,8 +1864,13 @@ export declare class TransactionBuilder {
1840
1864
  * Note this REPLACES any fee instructions already set, as it always has.
1841
1865
  */
1842
1866
  withFeeInstructionsBuilder(builder: (b: TransactionBuilder) => TransactionBuilder): this;
1843
- addInput(input: SubstateRequirement): this;
1844
- withInputs(inputs: SubstateRequirement[]): this;
1867
+ /**
1868
+ * Declare a transaction input. Since tari-ootle#2640 every input declares read or write
1869
+ * intent; a plain {@link SubstateRequirement} (no `is_write`) is declared a **write**, the
1870
+ * engine's own default — over-declaring only locks more, under-declaring aborts execution.
1871
+ */
1872
+ addInput(input: SubstateRequirement | InputDeclaration): this;
1873
+ withInputs(inputs: (SubstateRequirement | InputDeclaration)[]): this;
1845
1874
  /** @throws {InvalidArgumentError} if `minEpoch` is not a non-negative integer. */
1846
1875
  withMinEpoch(minEpoch: number): this;
1847
1876
  /**
@@ -2076,7 +2105,7 @@ export declare class WasmStealthCrypto implements StealthCryptoProvider {
2076
2105
  */
2077
2106
  private readonly network;
2078
2107
  constructor(network?: Network);
2079
- generateOutputsStatement(specs: Output[], revealedOutputAmount: bigint): Promise<{
2108
+ generateOutputsStatement(specs: Output[], revealedOutputAmount: bigint, revealedReceiver?: Uint8Array): Promise<{
2080
2109
  statement: StealthOutputsStatement;
2081
2110
  outputMask: Mask;
2082
2111
  }>;