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