@chironbuilder/ootle-sdk 0.1.13 → 0.1.15
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/README.md +45 -1
- package/dist/burnClaim.d.ts +62 -20
- package/dist/burnClaim.d.ts.map +1 -1
- package/dist/burnClaim.js +201 -34
- package/dist/htlcSafety.d.ts +123 -0
- package/dist/htlcSafety.d.ts.map +1 -0
- package/dist/htlcSafety.js +275 -0
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/storage.d.ts +92 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +85 -1
- package/dist/wallet.d.ts +201 -77
- package/dist/wallet.d.ts.map +1 -1
- package/dist/wallet.js +895 -275
- package/package.json +6 -2
- package/vendor/ootle-patched/dist/index.d.ts +57 -28
- package/vendor/ootle-patched/dist/index.js +1389 -1365
- package/vendor/ootle-patched/package.json +23 -23
- package/vendor/ootle-wasm-patched/ootle_wasm.d.ts +4 -4
- package/vendor/ootle-wasm-patched/ootle_wasm_bg.js +29 -17
- package/vendor/ootle-wasm-patched/ootle_wasm_bg.wasm +0 -0
- package/vendor/ootle-wasm-patched/ootle_wasm_bg.wasm.d.ts +4 -4
- package/vendor/ootle-wasm-patched/package.json +3 -4
package/dist/wallet.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Mask, OotleWallet, StealthInput, StealthInputsStatement, StealthOutputsStatement, StealthTransfer, StealthTransferStatement, TARI_RESOURCE_ADDRESS, TransactionBuilder, WalletStealthAuthorizer, WasmStealthCrypto, XTR_FAUCET_CLAIM_RESOURCE_ADDRESS, XTR_FAUCET_COMPONENT_ADDRESS, XTR_FAUCET_VAULT_ADDRESS, amountLiteral, createOutput, decryptOwnedUtxo, defaultIndexerUrl, getVaultIdsForAccount, resolveMaxEpoch, resolveTransaction, resourceAddressLiteral, sealTransaction, sendTransaction, serializeUnsignedTx, signTransaction, stealthUtxoSubstateId, submitTransaction, } from "@tari-project/ootle";
|
|
1
|
+
import { Mask, OotleWallet, StealthInput, StealthInputsStatement, StealthOutputsStatement, StealthTransfer, StealthTransferStatement, TARI_RESOURCE_ADDRESS, TransactionBuilder, WalletStealthAuthorizer, WasmStealthCrypto, XTR_FAUCET_CLAIM_RESOURCE_ADDRESS, XTR_FAUCET_COMPONENT_ADDRESS, XTR_FAUCET_VAULT_ADDRESS, amountLiteral, createOutput, decryptOwnedUtxo, defaultIndexerUrl, getVaultIdsForAccount, resolveMaxEpoch, resolveTransaction, resourceAddressLiteral, generateSealKeypair, sealTransaction, sendTransaction, serializeUnsignedTx, signTransaction, stealthUtxoSubstateId, submitTransaction, } from "@tari-project/ootle";
|
|
2
2
|
import { IndexerProvider } from "@tari-project/ootle-indexer";
|
|
3
3
|
import { SecretKeyWallet } from "@tari-project/ootle-secret-key-wallet";
|
|
4
4
|
import { buildScriptPathWitness, burnClaimStealthSecret, buildStealthTransferStatement, createConfidentialWithdrawProofLiteral, createStealthOutputWitness, parseOotleAddress, publicKeyFromSecretKey, schnorrSign, stealthDhSecret, validateBurnClaimOwnershipProof, validateStealthTransfer, } from "@tari-project/ootle-wasm";
|
|
@@ -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
|
|
@@ -159,6 +171,15 @@ export class OotleAccount {
|
|
|
159
171
|
async getWalletAddress() {
|
|
160
172
|
return this.signer.getAddress();
|
|
161
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* This account's owner public key, synchronously. Named as the receiver of every revealed
|
|
176
|
+
* stealth output this account's `StealthTransfer`s create (tari-ootle#2645): the engine only
|
|
177
|
+
* hands a revealed bucket to a key whose badge is in the transaction's auth scope, and those
|
|
178
|
+
* transfers are always signed with the account key.
|
|
179
|
+
*/
|
|
180
|
+
get accountPublicKey() {
|
|
181
|
+
return publicKeyFromSecretKey(this.ownerSecret);
|
|
182
|
+
}
|
|
162
183
|
async getPublicKey() {
|
|
163
184
|
return this.signer.getPublicKey();
|
|
164
185
|
}
|
|
@@ -769,7 +790,7 @@ export class OotleAccount {
|
|
|
769
790
|
// on-chain component address -- passing `account` here throws "Bech32 decode error" (confirmed
|
|
770
791
|
// live). See getWalletAddress()'s doc comment for why these two addresses are easy to conflate.
|
|
771
792
|
const walletAddress = await this.getWalletAddress();
|
|
772
|
-
const builder = new StealthTransfer(provider, resourceAddress)
|
|
793
|
+
const builder = new StealthTransfer(provider, resourceAddress).withRevealedReceiver(this.accountPublicKey)
|
|
773
794
|
// StealthTransfer.prepare() auto-adds the revealed account's own substate and any vault
|
|
774
795
|
// addresses embedded in its on-chain state, but never the resource's own substate -- fine
|
|
775
796
|
// for XTR (resource_0101...0101 is engine-special-cased and needs no lock), but any other
|
|
@@ -900,56 +921,70 @@ export class OotleAccount {
|
|
|
900
921
|
*/
|
|
901
922
|
async unshield(resourceAddress, revealedOutAmount, maxFee = 100000n, memo, feeType = { kind: "transparent" }) {
|
|
902
923
|
const accountId = localAccountId(this.index);
|
|
903
|
-
const
|
|
924
|
+
const opId = newOperationId("unshield");
|
|
925
|
+
const unavailable = await this.unavailableCommitments(accountId, opId);
|
|
926
|
+
const records = await this.filterReadableShieldedOutputs((await listShieldedOutputs(accountId)).filter((r) => !unavailable.has(r.commitment)), resourceAddress, revealedOutAmount);
|
|
904
927
|
const { commitments, remainder } = resolveUnshieldPlan(records, resourceAddress, revealedOutAmount);
|
|
905
|
-
|
|
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
|
-
});
|
|
928
|
+
await reserveCommitments(accountId, commitments, opId);
|
|
938
929
|
try {
|
|
939
|
-
const
|
|
940
|
-
await
|
|
941
|
-
|
|
930
|
+
const dust = 1n;
|
|
931
|
+
const provider = await this.getProvider();
|
|
932
|
+
const account = await this.getComponentAddress();
|
|
933
|
+
// See shield()'s comment: Output.destination needs the bech32m wallet address, not the
|
|
934
|
+
// on-chain component address.
|
|
935
|
+
const walletAddress = await this.getWalletAddress();
|
|
936
|
+
// An account that has only ever held private funds (e.g. a claimed L1 burn) has no component
|
|
937
|
+
// on-chain to withdraw dust from, deposit into, or pay a fee from. Its first unshield creates
|
|
938
|
+
// the account from the revealed funds instead, paying the fee from them too.
|
|
939
|
+
if (!(await substateExists(provider, account))) {
|
|
940
|
+
return await this.unshieldIntoNewAccount(provider, resourceAddress, revealedOutAmount, commitments, remainder, maxFee, memo);
|
|
941
|
+
}
|
|
942
|
+
// See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
|
|
943
|
+
// never adds it on its own.
|
|
944
|
+
let builder = new StealthTransfer(provider, resourceAddress).withRevealedReceiver(this.accountPublicKey)
|
|
945
|
+
.withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
|
|
946
|
+
.spendRevealedInput(account, dust);
|
|
942
947
|
for (const commitment of commitments) {
|
|
943
|
-
|
|
948
|
+
builder = builder.spendStealthInput(account, fromHex(commitment));
|
|
944
949
|
}
|
|
945
|
-
|
|
946
|
-
|
|
950
|
+
builder = builder
|
|
951
|
+
.toStealthOutput(createOutput({ destination: walletAddress, amount: remainder, resourceAddress, memo: toMemo(memo) }))
|
|
952
|
+
.toRevealedOutput(revealedOutAmount + dust);
|
|
953
|
+
const viewSecret = await this.signer.getViewSecret();
|
|
954
|
+
// See shield()'s comment: must pass this account's real network, not fromSpec's LocalNet
|
|
955
|
+
// default. `commitments` is excluded from the fee UTXO's own selection so the same shielded
|
|
956
|
+
// record can never be spent twice in one transaction (as both a main input here and the fee
|
|
957
|
+
// input) when `resourceAddress` and the fee's resource happen to be the same currency.
|
|
958
|
+
const { transactionId, spec, privateFee } = await this.prepareSignSubmit(builder, provider, account, maxFee, feeType, { viewSecret }, commitments, opId);
|
|
959
|
+
const ownCommitment = extractOutputCommitment(spec, 0);
|
|
960
|
+
await addPendingShield({
|
|
961
|
+
transactionId,
|
|
962
|
+
accountId,
|
|
963
|
+
resourceAddress,
|
|
964
|
+
amount: remainder.toString(),
|
|
965
|
+
spentCommitments: commitments,
|
|
966
|
+
ownCommitment,
|
|
967
|
+
memo,
|
|
968
|
+
});
|
|
969
|
+
try {
|
|
970
|
+
const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the unshield transaction");
|
|
971
|
+
await recordKnownVersions(response);
|
|
972
|
+
await recordKnownShieldedOutput(accountId, resourceAddress, ownCommitment, remainder, transactionId, memo);
|
|
973
|
+
for (const commitment of commitments) {
|
|
974
|
+
await markShieldedOutputSpent(accountId, commitment);
|
|
975
|
+
}
|
|
976
|
+
if (privateFee && feeType.kind === "private") {
|
|
977
|
+
await this.recordPrivateFeeSpend(accountId, feeType.feeResourceAddress, privateFee, transactionId);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
finally {
|
|
981
|
+
await removePendingShield(transactionId);
|
|
947
982
|
}
|
|
983
|
+
return { transactionId };
|
|
948
984
|
}
|
|
949
985
|
finally {
|
|
950
|
-
await
|
|
986
|
+
await releaseReservations(opId);
|
|
951
987
|
}
|
|
952
|
-
return { transactionId };
|
|
953
988
|
}
|
|
954
989
|
/**
|
|
955
990
|
* Withdraws `amount` of a Stealth-typed resource (e.g. XTR) from this account's revealed
|
|
@@ -994,7 +1029,7 @@ export class OotleAccount {
|
|
|
994
1029
|
// stealth output back to this account -- the same proven-working shape -- alongside the real
|
|
995
1030
|
// amount as a revealed bucket.
|
|
996
1031
|
const dust = 1n;
|
|
997
|
-
let builder = new StealthTransfer(provider, resourceAddress)
|
|
1032
|
+
let builder = new StealthTransfer(provider, resourceAddress).withRevealedReceiver(this.accountPublicKey)
|
|
998
1033
|
.withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
|
|
999
1034
|
.spendRevealedInput(account, amount + dust)
|
|
1000
1035
|
.toStealthOutput(createOutput({ destination: walletAddress, amount: dust, resourceAddress }))
|
|
@@ -1062,11 +1097,8 @@ export class OotleAccount {
|
|
|
1062
1097
|
inputs: [{ commitment: commitmentHex, witness: "KeyPath" }],
|
|
1063
1098
|
revealed_amount: "0",
|
|
1064
1099
|
});
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
revealed_output_amount: revealedAmount.toString(),
|
|
1068
|
-
agg_range_proof: "",
|
|
1069
|
-
});
|
|
1100
|
+
// The account key signs this transaction (the authorizer's default), so it takes the revealed funds.
|
|
1101
|
+
const outputsJson = revealOnlyOutputsJson(revealedAmount, this.accountPublicKey);
|
|
1070
1102
|
const statement = new StealthTransferStatement(new StealthInputsStatement([], 0n, inputsJson), new StealthOutputsStatement(outputsJson));
|
|
1071
1103
|
const privateFee = await this.resolvePrivateFee(feeType, maxFee);
|
|
1072
1104
|
const maxEpoch = await resolveMaxEpoch(provider);
|
|
@@ -1217,7 +1249,9 @@ export class OotleAccount {
|
|
|
1217
1249
|
const feeChange = decrypted.value - maxFee;
|
|
1218
1250
|
const inputSkeleton = JSON.stringify({ inputs: [{ commitment: feeCommitmentHex, witness: "KeyPath" }], revealed_amount: "0" });
|
|
1219
1251
|
const feeChangeOutput = createOutput({ destination: walletAddress, amount: feeChange, resourceAddress: feeResourceAddress });
|
|
1220
|
-
const { statement: feeOutputsStatement, outputMask: feeOutputMask } = await crypto.generateOutputsStatement([feeChangeOutput], maxFee
|
|
1252
|
+
const { statement: feeOutputsStatement, outputMask: feeOutputMask } = await crypto.generateOutputsStatement([feeChangeOutput], maxFee,
|
|
1253
|
+
// The revealed fee goes to the fee UTXO's one-time key, which signs for it (see `feeSigner`).
|
|
1254
|
+
utxoSpendKey(substate));
|
|
1221
1255
|
const feeInputsStatement = new StealthInputsStatement([], 0n, inputSkeleton);
|
|
1222
1256
|
const feeAggInputMask = await crypto.aggregateInputMasks([decrypted.mask]);
|
|
1223
1257
|
const feeBalanceProof = await crypto.generateBalanceProofSignature(feeAggInputMask, feeOutputMask, feeInputsStatement.statementJson, feeOutputsStatement.statementJson);
|
|
@@ -1305,11 +1339,17 @@ export class OotleAccount {
|
|
|
1305
1339
|
/** Commitments already claimed by the operation's own main effect (e.g. `unshield()`'s or
|
|
1306
1340
|
* `sendPrivately()`'s own multi-UTXO coin selection) -- excluded so the same commitment can
|
|
1307
1341
|
* never be selected as both a main input and the fee input in one transaction. */
|
|
1308
|
-
exclude = []
|
|
1342
|
+
exclude = [],
|
|
1343
|
+
/** When set, the chosen fee UTXO is reserved for this operation (see `reserveCommitments`) so a
|
|
1344
|
+
* concurrent operation can't pick it too. Whether set or not, UTXOs another operation has
|
|
1345
|
+
* reserved are never chosen. */
|
|
1346
|
+
reservationHolder) {
|
|
1309
1347
|
if (feeType.kind !== "private")
|
|
1310
1348
|
return null;
|
|
1311
1349
|
const accountId = localAccountId(this.index);
|
|
1312
1350
|
const records = await listShieldedOutputs(accountId);
|
|
1351
|
+
for (const unavailable of await this.unavailableCommitments(accountId, reservationHolder))
|
|
1352
|
+
exclude = [...exclude, unavailable];
|
|
1313
1353
|
// Retries with the next-smallest qualifying UTXO if one fails to read -- confirmed live that a
|
|
1314
1354
|
// record this wallet still has as "unspent" can 500 on the indexer (consistent with it having
|
|
1315
1355
|
// actually been spent already, e.g. from another device/session sharing this seed, hitting an
|
|
@@ -1336,6 +1376,17 @@ export class OotleAccount {
|
|
|
1336
1376
|
}
|
|
1337
1377
|
throw selectError;
|
|
1338
1378
|
}
|
|
1379
|
+
if (reservationHolder) {
|
|
1380
|
+
try {
|
|
1381
|
+
await reserveCommitments(accountId, [chosen.commitment], reservationHolder);
|
|
1382
|
+
}
|
|
1383
|
+
catch (e) {
|
|
1384
|
+
if (!(e instanceof CommitmentReservedError))
|
|
1385
|
+
throw e;
|
|
1386
|
+
tried.add(chosen.commitment); // taken by a concurrent operation since we listed -- try the next one
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1339
1390
|
try {
|
|
1340
1391
|
const built = await this.buildPrivateFeeInstructions(feeType.feeResourceAddress, chosen.commitment, maxFee);
|
|
1341
1392
|
return { ...built, spentCommitment: chosen.commitment };
|
|
@@ -1410,17 +1461,89 @@ export class OotleAccount {
|
|
|
1410
1461
|
*/
|
|
1411
1462
|
async prepareSignSubmit(builder, provider, account, maxFee, feeType, authorizerOpts = {},
|
|
1412
1463
|
/** See `resolvePrivateFee`'s own doc comment. */
|
|
1413
|
-
excludeFromFeeSelection = []) {
|
|
1414
|
-
const privateFee = await this.
|
|
1415
|
-
|
|
1464
|
+
excludeFromFeeSelection = [], reservationHolder) {
|
|
1465
|
+
const { envelope, spec, privateFee } = await this.sealStealthTransfer(builder, provider, account, maxFee, feeType, {
|
|
1466
|
+
authorizerOpts,
|
|
1467
|
+
excludeFromFeeSelection,
|
|
1468
|
+
reservationHolder,
|
|
1469
|
+
});
|
|
1470
|
+
const transactionId = await submitTransaction(provider, envelope);
|
|
1471
|
+
return { transactionId, spec, privateFee };
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* The prepare/authorize/seal half of `prepareSignSubmit`, without the submit — so the exact same
|
|
1475
|
+
* transaction shape can be dry-run first (`dryRun: true` marks it before it's signed) and so the
|
|
1476
|
+
* sealed envelope can be journaled before it ever reaches the network.
|
|
1477
|
+
*
|
|
1478
|
+
* `sourceless`: the operation's main lane spends only stealth inputs (no `spendRevealedInput` of
|
|
1479
|
+
* its resource at all — e.g. a stealth-created asset this account holds no public balance of).
|
|
1480
|
+
* A transparent fee then can't use `payFeeFromRevealed` (which needs a revealed source of the
|
|
1481
|
+
* *operation's* resource); instead it's paid in native TARI straight from `account` — the fee
|
|
1482
|
+
* lane — with the account and its vaults registered as inputs, exactly as `submitHtlcSpend` does.
|
|
1483
|
+
* A private fee needs no special handling: it never touched the revealed source anyway.
|
|
1484
|
+
*/
|
|
1485
|
+
async sealStealthTransfer(builder, provider, account, maxFee, feeType, opts = {}) {
|
|
1486
|
+
const privateFee = await this.resolvePrivateFee(feeType, maxFee, opts.excludeFromFeeSelection ?? [], opts.reservationHolder);
|
|
1487
|
+
let withFee;
|
|
1488
|
+
if (privateFee) {
|
|
1489
|
+
withFee = this.attachPrivateFee(builder, privateFee);
|
|
1490
|
+
}
|
|
1491
|
+
else if (opts.sourceless) {
|
|
1492
|
+
const vaults = await getVaultIdsForAccount(provider, account);
|
|
1493
|
+
withFee = builder.withBuilder((b) => {
|
|
1494
|
+
b.feeTransactionPayFromComponent(account, maxFee);
|
|
1495
|
+
b.addInput({ substate_id: account, version: null });
|
|
1496
|
+
for (const vaultId of vaults)
|
|
1497
|
+
b.addInput({ substate_id: vaultId, version: null });
|
|
1498
|
+
return b;
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
else {
|
|
1502
|
+
withFee = builder.payFeeFromRevealed(maxFee);
|
|
1503
|
+
}
|
|
1504
|
+
const spec = await withFee.prepare();
|
|
1505
|
+
// `dry_run` is part of the signed transaction, so it must be set before authorization/sealing.
|
|
1506
|
+
if (opts.dryRun)
|
|
1507
|
+
spec.unsignedTx.dry_run = true;
|
|
1416
1508
|
const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
|
|
1417
1509
|
const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, {
|
|
1418
1510
|
crypto: new WasmStealthCrypto(this.network),
|
|
1419
|
-
...authorizerOpts,
|
|
1511
|
+
...(opts.authorizerOpts ?? {}),
|
|
1420
1512
|
}).prepare(provider);
|
|
1421
1513
|
const envelope = privateFee ? await this.sealWithPrivateFee(authorized, privateFee.feeSigner) : await authorized.seal();
|
|
1422
|
-
|
|
1423
|
-
|
|
1514
|
+
return { envelope, spec, privateFee };
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Dry-runs a sealed envelope (one built with `dry_run` set) against the indexer's dry-run endpoint
|
|
1518
|
+
* and returns the execution result, throwing — in the same "was rejected: …" shape real
|
|
1519
|
+
* submissions use — if it would be rejected. Nothing reaches consensus; nothing is spent.
|
|
1520
|
+
*/
|
|
1521
|
+
async dryRunEnvelope(envelope) {
|
|
1522
|
+
const res = await fetch(`${defaultIndexerUrl(this.network)}/transactions/dry-run`, {
|
|
1523
|
+
method: "POST",
|
|
1524
|
+
headers: { "Content-Type": "application/json" },
|
|
1525
|
+
body: JSON.stringify({ transaction: envelope }),
|
|
1526
|
+
});
|
|
1527
|
+
const text = await res.text();
|
|
1528
|
+
let body;
|
|
1529
|
+
try {
|
|
1530
|
+
body = text ? JSON.parse(text) : undefined;
|
|
1531
|
+
}
|
|
1532
|
+
catch {
|
|
1533
|
+
// Not JSON — reported raw below.
|
|
1534
|
+
}
|
|
1535
|
+
if (!res.ok || body?.error || !body?.result) {
|
|
1536
|
+
throw new Error(`The dry run failed: ${body?.error?.message ?? text ?? res.statusText}`);
|
|
1537
|
+
}
|
|
1538
|
+
const outcome = body.result.finalize.result;
|
|
1539
|
+
if (typeof outcome === "object" && outcome !== null) {
|
|
1540
|
+
if ("Reject" in outcome)
|
|
1541
|
+
throw new Error(`The dry run was rejected: ${JSON.stringify(outcome.Reject)}`);
|
|
1542
|
+
if ("AcceptFeeRejectRest" in outcome) {
|
|
1543
|
+
throw new Error(`The dry run accepted the fee but rejected the rest: ${JSON.stringify(outcome.AcceptFeeRejectRest)}`);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return body.result;
|
|
1424
1547
|
}
|
|
1425
1548
|
async redeemStealthOutputWithPrivateFee(resourceAddress, commitmentHex, revealedAmount, followUpInstructions, feeResourceAddress, feeCommitmentHex, maxFee, relatedComponents = []) {
|
|
1426
1549
|
if (revealedAmount <= 0n)
|
|
@@ -1442,7 +1565,10 @@ export class OotleAccount {
|
|
|
1442
1565
|
// Redeemed resource: 1 stealth input -> 0 stealth outputs, fully revealed into the bucket
|
|
1443
1566
|
// `followUpInstructions` consumes.
|
|
1444
1567
|
const redeemedInputsStatement = new StealthInputsStatement([], 0n, inputSkeleton);
|
|
1445
|
-
const redeemedOutputsStatement = new StealthOutputsStatement(
|
|
1568
|
+
const redeemedOutputsStatement = new StealthOutputsStatement(
|
|
1569
|
+
// This transaction deliberately carries no account signature, so the revealed funds go to the
|
|
1570
|
+
// key that does sign for them: the redeemed UTXO's own one-time spend key.
|
|
1571
|
+
revealOnlyOutputsJson(revealedAmount, utxoSpendKey(substate)));
|
|
1446
1572
|
const redeemedAggInputMask = await crypto.aggregateInputMasks([redeemed.mask]);
|
|
1447
1573
|
const redeemedBalanceProof = await crypto.generateBalanceProofSignature(redeemedAggInputMask, Mask.zero(), redeemedInputsStatement.statementJson, redeemedOutputsStatement.statementJson);
|
|
1448
1574
|
const redeemedStatement = new StealthTransferStatement(redeemedInputsStatement, redeemedOutputsStatement, redeemedBalanceProof);
|
|
@@ -1496,33 +1622,43 @@ export class OotleAccount {
|
|
|
1496
1622
|
* Funds an HTLC (hashed timelock contract): creates a stealth output for `amount` of
|
|
1497
1623
|
* `resourceAddress`, gated not by a normal one-time stealth key but by a two-leaf TIP-0006
|
|
1498
1624
|
* condition tree (`htlcConditions` in `./htlc.ts`) — a claim path admissible only to
|
|
1499
|
-
* `claimantWalletAddress` (revealing the SHA-256 preimage of `hashLockHex`, before
|
|
1625
|
+
* `claimantWalletAddress` (revealing the SHA-256 preimage of `hashLockHex`, strictly before
|
|
1500
1626
|
* `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
1627
|
*
|
|
1503
|
-
* This account never needs (and must never be given) the actual preimage — only its hash.
|
|
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.
|
|
1628
|
+
* This account never needs (and must never be given) the actual preimage — only its hash.
|
|
1506
1629
|
*
|
|
1507
|
-
*
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
1510
|
-
* the
|
|
1630
|
+
* **Two lanes.** The fee and the HTLC value never share a UTXO: the fee is paid in native TARI —
|
|
1631
|
+
* privately from a separate stealth TARI UTXO (`feeType: private`) or from this account's public
|
|
1632
|
+
* TARI — while the main lane moves only `resourceAddress`. With `source: { kind: "stealth" }` the
|
|
1633
|
+
* main lane spends this account's *own stealth outputs* of the resource (exactly the given
|
|
1634
|
+
* `commitments`, or selected automatically), returning any excess as same-resource stealth change;
|
|
1635
|
+
* no public balance of the resource is needed at all, so a stealth-created asset can be locked.
|
|
1636
|
+
* The default `source: { kind: "revealed" }` keeps the original behavior (public balance).
|
|
1511
1637
|
*
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
1638
|
+
* **Settlement safety.**
|
|
1639
|
+
* - The exact transaction is dry-run first (`preflight`, default on), so a doomed fund never
|
|
1640
|
+
* reaches the network.
|
|
1641
|
+
* - Before submission, the `conditions` tree, the HTLC output's own blinding `outputMask` and the
|
|
1642
|
+
* sealed envelope are written to the HTLC journal. The funder can't decrypt an output
|
|
1643
|
+
* addressed to the claimant, so the mask is the only thing that makes a refund possible —
|
|
1644
|
+
* it's never held only in memory.
|
|
1645
|
+
* - With stealth change, `outputMask` is derived as `aggregateMask − changeMask` and then
|
|
1646
|
+
* *proven* before submit: a refund built from it must reproduce the HTLC output's commitment,
|
|
1647
|
+
* or the fund is aborted.
|
|
1648
|
+
* - Inputs (and a private fee's UTXO) are reserved for the duration, so a concurrent operation
|
|
1649
|
+
* can't select them.
|
|
1650
|
+
* - If the outcome doesn't arrive in time this throws `HtlcUnknownOutcomeError` (carrying the
|
|
1651
|
+
* refund data) instead of a plain failure; `reconcileHtlcs()` resolves it later.
|
|
1519
1652
|
*
|
|
1520
|
-
* `
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
1523
|
-
* `payTo`.
|
|
1653
|
+
* Returns the full `conditions` tree (only its root is on-chain — the claimant needs the leaves,
|
|
1654
|
+
* out of band), the HTLC output's commitment (`ownCommitment`, kept for compatibility), its
|
|
1655
|
+
* `outputMask`, the change commitment when there was change, and the `journalId`.
|
|
1524
1656
|
*/
|
|
1525
|
-
async htlcFund(resourceAddress, amount, claimantWalletAddress, hashLockHex, refundEpoch, maxFee = 50000n, feeType = { kind: "transparent" }) {
|
|
1657
|
+
async htlcFund(resourceAddress, amount, claimantWalletAddress, hashLockHex, refundEpoch, maxFee = 50000n, feeType = { kind: "transparent" }, options = {}) {
|
|
1658
|
+
if (amount <= 0n)
|
|
1659
|
+
throw new Error("htlcFund: amount must be greater than zero.");
|
|
1660
|
+
const opId = newOperationId("htlc-fund");
|
|
1661
|
+
const accountId = localAccountId(this.index);
|
|
1526
1662
|
const provider = await this.getProvider();
|
|
1527
1663
|
const account = await this.getComponentAddress();
|
|
1528
1664
|
const walletAddress = await this.getWalletAddress();
|
|
@@ -1534,124 +1670,256 @@ export class OotleAccount {
|
|
|
1534
1670
|
claimantPublicKeyHex: toHex(claimant.owner_key),
|
|
1535
1671
|
refunderPublicKeyHex: toHex(refunder.owner_key),
|
|
1536
1672
|
});
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
const
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1673
|
+
const source = options.source ?? { kind: "revealed" };
|
|
1674
|
+
const stealth = source.kind === "stealth";
|
|
1675
|
+
const viewSecret = stealth ? await this.signer.getViewSecret() : undefined;
|
|
1676
|
+
let commitments = [];
|
|
1677
|
+
let change = 0n;
|
|
1678
|
+
try {
|
|
1679
|
+
if (stealth) {
|
|
1680
|
+
({ commitments, change } = await this.selectStealthSource(accountId, opId, resourceAddress, amount, source.commitments));
|
|
1681
|
+
await reserveCommitments(accountId, commitments, opId);
|
|
1682
|
+
}
|
|
1683
|
+
// See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
|
|
1684
|
+
// never adds it on its own. Output 0 is the HTLC; output 1 (only with change) is ours.
|
|
1685
|
+
const build = () => {
|
|
1686
|
+
let b = new StealthTransfer(provider, resourceAddress).withRevealedReceiver(this.accountPublicKey).withBuilder((bb) => bb.addInput({ substate_id: resourceAddress, version: null }));
|
|
1687
|
+
if (stealth)
|
|
1688
|
+
for (const c of commitments)
|
|
1689
|
+
b = b.spendStealthInput(account, fromHex(c));
|
|
1690
|
+
else
|
|
1691
|
+
b = b.spendRevealedInput(account, amount);
|
|
1692
|
+
// `destination` is the claimant's own wallet address so they can independently decrypt and
|
|
1693
|
+
// verify the funded amount; spend authority is governed entirely by `payTo`.
|
|
1694
|
+
b = b.toStealthOutput(createOutput({ destination: claimantWalletAddress, amount, resourceAddress, payTo: { Conditions: conditions } }));
|
|
1695
|
+
if (change > 0n)
|
|
1696
|
+
b = b.toStealthOutput(createOutput({ destination: walletAddress, amount: change, resourceAddress }));
|
|
1697
|
+
return b;
|
|
1698
|
+
};
|
|
1699
|
+
const sealOpts = {
|
|
1700
|
+
authorizerOpts: viewSecret ? { viewSecret } : {},
|
|
1701
|
+
excludeFromFeeSelection: commitments,
|
|
1702
|
+
reservationHolder: opId,
|
|
1703
|
+
sourceless: stealth,
|
|
1704
|
+
};
|
|
1705
|
+
if (options.preflight !== false) {
|
|
1706
|
+
const dry = await this.sealStealthTransfer(build(), provider, account, maxFee, feeType, { ...sealOpts, dryRun: true });
|
|
1707
|
+
await this.dryRunEnvelope(dry.envelope);
|
|
1708
|
+
}
|
|
1709
|
+
const { envelope, spec, privateFee } = await this.sealStealthTransfer(build(), provider, account, maxFee, feeType, sealOpts);
|
|
1710
|
+
const htlcCommitment = extractOutputCommitment(spec, 0);
|
|
1711
|
+
let outputMask = spec.outputMask.toHex();
|
|
1712
|
+
let changeCommitment;
|
|
1713
|
+
if (change > 0n) {
|
|
1714
|
+
const outputs = spec.statement.outputsStatement.parsed().outputs;
|
|
1715
|
+
const own = decryptOwnStatementOutput(outputs[1], viewSecret);
|
|
1716
|
+
if (own.value !== change)
|
|
1717
|
+
throw new Error(`htlcFund: change output decrypted to ${own.value}, expected ${change} — aborting before submit.`);
|
|
1718
|
+
changeCommitment = own.commitment;
|
|
1719
|
+
outputMask = subtractScalars(outputMask, own.maskHex);
|
|
1720
|
+
}
|
|
1721
|
+
// Prove the refund is buildable before anything is at stake: the mask we're about to rely on
|
|
1722
|
+
// must reproduce the HTLC output's exact commitment.
|
|
1723
|
+
const refundProbe = buildHtlcSpendStatement({
|
|
1724
|
+
network: this.network,
|
|
1725
|
+
conditions,
|
|
1726
|
+
leaf: conditions[1],
|
|
1727
|
+
data: new Uint8Array(0),
|
|
1728
|
+
mask: outputMask,
|
|
1729
|
+
value: amount,
|
|
1730
|
+
destinationWalletAddress: walletAddress,
|
|
1731
|
+
resourceAddress,
|
|
1732
|
+
});
|
|
1733
|
+
if (statementInputCommitment(refundProbe) !== htlcCommitment.toLowerCase()) {
|
|
1734
|
+
throw new Error("htlcFund: the derived output mask does not reproduce the HTLC commitment — refusing to fund an output that couldn't be refunded.");
|
|
1735
|
+
}
|
|
1736
|
+
const now = Date.now();
|
|
1737
|
+
const entry = {
|
|
1738
|
+
id: opId,
|
|
1739
|
+
accountId,
|
|
1740
|
+
kind: "fund",
|
|
1741
|
+
status: "prepared",
|
|
1742
|
+
resourceAddress,
|
|
1743
|
+
amount: amount.toString(),
|
|
1744
|
+
conditions,
|
|
1745
|
+
hashLockHex: hashLockHex.toLowerCase(),
|
|
1746
|
+
refundEpoch: refundEpoch.toString(),
|
|
1747
|
+
htlcCommitment,
|
|
1748
|
+
outputMask,
|
|
1749
|
+
ownCommitment: changeCommitment,
|
|
1750
|
+
ownAmount: change > 0n ? change.toString() : undefined,
|
|
1751
|
+
spentCommitments: commitments.length ? commitments : undefined,
|
|
1752
|
+
privateFee: journalFee(feeType, privateFee),
|
|
1753
|
+
envelope,
|
|
1754
|
+
createdAt: now,
|
|
1755
|
+
updatedAt: now,
|
|
1756
|
+
};
|
|
1757
|
+
const { transactionId } = await this.submitJournaled(entry, provider, "submitting the HTLC funding transaction");
|
|
1758
|
+
return { transactionId, conditions, ownCommitment: htlcCommitment, outputMask, changeCommitment, journalId: opId };
|
|
1759
|
+
}
|
|
1760
|
+
catch (e) {
|
|
1761
|
+
if (!(e instanceof HtlcUnknownOutcomeError))
|
|
1762
|
+
await releaseReservations(opId);
|
|
1763
|
+
throw e;
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Chooses the stealth inputs for a stealth-sourced HTLC fund: exactly `requested` when given
|
|
1768
|
+
* (each must be this account's own unspent, unreserved record of `resourceAddress`, and must
|
|
1769
|
+
* decrypt on-chain to the recorded amount), otherwise a largest-first selection covering `amount`.
|
|
1770
|
+
*/
|
|
1771
|
+
async selectStealthSource(accountId, holder, resourceAddress, amount, requested) {
|
|
1772
|
+
const unavailable = await this.unavailableCommitments(accountId, holder);
|
|
1773
|
+
const records = (await listShieldedOutputs(accountId)).filter((r) => !unavailable.has(r.commitment));
|
|
1774
|
+
let chosen;
|
|
1775
|
+
if (requested && requested.length > 0) {
|
|
1776
|
+
const provider = await this.getProvider();
|
|
1777
|
+
const crypto = new WasmStealthCrypto(this.network);
|
|
1778
|
+
const viewSecret = await this.signer.getViewSecret();
|
|
1779
|
+
chosen = [];
|
|
1780
|
+
for (const commitment of new Set(requested.map((c) => c.toLowerCase()))) {
|
|
1781
|
+
const record = records.find((r) => r.commitment.toLowerCase() === commitment);
|
|
1782
|
+
if (!record) {
|
|
1783
|
+
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).`);
|
|
1784
|
+
}
|
|
1785
|
+
if (record.resourceAddress !== resourceAddress)
|
|
1786
|
+
throw new Error(`htlcFund: ${commitment.slice(0, 12)}… holds ${record.resourceAddress}, not ${resourceAddress}.`);
|
|
1787
|
+
const substateId = stealthUtxoSubstateId(resourceAddress, fromHex(record.commitment));
|
|
1788
|
+
const decrypted = await decryptOwnedUtxo(crypto, viewSecret, await provider.getSubstate(substateId), substateId);
|
|
1789
|
+
if (!decrypted || decrypted.value !== BigInt(record.amount)) {
|
|
1790
|
+
throw new Error(`htlcFund: ${commitment.slice(0, 12)}… is not spendable as recorded (already spent, or its on-chain value differs).`);
|
|
1791
|
+
}
|
|
1792
|
+
chosen.push(record);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
else {
|
|
1796
|
+
const readable = await this.filterReadableShieldedOutputs(records, resourceAddress, amount);
|
|
1797
|
+
chosen = selectShieldedUtxosForAmount(readable, resourceAddress, amount).selected;
|
|
1552
1798
|
}
|
|
1553
|
-
|
|
1799
|
+
const total = chosen.reduce((s, r) => s + BigInt(r.amount), 0n);
|
|
1800
|
+
if (total < amount)
|
|
1801
|
+
throw new Error(`htlcFund: the selected stealth outputs hold ${total}, less than the ${amount} to lock.`);
|
|
1802
|
+
return { commitments: chosen.map((r) => r.commitment), change: total - amount };
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Checks a funded HTLC against agreed terms *without* claiming it — the verification a swap
|
|
1806
|
+
* protocol runs before it reveals anything (or before it funds its own side). Returns every
|
|
1807
|
+
* problem found; an empty list means the HTLC is exactly what was agreed. `htlcClaim` runs the
|
|
1808
|
+
* same checks itself and refuses to reveal the preimage if any fail.
|
|
1809
|
+
*/
|
|
1810
|
+
async verifyHtlc(resourceAddress, commitmentHex, conditions, expected = {}, preimageHex) {
|
|
1811
|
+
const provider = await this.getProvider();
|
|
1812
|
+
const substateId = stealthUtxoSubstateId(resourceAddress, fromHex(commitmentHex));
|
|
1813
|
+
let substate = null;
|
|
1814
|
+
try {
|
|
1815
|
+
substate = await provider.getSubstate(substateId);
|
|
1816
|
+
}
|
|
1817
|
+
catch {
|
|
1818
|
+
substate = null;
|
|
1819
|
+
}
|
|
1820
|
+
const decrypted = substate
|
|
1821
|
+
? await decryptOwnedUtxo(new WasmStealthCrypto(this.network), await this.signer.getViewSecret(), substate, substateId)
|
|
1822
|
+
: null;
|
|
1823
|
+
const currentEpoch = BigInt(await provider.getCurrentEpoch());
|
|
1824
|
+
const facts = {
|
|
1825
|
+
value: decrypted ? decrypted.value : null,
|
|
1826
|
+
onChainRoot: substate ? findScriptRoot(substate) : null,
|
|
1827
|
+
conditions,
|
|
1828
|
+
claimantPublicKeyHex: toHex(parseOotleAddress(await this.getWalletAddress()).owner_key),
|
|
1829
|
+
currentEpoch,
|
|
1830
|
+
preimageHashHex: preimageHex !== undefined ? await sha256Hex(fromHex(preimageHex)) : undefined,
|
|
1831
|
+
};
|
|
1832
|
+
const problems = substate
|
|
1833
|
+
? verifyHtlcTerms(facts, expected)
|
|
1834
|
+
: ["The HTLC output wasn't found on-chain — it may not be funded yet, or already claimed or refunded."];
|
|
1835
|
+
return { ok: problems.length === 0, problems, value: facts.value, currentEpoch };
|
|
1554
1836
|
}
|
|
1555
1837
|
/**
|
|
1556
1838
|
* Claims a funded HTLC (the counterpart to `htlcFund`): reveals the claim leaf's SHA-256
|
|
1557
|
-
* preimage to spend the script-path-locked output
|
|
1558
|
-
*
|
|
1839
|
+
* preimage to spend the script-path-locked output into a brand-new, normal (freely
|
|
1840
|
+
* key-spendable) stealth output owned by this account — which is recorded in the shielded
|
|
1841
|
+
* ledger, so the claimed funds are immediately spendable/visible.
|
|
1559
1842
|
*
|
|
1560
|
-
*
|
|
1561
|
-
*
|
|
1562
|
-
*
|
|
1563
|
-
*
|
|
1564
|
-
*
|
|
1565
|
-
*
|
|
1566
|
-
* root, and `buildStealthTransferStatement`/the network both reject the spend.
|
|
1843
|
+
* **Nothing is revealed until the HTLC is proven safe.** Before the preimage leaves this device
|
|
1844
|
+
* it checks (see `verifyHtlc`): the output decrypts as ours; its amount (`expected.amount` /
|
|
1845
|
+
* `minAmount`); the tree is a standard HTLC whose claim leaf is *our* key (and, if given, whose
|
|
1846
|
+
* refund leaf is the agreed counterparty); the on-chain condition root equals the tree's root;
|
|
1847
|
+
* the preimage hashes to the lock; and at least `expected.minEpochsBeforeRefund` (default 1)
|
|
1848
|
+
* claim-admissible epochs remain. Any failure throws `HtlcVerificationError`.
|
|
1567
1849
|
*
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
1571
|
-
*
|
|
1572
|
-
*
|
|
1573
|
-
*
|
|
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.
|
|
1850
|
+
* Then the exact claim is dry-run (`preflight`, default on) so a claim that would fail never
|
|
1851
|
+
* publishes the preimage on-chain, the journal entry moves to `claim_armed`, and only then is it
|
|
1852
|
+
* submitted. The fee stays in its own lane (native TARI, public or private) — the claim's main
|
|
1853
|
+
* instruction never funds its own fee. Note a dry run does send the transaction (preimage
|
|
1854
|
+
* included) to the indexer's dry-run endpoint; pass `preflight: false` if that indexer isn't one
|
|
1855
|
+
* you trust with it.
|
|
1581
1856
|
*/
|
|
1582
|
-
async htlcClaim(resourceAddress, commitmentHex, conditions, preimageHex, maxFee = 50000n, feeType = { kind: "transparent" }) {
|
|
1857
|
+
async htlcClaim(resourceAddress, commitmentHex, conditions, preimageHex, maxFee = 50000n, feeType = { kind: "transparent" }, options = {}) {
|
|
1583
1858
|
if (!/^[0-9a-f]{64}$/i.test(preimageHex)) {
|
|
1584
1859
|
throw new Error(`htlcClaim: preimageHex must be exactly 64 hex characters (32 bytes), got ${JSON.stringify(preimageHex)}`);
|
|
1585
1860
|
}
|
|
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
1861
|
const claimLeaf = conditions[0];
|
|
1598
1862
|
if (!claimLeaf)
|
|
1599
1863
|
throw new Error("htlcClaim: conditions must be the two-leaf [claim, refund] tree htlcFund returned");
|
|
1864
|
+
const verification = await this.verifyHtlc(resourceAddress, commitmentHex, conditions, options.expected ?? {}, preimageHex);
|
|
1865
|
+
if (!verification.ok)
|
|
1866
|
+
throw new HtlcVerificationError(verification.problems);
|
|
1867
|
+
const value = verification.value;
|
|
1868
|
+
const { hashLockHex, refundEpoch } = describeHtlcConditions(conditions);
|
|
1869
|
+
const provider = await this.getProvider();
|
|
1870
|
+
const substateId = stealthUtxoSubstateId(resourceAddress, fromHex(commitmentHex));
|
|
1871
|
+
const decrypted = await decryptOwnedUtxo(new WasmStealthCrypto(this.network), await this.signer.getViewSecret(), await provider.getSubstate(substateId), substateId);
|
|
1872
|
+
if (!decrypted)
|
|
1873
|
+
throw new Error("htlcClaim: the HTLC output no longer decrypts — it may have just been claimed or refunded.");
|
|
1874
|
+
const walletAddress = await this.getWalletAddress();
|
|
1600
1875
|
const statementJson = buildHtlcSpendStatement({
|
|
1601
1876
|
network: this.network,
|
|
1602
1877
|
conditions,
|
|
1603
1878
|
leaf: claimLeaf,
|
|
1604
1879
|
data: fromHex(preimageHex),
|
|
1605
1880
|
mask: decrypted.mask.toHex(),
|
|
1606
|
-
value
|
|
1881
|
+
value,
|
|
1607
1882
|
destinationWalletAddress: walletAddress,
|
|
1608
1883
|
resourceAddress,
|
|
1609
1884
|
});
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
provider,
|
|
1613
|
-
signer: this.signer,
|
|
1614
|
-
network: this.network,
|
|
1615
|
-
account,
|
|
1885
|
+
return this.spendHtlc({
|
|
1886
|
+
kind: "claim",
|
|
1616
1887
|
resourceAddress,
|
|
1617
|
-
|
|
1888
|
+
commitmentHex,
|
|
1889
|
+
conditions,
|
|
1890
|
+
hashLockHex,
|
|
1891
|
+
refundEpoch,
|
|
1892
|
+
value,
|
|
1618
1893
|
statementJson,
|
|
1619
1894
|
maxFee,
|
|
1620
|
-
|
|
1621
|
-
|
|
1895
|
+
feeType,
|
|
1896
|
+
preflight: options.preflight !== false,
|
|
1897
|
+
armedStatus: "claim_armed",
|
|
1898
|
+
label: "submitting the HTLC claim transaction",
|
|
1622
1899
|
});
|
|
1623
|
-
if (privateFee && feeType.kind === "private") {
|
|
1624
|
-
await this.recordPrivateFeeSpend(localAccountId(this.index), feeType.feeResourceAddress, privateFee, transactionId);
|
|
1625
|
-
}
|
|
1626
|
-
return { transactionId };
|
|
1627
1900
|
}
|
|
1628
1901
|
/**
|
|
1629
|
-
* Refunds an HTLC this account itself funded (via `htlcFund`), once
|
|
1630
|
-
* reveals the refund leaf to spend the output back into a brand-new, normal
|
|
1631
|
-
*
|
|
1902
|
+
* Refunds an HTLC this account itself funded (via `htlcFund`), once the refund path is open:
|
|
1903
|
+
* reveals the refund leaf to spend the output back into a brand-new, normal stealth output owned
|
|
1904
|
+
* by this account (recorded in the shielded ledger).
|
|
1632
1905
|
*
|
|
1633
|
-
*
|
|
1634
|
-
*
|
|
1635
|
-
*
|
|
1636
|
-
*
|
|
1637
|
-
*
|
|
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.
|
|
1641
|
-
*
|
|
1642
|
-
* Otherwise identical to `htlcClaim`: same fee-from-own-balance reasoning, same
|
|
1643
|
-
* `buildStealthTransferStatement`-in-one-call approach, same bypass of
|
|
1644
|
-
* `StealthTransfer`/`WalletStealthAuthorizer`.
|
|
1906
|
+
* The refund path is `AfterEpoch(refundEpoch)`, i.e. open when `currentEpoch >= refundEpoch` —
|
|
1907
|
+
* checked here first, so an early refund fails with a clear message instead of an on-chain
|
|
1908
|
+
* rejection. The funder can't decrypt the output (it's addressed to the claimant), so `amount` and
|
|
1909
|
+
* `outputMaskHex` must be exactly what `htlcFund` returned — or use `refundFromJournal(journalId)`,
|
|
1910
|
+
* which reads them back from the journal `htlcFund` wrote before submitting.
|
|
1645
1911
|
*/
|
|
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);
|
|
1912
|
+
async htlcRefund(resourceAddress, commitmentHex, conditions, amount, outputMaskHex, maxFee = 50000n, feeType = { kind: "transparent" }, options = {}) {
|
|
1652
1913
|
const refundLeaf = conditions[1];
|
|
1653
1914
|
if (!refundLeaf)
|
|
1654
1915
|
throw new Error("htlcRefund: conditions must be the two-leaf [claim, refund] tree htlcFund returned");
|
|
1916
|
+
const { hashLockHex, refundEpoch } = describeHtlcConditions(conditions);
|
|
1917
|
+
const provider = await this.getProvider();
|
|
1918
|
+
const currentEpoch = BigInt(await provider.getCurrentEpoch());
|
|
1919
|
+
if (!isHtlcRefundable(currentEpoch, refundEpoch)) {
|
|
1920
|
+
throw new Error(`htlcRefund: the refund path opens at epoch ${refundEpoch}; the current epoch is ${currentEpoch}.`);
|
|
1921
|
+
}
|
|
1922
|
+
const walletAddress = await this.getWalletAddress();
|
|
1655
1923
|
const statementJson = buildHtlcSpendStatement({
|
|
1656
1924
|
network: this.network,
|
|
1657
1925
|
conditions,
|
|
@@ -1662,23 +1930,215 @@ export class OotleAccount {
|
|
|
1662
1930
|
destinationWalletAddress: walletAddress,
|
|
1663
1931
|
resourceAddress,
|
|
1664
1932
|
});
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
account,
|
|
1933
|
+
if (statementInputCommitment(statementJson) !== commitmentHex.toLowerCase()) {
|
|
1934
|
+
throw new Error("htlcRefund: this amount/mask doesn't reproduce the HTLC commitment — use the exact values htlcFund returned (or refundFromJournal).");
|
|
1935
|
+
}
|
|
1936
|
+
return this.spendHtlc({
|
|
1937
|
+
kind: "refund",
|
|
1671
1938
|
resourceAddress,
|
|
1672
|
-
|
|
1939
|
+
commitmentHex,
|
|
1940
|
+
conditions,
|
|
1941
|
+
hashLockHex,
|
|
1942
|
+
refundEpoch,
|
|
1943
|
+
value: amount,
|
|
1673
1944
|
statementJson,
|
|
1674
1945
|
maxFee,
|
|
1675
|
-
|
|
1676
|
-
|
|
1946
|
+
feeType,
|
|
1947
|
+
preflight: options.preflight !== false,
|
|
1948
|
+
armedStatus: "prepared",
|
|
1949
|
+
label: "submitting the HTLC refund transaction",
|
|
1677
1950
|
});
|
|
1678
|
-
|
|
1679
|
-
|
|
1951
|
+
}
|
|
1952
|
+
/** Refunds an HTLC this account funded, using the conditions/mask/amount `htlcFund` journaled. */
|
|
1953
|
+
async refundFromJournal(journalId, maxFee = 50000n, feeType = { kind: "transparent" }) {
|
|
1954
|
+
const entry = await getHtlcJournalEntry(journalId);
|
|
1955
|
+
if (!entry || entry.kind !== "fund" || entry.accountId !== localAccountId(this.index)) {
|
|
1956
|
+
throw new Error(`refundFromJournal: ${journalId} is not an HTLC this account funded.`);
|
|
1957
|
+
}
|
|
1958
|
+
if (!entry.htlcCommitment || !entry.outputMask)
|
|
1959
|
+
throw new Error(`refundFromJournal: ${journalId} has no HTLC commitment/mask recorded.`);
|
|
1960
|
+
return this.htlcRefund(entry.resourceAddress, entry.htlcCommitment, entry.conditions, BigInt(entry.amount), entry.outputMask, maxFee, feeType);
|
|
1961
|
+
}
|
|
1962
|
+
/** Shared claim/refund path: private-fee reservation, dry run, journal, submit, bookkeeping. */
|
|
1963
|
+
async spendHtlc(p) {
|
|
1964
|
+
const opId = newOperationId(`htlc-${p.kind}`);
|
|
1965
|
+
const accountId = localAccountId(this.index);
|
|
1966
|
+
const provider = await this.getProvider();
|
|
1967
|
+
const account = await this.getComponentAddress();
|
|
1968
|
+
const substateId = stealthUtxoSubstateId(p.resourceAddress, fromHex(p.commitmentHex));
|
|
1969
|
+
const ownCommitment = statementOutputCommitment(p.statementJson, 0);
|
|
1970
|
+
try {
|
|
1971
|
+
const privateFee = await this.resolvePrivateFee(p.feeType, p.maxFee, [], opId);
|
|
1972
|
+
const envelopeFor = (dryRun) => buildHtlcSpendEnvelope({
|
|
1973
|
+
provider,
|
|
1974
|
+
signer: this.signer,
|
|
1975
|
+
network: this.network,
|
|
1976
|
+
account,
|
|
1977
|
+
resourceAddress: p.resourceAddress,
|
|
1978
|
+
substateId,
|
|
1979
|
+
statementJson: p.statementJson,
|
|
1980
|
+
maxFee: p.maxFee,
|
|
1981
|
+
privateFee,
|
|
1982
|
+
dryRun,
|
|
1983
|
+
});
|
|
1984
|
+
if (p.preflight)
|
|
1985
|
+
await this.dryRunEnvelope(await envelopeFor(true));
|
|
1986
|
+
const envelope = await envelopeFor(false);
|
|
1987
|
+
const now = Date.now();
|
|
1988
|
+
const entry = {
|
|
1989
|
+
id: opId,
|
|
1990
|
+
accountId,
|
|
1991
|
+
kind: p.kind,
|
|
1992
|
+
status: p.armedStatus,
|
|
1993
|
+
resourceAddress: p.resourceAddress,
|
|
1994
|
+
amount: p.value.toString(),
|
|
1995
|
+
conditions: p.conditions,
|
|
1996
|
+
hashLockHex: p.hashLockHex,
|
|
1997
|
+
refundEpoch: p.refundEpoch.toString(),
|
|
1998
|
+
htlcCommitment: p.commitmentHex.toLowerCase(),
|
|
1999
|
+
ownCommitment,
|
|
2000
|
+
ownAmount: p.value.toString(),
|
|
2001
|
+
privateFee: journalFee(p.feeType, privateFee),
|
|
2002
|
+
envelope,
|
|
2003
|
+
createdAt: now,
|
|
2004
|
+
updatedAt: now,
|
|
2005
|
+
};
|
|
2006
|
+
const { transactionId } = await this.submitJournaled(entry, provider, p.label);
|
|
2007
|
+
return { transactionId, receivedCommitment: ownCommitment, amount: p.value, journalId: opId };
|
|
2008
|
+
}
|
|
2009
|
+
catch (e) {
|
|
2010
|
+
if (!(e instanceof HtlcUnknownOutcomeError))
|
|
2011
|
+
await releaseReservations(opId);
|
|
2012
|
+
throw e;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* Journal → submit → poll → bookkeeping, for every HTLC operation. The entry (with its sealed
|
|
2017
|
+
* envelope and recovery data) is persisted before submission; a definitive rejection marks it
|
|
2018
|
+
* `failed`, anything ambiguous marks it `unknown` and throws `HtlcUnknownOutcomeError`.
|
|
2019
|
+
*/
|
|
2020
|
+
async submitJournaled(entry, provider, label) {
|
|
2021
|
+
await putHtlcJournalEntry(entry);
|
|
2022
|
+
let transactionId;
|
|
2023
|
+
try {
|
|
2024
|
+
transactionId = await submitTransaction(provider, entry.envelope);
|
|
2025
|
+
await updateHtlcJournalEntry(entry.id, { status: "submitted", transactionId });
|
|
2026
|
+
const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, label);
|
|
2027
|
+
await this.finalizeHtlcEntry({ ...entry, transactionId }, response);
|
|
2028
|
+
return { transactionId };
|
|
2029
|
+
}
|
|
2030
|
+
catch (e) {
|
|
2031
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
2032
|
+
if (isDefinitiveRejection(e)) {
|
|
2033
|
+
await updateHtlcJournalEntry(entry.id, { status: "failed", error, transactionId });
|
|
2034
|
+
await releaseReservations(entry.id);
|
|
2035
|
+
throw e;
|
|
2036
|
+
}
|
|
2037
|
+
await updateHtlcJournalEntry(entry.id, { status: "unknown", error, transactionId });
|
|
2038
|
+
throw new HtlcUnknownOutcomeError(entry.id, { kind: entry.kind, transactionId, conditions: entry.conditions, htlcCommitment: entry.htlcCommitment, outputMask: entry.outputMask }, e);
|
|
1680
2039
|
}
|
|
1681
|
-
|
|
2040
|
+
}
|
|
2041
|
+
/** Applies a confirmed HTLC operation's local bookkeeping. Idempotent (see `addShieldedOutput`). */
|
|
2042
|
+
async finalizeHtlcEntry(entry, response) {
|
|
2043
|
+
const txId = entry.transactionId;
|
|
2044
|
+
await recordKnownVersions(response);
|
|
2045
|
+
if (entry.ownCommitment && entry.ownAmount) {
|
|
2046
|
+
await recordKnownShieldedOutput(entry.accountId, entry.resourceAddress, entry.ownCommitment, BigInt(entry.ownAmount), txId);
|
|
2047
|
+
}
|
|
2048
|
+
for (const c of entry.spentCommitments ?? [])
|
|
2049
|
+
await markShieldedOutputSpent(entry.accountId, c);
|
|
2050
|
+
if (entry.privateFee) {
|
|
2051
|
+
await recordKnownShieldedOutput(entry.accountId, entry.privateFee.feeResourceAddress, entry.privateFee.changeCommitment, BigInt(entry.privateFee.changeAmount), txId);
|
|
2052
|
+
await markShieldedOutputSpent(entry.accountId, entry.privateFee.spentCommitment);
|
|
2053
|
+
}
|
|
2054
|
+
await updateHtlcJournalEntry(entry.id, { status: "confirmed", transactionId: txId, error: undefined });
|
|
2055
|
+
await releaseReservations(entry.id);
|
|
2056
|
+
}
|
|
2057
|
+
/** This account's HTLC journal (every fund/claim/refund and its state), newest first. The
|
|
2058
|
+
* entries' `envelope` of a claim contains the preimage — don't display or export it. */
|
|
2059
|
+
async listHtlcs() {
|
|
2060
|
+
return (await listHtlcJournal(localAccountId(this.index))).sort((a, b) => b.createdAt - a.createdAt);
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* Resolves every HTLC operation whose outcome isn't final (`prepared`, `claim_armed`,
|
|
2064
|
+
* `submitted`, `unknown`) — call at startup and after any `HtlcUnknownOutcomeError`. An entry
|
|
2065
|
+
* with no transaction id is resubmitted with its *identical* sealed envelope (the same
|
|
2066
|
+
* transaction, so it can't execute twice; an expired one is simply rejected); then its result is
|
|
2067
|
+
* checked once. Confirmed entries get their bookkeeping applied, rejected ones are marked
|
|
2068
|
+
* `failed` and release their reserved UTXOs, still-pending ones are left for the next call.
|
|
2069
|
+
*/
|
|
2070
|
+
async reconcileHtlcs() {
|
|
2071
|
+
const provider = await this.getProvider();
|
|
2072
|
+
const open = (await listHtlcJournal(localAccountId(this.index))).filter((e) => ["prepared", "claim_armed", "submitted", "unknown"].includes(e.status));
|
|
2073
|
+
const out = [];
|
|
2074
|
+
for (const entry of open) {
|
|
2075
|
+
let current = entry;
|
|
2076
|
+
try {
|
|
2077
|
+
let txId = entry.transactionId;
|
|
2078
|
+
if (!txId) {
|
|
2079
|
+
try {
|
|
2080
|
+
txId = await submitTransaction(provider, entry.envelope);
|
|
2081
|
+
current = (await updateHtlcJournalEntry(entry.id, { status: "submitted", transactionId: txId })) ?? current;
|
|
2082
|
+
}
|
|
2083
|
+
catch (e) {
|
|
2084
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
2085
|
+
if (isDefinitiveRejection(e) || /HTTP 4\d\d/.test(error)) {
|
|
2086
|
+
current = (await updateHtlcJournalEntry(entry.id, { status: "failed", error })) ?? current;
|
|
2087
|
+
await releaseReservations(entry.id);
|
|
2088
|
+
}
|
|
2089
|
+
else {
|
|
2090
|
+
current = (await updateHtlcJournalEntry(entry.id, { status: "unknown", error })) ?? current;
|
|
2091
|
+
}
|
|
2092
|
+
out.push(current);
|
|
2093
|
+
continue;
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
const response = await provider.getTransactionResult(txId);
|
|
2097
|
+
const result = response.result;
|
|
2098
|
+
if (result === "Pending") {
|
|
2099
|
+
out.push(current);
|
|
2100
|
+
continue;
|
|
2101
|
+
}
|
|
2102
|
+
if ("Rejected" in result) {
|
|
2103
|
+
current = (await updateHtlcJournalEntry(entry.id, { status: "failed", error: result.Rejected.details })) ?? current;
|
|
2104
|
+
await releaseReservations(entry.id);
|
|
2105
|
+
out.push(current);
|
|
2106
|
+
continue;
|
|
2107
|
+
}
|
|
2108
|
+
const outcome = result.Finalized.execution_result?.finalize.result;
|
|
2109
|
+
if (outcome && typeof outcome === "object" && "Accept" in outcome) {
|
|
2110
|
+
await this.finalizeHtlcEntry({ ...entry, transactionId: txId }, response);
|
|
2111
|
+
current = (await getHtlcJournalEntry(entry.id)) ?? current;
|
|
2112
|
+
}
|
|
2113
|
+
else {
|
|
2114
|
+
current = (await updateHtlcJournalEntry(entry.id, { status: "failed", error: JSON.stringify(outcome ?? null) })) ?? current;
|
|
2115
|
+
await releaseReservations(entry.id);
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
catch (e) {
|
|
2119
|
+
// A transient error on one entry must not block the rest; it stays open for next time.
|
|
2120
|
+
current = (await updateHtlcJournalEntry(entry.id, { error: e instanceof Error ? e.message : String(e) })) ?? current;
|
|
2121
|
+
}
|
|
2122
|
+
out.push(current);
|
|
2123
|
+
}
|
|
2124
|
+
return out;
|
|
2125
|
+
}
|
|
2126
|
+
/**
|
|
2127
|
+
* Stealth commitments this account must not select right now: reserved by another in-flight
|
|
2128
|
+
* operation, or spent (as an input or a private fee) by an HTLC operation whose outcome isn't
|
|
2129
|
+
* final — those may already be gone on-chain even after their reservation lapses.
|
|
2130
|
+
*/
|
|
2131
|
+
async unavailableCommitments(accountId, holder) {
|
|
2132
|
+
const out = await listReservedCommitments(accountId, holder);
|
|
2133
|
+
for (const e of await listHtlcJournal(accountId)) {
|
|
2134
|
+
if (e.id === holder || !["prepared", "claim_armed", "submitted", "unknown"].includes(e.status))
|
|
2135
|
+
continue;
|
|
2136
|
+
for (const c of e.spentCommitments ?? [])
|
|
2137
|
+
out.add(c);
|
|
2138
|
+
if (e.privateFee)
|
|
2139
|
+
out.add(e.privateFee.spentCommitment);
|
|
2140
|
+
}
|
|
2141
|
+
return out;
|
|
1682
2142
|
}
|
|
1683
2143
|
/**
|
|
1684
2144
|
* Sends `amount` of this account's shielded balance for `resourceAddress` directly to
|
|
@@ -1710,68 +2170,76 @@ export class OotleAccount {
|
|
|
1710
2170
|
async sendPrivately(resourceAddress, recipientWalletAddress, amount, maxFee = 100000n, memo, minimumValuePromise = 0n, feeType = { kind: "transparent" }) {
|
|
1711
2171
|
assertValidMinimumValuePromise(minimumValuePromise, amount);
|
|
1712
2172
|
const accountId = localAccountId(this.index);
|
|
1713
|
-
const
|
|
2173
|
+
const opId = newOperationId("send-private");
|
|
2174
|
+
const unavailable = await this.unavailableCommitments(accountId, opId);
|
|
2175
|
+
const records = await this.filterReadableShieldedOutputs((await listShieldedOutputs(accountId)).filter((r) => !unavailable.has(r.commitment)), resourceAddress, amount);
|
|
1714
2176
|
const { commitments, changeAmount } = resolveSendPrivatelyPlan(records, resourceAddress, amount);
|
|
1715
|
-
|
|
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
|
-
});
|
|
2177
|
+
await reserveCommitments(accountId, commitments, opId);
|
|
1754
2178
|
try {
|
|
1755
|
-
const
|
|
1756
|
-
await
|
|
1757
|
-
|
|
1758
|
-
|
|
2179
|
+
const dust = 1n;
|
|
2180
|
+
const provider = await this.getProvider();
|
|
2181
|
+
const account = await this.getComponentAddress();
|
|
2182
|
+
const ownWalletAddress = await this.getWalletAddress();
|
|
2183
|
+
// See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
|
|
2184
|
+
// never adds it on its own.
|
|
2185
|
+
let builder = new StealthTransfer(provider, resourceAddress).withRevealedReceiver(this.accountPublicKey)
|
|
2186
|
+
.withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
|
|
2187
|
+
.spendRevealedInput(account, dust);
|
|
1759
2188
|
for (const commitment of commitments) {
|
|
1760
|
-
|
|
2189
|
+
builder = builder.spendStealthInput(account, fromHex(commitment));
|
|
1761
2190
|
}
|
|
1762
|
-
|
|
1763
|
-
|
|
2191
|
+
// The promise rides on the recipient's output only. Putting one on the change output would
|
|
2192
|
+
// publish a floor on this account's own remaining private balance -- an unrelated disclosure the
|
|
2193
|
+
// caller never asked for, and one the recipient has no interest in.
|
|
2194
|
+
builder = builder.toStealthOutput(createOutput({ destination: recipientWalletAddress, amount, resourceAddress, memo: toMemo(memo), minimumValuePromise }));
|
|
2195
|
+
if (changeAmount > 0n) {
|
|
2196
|
+
builder = builder.toStealthOutput(createOutput({ destination: ownWalletAddress, amount: changeAmount, resourceAddress }));
|
|
2197
|
+
}
|
|
2198
|
+
builder = builder.toRevealedOutput(dust);
|
|
2199
|
+
const viewSecret = await this.signer.getViewSecret();
|
|
2200
|
+
// `commitments` excluded from the fee UTXO's own selection -- see unshield()'s identical
|
|
2201
|
+
// comment on why (this and unshield are the only two methods with their own multi-UTXO
|
|
2202
|
+
// coin selection of potentially the same resource as the fee).
|
|
2203
|
+
const { transactionId, spec, privateFee } = await this.prepareSignSubmit(builder, provider, account, maxFee, feeType, { viewSecret }, commitments, opId);
|
|
2204
|
+
// Output 0 is the recipient's; output 1 (only present when there's change) is ours. The
|
|
2205
|
+
// recipient has no way to discover their new output on their own (no scan-by-view-key API
|
|
2206
|
+
// exists) -- this commitment must be handed back to the caller so it can be shared with them
|
|
2207
|
+
// out of band; without it, the payment is invisible to them even though it succeeded on-chain.
|
|
2208
|
+
const recipientCommitment = extractOutputCommitment(spec, 0);
|
|
2209
|
+
const ownCommitment = changeAmount > 0n ? extractOutputCommitment(spec, 1) : undefined;
|
|
2210
|
+
await addPendingShield({
|
|
2211
|
+
transactionId,
|
|
2212
|
+
accountId,
|
|
2213
|
+
resourceAddress,
|
|
2214
|
+
amount: changeAmount.toString(),
|
|
2215
|
+
spentCommitments: commitments,
|
|
2216
|
+
ownCommitment,
|
|
2217
|
+
});
|
|
2218
|
+
try {
|
|
2219
|
+
const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the private send");
|
|
2220
|
+
await recordKnownVersions(response);
|
|
2221
|
+
if (ownCommitment)
|
|
2222
|
+
await recordKnownShieldedOutput(accountId, resourceAddress, ownCommitment, changeAmount, transactionId);
|
|
2223
|
+
for (const commitment of commitments) {
|
|
2224
|
+
await markShieldedOutputSpent(accountId, commitment);
|
|
2225
|
+
}
|
|
2226
|
+
if (privateFee && feeType.kind === "private") {
|
|
2227
|
+
await this.recordPrivateFeeSpend(accountId, feeType.feeResourceAddress, privateFee, transactionId);
|
|
2228
|
+
}
|
|
1764
2229
|
}
|
|
2230
|
+
finally {
|
|
2231
|
+
await removePendingShield(transactionId);
|
|
2232
|
+
}
|
|
2233
|
+
return {
|
|
2234
|
+
transactionId,
|
|
2235
|
+
recipientCommitment,
|
|
2236
|
+
recipientSubstateId: stealthUtxoSubstateId(resourceAddress, fromHex(recipientCommitment)),
|
|
2237
|
+
minimumValuePromise: minimumValuePromise.toString(),
|
|
2238
|
+
};
|
|
1765
2239
|
}
|
|
1766
2240
|
finally {
|
|
1767
|
-
await
|
|
2241
|
+
await releaseReservations(opId);
|
|
1768
2242
|
}
|
|
1769
|
-
return {
|
|
1770
|
-
transactionId,
|
|
1771
|
-
recipientCommitment,
|
|
1772
|
-
recipientSubstateId: stealthUtxoSubstateId(resourceAddress, fromHex(recipientCommitment)),
|
|
1773
|
-
minimumValuePromise: minimumValuePromise.toString(),
|
|
1774
|
-
};
|
|
1775
2243
|
}
|
|
1776
2244
|
/**
|
|
1777
2245
|
* The recipient-side counterpart to `sendPrivately()`'s commitment hand-off: given a commitment
|
|
@@ -1824,9 +2292,156 @@ export class OotleAccount {
|
|
|
1824
2292
|
async claimBurn(contents, maxFee, memo = "Burnt funds claimed from L1") {
|
|
1825
2293
|
if (maxFee !== undefined && maxFee <= 0n)
|
|
1826
2294
|
throw new Error(`claimBurn: maxFee must be > 0, got ${maxFee}`);
|
|
2295
|
+
const { buildClaim, value, provider } = await this.prepareBurnClaim(contents, memo);
|
|
2296
|
+
let fee = maxFee ?? (await this.estimateClaimFee(await buildClaim(claimFeeProbe(value), true)));
|
|
2297
|
+
let attempt = await buildClaim(fee, false);
|
|
2298
|
+
let transactionId = await submitTransaction(provider, attempt.envelope);
|
|
2299
|
+
let response;
|
|
2300
|
+
try {
|
|
2301
|
+
response = await withTimeout(pollTransactionResult(provider, transactionId), 90_000, "claiming the burn");
|
|
2302
|
+
}
|
|
2303
|
+
catch (e) {
|
|
2304
|
+
// A real run can meter a little differently from its dry run. When the rejection names the
|
|
2305
|
+
// fee it wanted, a measured fee is resubmitted once at exactly that; an explicit one is not.
|
|
2306
|
+
const required = maxFee === undefined ? requiredFeeFromRejection(e) : null;
|
|
2307
|
+
if (required === null || required <= fee)
|
|
2308
|
+
throw e;
|
|
2309
|
+
fee = required;
|
|
2310
|
+
attempt = await buildClaim(fee, false);
|
|
2311
|
+
transactionId = await submitTransaction(provider, attempt.envelope);
|
|
2312
|
+
response = await withTimeout(pollTransactionResult(provider, transactionId), 90_000, "claiming the burn");
|
|
2313
|
+
}
|
|
2314
|
+
await recordKnownVersions(response);
|
|
2315
|
+
await recordKnownShieldedOutput(localAccountId(this.index), TARI_RESOURCE_ADDRESS, attempt.ownCommitment, attempt.claimedAmount, transactionId, memo);
|
|
2316
|
+
return { transactionId, claimedAmount: attempt.claimedAmount, commitment: attempt.ownCommitment, fee };
|
|
2317
|
+
}
|
|
2318
|
+
/**
|
|
2319
|
+
* The fee `claimBurn(contents)` would pay, measured by dry-running the claim -- nothing is
|
|
2320
|
+
* submitted. The indexer's dry run checks everything about the claim except the burn's L1
|
|
2321
|
+
* inclusion (the burn output rules, its ownership proof, the claim-key signature), so this also
|
|
2322
|
+
* tells a caller whether the claim is well formed before anything is spent.
|
|
2323
|
+
*/
|
|
2324
|
+
async estimateClaimBurnFee(contents, memo = "Burnt funds claimed from L1") {
|
|
2325
|
+
const { buildClaim, value } = await this.prepareBurnClaim(contents, memo);
|
|
2326
|
+
return this.estimateClaimFee(await buildClaim(claimFeeProbe(value), true));
|
|
2327
|
+
}
|
|
2328
|
+
/**
|
|
2329
|
+
* `unshield()` for an account whose component doesn't exist on-chain yet. Everything runs in the
|
|
2330
|
+
* fee phase, the way Tari's own wallet unshields into a new account: the stealth inputs reveal
|
|
2331
|
+
* `amount + fee` (any excess returns as a stealth change output), `TakeFromBucket` splits off the
|
|
2332
|
+
* fee for `PayFeeFromBucket`, and `CreateAccount` creates this account from the rest. The fee is
|
|
2333
|
+
* measured with a dry run first, since a fee revealed from stealth funds is never refunded.
|
|
2334
|
+
*
|
|
2335
|
+
* The seal key is authorized as a signer and named as the revealed output's receiver
|
|
2336
|
+
* (tari-ootle#2645); each input's one-time key co-signs its key-path spend.
|
|
2337
|
+
*/
|
|
2338
|
+
async unshieldIntoNewAccount(provider, resourceAddress, amount, commitments, remainder, maxFee, memo) {
|
|
2339
|
+
if (resourceAddress !== TARI_RESOURCE_ADDRESS) {
|
|
2340
|
+
throw new Error("This account doesn't exist on-chain yet. Unshield some TARI first to create it, then unshield other resources.");
|
|
2341
|
+
}
|
|
2342
|
+
const accountId = localAccountId(this.index);
|
|
2343
|
+
const crypto = new WasmStealthCrypto(this.network);
|
|
2344
|
+
const viewSecret = await this.signer.getViewSecret();
|
|
2345
|
+
const walletAddress = await this.getWalletAddress();
|
|
2346
|
+
const inputs = await Promise.all(commitments.map(async (commitmentHex) => {
|
|
2347
|
+
const substateId = stealthUtxoSubstateId(resourceAddress, fromHex(commitmentHex));
|
|
2348
|
+
const substate = await provider.getSubstate(substateId);
|
|
2349
|
+
const decrypted = await decryptOwnedUtxo(crypto, viewSecret, substate, substateId);
|
|
2350
|
+
if (!decrypted)
|
|
2351
|
+
throw new Error(`unshield: cannot decrypt ${substateId} -- it isn't this account's, or it is already spent.`);
|
|
2352
|
+
const nonce = substate.substate.Utxo.output.output
|
|
2353
|
+
.public_nonce;
|
|
2354
|
+
return { commitmentHex, substateId, mask: decrypted.mask, nonce };
|
|
2355
|
+
}));
|
|
2356
|
+
const inputMask = await crypto.aggregateInputMasks(inputs.map((i) => i.mask));
|
|
2357
|
+
const inputsJson = JSON.stringify({
|
|
2358
|
+
inputs: inputs.map((i) => ({ commitment: i.commitmentHex, witness: "KeyPath" })),
|
|
2359
|
+
revealed_amount: "0",
|
|
2360
|
+
});
|
|
2361
|
+
const sealKeypair = generateSealKeypair();
|
|
2362
|
+
const build = async (fee, dryRun) => {
|
|
2363
|
+
// The fee comes out of the stealth side: what's revealed is `amount + fee`, and the change
|
|
2364
|
+
// output shrinks by `fee`.
|
|
2365
|
+
const change = remainder - fee;
|
|
2366
|
+
if (fee <= 0n || change < 0n) {
|
|
2367
|
+
throw new Error(`unshield: the private balance does not cover ${amount} plus the ${fee} fee to create the account`);
|
|
2368
|
+
}
|
|
2369
|
+
const revealed = amount + fee;
|
|
2370
|
+
let outputsStatement;
|
|
2371
|
+
let outputMask = Mask.zero();
|
|
2372
|
+
if (change > 0n) {
|
|
2373
|
+
const output = createOutput({ destination: walletAddress, amount: change, resourceAddress, memo: toMemo(memo) });
|
|
2374
|
+
({ statement: outputsStatement, outputMask } = await crypto.generateOutputsStatement([output], revealed, sealKeypair.public_key));
|
|
2375
|
+
}
|
|
2376
|
+
else {
|
|
2377
|
+
outputsStatement = new StealthOutputsStatement(revealOnlyOutputsJson(revealed, sealKeypair.public_key));
|
|
2378
|
+
}
|
|
2379
|
+
const inputsStatement = new StealthInputsStatement([], 0n, inputsJson);
|
|
2380
|
+
const balanceProof = await crypto.generateBalanceProofSignature(inputMask, outputMask, inputsJson, outputsStatement.statementJson);
|
|
2381
|
+
const statement = new StealthTransferStatement(inputsStatement, outputsStatement, balanceProof);
|
|
2382
|
+
await crypto.validateTransfer(statement);
|
|
2383
|
+
const changeCommitment = change > 0n ? outputsStatement.parsed().outputs[0].output.commitment : null;
|
|
2384
|
+
const builder = TransactionBuilder.new(this.network, await resolveMaxEpoch(provider));
|
|
2385
|
+
for (const instruction of [
|
|
2386
|
+
{
|
|
2387
|
+
StealthTransfer: {
|
|
2388
|
+
resource_address_ref: { Address: resourceAddress },
|
|
2389
|
+
statement: { __ootleRawJson: statement.toCompactJson() },
|
|
2390
|
+
revealed_input_bucket: null,
|
|
2391
|
+
},
|
|
2392
|
+
},
|
|
2393
|
+
{ PutLastInstructionOutputOnWorkspace: { key: 0 } },
|
|
2394
|
+
{ TakeFromBucket: { input_bucket: { id: 0, offset: null }, amount: fee.toString(), output_bucket: 1 } },
|
|
2395
|
+
{ PayFeeFromBucket: { bucket: { id: 1, offset: null } } },
|
|
2396
|
+
{
|
|
2397
|
+
CreateAccount: {
|
|
2398
|
+
owner_public_key: toHex(this.accountPublicKey),
|
|
2399
|
+
owner_rule: null,
|
|
2400
|
+
access_rules: null,
|
|
2401
|
+
bucket_workspace_id: { id: 0, offset: null },
|
|
2402
|
+
},
|
|
2403
|
+
},
|
|
2404
|
+
]) {
|
|
2405
|
+
builder.addFeeInstruction(instruction);
|
|
2406
|
+
}
|
|
2407
|
+
builder.addInput({ substate_id: resourceAddress, version: null, is_write: false });
|
|
2408
|
+
for (const input of inputs)
|
|
2409
|
+
builder.addInput({ substate_id: input.substateId, version: null });
|
|
2410
|
+
const unsignedBody = builder.buildUnsignedTransaction();
|
|
2411
|
+
unsignedBody.is_seal_signer_authorized = true;
|
|
2412
|
+
unsignedBody.dry_run = dryRun;
|
|
2413
|
+
const unsignedTx = await resolveTransaction(provider, unsignedBody);
|
|
2414
|
+
const signers = inputs.map((input) => ({
|
|
2415
|
+
getAddress: async () => walletAddress,
|
|
2416
|
+
getPublicKey: async () => parseOotleAddress(walletAddress).owner_key,
|
|
2417
|
+
signTransaction: async (tx, sealPublicKey) => {
|
|
2418
|
+
const json = serializeUnsignedTx(tx);
|
|
2419
|
+
return [await this.signer.addStealthSignature(json, fromHex(input.nonce), sealPublicKey, { crypto })];
|
|
2420
|
+
},
|
|
2421
|
+
}));
|
|
2422
|
+
const signed = await signTransaction(signers, unsignedTx, sealKeypair);
|
|
2423
|
+
return { envelope: sealTransaction(signed), change, changeCommitment };
|
|
2424
|
+
};
|
|
2425
|
+
const probe = maxFee < remainder ? maxFee : remainder;
|
|
2426
|
+
const fee = await this.estimateClaimFee(await build(probe, true), "unshield");
|
|
2427
|
+
if (fee > maxFee)
|
|
2428
|
+
throw new Error(`unshield: creating the account costs ${fee}, above the ${maxFee} limit`);
|
|
2429
|
+
const attempt = await build(fee, false);
|
|
2430
|
+
const transactionId = await submitTransaction(provider, attempt.envelope);
|
|
2431
|
+
const response = await withTimeout(pollTransactionResult(provider, transactionId), 90_000, "unshielding into a new account");
|
|
2432
|
+
await recordKnownVersions(response);
|
|
2433
|
+
for (const input of inputs)
|
|
2434
|
+
await markShieldedOutputSpent(accountId, input.commitmentHex);
|
|
2435
|
+
if (attempt.changeCommitment) {
|
|
2436
|
+
await recordKnownShieldedOutput(accountId, resourceAddress, attempt.changeCommitment, attempt.change, transactionId, memo);
|
|
2437
|
+
}
|
|
2438
|
+
return { transactionId };
|
|
2439
|
+
}
|
|
2440
|
+
/** Verifies a burn is ours and returns a builder for its claim transaction at a given fee. */
|
|
2441
|
+
async prepareBurnClaim(contents, memo) {
|
|
1827
2442
|
const proof = contents.claim_proof;
|
|
1828
2443
|
const burnCommitment = fromHex(proof.commitment);
|
|
1829
|
-
const senderOffset = fromHex(proof.sender_offset_public_key);
|
|
2444
|
+
const senderOffset = fromHex(proof.output.sender_offset_public_key);
|
|
1830
2445
|
const value = BigInt(proof.value);
|
|
1831
2446
|
const stealthSecret = burnClaimStealthSecret(this.ownerSecret, senderOffset);
|
|
1832
2447
|
const ownershipValid = validateBurnClaimOwnershipProof(this.network, fromHex(proof.ownership_proof.public_nonce), fromHex(proof.ownership_proof.signature), burnCommitment, value, stealthSecret);
|
|
@@ -1841,6 +2456,7 @@ export class OotleAccount {
|
|
|
1841
2456
|
}
|
|
1842
2457
|
const provider = await this.getProvider();
|
|
1843
2458
|
const walletAddress = await this.getWalletAddress();
|
|
2459
|
+
// The claim's only signer, so it is also the receiver of the revealed fee (tari-ootle#2645).
|
|
1844
2460
|
const sealKeypair = { secret_key: stealthSecret, public_key: publicKeyFromSecretKey(stealthSecret) };
|
|
1845
2461
|
// One claim transaction revealing `fee`. The output statement encodes the revealed amount, so
|
|
1846
2462
|
// every fee needs its own statement and proofs.
|
|
@@ -1850,7 +2466,7 @@ export class OotleAccount {
|
|
|
1850
2466
|
throw new Error(`claimBurn: the burn (${decrypted.value}) does not cover the claim fee (${fee})`);
|
|
1851
2467
|
}
|
|
1852
2468
|
const output = createOutput({ destination: walletAddress, amount: claimedAmount, resourceAddress: TARI_RESOURCE_ADDRESS, memo: toMemo(memo) });
|
|
1853
|
-
const { statement: outputsStatement, outputMask } = await crypto.generateOutputsStatement([output], fee);
|
|
2469
|
+
const { statement: outputsStatement, outputMask } = await crypto.generateOutputsStatement([output], fee, sealKeypair.public_key);
|
|
1854
2470
|
// The single input is the UTXO `ClaimBurn` mints in the same transaction; the instruction
|
|
1855
2471
|
// itself authorises spending it, so it carries no witness.
|
|
1856
2472
|
const inputsStatement = await crypto.buildInputsStatement([new StealthInput(burnCommitment)], 0n);
|
|
@@ -1871,7 +2487,7 @@ export class OotleAccount {
|
|
|
1871
2487
|
})
|
|
1872
2488
|
.addFeeInstruction({ PutLastInstructionOutputOnWorkspace: { key: 0 } })
|
|
1873
2489
|
.addFeeInstruction({ PayFeeFromBucket: { bucket: { id: 0, offset: null } } })
|
|
1874
|
-
.addInput({ substate_id: TARI_RESOURCE_ADDRESS, version: null });
|
|
2490
|
+
.addInput({ substate_id: TARI_RESOURCE_ADDRESS, version: null, is_write: false });
|
|
1875
2491
|
// The seal key `s` is the transaction's only signer, so it must be authorized as the main
|
|
1876
2492
|
// signer; the TS builder leaves that off by default.
|
|
1877
2493
|
const unsignedBody = builder.buildUnsignedTransaction();
|
|
@@ -1881,30 +2497,10 @@ export class OotleAccount {
|
|
|
1881
2497
|
const signed = await signTransaction([], unsignedTx, sealKeypair);
|
|
1882
2498
|
return { envelope: sealTransaction(signed), claimedAmount, ownCommitment };
|
|
1883
2499
|
};
|
|
1884
|
-
|
|
1885
|
-
let attempt = await buildClaim(fee, false);
|
|
1886
|
-
let transactionId = await submitTransaction(provider, attempt.envelope);
|
|
1887
|
-
let response;
|
|
1888
|
-
try {
|
|
1889
|
-
response = await withTimeout(pollTransactionResult(provider, transactionId), 90_000, "claiming the burn");
|
|
1890
|
-
}
|
|
1891
|
-
catch (e) {
|
|
1892
|
-
// A real run can meter a little differently from its dry run. When the rejection names the
|
|
1893
|
-
// fee it wanted, a measured fee is resubmitted once at exactly that; an explicit one is not.
|
|
1894
|
-
const required = maxFee === undefined ? requiredFeeFromRejection(e) : null;
|
|
1895
|
-
if (required === null || required <= fee)
|
|
1896
|
-
throw e;
|
|
1897
|
-
fee = required;
|
|
1898
|
-
attempt = await buildClaim(fee, false);
|
|
1899
|
-
transactionId = await submitTransaction(provider, attempt.envelope);
|
|
1900
|
-
response = await withTimeout(pollTransactionResult(provider, transactionId), 90_000, "claiming the burn");
|
|
1901
|
-
}
|
|
1902
|
-
await recordKnownVersions(response);
|
|
1903
|
-
await recordKnownShieldedOutput(localAccountId(this.index), TARI_RESOURCE_ADDRESS, attempt.ownCommitment, attempt.claimedAmount, transactionId, memo);
|
|
1904
|
-
return { transactionId, claimedAmount: attempt.claimedAmount, commitment: attempt.ownCommitment, fee };
|
|
2500
|
+
return { buildClaim, value: decrypted.value, provider };
|
|
1905
2501
|
}
|
|
1906
2502
|
/** Dry-runs a sealed claim and returns the fee it needs: what it was charged plus the allowance. */
|
|
1907
|
-
async estimateClaimFee(dryRun) {
|
|
2503
|
+
async estimateClaimFee(dryRun, what = "claim") {
|
|
1908
2504
|
const res = await fetch(`${defaultIndexerUrl(this.network)}/transactions/dry-run`, {
|
|
1909
2505
|
method: "POST",
|
|
1910
2506
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1919,7 +2515,7 @@ export class OotleAccount {
|
|
|
1919
2515
|
// Not JSON — reported raw below.
|
|
1920
2516
|
}
|
|
1921
2517
|
if (!res.ok || body?.error || !body?.result) {
|
|
1922
|
-
throw new Error(`Could not estimate the
|
|
2518
|
+
throw new Error(`Could not estimate the ${what} fee: ${body?.error?.message ?? text ?? res.statusText}`);
|
|
1923
2519
|
}
|
|
1924
2520
|
const finalize = body.result.finalize;
|
|
1925
2521
|
const outcome = finalize.result;
|
|
@@ -1932,7 +2528,7 @@ export class OotleAccount {
|
|
|
1932
2528
|
if (amount !== undefined)
|
|
1933
2529
|
charged += BigInt(amount);
|
|
1934
2530
|
if (charged === 0n)
|
|
1935
|
-
throw new Error(
|
|
2531
|
+
throw new Error(`Could not estimate the ${what} fee: the dry run reported no cost`);
|
|
1936
2532
|
return charged + FEE_ESTIMATE_ALLOWANCE;
|
|
1937
2533
|
}
|
|
1938
2534
|
/**
|
|
@@ -2197,7 +2793,7 @@ export function buildHtlcSpendStatement(params) {
|
|
|
2197
2793
|
const destination = parseOotleAddress(params.destinationWalletAddress);
|
|
2198
2794
|
const outputWitnessJson = createStealthOutputWitness(params.network, destination.owner_key, destination.view_key, params.value, params.resourceAddress, null, null, null, // pay_to_json: null -- default StealthPublicKey, a normal one-time key-spendable output.
|
|
2199
2795
|
0n);
|
|
2200
|
-
const statementJson = buildStealthTransferStatement(`[${inputEntry}]`, 0n, `[${outputWitnessJson}]`, 0n);
|
|
2796
|
+
const statementJson = buildStealthTransferStatement(`[${inputEntry}]`, 0n, `[${outputWitnessJson}]`, 0n, new Uint8Array(0));
|
|
2201
2797
|
// Fail fast locally rather than spend a network round trip on a malformed statement -- the same
|
|
2202
2798
|
// check tari-ootle's own `build_stealth_transfer_statement` unit tests run before trusting its
|
|
2203
2799
|
// output (crates/ootle_wasm/core/src/stealth/transfer.rs).
|
|
@@ -2205,17 +2801,17 @@ export function buildHtlcSpendStatement(params) {
|
|
|
2205
2801
|
return statementJson;
|
|
2206
2802
|
}
|
|
2207
2803
|
/**
|
|
2208
|
-
*
|
|
2209
|
-
* `statementJson` verbatim, spending `substateId`,
|
|
2804
|
+
* Builds and seals (but does not submit) a hand-built HTLC claim/refund transaction: one
|
|
2805
|
+
* `StealthTransfer` instruction carrying `statementJson` verbatim, spending `substateId`, with the fee
|
|
2806
|
+
* in its own lane -- native TARI from `account`'s public balance, or a private fee UTXO.
|
|
2210
2807
|
*
|
|
2211
|
-
* Deliberately bypasses `TransactionBuilder`'s usual `WalletStealthAuthorizer` companion
|
|
2212
|
-
* pipeline only ever produces key-path stealth-input signatures, which don't apply to a
|
|
2213
|
-
*
|
|
2214
|
-
*
|
|
2215
|
-
*
|
|
2216
|
-
* uses.
|
|
2808
|
+
* Deliberately bypasses `TransactionBuilder`'s usual `WalletStealthAuthorizer` companion -- that
|
|
2809
|
+
* pipeline only ever produces key-path stealth-input signatures, which don't apply to a script-path
|
|
2810
|
+
* spend (whose authorization is the revealed leaf's own `AccessRule`, satisfied by this account's
|
|
2811
|
+
* ordinary transaction signature) -- `signTransaction`/`sealTransaction` alone are both necessary and
|
|
2812
|
+
* sufficient here. `dryRun` marks the transaction before it's signed, for `dryRunEnvelope`.
|
|
2217
2813
|
*/
|
|
2218
|
-
async function
|
|
2814
|
+
async function buildHtlcSpendEnvelope(params) {
|
|
2219
2815
|
const maxEpoch = await resolveMaxEpoch(params.provider);
|
|
2220
2816
|
const builder = TransactionBuilder.new(params.network, maxEpoch)
|
|
2221
2817
|
.addInput({ substate_id: params.substateId, version: null })
|
|
@@ -2243,14 +2839,12 @@ async function submitHtlcSpend(params) {
|
|
|
2243
2839
|
for (const vaultId of await getVaultIdsForAccount(params.provider, params.account)) {
|
|
2244
2840
|
builder.addInput({ substate_id: vaultId, version: null });
|
|
2245
2841
|
}
|
|
2246
|
-
const
|
|
2842
|
+
const unsigned = builder.buildUnsignedTransaction();
|
|
2843
|
+
unsigned.dry_run = params.dryRun;
|
|
2844
|
+
const unsignedTx = await resolveTransaction(params.provider, unsigned);
|
|
2247
2845
|
const extraSigners = params.privateFee ? [params.privateFee.feeSigner] : [];
|
|
2248
2846
|
const signed = await signTransaction([params.signer, ...extraSigners], unsignedTx);
|
|
2249
|
-
|
|
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;
|
|
2847
|
+
return sealTransaction(signed);
|
|
2254
2848
|
}
|
|
2255
2849
|
function extractOutputCommitment(spec, outputIndex) {
|
|
2256
2850
|
const parsed = spec.statement.outputsStatement.parsed();
|
|
@@ -2642,6 +3236,14 @@ export async function pollTransactionResult(provider, transactionId, timeoutMs =
|
|
|
2642
3236
|
if ("Rejected" in result) {
|
|
2643
3237
|
throw new Error(`Transaction ${transactionId} was rejected: ${result.Rejected.details}`);
|
|
2644
3238
|
}
|
|
3239
|
+
// Consensus can abort a transaction whatever its execution said (a lock conflict, an expired
|
|
3240
|
+
// epoch, too little fee); only a `Commit` means it happened.
|
|
3241
|
+
const decision = result.Finalized.final_decision;
|
|
3242
|
+
if (decision !== "Commit") {
|
|
3243
|
+
const reason = typeof decision === "object" && decision && "Abort" in decision ? decision.Abort : JSON.stringify(decision);
|
|
3244
|
+
const details = result.Finalized.abort_details ? `: ${result.Finalized.abort_details}` : "";
|
|
3245
|
+
throw new Error(`Transaction ${transactionId} was aborted (${reason})${details}`);
|
|
3246
|
+
}
|
|
2645
3247
|
const outcome = result.Finalized.execution_result?.finalize.result;
|
|
2646
3248
|
if (outcome && typeof outcome === "object") {
|
|
2647
3249
|
if ("Reject" in outcome) {
|
|
@@ -2784,3 +3386,21 @@ function describeSignedTx(signed) {
|
|
|
2784
3386
|
return "unavailable";
|
|
2785
3387
|
}
|
|
2786
3388
|
}
|
|
3389
|
+
/**
|
|
3390
|
+
* The one-time spend key of a stealth UTXO substate (its `auth` `Key`, or `KeyAndScript.spend_key`).
|
|
3391
|
+
* A key-path spend of the UTXO is signed with this key, so its badge is in the transaction's auth
|
|
3392
|
+
* scope -- which makes it a valid receiver for a revealed output (tari-ootle#2645) in transactions
|
|
3393
|
+
* this account deliberately doesn't sign with its own key.
|
|
3394
|
+
*/
|
|
3395
|
+
function utxoSpendKey(substate) {
|
|
3396
|
+
const auth = substate.substate?.Utxo?.output?.auth;
|
|
3397
|
+
const key = auth && "Key" in auth ? auth.Key : auth && "KeyAndScript" in auth ? auth.KeyAndScript.spend_key : undefined;
|
|
3398
|
+
if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key)) {
|
|
3399
|
+
throw new Error("This stealth output has no one-time spend key (it is script-only), so it can't be spent by key.");
|
|
3400
|
+
}
|
|
3401
|
+
return fromHex(key);
|
|
3402
|
+
}
|
|
3403
|
+
/** A hand-built outputs statement that reveals `amount` to `receiver` and creates no stealth outputs. */
|
|
3404
|
+
function revealOnlyOutputsJson(amount, receiver) {
|
|
3405
|
+
return JSON.stringify({ outputs: [], revealed_output: { amount: amount.toString(), receiver: toHex(receiver) }, agg_range_proof: "" });
|
|
3406
|
+
}
|