@chironbuilder/ootle-sdk 0.1.7 → 0.1.9

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
@@ -320,6 +320,7 @@ export class OotleAccount {
320
320
  const provider = await this.getProvider();
321
321
  const account = await this.getComponentAddress();
322
322
  const maxFee = opts.maxFee ?? 5000n;
323
+ const feeType = opts.feeType ?? { kind: "transparent" };
323
324
  // Resolved once, outside the retry loop below: every transaction now carries a mandatory
324
325
  // `max_epoch`, and a fresh chain-tip lookup on every retry would buy nothing (the loop's
325
326
  // whole retry budget runs in well under an epoch in practice) at the cost of an extra
@@ -335,18 +336,34 @@ export class OotleAccount {
335
336
  const maxRetries = opts.maxRetries ?? 30;
336
337
  const seenAddresses = new Set();
337
338
  let inputs = await applyKnownVersions(opts.inputs ? [...opts.inputs] : []);
339
+ // Built once, outside the retry loop, like `maxEpoch` above: the fee stealth proof doesn't
340
+ // depend on which main-instruction substates a given attempt discovers, so redoing the
341
+ // indexer round trip + proof generation on every retry would buy nothing.
342
+ const accountId = localAccountId(this.index);
343
+ const privateFee = await this.resolvePrivateFee(feeType, maxFee);
338
344
  for (let attempt = 0;; attempt++) {
339
- const builder = TransactionBuilder.new(this.network, maxEpoch)
340
- .withInstructions(instructions)
341
- .feeTransactionPayFromComponent(account, maxFee);
345
+ const builder = TransactionBuilder.new(this.network, maxEpoch).withInstructions(instructions);
346
+ if (privateFee) {
347
+ for (const instr of privateFee.feeInstructions)
348
+ builder.addFeeInstruction(instr);
349
+ }
350
+ else {
351
+ builder.feeTransactionPayFromComponent(account, maxFee);
352
+ }
342
353
  if (inputs.length)
343
354
  builder.withInputs(inputs);
355
+ if (privateFee)
356
+ builder.withInputs(privateFee.feeInputs);
344
357
  const unsignedTx = builder.buildUnsignedTransaction();
345
358
  try {
359
+ const extraSigners = privateFee ? [privateFee.feeSigner] : [];
346
360
  if (opts.dryRun)
347
- return await withTimeout(this.submitDryRun(provider, unsignedTx), 30_000, "submitting the transaction");
348
- const result = await withTimeout(this.submitReal(provider, unsignedTx), 60_000, "submitting the transaction");
361
+ return await withTimeout(this.submitDryRun(provider, unsignedTx, extraSigners), 30_000, "submitting the transaction");
362
+ const result = await withTimeout(this.submitReal(provider, unsignedTx, extraSigners), 60_000, "submitting the transaction");
349
363
  await recordKnownVersions(result);
364
+ if (privateFee && feeType.kind === "private") {
365
+ await this.recordPrivateFeeSpend(accountId, feeType.feeResourceAddress, privateFee, String(result.transaction_id));
366
+ }
350
367
  return withTransactionId(result);
351
368
  }
352
369
  catch (e) {
@@ -431,9 +448,9 @@ export class OotleAccount {
431
448
  * (only the real-submission response, which no longer carries a `result` field at all as of
432
449
  * the 0.39.0 protocol upgrade).
433
450
  */
434
- async submitDryRun(provider, unsignedTx) {
451
+ async submitDryRun(provider, unsignedTx, extraSigners = []) {
435
452
  const resolved = await resolveTransaction(provider, { ...unsignedTx, dry_run: true });
436
- const signed = await signTransaction([this.signer], resolved);
453
+ const signed = await signTransaction([this.signer, ...extraSigners], resolved);
437
454
  const envelope = sealTransaction(signed);
438
455
  const res = await fetch(`${defaultIndexerUrl(this.network)}/transactions/dry-run`, {
439
456
  method: "POST",
@@ -486,7 +503,7 @@ export class OotleAccount {
486
503
  * proven correct; only the result-polling and error-message construction are reimplemented, in
487
504
  * the same detailed shape `submitDryRun()` above already produces.
488
505
  */
489
- async submitReal(provider, unsignedTx) {
506
+ async submitReal(provider, unsignedTx, extraSigners = []) {
490
507
  const resolved = await resolveTransaction(provider, unsignedTx);
491
508
  // Both of these parse the transaction inside the wasm as an untagged enum
492
509
  // (`ootle_wasm/core/src/transaction.rs`), so a single bad field anywhere collapses into "data
@@ -497,7 +514,7 @@ export class OotleAccount {
497
514
  // is attached here rather than guessed at afterwards.
498
515
  let signed;
499
516
  try {
500
- signed = await signTransaction([this.signer], resolved);
517
+ signed = await signTransaction([this.signer, ...extraSigners], resolved);
501
518
  }
502
519
  catch (e) {
503
520
  throw new Error(`${e instanceof Error ? e.message : String(e)} — at signTransaction; resolved tx: ${describeResolvedTx(resolved)}`, { cause: e });
@@ -510,7 +527,8 @@ export class OotleAccount {
510
527
  throw new Error(`${e instanceof Error ? e.message : String(e)} — at sealTransaction; signed: ${describeSignedTx(signed)}`, { cause: e });
511
528
  }
512
529
  const { transaction_id } = await provider.submitTransaction(envelope);
513
- return pollTransactionResult(provider, transaction_id);
530
+ const result = await pollTransactionResult(provider, transaction_id);
531
+ return { ...result, transaction_id };
514
532
  }
515
533
  /**
516
534
  * Transfers `amount` (raw, resource-native units) of `resourceAddress` from this account to
@@ -536,7 +554,7 @@ export class OotleAccount {
536
554
  * never reveals the owner key `CreateAccount` needs, so that form can only pay an account that
537
555
  * already exists.
538
556
  */
539
- async send(recipientAddress, resourceAddress, amount, maxFee = 5000n) {
557
+ async send(recipientAddress, resourceAddress, amount, maxFee = 5000n, feeType = { kind: "transparent" }) {
540
558
  const account = await this.getComponentAddress();
541
559
  const isWalletAddress = recipientAddress.startsWith("otl");
542
560
  const recipient = isWalletAddress ? componentAddressFromWalletAddress(recipientAddress) : recipientAddress;
@@ -562,7 +580,7 @@ export class OotleAccount {
562
580
  }
563
581
  }
564
582
  instructions.push({ CallMethod: { call: { Address: account }, method: "withdraw", args: [resourceAddressLiteral(resourceAddress), amountLiteral(amount)] } }, { PutLastInstructionOutputOnWorkspace: { key: 0 } }, { CallMethod: { call: { Address: recipient }, method: "deposit", args: [{ Workspace: { id: 0, offset: null } }] } });
565
- return this.execute(instructions, { maxFee });
583
+ return this.execute(instructions, { maxFee, feeType });
566
584
  }
567
585
  /**
568
586
  * Moves `amount` of this account's own REVEALED balance into a Confidential-type vault for the
@@ -594,7 +612,7 @@ export class OotleAccount {
594
612
  * produces a bucket holding the one new confidential output, which `deposit` puts back in the
595
613
  * same vault.
596
614
  */
597
- async depositConfidential(resourceAddress, amount, maxFee = 50000n) {
615
+ async depositConfidential(resourceAddress, amount, maxFee = 50000n, feeType = { kind: "transparent" }) {
598
616
  if (amount <= 0n)
599
617
  throw new Error(`depositConfidential: amount must be greater than zero, got ${amount}`);
600
618
  const account = await this.getComponentAddress();
@@ -621,7 +639,7 @@ export class OotleAccount {
621
639
  { PutLastInstructionOutputOnWorkspace: { key: 0 } },
622
640
  { CallMethod: { call: { Address: account }, method: "deposit", args: [{ Workspace: { id: 0, offset: null } }] } },
623
641
  ];
624
- return this.execute(instructions, { maxFee });
642
+ return this.execute(instructions, { maxFee, feeType });
625
643
  }
626
644
  /**
627
645
  * Claims free testnet XTR from the network's builtin faucet (esmeralda/igor only — there is no
@@ -739,7 +757,7 @@ export class OotleAccount {
739
757
  * erring high just wastes nothing; erring low burns the small fee-intent cost with nothing to
740
758
  * show for it, since only the fee half of the transaction gets committed.
741
759
  */
742
- async shield(resourceAddress, amount, maxFee = 50000n, memo, minimumValuePromise = 0n) {
760
+ async shield(resourceAddress, amount, maxFee = 50000n, memo, minimumValuePromise = 0n, feeType = { kind: "transparent" }) {
743
761
  assertValidMinimumValuePromise(minimumValuePromise, amount);
744
762
  const accountId = localAccountId(this.index);
745
763
  const provider = await this.getProvider();
@@ -748,7 +766,7 @@ export class OotleAccount {
748
766
  // on-chain component address -- passing `account` here throws "Bech32 decode error" (confirmed
749
767
  // live). See getWalletAddress()'s doc comment for why these two addresses are easy to conflate.
750
768
  const walletAddress = await this.getWalletAddress();
751
- const spec = await new StealthTransfer(provider, resourceAddress)
769
+ const builder = new StealthTransfer(provider, resourceAddress)
752
770
  // StealthTransfer.prepare() auto-adds the revealed account's own substate and any vault
753
771
  // addresses embedded in its on-chain state, but never the resource's own substate -- fine
754
772
  // for XTR (resource_0101...0101 is engine-special-cased and needs no lock), but any other
@@ -757,24 +775,25 @@ export class OotleAccount {
757
775
  // source (node_modules/@tari-project/ootle/dist/index.js) -- not assumed from the docs.
758
776
  .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
759
777
  .spendRevealedInput(account, amount)
760
- .toStealthOutput(createOutput({ destination: walletAddress, amount, resourceAddress, memo: toMemo(memo), minimumValuePromise }))
761
- .payFeeFromRevealed(maxFee)
762
- .prepare();
778
+ .toStealthOutput(createOutput({ destination: walletAddress, amount, resourceAddress, memo: toMemo(memo), minimumValuePromise }));
779
+ // No stealth inputs to unblind for a shield's own transfer (revealed-only source), so no
780
+ // viewSecret needed here even when the FEE is paid privately -- `prepareSignSubmit` handles
781
+ // that UTXO's own decryption internally. `fromSpec`'s own default crypto is
782
+ // `new WasmStealthCrypto()` -- Network.LocalNet, NOT this account's real network -- which
783
+ // produced an "Invalid transaction signature" server-side rejection (confirmed live) since
784
+ // the balance proof it computes is network-domain-separated. Must match the network
785
+ // StealthTransfer itself used when it built the outputs statement (`prepareSignSubmit`
786
+ // already passes `this.network` for exactly this reason).
787
+ const { transactionId, spec, privateFee } = await this.prepareSignSubmit(builder, provider, account, maxFee, feeType);
763
788
  const ownCommitment = extractOutputCommitment(spec, 0);
764
- const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
765
- // No stealth inputs to unblind for a shield (revealed-only source), so no viewSecret needed.
766
- // `fromSpec`'s own default crypto is `new WasmStealthCrypto()` -- Network.LocalNet, NOT this
767
- // account's real network -- which produced an "Invalid transaction signature" server-side
768
- // rejection (confirmed live) since the balance proof it computes is network-domain-separated.
769
- // Must match the network StealthTransfer itself used when it built the outputs statement.
770
- const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
771
- const envelope = await authorized.seal();
772
- const transactionId = await submitTransaction(provider, envelope);
773
789
  await addPendingShield({ transactionId, accountId, resourceAddress, amount: amount.toString(), ownCommitment, memo });
774
790
  try {
775
791
  const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the shield transaction");
776
792
  await recordKnownVersions(response);
777
793
  await recordKnownShieldedOutput(accountId, resourceAddress, ownCommitment, amount, transactionId, memo);
794
+ if (privateFee && feeType.kind === "private") {
795
+ await this.recordPrivateFeeSpend(accountId, feeType.feeResourceAddress, privateFee, transactionId);
796
+ }
778
797
  }
779
798
  finally {
780
799
  await removePendingShield(transactionId);
@@ -876,7 +895,7 @@ export class OotleAccount {
876
895
  * mark the spent ones, even if the service worker dies between finalization and the storage
877
896
  * writes.
878
897
  */
879
- async unshield(resourceAddress, revealedOutAmount, maxFee = 100000n, memo) {
898
+ async unshield(resourceAddress, revealedOutAmount, maxFee = 100000n, memo, feeType = { kind: "transparent" }) {
880
899
  const accountId = localAccountId(this.index);
881
900
  const records = await listShieldedOutputs(accountId);
882
901
  const { commitments, remainder } = resolveUnshieldPlan(records, resourceAddress, revealedOutAmount);
@@ -894,18 +913,16 @@ export class OotleAccount {
894
913
  for (const commitment of commitments) {
895
914
  builder = builder.spendStealthInput(account, fromHex(commitment));
896
915
  }
897
- const spec = await builder
916
+ builder = builder
898
917
  .toStealthOutput(createOutput({ destination: walletAddress, amount: remainder, resourceAddress, memo: toMemo(memo) }))
899
- .toRevealedOutput(revealedOutAmount + dust)
900
- .payFeeFromRevealed(maxFee)
901
- .prepare();
902
- const ownCommitment = extractOutputCommitment(spec, 0);
903
- const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
918
+ .toRevealedOutput(revealedOutAmount + dust);
904
919
  const viewSecret = await this.signer.getViewSecret();
905
- // See shield()'s comment: must pass this account's real network, not fromSpec's LocalNet default.
906
- const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { viewSecret, crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
907
- const envelope = await authorized.seal();
908
- const transactionId = await submitTransaction(provider, envelope);
920
+ // See shield()'s comment: must pass this account's real network, not fromSpec's LocalNet
921
+ // default. `commitments` is excluded from the fee UTXO's own selection so the same shielded
922
+ // record can never be spent twice in one transaction (as both a main input here and the fee
923
+ // input) when `resourceAddress` and the fee's resource happen to be the same currency.
924
+ const { transactionId, spec, privateFee } = await this.prepareSignSubmit(builder, provider, account, maxFee, feeType, { viewSecret }, commitments);
925
+ const ownCommitment = extractOutputCommitment(spec, 0);
909
926
  await addPendingShield({
910
927
  transactionId,
911
928
  accountId,
@@ -922,6 +939,9 @@ export class OotleAccount {
922
939
  for (const commitment of commitments) {
923
940
  await markShieldedOutputSpent(accountId, commitment);
924
941
  }
942
+ if (privateFee && feeType.kind === "private") {
943
+ await this.recordPrivateFeeSpend(accountId, feeType.feeResourceAddress, privateFee, transactionId);
944
+ }
925
945
  }
926
946
  finally {
927
947
  await removePendingShield(transactionId);
@@ -955,7 +975,7 @@ export class OotleAccount {
955
975
  * substate with `SubstateNotFound`. Each is registered by address, plus every vault its own
956
976
  * state references (the same vault-discovery this account's own address already gets).
957
977
  */
958
- async withdrawStealthAndExecute(resourceAddress, amount, workspaceVarName, followUpInstructions, relatedComponents = [], maxFee = 100000n) {
978
+ async withdrawStealthAndExecute(resourceAddress, amount, workspaceVarName, followUpInstructions, relatedComponents = [], maxFee = 100000n, feeType = { kind: "transparent" }) {
959
979
  if (amount <= 0n)
960
980
  throw new Error(`withdrawStealthAndExecute amount must be > 0, got ${amount}`);
961
981
  const provider = await this.getProvider();
@@ -983,14 +1003,13 @@ export class OotleAccount {
983
1003
  builder = builder.withBuilder((b) => b.addInput({ substate_id: vaultId, version: null }));
984
1004
  }
985
1005
  }
986
- const spec = await builder.payFeeFromRevealed(maxFee).prepare();
987
- const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
988
1006
  const viewSecret = await this.signer.getViewSecret();
989
- const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { viewSecret, crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
990
- const envelope = await authorized.seal();
991
- const transactionId = await submitTransaction(provider, envelope);
1007
+ const { transactionId, privateFee } = await this.prepareSignSubmit(builder, provider, account, maxFee, feeType, { viewSecret });
992
1008
  const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the transaction");
993
1009
  await recordKnownVersions(response);
1010
+ if (privateFee && feeType.kind === "private") {
1011
+ await this.recordPrivateFeeSpend(localAccountId(this.index), feeType.feeResourceAddress, privateFee, transactionId);
1012
+ }
994
1013
  return { transactionId };
995
1014
  }
996
1015
  /**
@@ -1021,7 +1040,7 @@ export class OotleAccount {
1021
1040
  * @param relatedComponents Every *other* component `followUpInstructions` touches — same
1022
1041
  * reasoning as `withdrawStealthAndExecute`'s own parameter of the same name.
1023
1042
  */
1024
- async redeemStealthOutputAndExecute(resourceAddress, commitmentHex, revealedAmount, followUpInstructions, relatedComponents = [], maxFee = 100000n) {
1043
+ async redeemStealthOutputAndExecute(resourceAddress, commitmentHex, revealedAmount, followUpInstructions, relatedComponents = [], maxFee = 100000n, feeType = { kind: "transparent" }) {
1025
1044
  if (revealedAmount <= 0n) {
1026
1045
  throw new Error(`redeemStealthOutputAndExecute: revealedAmount must be > 0, got ${revealedAmount}`);
1027
1046
  }
@@ -1046,6 +1065,7 @@ export class OotleAccount {
1046
1065
  agg_range_proof: "",
1047
1066
  });
1048
1067
  const statement = new StealthTransferStatement(new StealthInputsStatement([], 0n, inputsJson), new StealthOutputsStatement(outputsJson));
1068
+ const privateFee = await this.resolvePrivateFee(feeType, maxFee);
1049
1069
  const maxEpoch = await resolveMaxEpoch(provider);
1050
1070
  const builder = TransactionBuilder.new(this.network, maxEpoch)
1051
1071
  .addInstruction({
@@ -1060,11 +1080,22 @@ export class OotleAccount {
1060
1080
  // (name resolution only applies to instructions built through *this* same chain; raw
1061
1081
  // pre-built instructions bypass it, same constraint `withdrawStealthAndExecute` documents).
1062
1082
  .saveVar("redeemed")
1063
- .withInstructions(followUpInstructions)
1064
- .feeTransactionPayFromComponent(account, maxFee)
1083
+ .withInstructions(followUpInstructions);
1084
+ if (privateFee) {
1085
+ for (const instr of privateFee.feeInstructions)
1086
+ builder.addFeeInstruction(instr);
1087
+ }
1088
+ else {
1089
+ builder.feeTransactionPayFromComponent(account, maxFee);
1090
+ }
1091
+ builder
1065
1092
  .addInput({ substate_id: resourceAddress, version: null })
1066
1093
  .addInput({ substate_id: stealthUtxoSubstateId(resourceAddress, commitment), version: null })
1067
1094
  .addInput({ substate_id: account, version: null });
1095
+ if (privateFee) {
1096
+ for (const input of privateFee.feeInputs)
1097
+ builder.addInput(input);
1098
+ }
1068
1099
  for (const vaultId of await getVaultIdsForAccount(provider, account)) {
1069
1100
  builder.addInput({ substate_id: vaultId, version: null });
1070
1101
  }
@@ -1092,10 +1123,13 @@ export class OotleAccount {
1092
1123
  const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
1093
1124
  const viewSecret = await this.signer.getViewSecret();
1094
1125
  const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { viewSecret, crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
1095
- const envelope = await authorized.seal();
1126
+ const envelope = privateFee ? await this.sealWithPrivateFee(authorized, privateFee.feeSigner) : await authorized.seal();
1096
1127
  const transactionId = await submitTransaction(provider, envelope);
1097
1128
  const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the redemption transaction");
1098
1129
  await recordKnownVersions(response);
1130
+ if (privateFee && feeType.kind === "private") {
1131
+ await this.recordPrivateFeeSpend(localAccountId(this.index), feeType.feeResourceAddress, privateFee, transactionId);
1132
+ }
1099
1133
  return { transactionId };
1100
1134
  }
1101
1135
  /**
@@ -1141,49 +1175,193 @@ export class OotleAccount {
1141
1175
  * (which records its own outputs locally) — callers doing several of these in sequence must
1142
1176
  * thread this value into the next call's `feeCommitmentHex` themselves.
1143
1177
  */
1178
+ /**
1179
+ * Builds the fee-payment instructions/inputs for spending a single stealth UTXO of
1180
+ * `feeResourceAddress` to cover `maxFee`, plus the one-time `Signer` that must co-sign the
1181
+ * resulting `StealthTransfer` fee spend (an ordinary account signature can't authorize a
1182
+ * stealth spend -- see `redeemStealthOutputWithPrivateFee`'s doc comment, which this method's
1183
+ * fee-half logic was extracted from verbatim). The unspent remainder above `maxFee` becomes a
1184
+ * new stealth output back to this account (`feeChangeCommitment`); this only builds
1185
+ * instructions, it doesn't touch local storage -- the caller records the change output (e.g.
1186
+ * via `recordKnownShieldedOutput`) and marks `feeCommitmentHex` spent once the transaction
1187
+ * actually lands, exactly as `redeemStealthOutputWithPrivateFee` itself does inline.
1188
+ *
1189
+ * Deliberately single-UTXO only, like the method this was extracted from -- the only shape
1190
+ * confirmed live. Aggregating several stealth inputs into one `StealthTransfer` fee spend
1191
+ * isn't proven here: `unshield()`'s multi-commitment coin selection uses a different,
1192
+ * higher-level signing pipeline (`WalletStealthAuthorizer`) that isn't available inside this
1193
+ * raw `TransactionBuilder`/`resolveTransaction`/`signTransaction` pipeline. Callers needing
1194
+ * more than one stealth input's worth of fee should pick the smallest single covering UTXO,
1195
+ * not sum several.
1196
+ */
1197
+ async buildPrivateFeeInstructions(feeResourceAddress, feeCommitmentHex, maxFee) {
1198
+ if (maxFee <= 0n)
1199
+ throw new Error(`buildPrivateFeeInstructions: maxFee must be > 0, got ${maxFee}`);
1200
+ const provider = await this.getProvider();
1201
+ const crypto = new WasmStealthCrypto(this.network);
1202
+ const walletAddress = await this.getWalletAddress();
1203
+ const viewSecret = await this.signer.getViewSecret();
1204
+ const commitment = fromHex(feeCommitmentHex);
1205
+ const substateId = stealthUtxoSubstateId(feeResourceAddress, commitment);
1206
+ const substate = await provider.getSubstate(substateId);
1207
+ const decrypted = await decryptOwnedUtxo(crypto, viewSecret, substate, substateId);
1208
+ if (!decrypted) {
1209
+ throw new Error(`buildPrivateFeeInstructions: cannot decrypt ${substateId} -- it doesn't belong to this account, or is already spent.`);
1210
+ }
1211
+ if (decrypted.value <= maxFee) {
1212
+ throw new Error(`buildPrivateFeeInstructions: fee UTXO (${decrypted.value}) is too small to cover maxFee (${maxFee})`);
1213
+ }
1214
+ const feeChange = decrypted.value - maxFee;
1215
+ const inputSkeleton = JSON.stringify({ inputs: [{ commitment: feeCommitmentHex, witness: "KeyPath" }], revealed_amount: "0" });
1216
+ const feeChangeOutput = createOutput({ destination: walletAddress, amount: feeChange, resourceAddress: feeResourceAddress });
1217
+ const { statement: feeOutputsStatement, outputMask: feeOutputMask } = await crypto.generateOutputsStatement([feeChangeOutput], maxFee);
1218
+ const feeInputsStatement = new StealthInputsStatement([], 0n, inputSkeleton);
1219
+ const feeAggInputMask = await crypto.aggregateInputMasks([decrypted.mask]);
1220
+ const feeBalanceProof = await crypto.generateBalanceProofSignature(feeAggInputMask, feeOutputMask, feeInputsStatement.statementJson, feeOutputsStatement.statementJson);
1221
+ const feeStatement = new StealthTransferStatement(feeInputsStatement, feeOutputsStatement, feeBalanceProof);
1222
+ await crypto.validateTransfer(feeStatement);
1223
+ const feeChangeCommitment = feeOutputsStatement.parsed().outputs[0].output.commitment;
1224
+ const feeInstructions = [
1225
+ {
1226
+ StealthTransfer: {
1227
+ resource_address_ref: { Address: feeResourceAddress },
1228
+ statement: { __ootleRawJson: feeStatement.toCompactJson() },
1229
+ revealed_input_bucket: null,
1230
+ },
1231
+ },
1232
+ { PutLastInstructionOutputOnWorkspace: { key: 0 } },
1233
+ { PayFeeFromBucket: { bucket: { id: 0, offset: null } } },
1234
+ ];
1235
+ const feeInputs = [
1236
+ { substate_id: feeResourceAddress, version: null },
1237
+ { substate_id: substateId, version: null },
1238
+ ];
1239
+ // The fee input's own sender_public_nonce lives on its UTXO substate -- fetched again here
1240
+ // (decryptOwnedUtxo above didn't return it) rather than parsed out of the substate response
1241
+ // type, whose shape `decryptOwnedUtxo` already validated once. Same convention
1242
+ // `redeemStealthOutputWithPrivateFee` used before this was extracted.
1243
+ const feeSubstate = await provider.getSubstate(substateId);
1244
+ const feeNonce = feeSubstate.substate.Utxo.output
1245
+ .output.public_nonce;
1246
+ const feeSigner = {
1247
+ getAddress: async () => walletAddress,
1248
+ getPublicKey: async () => parseOotleAddress(walletAddress).owner_key,
1249
+ signTransaction: async (tx, sealPublicKey) => {
1250
+ const json = serializeUnsignedTx(tx);
1251
+ const sig = await this.signer.addStealthSignature(json, fromHex(feeNonce), sealPublicKey, { crypto });
1252
+ return [sig];
1253
+ },
1254
+ };
1255
+ return { feeInstructions, feeInputs, feeChangeCommitment, feeChangeAmount: feeChange, feeSigner };
1256
+ }
1257
+ /**
1258
+ * Wires a `buildPrivateFeeInstructions()` result into a `StealthTransfer` builder in place of
1259
+ * `.payFeeFromRevealed(maxFee)` -- via the builder's `withBuilder` escape hatch, since the
1260
+ * class has no first-class "pay fee from a stealth UTXO" method of its own.
1261
+ */
1262
+ attachPrivateFee(builder, fee) {
1263
+ return builder.withBuilder((b) => {
1264
+ for (const instr of fee.feeInstructions)
1265
+ b.addFeeInstruction(instr);
1266
+ for (const input of fee.feeInputs)
1267
+ b.addInput(input);
1268
+ return b;
1269
+ });
1270
+ }
1271
+ /**
1272
+ * Completes signing for a `StealthTransfer`-based operation whose fee was attached via
1273
+ * `attachPrivateFee`: adds the fee UTXO's one-time authorization as an extra signature
1274
+ * (`AuthorizedTransfer.addSignature`) alongside whatever `WalletStealthAuthorizer` already
1275
+ * produced for the operation's own stealth inputs, then seals. Bound to the same seal public
1276
+ * key `authorized` itself uses, so every signature verifies against the same tx hash (see
1277
+ * `AuthorizedTransfer.seal`'s doc comment).
1278
+ */
1279
+ async sealWithPrivateFee(authorized, feeSigner) {
1280
+ const sealPublicKey = authorized.getSealPublicKey();
1281
+ const signatures = await feeSigner.signTransaction(authorized.getSpec().unsignedTx, sealPublicKey);
1282
+ for (const signature of signatures)
1283
+ authorized.addSignature(signature);
1284
+ return authorized.seal();
1285
+ }
1286
+ /**
1287
+ * Shared bookkeeping after ANY operation (`execute()` included) whose fee was paid privately
1288
+ * lands on-chain: records the fee UTXO's change output and marks the spent one, mirroring
1289
+ * `shield()`/`unshield()`'s existing pattern for their own outputs. Never call this for a dry
1290
+ * run or a failed submission.
1291
+ */
1292
+ async recordPrivateFeeSpend(accountId, feeResourceAddress, fee, transactionId) {
1293
+ await recordKnownShieldedOutput(accountId, feeResourceAddress, fee.feeChangeCommitment, fee.feeChangeAmount, transactionId);
1294
+ await markShieldedOutputSpent(accountId, fee.spentCommitment);
1295
+ }
1296
+ /**
1297
+ * Selects and builds everything needed to pay a transaction's fee privately, or returns `null`
1298
+ * for `{ kind: "transparent" }` -- the single entry point every fee-paying method (`execute()`
1299
+ * and every `StealthTransfer`-based one below) uses to interpret a `FeeType` the same way.
1300
+ */
1301
+ async resolvePrivateFee(feeType, maxFee,
1302
+ /** Commitments already claimed by the operation's own main effect (e.g. `unshield()`'s or
1303
+ * `sendPrivately()`'s own multi-UTXO coin selection) -- excluded so the same commitment can
1304
+ * never be selected as both a main input and the fee input in one transaction. */
1305
+ exclude = []) {
1306
+ if (feeType.kind !== "private")
1307
+ return null;
1308
+ const accountId = localAccountId(this.index);
1309
+ const chosen = selectPrivateFeeUtxo(await listShieldedOutputs(accountId), feeType.feeResourceAddress, maxFee, exclude);
1310
+ const built = await this.buildPrivateFeeInstructions(feeType.feeResourceAddress, chosen.commitment, maxFee);
1311
+ return { ...built, spentCommitment: chosen.commitment };
1312
+ }
1313
+ /**
1314
+ * Shared prepare/authorize/seal/submit path for every `StealthTransfer`-based operation
1315
+ * (shield/unshield/withdrawStealthAndExecute/etc.): `builder` must already have every
1316
+ * operation-specific call applied (`spendRevealedInput`, `toStealthOutput`,
1317
+ * `spendStealthInput`, `andThen`, ...) EXCEPT the fee -- this attaches either
1318
+ * `.payFeeFromRevealed(maxFee)` or a private fee (via `resolvePrivateFee`/`attachPrivateFee`),
1319
+ * then authorizes with `WalletStealthAuthorizer` (passing `authorizerOpts` through, e.g.
1320
+ * `viewSecret` when the operation itself spends stealth inputs), seals (co-signing the private
1321
+ * fee's UTXO when there is one), and submits -- but does NOT poll to finality or record
1322
+ * anything: callers keep their own existing `addPendingShield`/poll/`recordKnownShieldedOutput`
1323
+ * logic for the operation's own effect, and must call `recordPrivateFeeSpend` themselves once
1324
+ * `transactionId` actually lands (never for a dry run) using the returned `privateFee`.
1325
+ */
1326
+ async prepareSignSubmit(builder, provider, account, maxFee, feeType, authorizerOpts = {},
1327
+ /** See `resolvePrivateFee`'s own doc comment. */
1328
+ excludeFromFeeSelection = []) {
1329
+ const privateFee = await this.resolvePrivateFee(feeType, maxFee, excludeFromFeeSelection);
1330
+ const spec = await (privateFee ? this.attachPrivateFee(builder, privateFee) : builder.payFeeFromRevealed(maxFee)).prepare();
1331
+ const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
1332
+ const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, {
1333
+ crypto: new WasmStealthCrypto(this.network),
1334
+ ...authorizerOpts,
1335
+ }).prepare(provider);
1336
+ const envelope = privateFee ? await this.sealWithPrivateFee(authorized, privateFee.feeSigner) : await authorized.seal();
1337
+ const transactionId = await submitTransaction(provider, envelope);
1338
+ return { transactionId, spec, privateFee };
1339
+ }
1144
1340
  async redeemStealthOutputWithPrivateFee(resourceAddress, commitmentHex, revealedAmount, followUpInstructions, feeResourceAddress, feeCommitmentHex, maxFee, relatedComponents = []) {
1145
1341
  if (revealedAmount <= 0n)
1146
1342
  throw new Error(`redeemStealthOutputWithPrivateFee: revealedAmount must be > 0, got ${revealedAmount}`);
1147
- if (maxFee <= 0n)
1148
- throw new Error(`redeemStealthOutputWithPrivateFee: maxFee must be > 0, got ${maxFee}`);
1149
1343
  const provider = await this.getProvider();
1150
1344
  const crypto = new WasmStealthCrypto(this.network);
1151
1345
  const walletAddress = await this.getWalletAddress();
1152
1346
  const viewSecret = await this.signer.getViewSecret();
1153
- const decryptStealthInput = async (resource, commitmentHexIn) => {
1154
- const commitment = fromHex(commitmentHexIn);
1155
- const substateId = stealthUtxoSubstateId(resource, commitment);
1156
- const substate = await provider.getSubstate(substateId);
1157
- const decrypted = await decryptOwnedUtxo(crypto, viewSecret, substate, substateId);
1158
- if (!decrypted) {
1159
- throw new Error(`redeemStealthOutputWithPrivateFee: cannot decrypt ${substateId} -- it doesn't belong to this account, or is already spent.`);
1160
- }
1161
- return { commitmentHex: commitmentHexIn, substateId, mask: decrypted.mask, value: decrypted.value };
1162
- };
1163
- const redeemed = await decryptStealthInput(resourceAddress, commitmentHex);
1164
- const feeInput = await decryptStealthInput(feeResourceAddress, feeCommitmentHex);
1165
- if (feeInput.value <= maxFee) {
1166
- throw new Error(`redeemStealthOutputWithPrivateFee: fee UTXO (${feeInput.value}) is too small to cover maxFee (${maxFee})`);
1347
+ const commitment = fromHex(commitmentHex);
1348
+ const substateId = stealthUtxoSubstateId(resourceAddress, commitment);
1349
+ const substate = await provider.getSubstate(substateId);
1350
+ const decrypted = await decryptOwnedUtxo(crypto, viewSecret, substate, substateId);
1351
+ if (!decrypted) {
1352
+ throw new Error(`redeemStealthOutputWithPrivateFee: cannot decrypt ${substateId} -- it doesn't belong to this account, or is already spent.`);
1167
1353
  }
1168
- const feeChange = feeInput.value - maxFee;
1169
- const inputSkeleton = (hex) => JSON.stringify({ inputs: [{ commitment: hex, witness: "KeyPath" }], revealed_amount: "0" });
1354
+ const redeemed = { commitmentHex, substateId, mask: decrypted.mask, value: decrypted.value };
1355
+ const { feeInstructions, feeInputs, feeChangeCommitment, feeSigner } = await this.buildPrivateFeeInstructions(feeResourceAddress, feeCommitmentHex, maxFee);
1356
+ const inputSkeleton = JSON.stringify({ inputs: [{ commitment: redeemed.commitmentHex, witness: "KeyPath" }], revealed_amount: "0" });
1170
1357
  // Redeemed resource: 1 stealth input -> 0 stealth outputs, fully revealed into the bucket
1171
1358
  // `followUpInstructions` consumes.
1172
- const redeemedInputsStatement = new StealthInputsStatement([], 0n, inputSkeleton(redeemed.commitmentHex));
1359
+ const redeemedInputsStatement = new StealthInputsStatement([], 0n, inputSkeleton);
1173
1360
  const redeemedOutputsStatement = new StealthOutputsStatement(JSON.stringify({ outputs: [], revealed_output_amount: revealedAmount.toString(), agg_range_proof: "" }));
1174
1361
  const redeemedAggInputMask = await crypto.aggregateInputMasks([redeemed.mask]);
1175
1362
  const redeemedBalanceProof = await crypto.generateBalanceProofSignature(redeemedAggInputMask, Mask.zero(), redeemedInputsStatement.statementJson, redeemedOutputsStatement.statementJson);
1176
1363
  const redeemedStatement = new StealthTransferStatement(redeemedInputsStatement, redeemedOutputsStatement, redeemedBalanceProof);
1177
1364
  await crypto.validateTransfer(redeemedStatement);
1178
- // Fee resource: 1 stealth input -> 1 stealth change output + revealed maxFee.
1179
- const feeChangeOutput = createOutput({ destination: walletAddress, amount: feeChange, resourceAddress: feeResourceAddress });
1180
- const { statement: feeOutputsStatement, outputMask: feeOutputMask } = await crypto.generateOutputsStatement([feeChangeOutput], maxFee);
1181
- const feeInputsStatement = new StealthInputsStatement([], 0n, inputSkeleton(feeInput.commitmentHex));
1182
- const feeAggInputMask = await crypto.aggregateInputMasks([feeInput.mask]);
1183
- const feeBalanceProof = await crypto.generateBalanceProofSignature(feeAggInputMask, feeOutputMask, feeInputsStatement.statementJson, feeOutputsStatement.statementJson);
1184
- const feeStatement = new StealthTransferStatement(feeInputsStatement, feeOutputsStatement, feeBalanceProof);
1185
- await crypto.validateTransfer(feeStatement);
1186
- const feeChangeCommitment = feeOutputsStatement.parsed().outputs[0].output.commitment;
1187
1365
  const maxEpoch = await resolveMaxEpoch(provider);
1188
1366
  const builder = TransactionBuilder.new(this.network, maxEpoch)
1189
1367
  .addInstruction({
@@ -1194,20 +1372,12 @@ export class OotleAccount {
1194
1372
  },
1195
1373
  })
1196
1374
  .addInstruction({ PutLastInstructionOutputOnWorkspace: { key: 0 } })
1197
- .withInstructions(followUpInstructions)
1198
- .addFeeInstruction({
1199
- StealthTransfer: {
1200
- resource_address_ref: { Address: feeResourceAddress },
1201
- statement: { __ootleRawJson: feeStatement.toCompactJson() },
1202
- revealed_input_bucket: null,
1203
- },
1204
- })
1205
- .addFeeInstruction({ PutLastInstructionOutputOnWorkspace: { key: 0 } })
1206
- .addFeeInstruction({ PayFeeFromBucket: { bucket: { id: 0, offset: null } } })
1207
- .addInput({ substate_id: resourceAddress, version: null })
1208
- .addInput({ substate_id: redeemed.substateId, version: null })
1209
- .addInput({ substate_id: feeResourceAddress, version: null })
1210
- .addInput({ substate_id: feeInput.substateId, version: null });
1375
+ .withInstructions(followUpInstructions);
1376
+ for (const instr of feeInstructions)
1377
+ builder.addFeeInstruction(instr);
1378
+ builder.addInput({ substate_id: resourceAddress, version: null }).addInput({ substate_id: redeemed.substateId, version: null });
1379
+ for (const input of feeInputs)
1380
+ builder.addInput(input);
1211
1381
  for (const component of relatedComponents) {
1212
1382
  builder.addInput({ substate_id: component, version: null });
1213
1383
  for (const vaultId of await getVaultIdsForAccount(provider, component)) {
@@ -1215,28 +1385,22 @@ export class OotleAccount {
1215
1385
  }
1216
1386
  }
1217
1387
  const unsignedTx = await resolveTransaction(provider, builder.buildUnsignedTransaction());
1218
- // One addStealthSignature per stealth input, bound to a shared (arbitrary) seal key that
1219
- // signTransaction generates -- see this method's own doc comment for why no ordinary
1388
+ // The redeemed input's one-time key co-signs the same way the fee input's does inside
1389
+ // `buildPrivateFeeInstructions` -- see this method's own doc comment for why no ordinary
1220
1390
  // account-key signature is added.
1221
- const oneTimeSigner = (publicNonceHex) => ({
1391
+ const redeemedSubstate = await provider.getSubstate(redeemed.substateId);
1392
+ const redeemedNonce = redeemedSubstate.substate.Utxo
1393
+ .output.output.public_nonce;
1394
+ const redeemedSigner = {
1222
1395
  getAddress: async () => walletAddress,
1223
1396
  getPublicKey: async () => parseOotleAddress(walletAddress).owner_key,
1224
1397
  signTransaction: async (tx, sealPublicKey) => {
1225
1398
  const json = serializeUnsignedTx(tx);
1226
- const sig = await this.signer.addStealthSignature(json, fromHex(publicNonceHex), sealPublicKey, { crypto });
1399
+ const sig = await this.signer.addStealthSignature(json, fromHex(redeemedNonce), sealPublicKey, { crypto });
1227
1400
  return [sig];
1228
1401
  },
1229
- });
1230
- // Each stealth input's own sender_public_nonce lives on its UTXO substate -- fetched again
1231
- // here (decryptOwnedUtxo above didn't return it) rather than parsed out of the substate
1232
- // response type, whose shape `decryptOwnedUtxo` already validated once.
1233
- const redeemedSubstate = await provider.getSubstate(redeemed.substateId);
1234
- const feeInputSubstate = await provider.getSubstate(feeInput.substateId);
1235
- const redeemedNonce = redeemedSubstate.substate.Utxo
1236
- .output.output.public_nonce;
1237
- const feeNonce = feeInputSubstate.substate.Utxo.output
1238
- .output.public_nonce;
1239
- const signed = await signTransaction([oneTimeSigner(redeemedNonce), oneTimeSigner(feeNonce)], unsignedTx);
1402
+ };
1403
+ const signed = await signTransaction([redeemedSigner, feeSigner], unsignedTx);
1240
1404
  const envelope = sealTransaction(signed);
1241
1405
  const transactionId = await submitTransaction(provider, envelope);
1242
1406
  const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the private-fee redemption transaction");
@@ -1273,7 +1437,7 @@ export class OotleAccount {
1273
1437
  * other stealth payment addressed to them — spend authority is governed entirely separately, by
1274
1438
  * `payTo`.
1275
1439
  */
1276
- async htlcFund(resourceAddress, amount, claimantWalletAddress, hashLockHex, refundEpoch, maxFee = 50000n) {
1440
+ async htlcFund(resourceAddress, amount, claimantWalletAddress, hashLockHex, refundEpoch, maxFee = 50000n, feeType = { kind: "transparent" }) {
1277
1441
  const provider = await this.getProvider();
1278
1442
  const account = await this.getComponentAddress();
1279
1443
  const walletAddress = await this.getWalletAddress();
@@ -1287,21 +1451,20 @@ export class OotleAccount {
1287
1451
  });
1288
1452
  // See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
1289
1453
  // never adds it on its own.
1290
- const spec = await new StealthTransfer(provider, resourceAddress)
1454
+ const builder = new StealthTransfer(provider, resourceAddress)
1291
1455
  .withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
1292
1456
  .spendRevealedInput(account, amount)
1293
- .toStealthOutput(createOutput({ destination: claimantWalletAddress, amount, resourceAddress, payTo: { Conditions: conditions } }))
1294
- .payFeeFromRevealed(maxFee)
1295
- .prepare();
1457
+ .toStealthOutput(createOutput({ destination: claimantWalletAddress, amount, resourceAddress, payTo: { Conditions: conditions } }));
1458
+ // No stealth inputs to unblind for this fund's own transfer (revealed-only source), so no
1459
+ // viewSecret needed here even when the FEE is paid privately -- same as shield().
1460
+ const { transactionId, spec, privateFee } = await this.prepareSignSubmit(builder, provider, account, maxFee, feeType);
1296
1461
  const ownCommitment = extractOutputCommitment(spec, 0);
1297
1462
  const outputMask = spec.outputMask.toHex();
1298
- const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
1299
- // No stealth inputs to unblind (revealed-only source), so no viewSecret needed -- same as shield().
1300
- const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
1301
- const envelope = await authorized.seal();
1302
- const transactionId = await submitTransaction(provider, envelope);
1303
1463
  const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the HTLC funding transaction");
1304
1464
  await recordKnownVersions(response);
1465
+ if (privateFee && feeType.kind === "private") {
1466
+ await this.recordPrivateFeeSpend(localAccountId(this.index), feeType.feeResourceAddress, privateFee, transactionId);
1467
+ }
1305
1468
  return { transactionId, conditions, ownCommitment, outputMask };
1306
1469
  }
1307
1470
  /**
@@ -1331,7 +1494,7 @@ export class OotleAccount {
1331
1494
  * unblinded mask+value and the two witnesses below, so there is nothing left for the builder
1332
1495
  * pipeline to add.
1333
1496
  */
1334
- async htlcClaim(resourceAddress, commitmentHex, conditions, preimageHex, maxFee = 50000n) {
1497
+ async htlcClaim(resourceAddress, commitmentHex, conditions, preimageHex, maxFee = 50000n, feeType = { kind: "transparent" }) {
1335
1498
  if (!/^[0-9a-f]{64}$/i.test(preimageHex)) {
1336
1499
  throw new Error(`htlcClaim: preimageHex must be exactly 64 hex characters (32 bytes), got ${JSON.stringify(preimageHex)}`);
1337
1500
  }
@@ -1359,6 +1522,7 @@ export class OotleAccount {
1359
1522
  destinationWalletAddress: walletAddress,
1360
1523
  resourceAddress,
1361
1524
  });
1525
+ const privateFee = await this.resolvePrivateFee(feeType, maxFee);
1362
1526
  const transactionId = await submitHtlcSpend({
1363
1527
  provider,
1364
1528
  signer: this.signer,
@@ -1369,7 +1533,11 @@ export class OotleAccount {
1369
1533
  statementJson,
1370
1534
  maxFee,
1371
1535
  timeoutLabel: "submitting the HTLC claim transaction",
1536
+ privateFee,
1372
1537
  });
1538
+ if (privateFee && feeType.kind === "private") {
1539
+ await this.recordPrivateFeeSpend(localAccountId(this.index), feeType.feeResourceAddress, privateFee, transactionId);
1540
+ }
1373
1541
  return { transactionId };
1374
1542
  }
1375
1543
  /**
@@ -1390,7 +1558,7 @@ export class OotleAccount {
1390
1558
  * `buildStealthTransferStatement`-in-one-call approach, same bypass of
1391
1559
  * `StealthTransfer`/`WalletStealthAuthorizer`.
1392
1560
  */
1393
- async htlcRefund(resourceAddress, commitmentHex, conditions, amount, outputMaskHex, maxFee = 50000n) {
1561
+ async htlcRefund(resourceAddress, commitmentHex, conditions, amount, outputMaskHex, maxFee = 50000n, feeType = { kind: "transparent" }) {
1394
1562
  const provider = await this.getProvider();
1395
1563
  const account = await this.getComponentAddress();
1396
1564
  const walletAddress = await this.getWalletAddress();
@@ -1409,6 +1577,7 @@ export class OotleAccount {
1409
1577
  destinationWalletAddress: walletAddress,
1410
1578
  resourceAddress,
1411
1579
  });
1580
+ const privateFee = await this.resolvePrivateFee(feeType, maxFee);
1412
1581
  const transactionId = await submitHtlcSpend({
1413
1582
  provider,
1414
1583
  signer: this.signer,
@@ -1419,7 +1588,11 @@ export class OotleAccount {
1419
1588
  statementJson,
1420
1589
  maxFee,
1421
1590
  timeoutLabel: "submitting the HTLC refund transaction",
1591
+ privateFee,
1422
1592
  });
1593
+ if (privateFee && feeType.kind === "private") {
1594
+ await this.recordPrivateFeeSpend(localAccountId(this.index), feeType.feeResourceAddress, privateFee, transactionId);
1595
+ }
1423
1596
  return { transactionId };
1424
1597
  }
1425
1598
  /**
@@ -1449,7 +1622,7 @@ export class OotleAccount {
1449
1622
  * grabbing "the first new stealth output" would risk recording the recipient's payment as our
1450
1623
  * own. See `PendingShield.ownCommitment`'s doc comment.
1451
1624
  */
1452
- async sendPrivately(resourceAddress, recipientWalletAddress, amount, maxFee = 100000n, memo, minimumValuePromise = 0n) {
1625
+ async sendPrivately(resourceAddress, recipientWalletAddress, amount, maxFee = 100000n, memo, minimumValuePromise = 0n, feeType = { kind: "transparent" }) {
1453
1626
  assertValidMinimumValuePromise(minimumValuePromise, amount);
1454
1627
  const accountId = localAccountId(this.index);
1455
1628
  const records = await listShieldedOutputs(accountId);
@@ -1473,18 +1646,18 @@ export class OotleAccount {
1473
1646
  if (changeAmount > 0n) {
1474
1647
  builder = builder.toStealthOutput(createOutput({ destination: ownWalletAddress, amount: changeAmount, resourceAddress }));
1475
1648
  }
1476
- const spec = await builder.toRevealedOutput(dust).payFeeFromRevealed(maxFee).prepare();
1649
+ builder = builder.toRevealedOutput(dust);
1650
+ const viewSecret = await this.signer.getViewSecret();
1651
+ // `commitments` excluded from the fee UTXO's own selection -- see unshield()'s identical
1652
+ // comment on why (this and unshield are the only two methods with their own multi-UTXO
1653
+ // coin selection of potentially the same resource as the fee).
1654
+ const { transactionId, spec, privateFee } = await this.prepareSignSubmit(builder, provider, account, maxFee, feeType, { viewSecret }, commitments);
1477
1655
  // Output 0 is the recipient's; output 1 (only present when there's change) is ours. The
1478
1656
  // recipient has no way to discover their new output on their own (no scan-by-view-key API
1479
1657
  // exists) -- this commitment must be handed back to the caller so it can be shared with them
1480
1658
  // out of band; without it, the payment is invisible to them even though it succeeded on-chain.
1481
1659
  const recipientCommitment = extractOutputCommitment(spec, 0);
1482
1660
  const ownCommitment = changeAmount > 0n ? extractOutputCommitment(spec, 1) : undefined;
1483
- const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
1484
- const viewSecret = await this.signer.getViewSecret();
1485
- const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { viewSecret, crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
1486
- const envelope = await authorized.seal();
1487
- const transactionId = await submitTransaction(provider, envelope);
1488
1661
  await addPendingShield({
1489
1662
  transactionId,
1490
1663
  accountId,
@@ -1501,6 +1674,9 @@ export class OotleAccount {
1501
1674
  for (const commitment of commitments) {
1502
1675
  await markShieldedOutputSpent(accountId, commitment);
1503
1676
  }
1677
+ if (privateFee && feeType.kind === "private") {
1678
+ await this.recordPrivateFeeSpend(accountId, feeType.feeResourceAddress, privateFee, transactionId);
1679
+ }
1504
1680
  }
1505
1681
  finally {
1506
1682
  await removePendingShield(transactionId);
@@ -1684,16 +1860,71 @@ export class OotleAccount {
1684
1860
  const known = await listShieldedOutputs(accountId);
1685
1861
  const knownCommitments = new Set(known.filter((r) => r.resourceAddress === resourceAddress).map((r) => r.commitment));
1686
1862
  const prefix = `utxo_${resourceAddress.startsWith("resource_") ? resourceAddress.slice(9) : resourceAddress}_`;
1687
- const pageSize = opts.pageSize ?? 50;
1688
- const pageBudget = opts.maxPages ?? 10;
1689
1863
  // Some resources (a voting template's ballot, a raffle ticket) mint at most one output per
1690
1864
  // account by construction -- a caller that already knows this can pass `limit: 1` to stop the
1691
- // walk (both the page loop and the per-transaction fetches) the moment it's satisfied, rather
1692
- // than paying for however much of the budget happened to be left.
1865
+ // walk the moment it's satisfied, rather than paying for however much of the budget happened
1866
+ // to be left.
1693
1867
  const limit = opts.limit ?? Infinity;
1694
- let lastId = null;
1695
1868
  const found = [];
1696
- pages: for (let page = 0; page < pageBudget; page++) {
1869
+ // Checks one already-known transaction id for a matching output. Failures here (a 404 for a
1870
+ // transaction the indexer hasn't finished materializing a full result for yet, a transient
1871
+ // network error, whatever) are swallowed and treated as "nothing found in this one" rather than
1872
+ // aborting the entire scan -- confirmed necessary empirically: a single bad lookup among dozens
1873
+ // in a page used to fail the whole call, forcing a full retry from scratch for something that
1874
+ // would otherwise have kept going and found the real match a few transactions later.
1875
+ const checkTransaction = async (transactionId) => {
1876
+ let response;
1877
+ try {
1878
+ response = await provider.getTransactionResult(transactionId);
1879
+ }
1880
+ catch {
1881
+ return;
1882
+ }
1883
+ const result = response.result;
1884
+ if (result === "Pending" || "Rejected" in result)
1885
+ return;
1886
+ const outcome = result.Finalized.execution_result?.finalize.result;
1887
+ const upSubstates = outcome && typeof outcome === "object" && "Accept" in outcome
1888
+ ? outcome.Accept.up_substates
1889
+ : outcome && typeof outcome === "object" && "AcceptFeeRejectRest" in outcome
1890
+ ? outcome.AcceptFeeRejectRest[0].up_substates
1891
+ : undefined;
1892
+ if (!upSubstates)
1893
+ return;
1894
+ for (const [substateId, substate] of upSubstates) {
1895
+ if (!substateId.startsWith(prefix))
1896
+ continue;
1897
+ const commitment = substateId.slice(prefix.length);
1898
+ if (knownCommitments.has(commitment))
1899
+ continue;
1900
+ const decrypted = await decryptOwnedUtxo(crypto, viewSecret, { ...substate, verified: true }, substateId);
1901
+ if (!decrypted)
1902
+ continue;
1903
+ const memo = fromMemo(decrypted.memo);
1904
+ await recordKnownShieldedOutput(accountId, resourceAddress, commitment, decrypted.value, transactionId, memo);
1905
+ knownCommitments.add(commitment);
1906
+ found.push({ resourceAddress, commitment, amount: decrypted.value, transactionId, memo });
1907
+ if (found.length >= limit)
1908
+ return;
1909
+ }
1910
+ };
1911
+ // Targeted path: when the caller already knows exactly where this resource's outputs were
1912
+ // created (e.g. a voting app's own election-creation transaction id, which is exactly where
1913
+ // every ballot for that election was minted), check those directly instead of ever touching
1914
+ // `listRecentTransactions` -- turns an O(recent chain history) walk into O(len(transactionIds))
1915
+ // direct lookups.
1916
+ for (const transactionId of opts.transactionIds ?? []) {
1917
+ await checkTransaction(transactionId);
1918
+ if (found.length >= limit)
1919
+ return found;
1920
+ }
1921
+ if (found.length > 0 && opts.transactionIds?.length)
1922
+ return found;
1923
+ const pageSize = opts.pageSize ?? 50;
1924
+ const pageBudget = opts.maxPages ?? 10;
1925
+ const alreadyChecked = new Set(opts.transactionIds ?? []);
1926
+ let lastId = null;
1927
+ for (let page = 0; page < pageBudget; page++) {
1697
1928
  const { transactions } = await provider.listRecentTransactions({ limit: pageSize, last_id: lastId, source: null });
1698
1929
  if (transactions.length === 0)
1699
1930
  break;
@@ -1701,34 +1932,11 @@ export class OotleAccount {
1701
1932
  // No receipt yet, or rejected at mempool submission -- neither ever produced up_substates.
1702
1933
  if (entry.rejected_reason !== null || !entry.summary)
1703
1934
  continue;
1704
- const response = await provider.getTransactionResult(entry.transaction_id);
1705
- const result = response.result;
1706
- if (result === "Pending" || "Rejected" in result)
1935
+ if (alreadyChecked.has(entry.transaction_id))
1707
1936
  continue;
1708
- const outcome = result.Finalized.execution_result?.finalize.result;
1709
- const upSubstates = outcome && typeof outcome === "object" && "Accept" in outcome
1710
- ? outcome.Accept.up_substates
1711
- : outcome && typeof outcome === "object" && "AcceptFeeRejectRest" in outcome
1712
- ? outcome.AcceptFeeRejectRest[0].up_substates
1713
- : undefined;
1714
- if (!upSubstates)
1715
- continue;
1716
- for (const [substateId, substate] of upSubstates) {
1717
- if (!substateId.startsWith(prefix))
1718
- continue;
1719
- const commitment = substateId.slice(prefix.length);
1720
- if (knownCommitments.has(commitment))
1721
- continue;
1722
- const decrypted = await decryptOwnedUtxo(crypto, viewSecret, { ...substate, verified: true }, substateId);
1723
- if (!decrypted)
1724
- continue;
1725
- const memo = fromMemo(decrypted.memo);
1726
- await recordKnownShieldedOutput(accountId, resourceAddress, commitment, decrypted.value, entry.transaction_id, memo);
1727
- knownCommitments.add(commitment);
1728
- found.push({ resourceAddress, commitment, amount: decrypted.value, transactionId: entry.transaction_id, memo });
1729
- if (found.length >= limit)
1730
- break pages;
1731
- }
1937
+ await checkTransaction(entry.transaction_id);
1938
+ if (found.length >= limit)
1939
+ return found;
1732
1940
  }
1733
1941
  lastId = transactions[transactions.length - 1].transaction_id;
1734
1942
  }
@@ -1795,7 +2003,6 @@ async function submitHtlcSpend(params) {
1795
2003
  const builder = TransactionBuilder.new(params.network, maxEpoch)
1796
2004
  .addInput({ substate_id: params.substateId, version: null })
1797
2005
  .addInput({ substate_id: params.account, version: null })
1798
- .feeTransactionPayFromComponent(params.account, params.maxFee)
1799
2006
  .addInstruction(
1800
2007
  // `statement` is spliced as a raw JSON fragment (see buildHtlcSpendStatement), the same
1801
2008
  // technique `@tari-project/ootle`'s own `statementAsWire` uses internally for the piecemeal
@@ -1807,11 +2014,21 @@ async function submitHtlcSpend(params) {
1807
2014
  revealed_input_bucket: null,
1808
2015
  },
1809
2016
  });
2017
+ if (params.privateFee) {
2018
+ for (const instr of params.privateFee.feeInstructions)
2019
+ builder.addFeeInstruction(instr);
2020
+ for (const input of params.privateFee.feeInputs)
2021
+ builder.addInput(input);
2022
+ }
2023
+ else {
2024
+ builder.feeTransactionPayFromComponent(params.account, params.maxFee);
2025
+ }
1810
2026
  for (const vaultId of await getVaultIdsForAccount(params.provider, params.account)) {
1811
2027
  builder.addInput({ substate_id: vaultId, version: null });
1812
2028
  }
1813
2029
  const unsignedTx = await resolveTransaction(params.provider, builder.buildUnsignedTransaction());
1814
- const signed = await signTransaction([params.signer], unsignedTx);
2030
+ const extraSigners = params.privateFee ? [params.privateFee.feeSigner] : [];
2031
+ const signed = await signTransaction([params.signer, ...extraSigners], unsignedTx);
1815
2032
  const envelope = await sealTransaction(signed);
1816
2033
  const transactionId = await submitTransaction(params.provider, envelope);
1817
2034
  const response = await withTimeout(pollTransactionResult(params.provider, transactionId), 60_000, params.timeoutLabel);
@@ -1970,6 +2187,27 @@ export function resolveUnshieldPlan(records, resourceAddress, revealedOutAmount)
1970
2187
  }
1971
2188
  return { commitments: finalSelected.map((r) => r.commitment), remainder: finalTotal - revealedOutAmount };
1972
2189
  }
2190
+ /**
2191
+ * Pure planning for `execute()`'s private-fee path: picks the smallest unspent stealth UTXO of
2192
+ * `feeResourceAddress` that alone covers `maxFee`. Deliberately the *smallest* qualifying record
2193
+ * (not the largest, and never several combined) -- `buildPrivateFeeInstructions` only supports a
2194
+ * single fee input (see its own doc comment for why), so this leaves larger shielded balances
2195
+ * untouched for whatever they were shielded for rather than reaching for them first.
2196
+ */
2197
+ export function selectPrivateFeeUtxo(records, feeResourceAddress, maxFee, exclude = []) {
2198
+ const excluded = new Set(exclude);
2199
+ const candidates = records
2200
+ .filter((r) => !r.spent && !excluded.has(r.commitment) && r.resourceAddress === feeResourceAddress && BigInt(r.amount) > maxFee)
2201
+ .sort((a, b) => {
2202
+ const diff = BigInt(a.amount) - BigInt(b.amount);
2203
+ return diff > 0n ? 1 : diff < 0n ? -1 : 0;
2204
+ });
2205
+ const chosen = candidates[0];
2206
+ if (!chosen) {
2207
+ throw new Error(`No single shielded ${feeResourceAddress} UTXO is large enough to cover a private fee of ${maxFee}. Shield some first (see shield()).`);
2208
+ }
2209
+ return chosen;
2210
+ }
1973
2211
  /**
1974
2212
  * Pure planning for `sendPrivately()`: selects enough of the caller's unspent records for this
1975
2213
  * resource to cover `amount` — unlike `resolveUnshieldPlan`, sending the *entire* selected total