@chironbuilder/ootle-sdk 0.1.13 → 0.1.14

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
@@ -6,11 +6,23 @@ import { componentAddressFromWalletAddress, deriveAccountComponentAddress } from
6
6
  import { scanTransactionsForOwnedOutputs, sumConfidentialCommitments } from "./confidential.js";
7
7
  import { deriveAccountKeys } from "./derivation.js";
8
8
  import { htlcConditions } from "./htlc.js";
9
+ import { HtlcUnknownOutcomeError, HtlcVerificationError, decryptOwnStatementOutput, describeHtlcConditions, findScriptRoot, isDefinitiveRejection, isHtlcRefundable, newOperationId, sha256Hex, statementInputCommitment, statementOutputCommitment, subtractScalars, verifyHtlcTerms, } from "./htlcSafety.js";
9
10
  import { buildOwnershipProofMessage, buildWalletOwnershipMessage } from "./ownershipProof.js";
10
11
  import { toOotleNetwork } from "./ootleNetwork.js";
11
- import { addPendingShield, addShieldedOutput, getKnownVersions, getPrivatePaymentScanCursor, listPendingShields, listShieldedOutputs, localAccountId, markShieldedOutputSpent, removePendingShield, setKnownVersions, setPrivatePaymentScanCursor, } from "./storage.js";
12
+ import { addPendingShield, addShieldedOutput, getHtlcJournalEntry, getKnownVersions, getPrivatePaymentScanCursor, listHtlcJournal, listPendingShields, listReservedCommitments, listShieldedOutputs, localAccountId, markShieldedOutputSpent, putHtlcJournalEntry, releaseReservations, removePendingShield, reserveCommitments, setKnownVersions, setPrivatePaymentScanCursor, updateHtlcJournalEntry, CommitmentReservedError, } from "./storage.js";
12
13
  import { withTimeout } from "./timeout.js";
13
14
  import { fromHex, toHex } from "./vault.js";
15
+ /** A private fee's bookkeeping, in the journal's serializable form. */
16
+ function journalFee(feeType, fee) {
17
+ if (!fee || feeType.kind !== "private")
18
+ return undefined;
19
+ return {
20
+ feeResourceAddress: feeType.feeResourceAddress,
21
+ spentCommitment: fee.spentCommitment,
22
+ changeCommitment: fee.feeChangeCommitment,
23
+ changeAmount: fee.feeChangeAmount.toString(),
24
+ };
25
+ }
14
26
  /**
15
27
  * Batch-reads each resource's on-chain `divisibility` and `metadata.SYMBOL`/`metadata.name`.
16
28
  * Decimal precision and display name are real chain data, never a client-side convention: XTR is 6
@@ -900,56 +912,64 @@ export class OotleAccount {
900
912
  */
901
913
  async unshield(resourceAddress, revealedOutAmount, maxFee = 100000n, memo, feeType = { kind: "transparent" }) {
902
914
  const accountId = localAccountId(this.index);
903
- const records = await this.filterReadableShieldedOutputs(await listShieldedOutputs(accountId), resourceAddress, revealedOutAmount);
915
+ const opId = newOperationId("unshield");
916
+ const unavailable = await this.unavailableCommitments(accountId, opId);
917
+ const records = await this.filterReadableShieldedOutputs((await listShieldedOutputs(accountId)).filter((r) => !unavailable.has(r.commitment)), resourceAddress, revealedOutAmount);
904
918
  const { commitments, remainder } = resolveUnshieldPlan(records, resourceAddress, revealedOutAmount);
905
- const dust = 1n;
906
- const provider = await this.getProvider();
907
- const account = await this.getComponentAddress();
908
- // See shield()'s comment: Output.destination needs the bech32m wallet address, not the
909
- // on-chain component address.
910
- const walletAddress = await this.getWalletAddress();
911
- // See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
912
- // never adds it on its own.
913
- let builder = new StealthTransfer(provider, resourceAddress)
914
- .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
915
- .spendRevealedInput(account, dust);
916
- for (const commitment of commitments) {
917
- builder = builder.spendStealthInput(account, fromHex(commitment));
918
- }
919
- builder = builder
920
- .toStealthOutput(createOutput({ destination: walletAddress, amount: remainder, resourceAddress, memo: toMemo(memo) }))
921
- .toRevealedOutput(revealedOutAmount + dust);
922
- const viewSecret = await this.signer.getViewSecret();
923
- // See shield()'s comment: must pass this account's real network, not fromSpec's LocalNet
924
- // default. `commitments` is excluded from the fee UTXO's own selection so the same shielded
925
- // record can never be spent twice in one transaction (as both a main input here and the fee
926
- // input) when `resourceAddress` and the fee's resource happen to be the same currency.
927
- const { transactionId, spec, privateFee } = await this.prepareSignSubmit(builder, provider, account, maxFee, feeType, { viewSecret }, commitments);
928
- const ownCommitment = extractOutputCommitment(spec, 0);
929
- await addPendingShield({
930
- transactionId,
931
- accountId,
932
- resourceAddress,
933
- amount: remainder.toString(),
934
- spentCommitments: commitments,
935
- ownCommitment,
936
- memo,
937
- });
919
+ await reserveCommitments(accountId, commitments, opId);
938
920
  try {
939
- const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the unshield transaction");
940
- await recordKnownVersions(response);
941
- await recordKnownShieldedOutput(accountId, resourceAddress, ownCommitment, remainder, transactionId, memo);
921
+ const dust = 1n;
922
+ const provider = await this.getProvider();
923
+ const account = await this.getComponentAddress();
924
+ // See shield()'s comment: Output.destination needs the bech32m wallet address, not the
925
+ // on-chain component address.
926
+ const walletAddress = await this.getWalletAddress();
927
+ // See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
928
+ // never adds it on its own.
929
+ let builder = new StealthTransfer(provider, resourceAddress)
930
+ .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
931
+ .spendRevealedInput(account, dust);
942
932
  for (const commitment of commitments) {
943
- await markShieldedOutputSpent(accountId, commitment);
933
+ builder = builder.spendStealthInput(account, fromHex(commitment));
944
934
  }
945
- if (privateFee && feeType.kind === "private") {
946
- await this.recordPrivateFeeSpend(accountId, feeType.feeResourceAddress, privateFee, transactionId);
935
+ builder = builder
936
+ .toStealthOutput(createOutput({ destination: walletAddress, amount: remainder, resourceAddress, memo: toMemo(memo) }))
937
+ .toRevealedOutput(revealedOutAmount + dust);
938
+ const viewSecret = await this.signer.getViewSecret();
939
+ // See shield()'s comment: must pass this account's real network, not fromSpec's LocalNet
940
+ // default. `commitments` is excluded from the fee UTXO's own selection so the same shielded
941
+ // record can never be spent twice in one transaction (as both a main input here and the fee
942
+ // input) when `resourceAddress` and the fee's resource happen to be the same currency.
943
+ const { transactionId, spec, privateFee } = await this.prepareSignSubmit(builder, provider, account, maxFee, feeType, { viewSecret }, commitments, opId);
944
+ const ownCommitment = extractOutputCommitment(spec, 0);
945
+ await addPendingShield({
946
+ transactionId,
947
+ accountId,
948
+ resourceAddress,
949
+ amount: remainder.toString(),
950
+ spentCommitments: commitments,
951
+ ownCommitment,
952
+ memo,
953
+ });
954
+ try {
955
+ const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the unshield transaction");
956
+ await recordKnownVersions(response);
957
+ await recordKnownShieldedOutput(accountId, resourceAddress, ownCommitment, remainder, transactionId, memo);
958
+ for (const commitment of commitments) {
959
+ await markShieldedOutputSpent(accountId, commitment);
960
+ }
961
+ if (privateFee && feeType.kind === "private") {
962
+ await this.recordPrivateFeeSpend(accountId, feeType.feeResourceAddress, privateFee, transactionId);
963
+ }
947
964
  }
965
+ finally {
966
+ await removePendingShield(transactionId);
967
+ }
968
+ return { transactionId };
948
969
  }
949
970
  finally {
950
- await removePendingShield(transactionId);
971
+ await releaseReservations(opId);
951
972
  }
952
- return { transactionId };
953
973
  }
954
974
  /**
955
975
  * Withdraws `amount` of a Stealth-typed resource (e.g. XTR) from this account's revealed
@@ -1305,11 +1325,17 @@ export class OotleAccount {
1305
1325
  /** Commitments already claimed by the operation's own main effect (e.g. `unshield()`'s or
1306
1326
  * `sendPrivately()`'s own multi-UTXO coin selection) -- excluded so the same commitment can
1307
1327
  * never be selected as both a main input and the fee input in one transaction. */
1308
- exclude = []) {
1328
+ exclude = [],
1329
+ /** When set, the chosen fee UTXO is reserved for this operation (see `reserveCommitments`) so a
1330
+ * concurrent operation can't pick it too. Whether set or not, UTXOs another operation has
1331
+ * reserved are never chosen. */
1332
+ reservationHolder) {
1309
1333
  if (feeType.kind !== "private")
1310
1334
  return null;
1311
1335
  const accountId = localAccountId(this.index);
1312
1336
  const records = await listShieldedOutputs(accountId);
1337
+ for (const unavailable of await this.unavailableCommitments(accountId, reservationHolder))
1338
+ exclude = [...exclude, unavailable];
1313
1339
  // Retries with the next-smallest qualifying UTXO if one fails to read -- confirmed live that a
1314
1340
  // record this wallet still has as "unspent" can 500 on the indexer (consistent with it having
1315
1341
  // actually been spent already, e.g. from another device/session sharing this seed, hitting an
@@ -1336,6 +1362,17 @@ export class OotleAccount {
1336
1362
  }
1337
1363
  throw selectError;
1338
1364
  }
1365
+ if (reservationHolder) {
1366
+ try {
1367
+ await reserveCommitments(accountId, [chosen.commitment], reservationHolder);
1368
+ }
1369
+ catch (e) {
1370
+ if (!(e instanceof CommitmentReservedError))
1371
+ throw e;
1372
+ tried.add(chosen.commitment); // taken by a concurrent operation since we listed -- try the next one
1373
+ continue;
1374
+ }
1375
+ }
1339
1376
  try {
1340
1377
  const built = await this.buildPrivateFeeInstructions(feeType.feeResourceAddress, chosen.commitment, maxFee);
1341
1378
  return { ...built, spentCommitment: chosen.commitment };
@@ -1410,17 +1447,89 @@ export class OotleAccount {
1410
1447
  */
1411
1448
  async prepareSignSubmit(builder, provider, account, maxFee, feeType, authorizerOpts = {},
1412
1449
  /** See `resolvePrivateFee`'s own doc comment. */
1413
- excludeFromFeeSelection = []) {
1414
- const privateFee = await this.resolvePrivateFee(feeType, maxFee, excludeFromFeeSelection);
1415
- const spec = await (privateFee ? this.attachPrivateFee(builder, privateFee) : builder.payFeeFromRevealed(maxFee)).prepare();
1450
+ excludeFromFeeSelection = [], reservationHolder) {
1451
+ const { envelope, spec, privateFee } = await this.sealStealthTransfer(builder, provider, account, maxFee, feeType, {
1452
+ authorizerOpts,
1453
+ excludeFromFeeSelection,
1454
+ reservationHolder,
1455
+ });
1456
+ const transactionId = await submitTransaction(provider, envelope);
1457
+ return { transactionId, spec, privateFee };
1458
+ }
1459
+ /**
1460
+ * The prepare/authorize/seal half of `prepareSignSubmit`, without the submit — so the exact same
1461
+ * transaction shape can be dry-run first (`dryRun: true` marks it before it's signed) and so the
1462
+ * sealed envelope can be journaled before it ever reaches the network.
1463
+ *
1464
+ * `sourceless`: the operation's main lane spends only stealth inputs (no `spendRevealedInput` of
1465
+ * its resource at all — e.g. a stealth-created asset this account holds no public balance of).
1466
+ * A transparent fee then can't use `payFeeFromRevealed` (which needs a revealed source of the
1467
+ * *operation's* resource); instead it's paid in native TARI straight from `account` — the fee
1468
+ * lane — with the account and its vaults registered as inputs, exactly as `submitHtlcSpend` does.
1469
+ * A private fee needs no special handling: it never touched the revealed source anyway.
1470
+ */
1471
+ async sealStealthTransfer(builder, provider, account, maxFee, feeType, opts = {}) {
1472
+ const privateFee = await this.resolvePrivateFee(feeType, maxFee, opts.excludeFromFeeSelection ?? [], opts.reservationHolder);
1473
+ let withFee;
1474
+ if (privateFee) {
1475
+ withFee = this.attachPrivateFee(builder, privateFee);
1476
+ }
1477
+ else if (opts.sourceless) {
1478
+ const vaults = await getVaultIdsForAccount(provider, account);
1479
+ withFee = builder.withBuilder((b) => {
1480
+ b.feeTransactionPayFromComponent(account, maxFee);
1481
+ b.addInput({ substate_id: account, version: null });
1482
+ for (const vaultId of vaults)
1483
+ b.addInput({ substate_id: vaultId, version: null });
1484
+ return b;
1485
+ });
1486
+ }
1487
+ else {
1488
+ withFee = builder.payFeeFromRevealed(maxFee);
1489
+ }
1490
+ const spec = await withFee.prepare();
1491
+ // `dry_run` is part of the signed transaction, so it must be set before authorization/sealing.
1492
+ if (opts.dryRun)
1493
+ spec.unsignedTx.dry_run = true;
1416
1494
  const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
1417
1495
  const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, {
1418
1496
  crypto: new WasmStealthCrypto(this.network),
1419
- ...authorizerOpts,
1497
+ ...(opts.authorizerOpts ?? {}),
1420
1498
  }).prepare(provider);
1421
1499
  const envelope = privateFee ? await this.sealWithPrivateFee(authorized, privateFee.feeSigner) : await authorized.seal();
1422
- const transactionId = await submitTransaction(provider, envelope);
1423
- return { transactionId, spec, privateFee };
1500
+ return { envelope, spec, privateFee };
1501
+ }
1502
+ /**
1503
+ * Dry-runs a sealed envelope (one built with `dry_run` set) against the indexer's dry-run endpoint
1504
+ * and returns the execution result, throwing — in the same "was rejected: …" shape real
1505
+ * submissions use — if it would be rejected. Nothing reaches consensus; nothing is spent.
1506
+ */
1507
+ async dryRunEnvelope(envelope) {
1508
+ const res = await fetch(`${defaultIndexerUrl(this.network)}/transactions/dry-run`, {
1509
+ method: "POST",
1510
+ headers: { "Content-Type": "application/json" },
1511
+ body: JSON.stringify({ transaction: envelope }),
1512
+ });
1513
+ const text = await res.text();
1514
+ let body;
1515
+ try {
1516
+ body = text ? JSON.parse(text) : undefined;
1517
+ }
1518
+ catch {
1519
+ // Not JSON — reported raw below.
1520
+ }
1521
+ if (!res.ok || body?.error || !body?.result) {
1522
+ throw new Error(`The dry run failed: ${body?.error?.message ?? text ?? res.statusText}`);
1523
+ }
1524
+ const outcome = body.result.finalize.result;
1525
+ if (typeof outcome === "object" && outcome !== null) {
1526
+ if ("Reject" in outcome)
1527
+ throw new Error(`The dry run was rejected: ${JSON.stringify(outcome.Reject)}`);
1528
+ if ("AcceptFeeRejectRest" in outcome) {
1529
+ throw new Error(`The dry run accepted the fee but rejected the rest: ${JSON.stringify(outcome.AcceptFeeRejectRest)}`);
1530
+ }
1531
+ }
1532
+ return body.result;
1424
1533
  }
1425
1534
  async redeemStealthOutputWithPrivateFee(resourceAddress, commitmentHex, revealedAmount, followUpInstructions, feeResourceAddress, feeCommitmentHex, maxFee, relatedComponents = []) {
1426
1535
  if (revealedAmount <= 0n)
@@ -1496,33 +1605,43 @@ export class OotleAccount {
1496
1605
  * Funds an HTLC (hashed timelock contract): creates a stealth output for `amount` of
1497
1606
  * `resourceAddress`, gated not by a normal one-time stealth key but by a two-leaf TIP-0006
1498
1607
  * condition tree (`htlcConditions` in `./htlc.ts`) — a claim path admissible only to
1499
- * `claimantWalletAddress` (revealing the SHA-256 preimage of `hashLockHex`, before
1608
+ * `claimantWalletAddress` (revealing the SHA-256 preimage of `hashLockHex`, strictly before
1500
1609
  * `refundEpoch`), and a refund path admissible only to this account (at/after `refundEpoch`).
1501
- * `htlcConditions`'s own doc comment covers the exact leaf shapes.
1502
1610
  *
1503
- * This account never needs (and must never be given) the actual preimage — only its hash. The
1504
- * caller resolves `refundEpoch` from a desired wall-clock deadline via the provider/network's
1505
- * current epoch, the same as any other epoch-relative on-chain deadline.
1611
+ * This account never needs (and must never be given) the actual preimage — only its hash.
1506
1612
  *
1507
- * Returns the full `conditions` tree alongside the transaction id and this output's own
1508
- * commitment: only the tree's **root** is committed on-chain, so the claimant needs the exact
1509
- * leaves — out of band, via whatever swap protocol coordinates the two sides — to later reveal
1510
- * the claim leaf.
1613
+ * **Two lanes.** The fee and the HTLC value never share a UTXO: the fee is paid in native TARI —
1614
+ * privately from a separate stealth TARI UTXO (`feeType: private`) or from this account's public
1615
+ * TARI — while the main lane moves only `resourceAddress`. With `source: { kind: "stealth" }` the
1616
+ * main lane spends this account's *own stealth outputs* of the resource (exactly the given
1617
+ * `commitments`, or selected automatically), returning any excess as same-resource stealth change;
1618
+ * no public balance of the resource is needed at all, so a stealth-created asset can be locked.
1619
+ * The default `source: { kind: "revealed" }` keeps the original behavior (public balance).
1511
1620
  *
1512
- * Also returns `outputMask` (hex): this account funded the output, so it's the one place the
1513
- * blinding mask is ever available to it without decryption — `encrypted_data` is encrypted for
1514
- * `claimantWalletAddress`'s view key, not this account's, so if the claim path is never taken
1515
- * and this account later calls `htlcRefund`, it cannot decrypt the output the normal way (the
1516
- * way `htlcClaim`'s claimant can, since the output is addressed to *them*). Retaining this mask
1517
- * now — alongside the known `amount` — is what makes an undecryptable refund possible at all.
1518
- * `htlcRefund`'s own doc comment covers the rest.
1621
+ * **Settlement safety.**
1622
+ * - The exact transaction is dry-run first (`preflight`, default on), so a doomed fund never
1623
+ * reaches the network.
1624
+ * - Before submission, the `conditions` tree, the HTLC output's own blinding `outputMask` and the
1625
+ * sealed envelope are written to the HTLC journal. The funder can't decrypt an output
1626
+ * addressed to the claimant, so the mask is the only thing that makes a refund possible —
1627
+ * it's never held only in memory.
1628
+ * - With stealth change, `outputMask` is derived as `aggregateMask − changeMask` and then
1629
+ * *proven* before submit: a refund built from it must reproduce the HTLC output's commitment,
1630
+ * or the fund is aborted.
1631
+ * - Inputs (and a private fee's UTXO) are reserved for the duration, so a concurrent operation
1632
+ * can't select them.
1633
+ * - If the outcome doesn't arrive in time this throws `HtlcUnknownOutcomeError` (carrying the
1634
+ * refund data) instead of a plain failure; `reconcileHtlcs()` resolves it later.
1519
1635
  *
1520
- * `destination` is set to the claimant's own wallet address (not this account's) so the
1521
- * claimant can independently decrypt and verify the funded amount themselves, the same as any
1522
- * other stealth payment addressed to them — spend authority is governed entirely separately, by
1523
- * `payTo`.
1636
+ * Returns the full `conditions` tree (only its root is on-chain — the claimant needs the leaves,
1637
+ * out of band), the HTLC output's commitment (`ownCommitment`, kept for compatibility), its
1638
+ * `outputMask`, the change commitment when there was change, and the `journalId`.
1524
1639
  */
1525
- async htlcFund(resourceAddress, amount, claimantWalletAddress, hashLockHex, refundEpoch, maxFee = 50000n, feeType = { kind: "transparent" }) {
1640
+ async htlcFund(resourceAddress, amount, claimantWalletAddress, hashLockHex, refundEpoch, maxFee = 50000n, feeType = { kind: "transparent" }, options = {}) {
1641
+ if (amount <= 0n)
1642
+ throw new Error("htlcFund: amount must be greater than zero.");
1643
+ const opId = newOperationId("htlc-fund");
1644
+ const accountId = localAccountId(this.index);
1526
1645
  const provider = await this.getProvider();
1527
1646
  const account = await this.getComponentAddress();
1528
1647
  const walletAddress = await this.getWalletAddress();
@@ -1534,124 +1653,256 @@ export class OotleAccount {
1534
1653
  claimantPublicKeyHex: toHex(claimant.owner_key),
1535
1654
  refunderPublicKeyHex: toHex(refunder.owner_key),
1536
1655
  });
1537
- // See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
1538
- // never adds it on its own.
1539
- const builder = new StealthTransfer(provider, resourceAddress)
1540
- .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
1541
- .spendRevealedInput(account, amount)
1542
- .toStealthOutput(createOutput({ destination: claimantWalletAddress, amount, resourceAddress, payTo: { Conditions: conditions } }));
1543
- // No stealth inputs to unblind for this fund's own transfer (revealed-only source), so no
1544
- // viewSecret needed here even when the FEE is paid privately -- same as shield().
1545
- const { transactionId, spec, privateFee } = await this.prepareSignSubmit(builder, provider, account, maxFee, feeType);
1546
- const ownCommitment = extractOutputCommitment(spec, 0);
1547
- const outputMask = spec.outputMask.toHex();
1548
- const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the HTLC funding transaction");
1549
- await recordKnownVersions(response);
1550
- if (privateFee && feeType.kind === "private") {
1551
- await this.recordPrivateFeeSpend(localAccountId(this.index), feeType.feeResourceAddress, privateFee, transactionId);
1656
+ const source = options.source ?? { kind: "revealed" };
1657
+ const stealth = source.kind === "stealth";
1658
+ const viewSecret = stealth ? await this.signer.getViewSecret() : undefined;
1659
+ let commitments = [];
1660
+ let change = 0n;
1661
+ try {
1662
+ if (stealth) {
1663
+ ({ commitments, change } = await this.selectStealthSource(accountId, opId, resourceAddress, amount, source.commitments));
1664
+ await reserveCommitments(accountId, commitments, opId);
1665
+ }
1666
+ // See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
1667
+ // never adds it on its own. Output 0 is the HTLC; output 1 (only with change) is ours.
1668
+ const build = () => {
1669
+ let b = new StealthTransfer(provider, resourceAddress).withBuilder((bb) => bb.addInput({ substate_id: resourceAddress, version: null }));
1670
+ if (stealth)
1671
+ for (const c of commitments)
1672
+ b = b.spendStealthInput(account, fromHex(c));
1673
+ else
1674
+ b = b.spendRevealedInput(account, amount);
1675
+ // `destination` is the claimant's own wallet address so they can independently decrypt and
1676
+ // verify the funded amount; spend authority is governed entirely by `payTo`.
1677
+ b = b.toStealthOutput(createOutput({ destination: claimantWalletAddress, amount, resourceAddress, payTo: { Conditions: conditions } }));
1678
+ if (change > 0n)
1679
+ b = b.toStealthOutput(createOutput({ destination: walletAddress, amount: change, resourceAddress }));
1680
+ return b;
1681
+ };
1682
+ const sealOpts = {
1683
+ authorizerOpts: viewSecret ? { viewSecret } : {},
1684
+ excludeFromFeeSelection: commitments,
1685
+ reservationHolder: opId,
1686
+ sourceless: stealth,
1687
+ };
1688
+ if (options.preflight !== false) {
1689
+ const dry = await this.sealStealthTransfer(build(), provider, account, maxFee, feeType, { ...sealOpts, dryRun: true });
1690
+ await this.dryRunEnvelope(dry.envelope);
1691
+ }
1692
+ const { envelope, spec, privateFee } = await this.sealStealthTransfer(build(), provider, account, maxFee, feeType, sealOpts);
1693
+ const htlcCommitment = extractOutputCommitment(spec, 0);
1694
+ let outputMask = spec.outputMask.toHex();
1695
+ let changeCommitment;
1696
+ if (change > 0n) {
1697
+ const outputs = spec.statement.outputsStatement.parsed().outputs;
1698
+ const own = decryptOwnStatementOutput(outputs[1], viewSecret);
1699
+ if (own.value !== change)
1700
+ throw new Error(`htlcFund: change output decrypted to ${own.value}, expected ${change} — aborting before submit.`);
1701
+ changeCommitment = own.commitment;
1702
+ outputMask = subtractScalars(outputMask, own.maskHex);
1703
+ }
1704
+ // Prove the refund is buildable before anything is at stake: the mask we're about to rely on
1705
+ // must reproduce the HTLC output's exact commitment.
1706
+ const refundProbe = buildHtlcSpendStatement({
1707
+ network: this.network,
1708
+ conditions,
1709
+ leaf: conditions[1],
1710
+ data: new Uint8Array(0),
1711
+ mask: outputMask,
1712
+ value: amount,
1713
+ destinationWalletAddress: walletAddress,
1714
+ resourceAddress,
1715
+ });
1716
+ if (statementInputCommitment(refundProbe) !== htlcCommitment.toLowerCase()) {
1717
+ throw new Error("htlcFund: the derived output mask does not reproduce the HTLC commitment — refusing to fund an output that couldn't be refunded.");
1718
+ }
1719
+ const now = Date.now();
1720
+ const entry = {
1721
+ id: opId,
1722
+ accountId,
1723
+ kind: "fund",
1724
+ status: "prepared",
1725
+ resourceAddress,
1726
+ amount: amount.toString(),
1727
+ conditions,
1728
+ hashLockHex: hashLockHex.toLowerCase(),
1729
+ refundEpoch: refundEpoch.toString(),
1730
+ htlcCommitment,
1731
+ outputMask,
1732
+ ownCommitment: changeCommitment,
1733
+ ownAmount: change > 0n ? change.toString() : undefined,
1734
+ spentCommitments: commitments.length ? commitments : undefined,
1735
+ privateFee: journalFee(feeType, privateFee),
1736
+ envelope,
1737
+ createdAt: now,
1738
+ updatedAt: now,
1739
+ };
1740
+ const { transactionId } = await this.submitJournaled(entry, provider, "submitting the HTLC funding transaction");
1741
+ return { transactionId, conditions, ownCommitment: htlcCommitment, outputMask, changeCommitment, journalId: opId };
1742
+ }
1743
+ catch (e) {
1744
+ if (!(e instanceof HtlcUnknownOutcomeError))
1745
+ await releaseReservations(opId);
1746
+ throw e;
1747
+ }
1748
+ }
1749
+ /**
1750
+ * Chooses the stealth inputs for a stealth-sourced HTLC fund: exactly `requested` when given
1751
+ * (each must be this account's own unspent, unreserved record of `resourceAddress`, and must
1752
+ * decrypt on-chain to the recorded amount), otherwise a largest-first selection covering `amount`.
1753
+ */
1754
+ async selectStealthSource(accountId, holder, resourceAddress, amount, requested) {
1755
+ const unavailable = await this.unavailableCommitments(accountId, holder);
1756
+ const records = (await listShieldedOutputs(accountId)).filter((r) => !unavailable.has(r.commitment));
1757
+ let chosen;
1758
+ if (requested && requested.length > 0) {
1759
+ const provider = await this.getProvider();
1760
+ const crypto = new WasmStealthCrypto(this.network);
1761
+ const viewSecret = await this.signer.getViewSecret();
1762
+ chosen = [];
1763
+ for (const commitment of new Set(requested.map((c) => c.toLowerCase()))) {
1764
+ const record = records.find((r) => r.commitment.toLowerCase() === commitment);
1765
+ if (!record) {
1766
+ throw new Error(`htlcFund: ${commitment.slice(0, 12)}… is not one of this account's unspent, available stealth outputs (unknown, spent, or in use by another operation).`);
1767
+ }
1768
+ if (record.resourceAddress !== resourceAddress)
1769
+ throw new Error(`htlcFund: ${commitment.slice(0, 12)}… holds ${record.resourceAddress}, not ${resourceAddress}.`);
1770
+ const substateId = stealthUtxoSubstateId(resourceAddress, fromHex(record.commitment));
1771
+ const decrypted = await decryptOwnedUtxo(crypto, viewSecret, await provider.getSubstate(substateId), substateId);
1772
+ if (!decrypted || decrypted.value !== BigInt(record.amount)) {
1773
+ throw new Error(`htlcFund: ${commitment.slice(0, 12)}… is not spendable as recorded (already spent, or its on-chain value differs).`);
1774
+ }
1775
+ chosen.push(record);
1776
+ }
1777
+ }
1778
+ else {
1779
+ const readable = await this.filterReadableShieldedOutputs(records, resourceAddress, amount);
1780
+ chosen = selectShieldedUtxosForAmount(readable, resourceAddress, amount).selected;
1552
1781
  }
1553
- return { transactionId, conditions, ownCommitment, outputMask };
1782
+ const total = chosen.reduce((s, r) => s + BigInt(r.amount), 0n);
1783
+ if (total < amount)
1784
+ throw new Error(`htlcFund: the selected stealth outputs hold ${total}, less than the ${amount} to lock.`);
1785
+ return { commitments: chosen.map((r) => r.commitment), change: total - amount };
1786
+ }
1787
+ /**
1788
+ * Checks a funded HTLC against agreed terms *without* claiming it — the verification a swap
1789
+ * protocol runs before it reveals anything (or before it funds its own side). Returns every
1790
+ * problem found; an empty list means the HTLC is exactly what was agreed. `htlcClaim` runs the
1791
+ * same checks itself and refuses to reveal the preimage if any fail.
1792
+ */
1793
+ async verifyHtlc(resourceAddress, commitmentHex, conditions, expected = {}, preimageHex) {
1794
+ const provider = await this.getProvider();
1795
+ const substateId = stealthUtxoSubstateId(resourceAddress, fromHex(commitmentHex));
1796
+ let substate = null;
1797
+ try {
1798
+ substate = await provider.getSubstate(substateId);
1799
+ }
1800
+ catch {
1801
+ substate = null;
1802
+ }
1803
+ const decrypted = substate
1804
+ ? await decryptOwnedUtxo(new WasmStealthCrypto(this.network), await this.signer.getViewSecret(), substate, substateId)
1805
+ : null;
1806
+ const currentEpoch = BigInt(await provider.getCurrentEpoch());
1807
+ const facts = {
1808
+ value: decrypted ? decrypted.value : null,
1809
+ onChainRoot: substate ? findScriptRoot(substate) : null,
1810
+ conditions,
1811
+ claimantPublicKeyHex: toHex(parseOotleAddress(await this.getWalletAddress()).owner_key),
1812
+ currentEpoch,
1813
+ preimageHashHex: preimageHex !== undefined ? await sha256Hex(fromHex(preimageHex)) : undefined,
1814
+ };
1815
+ const problems = substate
1816
+ ? verifyHtlcTerms(facts, expected)
1817
+ : ["The HTLC output wasn't found on-chain — it may not be funded yet, or already claimed or refunded."];
1818
+ return { ok: problems.length === 0, problems, value: facts.value, currentEpoch };
1554
1819
  }
1555
1820
  /**
1556
1821
  * Claims a funded HTLC (the counterpart to `htlcFund`): reveals the claim leaf's SHA-256
1557
- * preimage to spend the script-path-locked output, moving `amount` into a brand-new, normal
1558
- * (freely key-spendable) stealth output owned by this account.
1822
+ * preimage to spend the script-path-locked output into a brand-new, normal (freely
1823
+ * key-spendable) stealth output owned by this account — which is recorded in the shielded
1824
+ * ledger, so the claimed funds are immediately spendable/visible.
1559
1825
  *
1560
- * This account must be the intended claimant — `htlcFund` addressed the output to this
1561
- * account's own wallet address, so it can unblind it the normal way: fetch the `utxo_...`
1562
- * substate and decrypt with this account's own view secret, exactly like `claimPrivatePayment`
1563
- * does for any other incoming stealth payment. `conditions` must be the *exact* two-leaf tree
1564
- * `htlcFund` returned (or was handed out of band) — the wrong tree produces a script-path
1565
- * witness whose condition root won't match the output's committed `SpendAuthorization::Script`
1566
- * root, and `buildStealthTransferStatement`/the network both reject the spend.
1826
+ * **Nothing is revealed until the HTLC is proven safe.** Before the preimage leaves this device
1827
+ * it checks (see `verifyHtlc`): the output decrypts as ours; its amount (`expected.amount` /
1828
+ * `minAmount`); the tree is a standard HTLC whose claim leaf is *our* key (and, if given, whose
1829
+ * refund leaf is the agreed counterparty); the on-chain condition root equals the tree's root;
1830
+ * the preimage hashes to the lock; and at least `expected.minEpochsBeforeRefund` (default 1)
1831
+ * claim-admissible epochs remain. Any failure throws `HtlcVerificationError`.
1567
1832
  *
1568
- * Fees are paid from this account's own existing revealed balance via `pay_fee` (same as any
1569
- * other transaction this wallet submits), not from the claimed HTLC funds — a transaction's fee
1570
- * instructions execute in a separate phase *before* its main instructions, so the claim's own
1571
- * `StealthTransfer` instruction (a main instruction) can't fund its own fee even in principle.
1572
- *
1573
- * Bypasses `StealthTransfer`/`WalletStealthAuthorizer` entirely — that builder pipeline only
1574
- * ever produces key-path stealth inputs (a one-time DH signature per input), which doesn't
1575
- * apply here: this spend's authorization is the revealed script-path leaf itself (its
1576
- * `AccessRule` atom, satisfied by this account's normal transaction signature), not a stealth
1577
- * spend key. `buildStealthTransferStatement` (tari-project/tari-ootle#2431) produces the
1578
- * complete statement — inputs/outputs/balance proof/covenant claims — in one call from the
1579
- * unblinded mask+value and the two witnesses below, so there is nothing left for the builder
1580
- * pipeline to add.
1833
+ * Then the exact claim is dry-run (`preflight`, default on) so a claim that would fail never
1834
+ * publishes the preimage on-chain, the journal entry moves to `claim_armed`, and only then is it
1835
+ * submitted. The fee stays in its own lane (native TARI, public or private) — the claim's main
1836
+ * instruction never funds its own fee. Note a dry run does send the transaction (preimage
1837
+ * included) to the indexer's dry-run endpoint; pass `preflight: false` if that indexer isn't one
1838
+ * you trust with it.
1581
1839
  */
1582
- async htlcClaim(resourceAddress, commitmentHex, conditions, preimageHex, maxFee = 50000n, feeType = { kind: "transparent" }) {
1840
+ async htlcClaim(resourceAddress, commitmentHex, conditions, preimageHex, maxFee = 50000n, feeType = { kind: "transparent" }, options = {}) {
1583
1841
  if (!/^[0-9a-f]{64}$/i.test(preimageHex)) {
1584
1842
  throw new Error(`htlcClaim: preimageHex must be exactly 64 hex characters (32 bytes), got ${JSON.stringify(preimageHex)}`);
1585
1843
  }
1586
- const provider = await this.getProvider();
1587
- const account = await this.getComponentAddress();
1588
- const walletAddress = await this.getWalletAddress();
1589
- const commitment = fromHex(commitmentHex);
1590
- const substateId = stealthUtxoSubstateId(resourceAddress, commitment);
1591
- const substate = await provider.getSubstate(substateId);
1592
- const viewSecret = await this.signer.getViewSecret();
1593
- const decrypted = await decryptOwnedUtxo(new WasmStealthCrypto(this.network), viewSecret, substate, substateId);
1594
- if (!decrypted) {
1595
- 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.");
1596
- }
1597
1844
  const claimLeaf = conditions[0];
1598
1845
  if (!claimLeaf)
1599
1846
  throw new Error("htlcClaim: conditions must be the two-leaf [claim, refund] tree htlcFund returned");
1847
+ const verification = await this.verifyHtlc(resourceAddress, commitmentHex, conditions, options.expected ?? {}, preimageHex);
1848
+ if (!verification.ok)
1849
+ throw new HtlcVerificationError(verification.problems);
1850
+ const value = verification.value;
1851
+ const { hashLockHex, refundEpoch } = describeHtlcConditions(conditions);
1852
+ const provider = await this.getProvider();
1853
+ const substateId = stealthUtxoSubstateId(resourceAddress, fromHex(commitmentHex));
1854
+ const decrypted = await decryptOwnedUtxo(new WasmStealthCrypto(this.network), await this.signer.getViewSecret(), await provider.getSubstate(substateId), substateId);
1855
+ if (!decrypted)
1856
+ throw new Error("htlcClaim: the HTLC output no longer decrypts — it may have just been claimed or refunded.");
1857
+ const walletAddress = await this.getWalletAddress();
1600
1858
  const statementJson = buildHtlcSpendStatement({
1601
1859
  network: this.network,
1602
1860
  conditions,
1603
1861
  leaf: claimLeaf,
1604
1862
  data: fromHex(preimageHex),
1605
1863
  mask: decrypted.mask.toHex(),
1606
- value: decrypted.value,
1864
+ value,
1607
1865
  destinationWalletAddress: walletAddress,
1608
1866
  resourceAddress,
1609
1867
  });
1610
- const privateFee = await this.resolvePrivateFee(feeType, maxFee);
1611
- const transactionId = await submitHtlcSpend({
1612
- provider,
1613
- signer: this.signer,
1614
- network: this.network,
1615
- account,
1868
+ return this.spendHtlc({
1869
+ kind: "claim",
1616
1870
  resourceAddress,
1617
- substateId,
1871
+ commitmentHex,
1872
+ conditions,
1873
+ hashLockHex,
1874
+ refundEpoch,
1875
+ value,
1618
1876
  statementJson,
1619
1877
  maxFee,
1620
- timeoutLabel: "submitting the HTLC claim transaction",
1621
- privateFee,
1878
+ feeType,
1879
+ preflight: options.preflight !== false,
1880
+ armedStatus: "claim_armed",
1881
+ label: "submitting the HTLC claim transaction",
1622
1882
  });
1623
- if (privateFee && feeType.kind === "private") {
1624
- await this.recordPrivateFeeSpend(localAccountId(this.index), feeType.feeResourceAddress, privateFee, transactionId);
1625
- }
1626
- return { transactionId };
1627
1883
  }
1628
1884
  /**
1629
- * Refunds an HTLC this account itself funded (via `htlcFund`), once `refundEpoch` has passed:
1630
- * reveals the refund leaf to spend the output back into a brand-new, normal (freely
1631
- * key-spendable) stealth output owned by this account.
1632
- *
1633
- * Unlike `htlcClaim`, this account **cannot** decrypt the output on-chain — `htlcFund` addressed
1634
- * it to the claimant's wallet, so `encrypted_data` is encrypted for the claimant's view key, not
1635
- * this account's. `amount` and `outputMaskHex` must be exactly what `htlcFund` returned (or
1636
- * otherwise already known — this account chose `amount` itself when funding, and `outputMaskHex`
1637
- * is `htlcFund`'s own return value for exactly this purpose): reconstructing the commitment from
1638
- * a wrong mask or amount fails cleanly (the resulting input just doesn't match the on-chain
1639
- * commitment, so the network rejects the spend) rather than silently, but there is no recovery
1640
- * path if this account discarded the mask — see `htlcFund`'s doc comment.
1885
+ * Refunds an HTLC this account itself funded (via `htlcFund`), once the refund path is open:
1886
+ * reveals the refund leaf to spend the output back into a brand-new, normal stealth output owned
1887
+ * by this account (recorded in the shielded ledger).
1641
1888
  *
1642
- * Otherwise identical to `htlcClaim`: same fee-from-own-balance reasoning, same
1643
- * `buildStealthTransferStatement`-in-one-call approach, same bypass of
1644
- * `StealthTransfer`/`WalletStealthAuthorizer`.
1889
+ * The refund path is `AfterEpoch(refundEpoch)`, i.e. open when `currentEpoch >= refundEpoch` —
1890
+ * checked here first, so an early refund fails with a clear message instead of an on-chain
1891
+ * rejection. The funder can't decrypt the output (it's addressed to the claimant), so `amount` and
1892
+ * `outputMaskHex` must be exactly what `htlcFund` returned — or use `refundFromJournal(journalId)`,
1893
+ * which reads them back from the journal `htlcFund` wrote before submitting.
1645
1894
  */
1646
- async htlcRefund(resourceAddress, commitmentHex, conditions, amount, outputMaskHex, maxFee = 50000n, feeType = { kind: "transparent" }) {
1647
- const provider = await this.getProvider();
1648
- const account = await this.getComponentAddress();
1649
- const walletAddress = await this.getWalletAddress();
1650
- const commitment = fromHex(commitmentHex);
1651
- const substateId = stealthUtxoSubstateId(resourceAddress, commitment);
1895
+ async htlcRefund(resourceAddress, commitmentHex, conditions, amount, outputMaskHex, maxFee = 50000n, feeType = { kind: "transparent" }, options = {}) {
1652
1896
  const refundLeaf = conditions[1];
1653
1897
  if (!refundLeaf)
1654
1898
  throw new Error("htlcRefund: conditions must be the two-leaf [claim, refund] tree htlcFund returned");
1899
+ const { hashLockHex, refundEpoch } = describeHtlcConditions(conditions);
1900
+ const provider = await this.getProvider();
1901
+ const currentEpoch = BigInt(await provider.getCurrentEpoch());
1902
+ if (!isHtlcRefundable(currentEpoch, refundEpoch)) {
1903
+ throw new Error(`htlcRefund: the refund path opens at epoch ${refundEpoch}; the current epoch is ${currentEpoch}.`);
1904
+ }
1905
+ const walletAddress = await this.getWalletAddress();
1655
1906
  const statementJson = buildHtlcSpendStatement({
1656
1907
  network: this.network,
1657
1908
  conditions,
@@ -1662,23 +1913,215 @@ export class OotleAccount {
1662
1913
  destinationWalletAddress: walletAddress,
1663
1914
  resourceAddress,
1664
1915
  });
1665
- const privateFee = await this.resolvePrivateFee(feeType, maxFee);
1666
- const transactionId = await submitHtlcSpend({
1667
- provider,
1668
- signer: this.signer,
1669
- network: this.network,
1670
- account,
1916
+ if (statementInputCommitment(statementJson) !== commitmentHex.toLowerCase()) {
1917
+ throw new Error("htlcRefund: this amount/mask doesn't reproduce the HTLC commitment — use the exact values htlcFund returned (or refundFromJournal).");
1918
+ }
1919
+ return this.spendHtlc({
1920
+ kind: "refund",
1671
1921
  resourceAddress,
1672
- substateId,
1922
+ commitmentHex,
1923
+ conditions,
1924
+ hashLockHex,
1925
+ refundEpoch,
1926
+ value: amount,
1673
1927
  statementJson,
1674
1928
  maxFee,
1675
- timeoutLabel: "submitting the HTLC refund transaction",
1676
- privateFee,
1929
+ feeType,
1930
+ preflight: options.preflight !== false,
1931
+ armedStatus: "prepared",
1932
+ label: "submitting the HTLC refund transaction",
1677
1933
  });
1678
- if (privateFee && feeType.kind === "private") {
1679
- await this.recordPrivateFeeSpend(localAccountId(this.index), feeType.feeResourceAddress, privateFee, transactionId);
1934
+ }
1935
+ /** Refunds an HTLC this account funded, using the conditions/mask/amount `htlcFund` journaled. */
1936
+ async refundFromJournal(journalId, maxFee = 50000n, feeType = { kind: "transparent" }) {
1937
+ const entry = await getHtlcJournalEntry(journalId);
1938
+ if (!entry || entry.kind !== "fund" || entry.accountId !== localAccountId(this.index)) {
1939
+ throw new Error(`refundFromJournal: ${journalId} is not an HTLC this account funded.`);
1680
1940
  }
1681
- return { transactionId };
1941
+ if (!entry.htlcCommitment || !entry.outputMask)
1942
+ throw new Error(`refundFromJournal: ${journalId} has no HTLC commitment/mask recorded.`);
1943
+ return this.htlcRefund(entry.resourceAddress, entry.htlcCommitment, entry.conditions, BigInt(entry.amount), entry.outputMask, maxFee, feeType);
1944
+ }
1945
+ /** Shared claim/refund path: private-fee reservation, dry run, journal, submit, bookkeeping. */
1946
+ async spendHtlc(p) {
1947
+ const opId = newOperationId(`htlc-${p.kind}`);
1948
+ const accountId = localAccountId(this.index);
1949
+ const provider = await this.getProvider();
1950
+ const account = await this.getComponentAddress();
1951
+ const substateId = stealthUtxoSubstateId(p.resourceAddress, fromHex(p.commitmentHex));
1952
+ const ownCommitment = statementOutputCommitment(p.statementJson, 0);
1953
+ try {
1954
+ const privateFee = await this.resolvePrivateFee(p.feeType, p.maxFee, [], opId);
1955
+ const envelopeFor = (dryRun) => buildHtlcSpendEnvelope({
1956
+ provider,
1957
+ signer: this.signer,
1958
+ network: this.network,
1959
+ account,
1960
+ resourceAddress: p.resourceAddress,
1961
+ substateId,
1962
+ statementJson: p.statementJson,
1963
+ maxFee: p.maxFee,
1964
+ privateFee,
1965
+ dryRun,
1966
+ });
1967
+ if (p.preflight)
1968
+ await this.dryRunEnvelope(await envelopeFor(true));
1969
+ const envelope = await envelopeFor(false);
1970
+ const now = Date.now();
1971
+ const entry = {
1972
+ id: opId,
1973
+ accountId,
1974
+ kind: p.kind,
1975
+ status: p.armedStatus,
1976
+ resourceAddress: p.resourceAddress,
1977
+ amount: p.value.toString(),
1978
+ conditions: p.conditions,
1979
+ hashLockHex: p.hashLockHex,
1980
+ refundEpoch: p.refundEpoch.toString(),
1981
+ htlcCommitment: p.commitmentHex.toLowerCase(),
1982
+ ownCommitment,
1983
+ ownAmount: p.value.toString(),
1984
+ privateFee: journalFee(p.feeType, privateFee),
1985
+ envelope,
1986
+ createdAt: now,
1987
+ updatedAt: now,
1988
+ };
1989
+ const { transactionId } = await this.submitJournaled(entry, provider, p.label);
1990
+ return { transactionId, receivedCommitment: ownCommitment, amount: p.value, journalId: opId };
1991
+ }
1992
+ catch (e) {
1993
+ if (!(e instanceof HtlcUnknownOutcomeError))
1994
+ await releaseReservations(opId);
1995
+ throw e;
1996
+ }
1997
+ }
1998
+ /**
1999
+ * Journal → submit → poll → bookkeeping, for every HTLC operation. The entry (with its sealed
2000
+ * envelope and recovery data) is persisted before submission; a definitive rejection marks it
2001
+ * `failed`, anything ambiguous marks it `unknown` and throws `HtlcUnknownOutcomeError`.
2002
+ */
2003
+ async submitJournaled(entry, provider, label) {
2004
+ await putHtlcJournalEntry(entry);
2005
+ let transactionId;
2006
+ try {
2007
+ transactionId = await submitTransaction(provider, entry.envelope);
2008
+ await updateHtlcJournalEntry(entry.id, { status: "submitted", transactionId });
2009
+ const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, label);
2010
+ await this.finalizeHtlcEntry({ ...entry, transactionId }, response);
2011
+ return { transactionId };
2012
+ }
2013
+ catch (e) {
2014
+ const error = e instanceof Error ? e.message : String(e);
2015
+ if (isDefinitiveRejection(e)) {
2016
+ await updateHtlcJournalEntry(entry.id, { status: "failed", error, transactionId });
2017
+ await releaseReservations(entry.id);
2018
+ throw e;
2019
+ }
2020
+ await updateHtlcJournalEntry(entry.id, { status: "unknown", error, transactionId });
2021
+ throw new HtlcUnknownOutcomeError(entry.id, { kind: entry.kind, transactionId, conditions: entry.conditions, htlcCommitment: entry.htlcCommitment, outputMask: entry.outputMask }, e);
2022
+ }
2023
+ }
2024
+ /** Applies a confirmed HTLC operation's local bookkeeping. Idempotent (see `addShieldedOutput`). */
2025
+ async finalizeHtlcEntry(entry, response) {
2026
+ const txId = entry.transactionId;
2027
+ await recordKnownVersions(response);
2028
+ if (entry.ownCommitment && entry.ownAmount) {
2029
+ await recordKnownShieldedOutput(entry.accountId, entry.resourceAddress, entry.ownCommitment, BigInt(entry.ownAmount), txId);
2030
+ }
2031
+ for (const c of entry.spentCommitments ?? [])
2032
+ await markShieldedOutputSpent(entry.accountId, c);
2033
+ if (entry.privateFee) {
2034
+ await recordKnownShieldedOutput(entry.accountId, entry.privateFee.feeResourceAddress, entry.privateFee.changeCommitment, BigInt(entry.privateFee.changeAmount), txId);
2035
+ await markShieldedOutputSpent(entry.accountId, entry.privateFee.spentCommitment);
2036
+ }
2037
+ await updateHtlcJournalEntry(entry.id, { status: "confirmed", transactionId: txId, error: undefined });
2038
+ await releaseReservations(entry.id);
2039
+ }
2040
+ /** This account's HTLC journal (every fund/claim/refund and its state), newest first. The
2041
+ * entries' `envelope` of a claim contains the preimage — don't display or export it. */
2042
+ async listHtlcs() {
2043
+ return (await listHtlcJournal(localAccountId(this.index))).sort((a, b) => b.createdAt - a.createdAt);
2044
+ }
2045
+ /**
2046
+ * Resolves every HTLC operation whose outcome isn't final (`prepared`, `claim_armed`,
2047
+ * `submitted`, `unknown`) — call at startup and after any `HtlcUnknownOutcomeError`. An entry
2048
+ * with no transaction id is resubmitted with its *identical* sealed envelope (the same
2049
+ * transaction, so it can't execute twice; an expired one is simply rejected); then its result is
2050
+ * checked once. Confirmed entries get their bookkeeping applied, rejected ones are marked
2051
+ * `failed` and release their reserved UTXOs, still-pending ones are left for the next call.
2052
+ */
2053
+ async reconcileHtlcs() {
2054
+ const provider = await this.getProvider();
2055
+ const open = (await listHtlcJournal(localAccountId(this.index))).filter((e) => ["prepared", "claim_armed", "submitted", "unknown"].includes(e.status));
2056
+ const out = [];
2057
+ for (const entry of open) {
2058
+ let current = entry;
2059
+ try {
2060
+ let txId = entry.transactionId;
2061
+ if (!txId) {
2062
+ try {
2063
+ txId = await submitTransaction(provider, entry.envelope);
2064
+ current = (await updateHtlcJournalEntry(entry.id, { status: "submitted", transactionId: txId })) ?? current;
2065
+ }
2066
+ catch (e) {
2067
+ const error = e instanceof Error ? e.message : String(e);
2068
+ if (isDefinitiveRejection(e) || /HTTP 4\d\d/.test(error)) {
2069
+ current = (await updateHtlcJournalEntry(entry.id, { status: "failed", error })) ?? current;
2070
+ await releaseReservations(entry.id);
2071
+ }
2072
+ else {
2073
+ current = (await updateHtlcJournalEntry(entry.id, { status: "unknown", error })) ?? current;
2074
+ }
2075
+ out.push(current);
2076
+ continue;
2077
+ }
2078
+ }
2079
+ const response = await provider.getTransactionResult(txId);
2080
+ const result = response.result;
2081
+ if (result === "Pending") {
2082
+ out.push(current);
2083
+ continue;
2084
+ }
2085
+ if ("Rejected" in result) {
2086
+ current = (await updateHtlcJournalEntry(entry.id, { status: "failed", error: result.Rejected.details })) ?? current;
2087
+ await releaseReservations(entry.id);
2088
+ out.push(current);
2089
+ continue;
2090
+ }
2091
+ const outcome = result.Finalized.execution_result?.finalize.result;
2092
+ if (outcome && typeof outcome === "object" && "Accept" in outcome) {
2093
+ await this.finalizeHtlcEntry({ ...entry, transactionId: txId }, response);
2094
+ current = (await getHtlcJournalEntry(entry.id)) ?? current;
2095
+ }
2096
+ else {
2097
+ current = (await updateHtlcJournalEntry(entry.id, { status: "failed", error: JSON.stringify(outcome ?? null) })) ?? current;
2098
+ await releaseReservations(entry.id);
2099
+ }
2100
+ }
2101
+ catch (e) {
2102
+ // A transient error on one entry must not block the rest; it stays open for next time.
2103
+ current = (await updateHtlcJournalEntry(entry.id, { error: e instanceof Error ? e.message : String(e) })) ?? current;
2104
+ }
2105
+ out.push(current);
2106
+ }
2107
+ return out;
2108
+ }
2109
+ /**
2110
+ * Stealth commitments this account must not select right now: reserved by another in-flight
2111
+ * operation, or spent (as an input or a private fee) by an HTLC operation whose outcome isn't
2112
+ * final — those may already be gone on-chain even after their reservation lapses.
2113
+ */
2114
+ async unavailableCommitments(accountId, holder) {
2115
+ const out = await listReservedCommitments(accountId, holder);
2116
+ for (const e of await listHtlcJournal(accountId)) {
2117
+ if (e.id === holder || !["prepared", "claim_armed", "submitted", "unknown"].includes(e.status))
2118
+ continue;
2119
+ for (const c of e.spentCommitments ?? [])
2120
+ out.add(c);
2121
+ if (e.privateFee)
2122
+ out.add(e.privateFee.spentCommitment);
2123
+ }
2124
+ return out;
1682
2125
  }
1683
2126
  /**
1684
2127
  * Sends `amount` of this account's shielded balance for `resourceAddress` directly to
@@ -1710,68 +2153,76 @@ export class OotleAccount {
1710
2153
  async sendPrivately(resourceAddress, recipientWalletAddress, amount, maxFee = 100000n, memo, minimumValuePromise = 0n, feeType = { kind: "transparent" }) {
1711
2154
  assertValidMinimumValuePromise(minimumValuePromise, amount);
1712
2155
  const accountId = localAccountId(this.index);
1713
- const records = await this.filterReadableShieldedOutputs(await listShieldedOutputs(accountId), resourceAddress, amount);
2156
+ const opId = newOperationId("send-private");
2157
+ const unavailable = await this.unavailableCommitments(accountId, opId);
2158
+ const records = await this.filterReadableShieldedOutputs((await listShieldedOutputs(accountId)).filter((r) => !unavailable.has(r.commitment)), resourceAddress, amount);
1714
2159
  const { commitments, changeAmount } = resolveSendPrivatelyPlan(records, resourceAddress, amount);
1715
- const dust = 1n;
1716
- const provider = await this.getProvider();
1717
- const account = await this.getComponentAddress();
1718
- const ownWalletAddress = await this.getWalletAddress();
1719
- // See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
1720
- // never adds it on its own.
1721
- let builder = new StealthTransfer(provider, resourceAddress)
1722
- .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
1723
- .spendRevealedInput(account, dust);
1724
- for (const commitment of commitments) {
1725
- builder = builder.spendStealthInput(account, fromHex(commitment));
1726
- }
1727
- // The promise rides on the recipient's output only. Putting one on the change output would
1728
- // publish a floor on this account's own remaining private balance -- an unrelated disclosure the
1729
- // caller never asked for, and one the recipient has no interest in.
1730
- builder = builder.toStealthOutput(createOutput({ destination: recipientWalletAddress, amount, resourceAddress, memo: toMemo(memo), minimumValuePromise }));
1731
- if (changeAmount > 0n) {
1732
- builder = builder.toStealthOutput(createOutput({ destination: ownWalletAddress, amount: changeAmount, resourceAddress }));
1733
- }
1734
- builder = builder.toRevealedOutput(dust);
1735
- const viewSecret = await this.signer.getViewSecret();
1736
- // `commitments` excluded from the fee UTXO's own selection -- see unshield()'s identical
1737
- // comment on why (this and unshield are the only two methods with their own multi-UTXO
1738
- // coin selection of potentially the same resource as the fee).
1739
- const { transactionId, spec, privateFee } = await this.prepareSignSubmit(builder, provider, account, maxFee, feeType, { viewSecret }, commitments);
1740
- // Output 0 is the recipient's; output 1 (only present when there's change) is ours. The
1741
- // recipient has no way to discover their new output on their own (no scan-by-view-key API
1742
- // exists) -- this commitment must be handed back to the caller so it can be shared with them
1743
- // out of band; without it, the payment is invisible to them even though it succeeded on-chain.
1744
- const recipientCommitment = extractOutputCommitment(spec, 0);
1745
- const ownCommitment = changeAmount > 0n ? extractOutputCommitment(spec, 1) : undefined;
1746
- await addPendingShield({
1747
- transactionId,
1748
- accountId,
1749
- resourceAddress,
1750
- amount: changeAmount.toString(),
1751
- spentCommitments: commitments,
1752
- ownCommitment,
1753
- });
2160
+ await reserveCommitments(accountId, commitments, opId);
1754
2161
  try {
1755
- const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the private send");
1756
- await recordKnownVersions(response);
1757
- if (ownCommitment)
1758
- await recordKnownShieldedOutput(accountId, resourceAddress, ownCommitment, changeAmount, transactionId);
2162
+ const dust = 1n;
2163
+ const provider = await this.getProvider();
2164
+ const account = await this.getComponentAddress();
2165
+ const ownWalletAddress = await this.getWalletAddress();
2166
+ // See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
2167
+ // never adds it on its own.
2168
+ let builder = new StealthTransfer(provider, resourceAddress)
2169
+ .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
2170
+ .spendRevealedInput(account, dust);
1759
2171
  for (const commitment of commitments) {
1760
- await markShieldedOutputSpent(accountId, commitment);
2172
+ builder = builder.spendStealthInput(account, fromHex(commitment));
1761
2173
  }
1762
- if (privateFee && feeType.kind === "private") {
1763
- await this.recordPrivateFeeSpend(accountId, feeType.feeResourceAddress, privateFee, transactionId);
2174
+ // The promise rides on the recipient's output only. Putting one on the change output would
2175
+ // publish a floor on this account's own remaining private balance -- an unrelated disclosure the
2176
+ // caller never asked for, and one the recipient has no interest in.
2177
+ builder = builder.toStealthOutput(createOutput({ destination: recipientWalletAddress, amount, resourceAddress, memo: toMemo(memo), minimumValuePromise }));
2178
+ if (changeAmount > 0n) {
2179
+ builder = builder.toStealthOutput(createOutput({ destination: ownWalletAddress, amount: changeAmount, resourceAddress }));
1764
2180
  }
2181
+ builder = builder.toRevealedOutput(dust);
2182
+ const viewSecret = await this.signer.getViewSecret();
2183
+ // `commitments` excluded from the fee UTXO's own selection -- see unshield()'s identical
2184
+ // comment on why (this and unshield are the only two methods with their own multi-UTXO
2185
+ // coin selection of potentially the same resource as the fee).
2186
+ const { transactionId, spec, privateFee } = await this.prepareSignSubmit(builder, provider, account, maxFee, feeType, { viewSecret }, commitments, opId);
2187
+ // Output 0 is the recipient's; output 1 (only present when there's change) is ours. The
2188
+ // recipient has no way to discover their new output on their own (no scan-by-view-key API
2189
+ // exists) -- this commitment must be handed back to the caller so it can be shared with them
2190
+ // out of band; without it, the payment is invisible to them even though it succeeded on-chain.
2191
+ const recipientCommitment = extractOutputCommitment(spec, 0);
2192
+ const ownCommitment = changeAmount > 0n ? extractOutputCommitment(spec, 1) : undefined;
2193
+ await addPendingShield({
2194
+ transactionId,
2195
+ accountId,
2196
+ resourceAddress,
2197
+ amount: changeAmount.toString(),
2198
+ spentCommitments: commitments,
2199
+ ownCommitment,
2200
+ });
2201
+ try {
2202
+ const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the private send");
2203
+ await recordKnownVersions(response);
2204
+ if (ownCommitment)
2205
+ await recordKnownShieldedOutput(accountId, resourceAddress, ownCommitment, changeAmount, transactionId);
2206
+ for (const commitment of commitments) {
2207
+ await markShieldedOutputSpent(accountId, commitment);
2208
+ }
2209
+ if (privateFee && feeType.kind === "private") {
2210
+ await this.recordPrivateFeeSpend(accountId, feeType.feeResourceAddress, privateFee, transactionId);
2211
+ }
2212
+ }
2213
+ finally {
2214
+ await removePendingShield(transactionId);
2215
+ }
2216
+ return {
2217
+ transactionId,
2218
+ recipientCommitment,
2219
+ recipientSubstateId: stealthUtxoSubstateId(resourceAddress, fromHex(recipientCommitment)),
2220
+ minimumValuePromise: minimumValuePromise.toString(),
2221
+ };
1765
2222
  }
1766
2223
  finally {
1767
- await removePendingShield(transactionId);
2224
+ await releaseReservations(opId);
1768
2225
  }
1769
- return {
1770
- transactionId,
1771
- recipientCommitment,
1772
- recipientSubstateId: stealthUtxoSubstateId(resourceAddress, fromHex(recipientCommitment)),
1773
- minimumValuePromise: minimumValuePromise.toString(),
1774
- };
1775
2226
  }
1776
2227
  /**
1777
2228
  * The recipient-side counterpart to `sendPrivately()`'s commitment hand-off: given a commitment
@@ -2205,17 +2656,17 @@ export function buildHtlcSpendStatement(params) {
2205
2656
  return statementJson;
2206
2657
  }
2207
2658
  /**
2208
- * Submits a hand-built HTLC claim/refund transaction: one `StealthTransfer` instruction carrying
2209
- * `statementJson` verbatim, spending `substateId`, fee paid from `account`'s own revealed balance.
2659
+ * Builds and seals (but does not submit) a hand-built HTLC claim/refund transaction: one
2660
+ * `StealthTransfer` instruction carrying `statementJson` verbatim, spending `substateId`, with the fee
2661
+ * in its own lane -- native TARI from `account`'s public balance, or a private fee UTXO.
2210
2662
  *
2211
- * Deliberately bypasses `TransactionBuilder`'s usual `WalletStealthAuthorizer` companion — that
2212
- * pipeline only ever produces key-path stealth-input signatures, which don't apply to a
2213
- * script-path spend (whose authorization is the revealed leaf's own `AccessRule`, satisfied by
2214
- * this account's ordinary transaction signature) — `signTransaction`/`sealTransaction` alone are
2215
- * both necessary and sufficient here, the same pipeline any plain `CallMethod`-only transaction
2216
- * uses.
2663
+ * Deliberately bypasses `TransactionBuilder`'s usual `WalletStealthAuthorizer` companion -- that
2664
+ * pipeline only ever produces key-path stealth-input signatures, which don't apply to a script-path
2665
+ * spend (whose authorization is the revealed leaf's own `AccessRule`, satisfied by this account's
2666
+ * ordinary transaction signature) -- `signTransaction`/`sealTransaction` alone are both necessary and
2667
+ * sufficient here. `dryRun` marks the transaction before it's signed, for `dryRunEnvelope`.
2217
2668
  */
2218
- async function submitHtlcSpend(params) {
2669
+ async function buildHtlcSpendEnvelope(params) {
2219
2670
  const maxEpoch = await resolveMaxEpoch(params.provider);
2220
2671
  const builder = TransactionBuilder.new(params.network, maxEpoch)
2221
2672
  .addInput({ substate_id: params.substateId, version: null })
@@ -2243,14 +2694,12 @@ async function submitHtlcSpend(params) {
2243
2694
  for (const vaultId of await getVaultIdsForAccount(params.provider, params.account)) {
2244
2695
  builder.addInput({ substate_id: vaultId, version: null });
2245
2696
  }
2246
- const unsignedTx = await resolveTransaction(params.provider, builder.buildUnsignedTransaction());
2697
+ const unsigned = builder.buildUnsignedTransaction();
2698
+ unsigned.dry_run = params.dryRun;
2699
+ const unsignedTx = await resolveTransaction(params.provider, unsigned);
2247
2700
  const extraSigners = params.privateFee ? [params.privateFee.feeSigner] : [];
2248
2701
  const signed = await signTransaction([params.signer, ...extraSigners], unsignedTx);
2249
- const envelope = await sealTransaction(signed);
2250
- const transactionId = await submitTransaction(params.provider, envelope);
2251
- const response = await withTimeout(pollTransactionResult(params.provider, transactionId), 60_000, params.timeoutLabel);
2252
- await recordKnownVersions(response);
2253
- return transactionId;
2702
+ return sealTransaction(signed);
2254
2703
  }
2255
2704
  function extractOutputCommitment(spec, outputIndex) {
2256
2705
  const parsed = spec.statement.outputsStatement.parsed();