@chironbuilder/ootle-sdk 0.1.0
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/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/accountApi.d.ts +33 -0
- package/dist/accountApi.d.ts.map +1 -0
- package/dist/accountApi.js +1 -0
- package/dist/adapters.d.ts +13 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +68 -0
- package/dist/componentAddress.d.ts +27 -0
- package/dist/componentAddress.d.ts.map +1 -0
- package/dist/componentAddress.js +68 -0
- package/dist/confidential.d.ts +50 -0
- package/dist/confidential.d.ts.map +1 -0
- package/dist/confidential.js +86 -0
- package/dist/derivation.d.ts +7 -0
- package/dist/derivation.d.ts.map +1 -0
- package/dist/derivation.js +47 -0
- package/dist/domainHash.d.ts +11 -0
- package/dist/domainHash.d.ts.map +1 -0
- package/dist/domainHash.js +44 -0
- package/dist/htlc.d.ts +26 -0
- package/dist/htlc.d.ts.map +1 -0
- package/dist/htlc.js +69 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/ootleNetwork.d.ts +4 -0
- package/dist/ootleNetwork.d.ts.map +1 -0
- package/dist/ootleNetwork.js +11 -0
- package/dist/ownershipProof.d.ts +4 -0
- package/dist/ownershipProof.d.ts.map +1 -0
- package/dist/ownershipProof.js +53 -0
- package/dist/storage.d.ts +61 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +110 -0
- package/dist/timeout.d.ts +2 -0
- package/dist/timeout.d.ts.map +1 -0
- package/dist/timeout.js +19 -0
- package/dist/vault.d.ts +12 -0
- package/dist/vault.d.ts.map +1 -0
- package/dist/vault.js +44 -0
- package/dist/wallet.d.ts +733 -0
- package/dist/wallet.d.ts.map +1 -0
- package/dist/wallet.js +1964 -0
- package/package.json +46 -0
- package/vendor/ootle-patched/README.md +67 -0
- package/vendor/ootle-patched/dist/index.d.ts +2129 -0
- package/vendor/ootle-patched/dist/index.js +1365 -0
- package/vendor/ootle-patched/package.json +23 -0
- package/vendor/ootle-wasm-patched/README.md +128 -0
- package/vendor/ootle-wasm-patched/ootle_wasm.d.ts +454 -0
- package/vendor/ootle-wasm-patched/ootle_wasm.js +9 -0
- package/vendor/ootle-wasm-patched/ootle_wasm_bg.js +1485 -0
- package/vendor/ootle-wasm-patched/ootle_wasm_bg.wasm +0 -0
- package/vendor/ootle-wasm-patched/ootle_wasm_bg.wasm.d.ts +83 -0
- package/vendor/ootle-wasm-patched/package.json +27 -0
package/dist/wallet.js
ADDED
|
@@ -0,0 +1,1964 @@
|
|
|
1
|
+
import { OotleWallet, StealthTransfer, 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, signTransaction, stealthUtxoSubstateId, submitTransaction, } from "@tari-project/ootle";
|
|
2
|
+
import { IndexerProvider } from "@tari-project/ootle-indexer";
|
|
3
|
+
import { SecretKeyWallet } from "@tari-project/ootle-secret-key-wallet";
|
|
4
|
+
import { buildScriptPathWitness, buildStealthTransferStatement, createConfidentialWithdrawProofLiteral, createStealthOutputWitness, parseOotleAddress, publicKeyFromSecretKey, schnorrSign, stealthDhSecret, validateStealthTransfer, } from "@tari-project/ootle-wasm";
|
|
5
|
+
import { componentAddressFromWalletAddress, deriveAccountComponentAddress } from "./componentAddress";
|
|
6
|
+
import { scanTransactionsForOwnedOutputs, sumConfidentialCommitments } from "./confidential";
|
|
7
|
+
import { deriveAccountKeys } from "./derivation";
|
|
8
|
+
import { htlcConditions } from "./htlc";
|
|
9
|
+
import { buildOwnershipProofMessage, buildWalletOwnershipMessage } from "./ownershipProof";
|
|
10
|
+
import { toOotleNetwork } from "./ootleNetwork";
|
|
11
|
+
import { addPendingShield, addShieldedOutput, getKnownVersions, getPrivatePaymentScanCursor, listPendingShields, listShieldedOutputs, localAccountId, markShieldedOutputSpent, removePendingShield, serialized, setKnownVersions, setPrivatePaymentScanCursor, } from "./storage";
|
|
12
|
+
import { withTimeout } from "./timeout";
|
|
13
|
+
import { fromHex, toHex } from "./vault";
|
|
14
|
+
/**
|
|
15
|
+
* Batch-reads each resource's on-chain `divisibility` and `metadata.SYMBOL`/`metadata.name`.
|
|
16
|
+
* Decimal precision and display name are real chain data, never a client-side convention: XTR is 6
|
|
17
|
+
* and a typical DemoToken defaults to 8, and assuming one for the other misprices amounts by two
|
|
18
|
+
* orders of magnitude. A resource whose substate is missing or isn't a `Resource` degrades to
|
|
19
|
+
* `divisibility: 0` and null names rather than throwing -- one unreadable resource must not take
|
|
20
|
+
* down a whole balance listing.
|
|
21
|
+
*/
|
|
22
|
+
async function fetchResourceMetadata(provider, resourceIds) {
|
|
23
|
+
const divisibilityByResource = new Map();
|
|
24
|
+
const symbolByResource = new Map();
|
|
25
|
+
const nameByResource = new Map();
|
|
26
|
+
if (resourceIds.length === 0)
|
|
27
|
+
return { divisibilityByResource, symbolByResource, nameByResource };
|
|
28
|
+
const { substates } = await withTimeout(provider.fetchSubstates(resourceIds), 15_000, "reading token decimal precision");
|
|
29
|
+
for (const id of resourceIds) {
|
|
30
|
+
const value = substates[id]?.substate;
|
|
31
|
+
const resource = value && "Resource" in value ? value.Resource : undefined;
|
|
32
|
+
divisibilityByResource.set(id, typeof resource?.divisibility === "number" ? resource.divisibility : 0);
|
|
33
|
+
const metadata = resource?.metadata;
|
|
34
|
+
symbolByResource.set(id, typeof metadata?.SYMBOL === "string" ? metadata.SYMBOL : null);
|
|
35
|
+
nameByResource.set(id, typeof metadata?.name === "string" ? metadata.name : null);
|
|
36
|
+
}
|
|
37
|
+
return { divisibilityByResource, symbolByResource, nameByResource };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A stealth output's **minimum value promise**: a public claim, committed into the output's own
|
|
41
|
+
* range proof, that it is worth at least this much.
|
|
42
|
+
*
|
|
43
|
+
* A confidential output normally carries a range proof for `0 <= v < 2^64`, hiding `v` completely.
|
|
44
|
+
* Set a promise `m` and the proof instead attests `m <= v < 2^64`, with `m` stored in the clear on
|
|
45
|
+
* the output (`UnspentOutput.minimum_value_promise`). Anyone reading the output on-chain learns
|
|
46
|
+
* "worth at least `m`" and nothing more precise -- which is exactly a proof of funds: a permanent,
|
|
47
|
+
* non-interactive, publicly verifiable artifact that needs no cooperation from this wallet to check
|
|
48
|
+
* (fetch the `utxo_{resource}_{commitment}` substate, read the field, confirm it is still unspent).
|
|
49
|
+
*
|
|
50
|
+
* Two things follow that callers must get right, which is why this is validated rather than passed
|
|
51
|
+
* straight through:
|
|
52
|
+
*
|
|
53
|
+
* 1. **A promise above the real value is unprovable.** The bulletproof asserting `m <= v` cannot be
|
|
54
|
+
* generated when `m > v`, so this fails here with a message that says so, rather than deeper in
|
|
55
|
+
* the wasm with something opaque -- or, worse, producing a proof the network then rejects after
|
|
56
|
+
* the fee half of the transaction has already been committed.
|
|
57
|
+
* 2. **It is a permanent, irreversible privacy disclosure.** Shielding is how value stops being
|
|
58
|
+
* publicly visible; a promise puts a floor back on public view, for the life of the output, for
|
|
59
|
+
* everyone -- not just whoever the proof was made for. That is the deliberate trade being made,
|
|
60
|
+
* and it is why every path reaching this asks the user first.
|
|
61
|
+
*/
|
|
62
|
+
// Exported (unlike this file's other module-level guards) so the rule can be exercised directly --
|
|
63
|
+
// it is the one place standing between a caller and an unprovable range proof, and a regression here
|
|
64
|
+
// would surface as an opaque wasm failure after the fee half of a transaction had already committed.
|
|
65
|
+
export function assertValidMinimumValuePromise(promise, amount) {
|
|
66
|
+
if (promise < 0n) {
|
|
67
|
+
throw new Error(`minimumValuePromise cannot be negative (got ${promise}).`);
|
|
68
|
+
}
|
|
69
|
+
if (promise > amount) {
|
|
70
|
+
throw new Error(`minimumValuePromise (${promise}) cannot exceed the output's own amount (${amount}) -- the range proof asserting "at least ${promise}" is impossible for an output actually worth ${amount}.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* `Output.memo`/`OutputInit.memo` is typed `object` in `@tari-project/ootle` (untyped there), but
|
|
75
|
+
* is actually the tagged `Memo` union from `@tari-project/ootle-ts-bindings` -- confirmed by
|
|
76
|
+
* reading that package's generated `Memo.d.ts` directly rather than assuming the Rust SDK docs'
|
|
77
|
+
* `.with_memo_message(...)` name maps 1:1 onto this TS binding. `{ Message: text }` is the plain
|
|
78
|
+
* free-text variant; the other variants (`U256`, `Bytes`, `PayRefAndBytes`) aren't used by this
|
|
79
|
+
* wallet's UI, which only ever offers a plain text note.
|
|
80
|
+
*/
|
|
81
|
+
function toMemo(memo) {
|
|
82
|
+
return memo ? { Message: memo } : undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Inverse of `toMemo()`, for a *received* output: `DecryptedData.memo` (confidential.ts's
|
|
86
|
+
* `ScannedStealthOutput.memo`, `claimPrivatePayment()`'s decrypt result) is the raw JSON-encoded
|
|
87
|
+
* `Memo` union string, not plain text -- confirmed against the SDK's own `DecryptedData` doc
|
|
88
|
+
* comment, not assumed. This wallet only ever creates `Message` memos, but a payment from a
|
|
89
|
+
* different sender/tool could use any variant -- render something readable for those instead of
|
|
90
|
+
* leaking raw JSON or silently dropping the memo. Returns `undefined` for no memo, an unparseable
|
|
91
|
+
* value, or an empty `Message`.
|
|
92
|
+
*/
|
|
93
|
+
function fromMemo(memoJson) {
|
|
94
|
+
if (!memoJson)
|
|
95
|
+
return undefined;
|
|
96
|
+
try {
|
|
97
|
+
const memo = JSON.parse(memoJson);
|
|
98
|
+
if ("Message" in memo)
|
|
99
|
+
return memo.Message || undefined;
|
|
100
|
+
if ("SenderAddress" in memo)
|
|
101
|
+
return `From: ${memo.SenderAddress}`;
|
|
102
|
+
const [kind, value] = Object.entries(memo)[0];
|
|
103
|
+
return `[${kind} memo: ${value}]`;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* One derived Ootle account: a signer (owner + view keypair) plus the network connection needed
|
|
111
|
+
* to read its state and submit transactions, entirely independent of the wallet daemon.
|
|
112
|
+
*/
|
|
113
|
+
export class OotleAccount {
|
|
114
|
+
index;
|
|
115
|
+
network;
|
|
116
|
+
signer;
|
|
117
|
+
provider = null;
|
|
118
|
+
// Held only for signOwnershipProof(), which needs stealthDhSecret() directly -- SecretKeyWallet
|
|
119
|
+
// keeps its own copy of this exact value in a private field already (for addStealthSignature's
|
|
120
|
+
// transaction-signing path), so this adds no new exposure, just a second reference to the same
|
|
121
|
+
// in-memory secret for a second, narrowly-scoped purpose.
|
|
122
|
+
ownerSecret;
|
|
123
|
+
constructor(index, network, signer, ownerSecret) {
|
|
124
|
+
this.index = index;
|
|
125
|
+
this.network = network;
|
|
126
|
+
this.signer = signer;
|
|
127
|
+
this.ownerSecret = ownerSecret;
|
|
128
|
+
}
|
|
129
|
+
/** `entropy` is the 16-byte CipherSeed entropy (see cipherSeed.ts), not a raw 32-byte seed. */
|
|
130
|
+
static fromSeed(entropy, index, networkName) {
|
|
131
|
+
const network = toOotleNetwork(networkName);
|
|
132
|
+
const { ownerSecret, viewSecret } = deriveAccountKeys(entropy, index);
|
|
133
|
+
const signer = SecretKeyWallet.fromSecretKey(ownerSecret, network, viewSecret);
|
|
134
|
+
return new OotleAccount(index, network, signer, ownerSecret);
|
|
135
|
+
}
|
|
136
|
+
async getProvider() {
|
|
137
|
+
if (!this.provider) {
|
|
138
|
+
// Nothing has run yet at this point, so any failure here is by definition a connectivity
|
|
139
|
+
// problem (unlike execute()'s later errors, which are meaningful on-chain rejections this
|
|
140
|
+
// class deliberately leaves unwrapped) — mirrors the same "unreachable" framing
|
|
141
|
+
// DaemonAccount.connectClient() uses for the equivalent first-contact step.
|
|
142
|
+
const url = defaultIndexerUrl(this.network);
|
|
143
|
+
try {
|
|
144
|
+
this.provider = await withTimeout(IndexerProvider.connect({ url, network: this.network }), 15_000, "connecting to the Tari indexer");
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
const details = e instanceof Error ? e.message : String(e);
|
|
148
|
+
throw new Error(`Could not reach the Tari indexer at ${url}. (${details})`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return this.provider;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The bech32m "otl_..." wallet address, for display / receiving funds. Confirmed (empirically,
|
|
155
|
+
* against `generateOotleAddress`) to be what `Signer.getAddress()` actually returns for a
|
|
156
|
+
* `SecretKeyWallet` — despite the base `Signer` interface's JSDoc calling it "the component
|
|
157
|
+
* address", it is NOT the on-chain account component address. See `getComponentAddress()`.
|
|
158
|
+
*/
|
|
159
|
+
async getWalletAddress() {
|
|
160
|
+
return this.signer.getAddress();
|
|
161
|
+
}
|
|
162
|
+
async getPublicKey() {
|
|
163
|
+
return this.signer.getPublicKey();
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The account's on-chain component address — what instructions use as the `account` argument
|
|
167
|
+
* (deposit/withdraw/pay_fee/etc.) and what `getVaultIdsForAccount` needs to find balances.
|
|
168
|
+
*
|
|
169
|
+
* Computed client-side via `deriveAccountComponentAddress` (`componentAddress.ts`), which
|
|
170
|
+
* reproduces Ootle's domain-separated Blake2b hash of (ACCOUNT_TEMPLATE_ADDRESS, owner_public_key)
|
|
171
|
+
* byte-for-byte — verified against all three committed golden vectors from
|
|
172
|
+
* `tari-ootle`'s `crates/ootle_sdk_core/fixtures/address_derive/`.
|
|
173
|
+
*/
|
|
174
|
+
async getComponentAddress() {
|
|
175
|
+
const publicKey = await this.getPublicKey();
|
|
176
|
+
return deriveAccountComponentAddress(publicKey);
|
|
177
|
+
}
|
|
178
|
+
async getBalances() {
|
|
179
|
+
const provider = await this.getProvider();
|
|
180
|
+
const account = await this.getComponentAddress();
|
|
181
|
+
let vaultIds;
|
|
182
|
+
try {
|
|
183
|
+
vaultIds = await withTimeout(getVaultIdsForAccount(provider, account), 15_000, "looking up this account's vaults");
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
// A real timeout here (the indexer connection above already succeeded, so this specifically
|
|
187
|
+
// means it's up but slow/overloaded) must not look identical to "this account genuinely has
|
|
188
|
+
// no vaults yet" — silently mapping both to an empty balance list would show a brand-new
|
|
189
|
+
// account and a degraded-indexer account the exact same way, with no indication anything's
|
|
190
|
+
// actually wrong in the latter case.
|
|
191
|
+
if (e instanceof Error && e.message.startsWith("Timed out"))
|
|
192
|
+
throw e;
|
|
193
|
+
return []; // account not yet on-chain (never funded)
|
|
194
|
+
}
|
|
195
|
+
// Fold in this account's own known-good stealth outputs (from shield()/unshield(), or from
|
|
196
|
+
// redeeming someone else's shared commitment via the "Advanced" unshield flow) up front, not
|
|
197
|
+
// gated behind a vault existing -- see ShieldedOutputRecord's doc comment for why this local
|
|
198
|
+
// ledger is the only lead to them at all: they're freestanding `utxo_{resource}_{commitment}`
|
|
199
|
+
// substates, never entries in a vault's own `Confidential.commitments` map, so nothing below
|
|
200
|
+
// this point would otherwise see them. A resource whose *only* balance came from redeeming a
|
|
201
|
+
// shared commitment has no on-chain vault at all (`vaultIds.length === 0` for it), so this
|
|
202
|
+
// can't be computed only after confirming a vault exists.
|
|
203
|
+
// A local record only says this account once *held* the output. It says nothing about whether
|
|
204
|
+
// the output has since been spent — by another wallet on the same seed, by another device, or
|
|
205
|
+
// by a spend this ledger never saw — and `scanForPrivatePayments` happily re-claims outputs
|
|
206
|
+
// that were already consumed. Counting those inflates the private balance with money that
|
|
207
|
+
// cannot be spent, and the failure only surfaces later as an unresolvable input mid-transfer.
|
|
208
|
+
//
|
|
209
|
+
// The chain is the authority: a stealth output is a substate, and a consumed one is simply
|
|
210
|
+
// gone. Confirmed against the live Esmeralda indexer that `getSubstate` returns the substate
|
|
211
|
+
// for a live output and errors (HTTP 500) for a consumed one, and that batching them through
|
|
212
|
+
// `fetchSubstates` fails the *whole* call as soon as one id is consumed — so each is checked
|
|
213
|
+
// on its own, in parallel.
|
|
214
|
+
//
|
|
215
|
+
// Deliberately not persisted as `spent`: the indexer's error is indistinguishable from a real
|
|
216
|
+
// server fault, and marking the ledger on an ambiguous signal is a one-way door that would
|
|
217
|
+
// hide genuine funds. Excluding it from this reading is self-healing — if the output really is
|
|
218
|
+
// live, the next balance read counts it again. The vault lookup above already proved the
|
|
219
|
+
// indexer is reachable in this same call, which is what makes a per-id failure here meaningful
|
|
220
|
+
// rather than just noise.
|
|
221
|
+
const unspentRecords = (await listShieldedOutputs(localAccountId(this.index))).filter((r) => !r.spent);
|
|
222
|
+
const liveness = await Promise.all(unspentRecords.map(async (record) => {
|
|
223
|
+
try {
|
|
224
|
+
await withTimeout(provider.getSubstate(stealthUtxoSubstateId(record.resourceAddress, fromHex(record.commitment))), 15_000, "checking a shielded output");
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}));
|
|
231
|
+
const shieldedByResource = new Map();
|
|
232
|
+
unspentRecords.forEach((record, i) => {
|
|
233
|
+
if (!liveness[i])
|
|
234
|
+
return;
|
|
235
|
+
shieldedByResource.set(record.resourceAddress, (shieldedByResource.get(record.resourceAddress) ?? 0n) + BigInt(record.amount));
|
|
236
|
+
});
|
|
237
|
+
if (vaultIds.length === 0 && shieldedByResource.size === 0)
|
|
238
|
+
return [];
|
|
239
|
+
const parsed = [];
|
|
240
|
+
if (vaultIds.length > 0) {
|
|
241
|
+
const { substates } = await withTimeout(provider.fetchSubstates(vaultIds), 15_000, "fetching vault balances");
|
|
242
|
+
for (const id of vaultIds) {
|
|
243
|
+
const substate = substates[id];
|
|
244
|
+
const value = substate?.substate;
|
|
245
|
+
if (!value || !("Vault" in value))
|
|
246
|
+
continue;
|
|
247
|
+
const container = value.Vault.resource_container;
|
|
248
|
+
const [kind, data] = Object.entries(container)[0];
|
|
249
|
+
if (kind === "NonFungible") {
|
|
250
|
+
const tokenIds = (data.token_ids ?? []).map(stringifyNonFungibleId);
|
|
251
|
+
parsed.push({ resourceAddress: data.address, kind, amount: BigInt(tokenIds.length), nonFungibleTokenIds: tokenIds });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const rawAmount = (data.amount ?? data.revealed_amount ?? 0);
|
|
255
|
+
const commitments = kind === "Confidential" ? data.commitments : undefined;
|
|
256
|
+
parsed.push({ resourceAddress: data.address, kind, amount: BigInt(rawAmount), commitments });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (parsed.length === 0 && shieldedByResource.size === 0)
|
|
260
|
+
return [];
|
|
261
|
+
// Decrypt each Confidential vault's hidden commitments with this account's own view key —
|
|
262
|
+
// pure local decryption of data already fetched above, no new network calls. One crypto
|
|
263
|
+
// provider + one view-secret fetch is reused across every confidential vault in this
|
|
264
|
+
// account rather than re-deriving per vault.
|
|
265
|
+
const confidentialByIndex = new Map();
|
|
266
|
+
const hasConfidential = parsed.some((p) => p.commitments && Object.keys(p.commitments).length > 0);
|
|
267
|
+
if (hasConfidential) {
|
|
268
|
+
const crypto = new WasmStealthCrypto(this.network);
|
|
269
|
+
const viewSecret = await this.signer.getViewSecret();
|
|
270
|
+
for (const [i, p] of parsed.entries()) {
|
|
271
|
+
const commitments = p.commitments;
|
|
272
|
+
if (!commitments || Object.keys(commitments).length === 0)
|
|
273
|
+
continue;
|
|
274
|
+
confidentialByIndex.set(i, await sumConfidentialCommitments(crypto, viewSecret, commitments));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Batch-fetch each distinct resource's own substate for its real `divisibility` and metadata
|
|
278
|
+
// symbol/name — decimal precision and display name are both on-chain data, not a client-side
|
|
279
|
+
// guess (confirmed empirically: XTR is 6, a typical DemoToken defaults to 8, and assuming one
|
|
280
|
+
// divisibility for both silently misprices trades by orders of magnitude).
|
|
281
|
+
// Union with shielded-only resources (computed up front, before the vault check above) so a
|
|
282
|
+
// resource with no vault at all still gets its real divisibility/symbol/name looked up.
|
|
283
|
+
const resourceIds = [...new Set([...parsed.map((p) => p.resourceAddress), ...shieldedByResource.keys()])];
|
|
284
|
+
const { divisibilityByResource, symbolByResource, nameByResource } = await fetchResourceMetadata(provider, resourceIds);
|
|
285
|
+
const balances = parsed.map((p, i) => ({
|
|
286
|
+
resourceAddress: p.resourceAddress,
|
|
287
|
+
kind: p.kind,
|
|
288
|
+
amount: p.amount,
|
|
289
|
+
confidentialAmount: (confidentialByIndex.get(i)?.total ?? 0n) + (shieldedByResource.get(p.resourceAddress) ?? 0n),
|
|
290
|
+
confidentialDecryptFailures: confidentialByIndex.get(i)?.failedCount ?? 0,
|
|
291
|
+
divisibility: divisibilityByResource.get(p.resourceAddress) ?? 0,
|
|
292
|
+
symbol: symbolByResource.get(p.resourceAddress) ?? null,
|
|
293
|
+
name: nameByResource.get(p.resourceAddress) ?? null,
|
|
294
|
+
nonFungibleTokenIds: p.nonFungibleTokenIds ?? null,
|
|
295
|
+
}));
|
|
296
|
+
// Resources whose only balance is a shielded output with no on-chain vault at all (e.g.
|
|
297
|
+
// redeemed via the "Advanced" unshield flow from someone else's shared commitment) never
|
|
298
|
+
// appear in `parsed` above -- synthesize an entry for each so they aren't silently dropped.
|
|
299
|
+
balances.push(...synthesizeShieldedOnlyBalances(new Set(parsed.map((p) => p.resourceAddress)), shieldedByResource, divisibilityByResource, symbolByResource, nameByResource));
|
|
300
|
+
return balances;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Builds, signs (with this account's key) and submits/dry-runs a transaction. `opts.inputs` lets
|
|
304
|
+
* a caller pre-pin substates it already knows are needed; beyond that, this indexer requires
|
|
305
|
+
* *every* substate an instruction touches to be listed as an input — even ones referenced only
|
|
306
|
+
* by address in a `CallMethod` on an already-existing component (confirmed empirically, first
|
|
307
|
+
* with the faucet, and again with this account's own existing component when calling into it for
|
|
308
|
+
* a `pay_fee`/`deposit`/`withdraw` on a second use) — and there's no want-derivation pass in this
|
|
309
|
+
* TS SDK to discover that dependency graph up front the way the Rust `ootle_sdk_core` can.
|
|
310
|
+
*
|
|
311
|
+
* Rather than hand-tracing each call's full component/vault graph, this retries on the specific
|
|
312
|
+
* rejection the engine itself gives for a missing input — "At instruction #N: <address> not
|
|
313
|
+
* found" — extracting that address, resolving its current version, adding it to the pinned
|
|
314
|
+
* inputs, and resubmitting. Each retry can surface a *different* missing address (a component's
|
|
315
|
+
* vaults are only discoverable after the component itself is known), so this loops until either
|
|
316
|
+
* success or the same address reappears (nothing new left to resolve). The version-race retry
|
|
317
|
+
* `claimTestnetXtr()` needs for a heavily-contended shared substate is folded in too.
|
|
318
|
+
*/
|
|
319
|
+
async execute(instructions, opts = {}) {
|
|
320
|
+
const provider = await this.getProvider();
|
|
321
|
+
const account = await this.getComponentAddress();
|
|
322
|
+
const maxFee = opts.maxFee ?? 5000n;
|
|
323
|
+
// Resolved once, outside the retry loop below: every transaction now carries a mandatory
|
|
324
|
+
// `max_epoch`, and a fresh chain-tip lookup on every retry would buy nothing (the loop's
|
|
325
|
+
// whole retry budget runs in well under an epoch in practice) at the cost of an extra
|
|
326
|
+
// round trip per attempt.
|
|
327
|
+
const maxEpoch = await resolveMaxEpoch(provider);
|
|
328
|
+
// A first-time transaction into brand-new resources needs one retry per previously-unknown
|
|
329
|
+
// substate it discovers (each pool, each pool's own internal vaults, and any new vault the
|
|
330
|
+
// account itself needs created to hold a token it's never held before) — confirmed empirically
|
|
331
|
+
// that a single-hop swap into a new resource needed more than the original budget of 12, and a
|
|
332
|
+
// routed (multi-hop) swap touches roughly double the substates a direct one does (two pools
|
|
333
|
+
// instead of one, up to two new account vaults instead of one for the intermediate *and* final
|
|
334
|
+
// tokens) — hit exactly this exhausting 20 on a real 2-hop swap.
|
|
335
|
+
const maxRetries = opts.maxRetries ?? 30;
|
|
336
|
+
const seenAddresses = new Set();
|
|
337
|
+
let inputs = await applyKnownVersions(opts.inputs ? [...opts.inputs] : []);
|
|
338
|
+
for (let attempt = 0;; attempt++) {
|
|
339
|
+
const builder = TransactionBuilder.new(this.network, maxEpoch)
|
|
340
|
+
.withInstructions(instructions)
|
|
341
|
+
.feeTransactionPayFromComponent(account, maxFee);
|
|
342
|
+
if (inputs.length)
|
|
343
|
+
builder.withInputs(inputs);
|
|
344
|
+
const unsignedTx = builder.buildUnsignedTransaction();
|
|
345
|
+
try {
|
|
346
|
+
if (opts.dryRun)
|
|
347
|
+
return await withTimeout(this.submitDryRun(provider, unsignedTx), 30_000, "submitting the transaction");
|
|
348
|
+
const result = await withTimeout(this.submitReal(provider, unsignedTx), 60_000, "submitting the transaction");
|
|
349
|
+
await recordKnownVersions(result);
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
if (!(e instanceof Error) || attempt >= maxRetries)
|
|
354
|
+
throw e;
|
|
355
|
+
// No amount of retrying fixes an empty fee vault — surface this immediately instead of
|
|
356
|
+
// burning the retry budget re-discovering the same "insufficient balance" outcome.
|
|
357
|
+
if (e.message.includes("InsufficientFeesPaid") || /insufficient/i.test(e.message)) {
|
|
358
|
+
throw new Error(`This account doesn't have enough XTR to pay the transaction fee. Claim testnet XTR first. (${e.message})`);
|
|
359
|
+
}
|
|
360
|
+
if (e.message.includes("Lock failure")) {
|
|
361
|
+
// A "Lock failure: Substate X:N is DOWN" names the *exact* version that was just
|
|
362
|
+
// consumed — the next version is deterministically N+1, computable locally with no
|
|
363
|
+
// network round trip. This matters: re-querying the indexer here (the previous
|
|
364
|
+
// approach) can hand back the very same stale N again, and worse, this loop's *own*
|
|
365
|
+
// prior attempt can have advanced the real version further still if it reached and paid
|
|
366
|
+
// the fee phase before its main instructions failed (`AcceptFeeRejectRest` still commits
|
|
367
|
+
// the fee) — so trusting a fresh `resolveInputs()` call to have caught up is exactly the
|
|
368
|
+
// race that was producing an apparently-stuck version across many retries.
|
|
369
|
+
const staleVersion = extractStaleLockVersion(e.message);
|
|
370
|
+
if (staleVersion) {
|
|
371
|
+
// The known-versions cache would otherwise keep reasserting the very version that was
|
|
372
|
+
// just rejected over every future resolve for this substate (see
|
|
373
|
+
// `forgetKnownVersions`'s doc comment) -- the local N+1 computed below is correct for
|
|
374
|
+
// *this* attempt, but leaving the stale entry in place would win out again on some
|
|
375
|
+
// later, unrelated transaction that resolves this same substate from scratch.
|
|
376
|
+
await forgetKnownVersions([staleVersion.substateId]);
|
|
377
|
+
inputs = inputs.map((input) => input.substate_id === staleVersion.substateId ? { ...input, version: staleVersion.version + 1 } : input);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const resolved = await resolveInputsWithRetry(provider, inputs.map(({ substate_id }) => ({ substate_id, version: null })));
|
|
381
|
+
// Deliberately NOT `applyKnownVersions(resolved)`: the remembered version is exactly
|
|
382
|
+
// what just failed, and it is `>` whatever the indexer reports, so re-applying it
|
|
383
|
+
// overwrites the freshly resolved (correct) version with the stale one and the next
|
|
384
|
+
// attempt fails identically. Persisted, so one bad write wedges every later transaction
|
|
385
|
+
// across reloads. Forget those entries and trust the chain.
|
|
386
|
+
await forgetKnownVersions(inputs.map((i) => i.substate_id));
|
|
387
|
+
inputs = resolved;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const missing = extractMissingSubstateAddress(e.message);
|
|
391
|
+
if (!missing || seenAddresses.has(missing))
|
|
392
|
+
throw e;
|
|
393
|
+
seenAddresses.add(missing);
|
|
394
|
+
// `resolveInputsWithRetry` already rides out the ordinary case (a producer that hasn't
|
|
395
|
+
// finalized on the indexer yet). If it's *still* failing after that budget, the substate
|
|
396
|
+
// is most likely genuinely absent rather than merely slow -- the common way that happens
|
|
397
|
+
// is a recipient account that has never been funded, since an Ootle account component
|
|
398
|
+
// only exists on chain once something has been deposited into it. Surface that plainly
|
|
399
|
+
// instead of the indexer's raw 404, which names neither the cause nor the fix.
|
|
400
|
+
let resolved;
|
|
401
|
+
try {
|
|
402
|
+
const [firstResolved] = await resolveInputsWithRetry(provider, [{ substate_id: missing, version: null }]);
|
|
403
|
+
resolved = firstResolved;
|
|
404
|
+
}
|
|
405
|
+
catch (resolveError) {
|
|
406
|
+
if (resolveError instanceof Error && resolveError.message.startsWith("Timed out"))
|
|
407
|
+
throw resolveError;
|
|
408
|
+
throw new Error(`${missing} does not exist on the Ootle network yet. An account is created the first ` +
|
|
409
|
+
`time it receives funds, so a brand-new account cannot be paid until it has been ` +
|
|
410
|
+
`funded once. (${resolveError instanceof Error ? resolveError.message.slice(0, 120) : String(resolveError)})`);
|
|
411
|
+
}
|
|
412
|
+
inputs = await applyKnownVersions([...inputs, resolved]);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* A working replacement for the SDK's exported `sendDryRun()`, which is broken against this
|
|
418
|
+
* indexer: it builds the identical signed/sealed envelope but POSTs it to the regular
|
|
419
|
+
* `transactions` endpoint, which rejects dry-run envelopes with "Dry-run transactions must be
|
|
420
|
+
* submitted to the /transactions/dry-run endpoint" (confirmed empirically). This reproduces
|
|
421
|
+
* `sendDryRun`'s own pipeline — set `dry_run`, `resolveTransaction`, `signTransaction`,
|
|
422
|
+
* `sealTransaction` (all exported, same functions it uses internally) — but posts the sealed
|
|
423
|
+
* envelope to the correct path via the indexer client's transport directly. Dry-run responses
|
|
424
|
+
* come back synchronously with the full result already attached (no `transactions/{id}/result`
|
|
425
|
+
* polling needed, unlike a real submission).
|
|
426
|
+
*
|
|
427
|
+
* The response shape (`{transaction_id, result: ExecuteResult}`) is the indexer's own
|
|
428
|
+
* `SubmitTransactionDryRunResponse` (`applications/tari_indexer/src/rest_api/handlers/
|
|
429
|
+
* transactions.rs`'s `submit_transaction_dry_run` handler) — confirmed directly against that
|
|
430
|
+
* Rust source since `@tari-project/ootle-ts-bindings` doesn't export a matching type for it
|
|
431
|
+
* (only the real-submission response, which no longer carries a `result` field at all as of
|
|
432
|
+
* the 0.39.0 protocol upgrade).
|
|
433
|
+
*/
|
|
434
|
+
async submitDryRun(provider, unsignedTx) {
|
|
435
|
+
const resolved = await resolveTransaction(provider, { ...unsignedTx, dry_run: true });
|
|
436
|
+
const signed = await signTransaction([this.signer], resolved);
|
|
437
|
+
const envelope = sealTransaction(signed);
|
|
438
|
+
const res = await fetch(`${defaultIndexerUrl(this.network)}/transactions/dry-run`, {
|
|
439
|
+
method: "POST",
|
|
440
|
+
headers: { "Content-Type": "application/json" },
|
|
441
|
+
// `TransactionEnvelope` (ootle-ts-bindings) is just `string` — the request body is the
|
|
442
|
+
// `{ transaction }` wrapper (`IndexerSubmitTransactionRequest`), not the bare envelope string.
|
|
443
|
+
body: JSON.stringify({ transaction: envelope }),
|
|
444
|
+
});
|
|
445
|
+
const text = await res.text();
|
|
446
|
+
let body;
|
|
447
|
+
try {
|
|
448
|
+
body = text ? JSON.parse(text) : undefined;
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
// Not JSON — fall through and report the raw text below.
|
|
452
|
+
}
|
|
453
|
+
if (!res.ok)
|
|
454
|
+
throw new Error(`HTTP ${res.status}: ${body?.error?.message ?? text ?? res.statusText}`);
|
|
455
|
+
if (body?.error)
|
|
456
|
+
throw new Error(`${body.error.code}: ${body.error.message}`);
|
|
457
|
+
const response = body;
|
|
458
|
+
// A rejected dry-run comes back as a normal HTTP 200 with the rejection embedded in the
|
|
459
|
+
// result (there's nothing to poll — dry runs never reach consensus). Throwing here, in the
|
|
460
|
+
// same "Transaction {id} was rejected: {reason}" shape `sendTransaction`'s own polling throws
|
|
461
|
+
// in, means `execute()`'s retry loop only needs one error-handling path for both. Two failure
|
|
462
|
+
// variants matter: a clean `Reject`, and `AcceptFeeRejectRest` (the fee phase succeeded — it
|
|
463
|
+
// only touches the account, which by definition already resolved — but the *main* instructions
|
|
464
|
+
// aborted, confirmed empirically hitting this on a first read of the DEX pool: the fee-only
|
|
465
|
+
// success masked the "pool component not found" this same regex needs to see to retry).
|
|
466
|
+
const outcome = response.result.finalize.result;
|
|
467
|
+
if (typeof outcome === "object" && outcome !== null) {
|
|
468
|
+
if ("Reject" in outcome) {
|
|
469
|
+
throw new Error(`Transaction ${response.transaction_id} was rejected: ${JSON.stringify(outcome.Reject)}`);
|
|
470
|
+
}
|
|
471
|
+
if ("AcceptFeeRejectRest" in outcome) {
|
|
472
|
+
throw new Error(`Transaction ${response.transaction_id} accepted the fee but rejected the rest: ${JSON.stringify(outcome.AcceptFeeRejectRest)}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return response;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* A working replacement for the SDK's exported `sendTransaction()`. That function still submits
|
|
479
|
+
* correctly, but its own polling (`ct()`/`st()` internally) only surfaces a generic
|
|
480
|
+
* `abort_details` summary on rejection — not the full structured `RejectReason`/`SubstateDiff`
|
|
481
|
+
* this class's auto-resolve retry (see `execute()`) needs to find a missing substate's address
|
|
482
|
+
* in. Confirmed empirically: a real submission that hit "SubstateNotFound: vault_... not found"
|
|
483
|
+
* came back from `sendTransaction()` with no address in the thrown message at all, so the retry
|
|
484
|
+
* loop had nothing to extract and gave up on the first attempt — the exact failure this method
|
|
485
|
+
* exists to fix. Submission itself (`provider.submitTransaction`) is unchanged and already
|
|
486
|
+
* proven correct; only the result-polling and error-message construction are reimplemented, in
|
|
487
|
+
* the same detailed shape `submitDryRun()` above already produces.
|
|
488
|
+
*/
|
|
489
|
+
async submitReal(provider, unsignedTx) {
|
|
490
|
+
const resolved = await resolveTransaction(provider, unsignedTx);
|
|
491
|
+
// Both of these parse the transaction inside the wasm as an untagged enum
|
|
492
|
+
// (`ootle_wasm/core/src/transaction.rs`), so a single bad field anywhere collapses into "data
|
|
493
|
+
// did not match any variant of untagged enum TransactionInput" with nothing naming the
|
|
494
|
+
// culprit. `signTransaction` parses `{transaction, signatures}` -- so a malformed *signature*
|
|
495
|
+
// fails it exactly like a malformed transaction does -- and `sealTransaction` parses it again.
|
|
496
|
+
// The shape is the only evidence there is and it is gone by the time the error surfaces, so it
|
|
497
|
+
// is attached here rather than guessed at afterwards.
|
|
498
|
+
let signed;
|
|
499
|
+
try {
|
|
500
|
+
signed = await signTransaction([this.signer], resolved);
|
|
501
|
+
}
|
|
502
|
+
catch (e) {
|
|
503
|
+
throw new Error(`${e instanceof Error ? e.message : String(e)} — at signTransaction; resolved tx: ${describeResolvedTx(resolved)}`, { cause: e });
|
|
504
|
+
}
|
|
505
|
+
let envelope;
|
|
506
|
+
try {
|
|
507
|
+
envelope = sealTransaction(signed);
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
throw new Error(`${e instanceof Error ? e.message : String(e)} — at sealTransaction; signed: ${describeSignedTx(signed)}`, { cause: e });
|
|
511
|
+
}
|
|
512
|
+
const { transaction_id } = await provider.submitTransaction(envelope);
|
|
513
|
+
return pollTransactionResult(provider, transaction_id);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Transfers `amount` (raw, resource-native units) of `resourceAddress` from this account to
|
|
517
|
+
* `recipientAddress` — a plain `withdraw` off this account's own vault, handed straight to
|
|
518
|
+
* `deposit` on the recipient's account component via a workspace bucket, in one transaction.
|
|
519
|
+
* Works for any resource this account holds, including XTR; `execute()`'s auto-resolve retry
|
|
520
|
+
* (see its own doc comment) discovers and pins whichever vaults/components aren't already
|
|
521
|
+
* known, exactly as it does for every other instruction this class builds by hand.
|
|
522
|
+
*
|
|
523
|
+
* `recipientAddress` accepts either form (see `WalletAccountApi.send`'s doc comment): the
|
|
524
|
+
* recipient's bech32m wallet address, or a bare `component_...` address. `CallMethod.call` is a
|
|
525
|
+
* `SubstateId`, so it must end up being the latter either way — handing an "otl_…" straight
|
|
526
|
+
* through is not a friendly error: `SubstateId::from_str` splits on the first underscore, does
|
|
527
|
+
* not recognise the `otl` prefix, and the instruction then fails to deserialize, surfacing from
|
|
528
|
+
* the wasm as "data did not match any variant of untagged enum TransactionInput", naming neither
|
|
529
|
+
* the field nor the reason.
|
|
530
|
+
*
|
|
531
|
+
* `deposit` is a method call on an *existing* component, and `CreateAccount` is create-or-conflict
|
|
532
|
+
* (against an account that already exists it rejects with "Substate <component>:0 is already UP
|
|
533
|
+
* and conflicts with an existing output" -- Tari's own walletd reads that exact string as "this
|
|
534
|
+
* already exists" in its faucet handler), so a brand-new recipient's account is created only when
|
|
535
|
+
* it is genuinely missing, and only when a wallet address was given -- a bare component address
|
|
536
|
+
* never reveals the owner key `CreateAccount` needs, so that form can only pay an account that
|
|
537
|
+
* already exists.
|
|
538
|
+
*/
|
|
539
|
+
async send(recipientAddress, resourceAddress, amount, maxFee = 5000n) {
|
|
540
|
+
const account = await this.getComponentAddress();
|
|
541
|
+
const isWalletAddress = recipientAddress.startsWith("otl");
|
|
542
|
+
const recipient = isWalletAddress ? componentAddressFromWalletAddress(recipientAddress) : recipientAddress;
|
|
543
|
+
const instructions = [];
|
|
544
|
+
if (isWalletAddress) {
|
|
545
|
+
const provider = await this.getProvider();
|
|
546
|
+
let recipientExists = true;
|
|
547
|
+
try {
|
|
548
|
+
await withTimeout(provider.getSubstate(recipient), 15_000, "checking the recipient's account");
|
|
549
|
+
}
|
|
550
|
+
catch (e) {
|
|
551
|
+
// A timeout means the indexer is degraded, not that the account is missing — creating one
|
|
552
|
+
// on that assumption is how a working transfer turns into an "already UP" rejection.
|
|
553
|
+
if (e instanceof Error && e.message.startsWith("Timed out"))
|
|
554
|
+
throw e;
|
|
555
|
+
recipientExists = false;
|
|
556
|
+
}
|
|
557
|
+
if (!recipientExists) {
|
|
558
|
+
const { owner_key } = parseOotleAddress(recipientAddress);
|
|
559
|
+
instructions.push({
|
|
560
|
+
CreateAccount: { owner_public_key: toHex(owner_key), owner_rule: null, access_rules: null, bucket_workspace_id: null },
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
instructions.push({ CallMethod: { call: { Address: account }, method: "withdraw", args: [resourceAddressLiteral(resourceAddress), amountLiteral(amount)] } }, { PutLastInstructionOutputOnWorkspace: { key: 0 } }, { CallMethod: { call: { Address: recipient }, method: "deposit", args: [{ Workspace: { id: 0, offset: null } }] } });
|
|
565
|
+
return this.execute(instructions, { maxFee });
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Moves `amount` of this account's own REVEALED balance into a Confidential-type vault for the
|
|
569
|
+
* same resource -- the "Confidential" `ResourceType`'s equivalent of `shield()`, a different
|
|
570
|
+
* privacy mechanism from Stealth (vault-based, ElGamal-encrypted to a resource view key, rather
|
|
571
|
+
* than freestanding one-time-key UTXOs -- see the resource-types write-up in the integration
|
|
572
|
+
* docs). Only meaningful for a resource actually created as `ResourceType::Confidential`; calling
|
|
573
|
+
* this against a Fungible or Stealth resource's vault fails on-chain ("resource type mismatch" or
|
|
574
|
+
* similar), not client-side -- this class has no way to know a resource's type up front without
|
|
575
|
+
* an extra substate fetch, so the failure is left to the engine.
|
|
576
|
+
*
|
|
577
|
+
* Builds a `ConfidentialWithdrawProof` client-side (no wallet-daemon round trip) via the vendored
|
|
578
|
+
* `createConfidentialWithdrawProofLiteral` -- see that WASM export's own doc comment for why the
|
|
579
|
+
* `tari_bor` encoding happens in Rust rather than being hand-rolled here: the official
|
|
580
|
+
* `@tari-project/ootle` SDK's own `feeTransactionPayFromComponentConfidential` is unimplemented
|
|
581
|
+
* for exactly this reason ("a ConfidentialWithdrawProof Literal must be tari_bor-CBOR-encoded,
|
|
582
|
+
* which the TS SDK does not yet support").
|
|
583
|
+
*
|
|
584
|
+
* The new confidential output is addressed to this account's own owner/view keys -- the same
|
|
585
|
+
* one-time-output-witness construction `createStealthOutputWitness` already builds for Stealth
|
|
586
|
+
* outputs, reused here since `ConfidentialOutputStatement.output` and a `StealthOutputWitness`'s
|
|
587
|
+
* `witness` share the exact same shape (mask, sender_public_nonce, encrypted_data). Only the
|
|
588
|
+
* `witness` half of that result is used -- the `auth`/`tag` halves are Stealth-specific
|
|
589
|
+
* (`SpendAuthorization`/`UtxoTag`); a Confidential output has neither, ownership being purely
|
|
590
|
+
* "which vault holds it," not a spend-authorization key.
|
|
591
|
+
*
|
|
592
|
+
* Two instructions, mirroring `send()`'s withdraw-then-deposit shape: `withdraw_confidential`
|
|
593
|
+
* (drawing `amount` from the vault's revealed side, per the proof's `input_revealed_amount`)
|
|
594
|
+
* produces a bucket holding the one new confidential output, which `deposit` puts back in the
|
|
595
|
+
* same vault.
|
|
596
|
+
*/
|
|
597
|
+
async depositConfidential(resourceAddress, amount, maxFee = 50000n) {
|
|
598
|
+
if (amount <= 0n)
|
|
599
|
+
throw new Error(`depositConfidential: amount must be greater than zero, got ${amount}`);
|
|
600
|
+
const account = await this.getComponentAddress();
|
|
601
|
+
const ownerPublicKey = await this.getPublicKey();
|
|
602
|
+
const viewSecret = await this.signer.getViewSecret();
|
|
603
|
+
const viewPublicKey = publicKeyFromSecretKey(viewSecret);
|
|
604
|
+
const witnessJson = createStealthOutputWitness(this.network, ownerPublicKey, viewPublicKey, amount, resourceAddress, undefined, // resource_view_key -- irrelevant here; that grants a THIRD PARTY decrypt access, not needed to address an output to ourselves
|
|
605
|
+
undefined, // memo_json
|
|
606
|
+
undefined, // pay_to_json -- irrelevant: only `witness` below is used, never this call's `auth`/`tag`
|
|
607
|
+
0n);
|
|
608
|
+
const { witness } = JSON.parse(witnessJson);
|
|
609
|
+
const outputJson = JSON.stringify(witness);
|
|
610
|
+
const proofBytes = createConfidentialWithdrawProofLiteral("[]", // no confidential inputs spent -- drawing purely from the vault's revealed side
|
|
611
|
+
amount, outputJson, 0n, undefined, 0n);
|
|
612
|
+
const proofLiteral = { Literal: toHex(proofBytes) };
|
|
613
|
+
const instructions = [
|
|
614
|
+
{
|
|
615
|
+
CallMethod: {
|
|
616
|
+
call: { Address: account },
|
|
617
|
+
method: "withdraw_confidential",
|
|
618
|
+
args: [resourceAddressLiteral(resourceAddress), proofLiteral],
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
{ PutLastInstructionOutputOnWorkspace: { key: 0 } },
|
|
622
|
+
{ CallMethod: { call: { Address: account }, method: "deposit", args: [{ Workspace: { id: 0, offset: null } }] } },
|
|
623
|
+
];
|
|
624
|
+
return this.execute(instructions, { maxFee });
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Claims free testnet XTR from the network's builtin faucet (esmeralda/igor only — there is no
|
|
628
|
+
* such faucet on mainnet). Mirrors both `build_faucet_claim_with_wants` in tari-ootle's
|
|
629
|
+
* `crates/ootle_sdk_core/src/faucet.rs` and (confirmed against its actual pinned-input list)
|
|
630
|
+
* `handle_create_free_test_coins` in `applications/tari_walletd/src/handlers/accounts.rs` — the
|
|
631
|
+
* handler backing the `accounts.create_free_test_coins` walletd RPC that tari-dex's swap-ui
|
|
632
|
+
* calls. Self-funding, so it works even for a brand-new account that doesn't exist on-chain yet:
|
|
633
|
+
* the fee phase itself creates the account, funds it from the faucet
|
|
634
|
+
* (`XTR_FAUCET_COMPONENT_ADDRESS.take(account)`), then pays its own fee out of what it just
|
|
635
|
+
* received.
|
|
636
|
+
*
|
|
637
|
+
* Unlike every other component this class calls, none of the faucet's substates (component,
|
|
638
|
+
* vault, claim resource) get auto-resolved from the instructions by this indexer — each has to
|
|
639
|
+
* be pinned as an explicit transaction input (confirmed empirically: submitting with only the
|
|
640
|
+
* claim resource pinned rejected with "component_...0000 not found" at the `take` call).
|
|
641
|
+
* `provider.resolveInputs()` fills in each one's current version.
|
|
642
|
+
*
|
|
643
|
+
* The faucet vault is a single shared substate every claimant on the testnet contends for, so
|
|
644
|
+
* the version pinned by `resolveInputs()` routinely goes stale between resolution and consensus
|
|
645
|
+
* (confirmed empirically: "Lock failure: Substate vault_...:8 is DOWN" — someone else's claim
|
|
646
|
+
* landed first). This isn't a bug to fix, just contention to ride out: catch that specific
|
|
647
|
+
* rejection and retry with freshly-resolved versions and a new transaction id.
|
|
648
|
+
*
|
|
649
|
+
* Retries back off (`retryDelayMs * (attempt + 1)`, so 300ms, 600ms, 900ms, ...) rather than
|
|
650
|
+
* resubmitting back-to-back: confirmed empirically that hammering `resolveInputs()` immediately
|
|
651
|
+
* after a rejection can keep handing back the same already-stale version every time (the
|
|
652
|
+
* indexer's own view of a shared, heavily-contended substate can lag behind consensus by more
|
|
653
|
+
* than one round trip takes) — a short, growing pause gives that view time to catch up instead
|
|
654
|
+
* of burning the whole retry budget re-observing the same stale state.
|
|
655
|
+
*/
|
|
656
|
+
async claimTestnetXtr(maxFee = 5000n, retries = 10, retryDelayMs = 300) {
|
|
657
|
+
const provider = await this.getProvider();
|
|
658
|
+
const publicKeyHex = bytesToHex(await this.getPublicKey());
|
|
659
|
+
const account = await this.getComponentAddress();
|
|
660
|
+
const maxEpoch = await resolveMaxEpoch(provider);
|
|
661
|
+
for (let attempt = 0;; attempt++) {
|
|
662
|
+
const inputs = await withTimeout(provider.resolveInputs([
|
|
663
|
+
{ substate_id: XTR_FAUCET_COMPONENT_ADDRESS, version: null },
|
|
664
|
+
{ substate_id: XTR_FAUCET_VAULT_ADDRESS, version: null },
|
|
665
|
+
{ substate_id: XTR_FAUCET_CLAIM_RESOURCE_ADDRESS, version: null },
|
|
666
|
+
]), 15_000, "resolving the faucet's current state");
|
|
667
|
+
const unsignedTx = TransactionBuilder.new(this.network, maxEpoch)
|
|
668
|
+
.withFeeInstructionsBuilder((b) => b
|
|
669
|
+
.createAccount(publicKeyHex)
|
|
670
|
+
.saveVar("faucet_account")
|
|
671
|
+
.callMethod({ componentAddress: XTR_FAUCET_COMPONENT_ADDRESS, methodName: "take" }, [{ Workspace: "faucet_account" }]))
|
|
672
|
+
.feeTransactionPayFromComponent(account, maxFee)
|
|
673
|
+
.withInputs(inputs)
|
|
674
|
+
.buildUnsignedTransaction();
|
|
675
|
+
try {
|
|
676
|
+
const result = await withTimeout(sendTransaction(provider, this.signer, unsignedTx), 30_000, "submitting the claim");
|
|
677
|
+
// This is typically the first transaction for a brand-new account — recording its
|
|
678
|
+
// resulting versions (the newly-created fee vault included) closes the exact gap that
|
|
679
|
+
// otherwise bites the *next* transaction (see `applyKnownVersions`'s doc comment).
|
|
680
|
+
await recordKnownVersions(result);
|
|
681
|
+
return result;
|
|
682
|
+
}
|
|
683
|
+
catch (e) {
|
|
684
|
+
// "Failed to decode transaction: unexpected type null at position N: expected u64" is a
|
|
685
|
+
// serde failure at the indexer, and the byte offset alone names nothing. The transaction
|
|
686
|
+
// is the only evidence and it is gone once this throws, so attach the field shapes — the
|
|
687
|
+
// u64-typed fields first, since one of them arriving null is what the message describes.
|
|
688
|
+
if (e instanceof Error && /Failed to decode transaction/i.test(e.message)) {
|
|
689
|
+
const t = unsignedTx;
|
|
690
|
+
const shape = [
|
|
691
|
+
`network=${typeof t.network}:${JSON.stringify(t.network)}`,
|
|
692
|
+
`min_epoch=${JSON.stringify(t.min_epoch)}`,
|
|
693
|
+
`max_epoch=${typeof t.max_epoch}:${JSON.stringify(t.max_epoch)}`,
|
|
694
|
+
`nonce=${typeof t.nonce}:${JSON.stringify(t.nonce)}`,
|
|
695
|
+
`dry_run=${JSON.stringify(t.dry_run)}`,
|
|
696
|
+
`is_seal_signer_authorized=${JSON.stringify(t.is_seal_signer_authorized)}`,
|
|
697
|
+
`blobs=${Array.isArray(t.blobs) ? `array[${t.blobs.length}]` : typeof t.blobs}`,
|
|
698
|
+
`inputs=${JSON.stringify(t.inputs)}`,
|
|
699
|
+
`fee_instructions=${JSON.stringify(t.fee_instructions)?.slice(0, 400)}`,
|
|
700
|
+
].join(" ");
|
|
701
|
+
throw new Error(`${e.message} — tx: ${shape}`);
|
|
702
|
+
}
|
|
703
|
+
// The claim's fee phase calls `createAccount` for *this* account, and `CreateAccount` is
|
|
704
|
+
// create-or-conflict: once the account exists the instruction rejects with "is already UP
|
|
705
|
+
// and conflicts with an existing output". Tari's own walletd reads that exact string as
|
|
706
|
+
// "faucet already claimed" (`applications/tari_walletd/src/handlers/accounts.rs`), so it
|
|
707
|
+
// is reported that way here rather than as a raw consensus rejection — retrying cannot
|
|
708
|
+
// help, and the account is fine.
|
|
709
|
+
if (e instanceof Error && e.message.includes("is already UP and conflicts with an existing output")) {
|
|
710
|
+
throw new Error("This account has already claimed from the faucet — the faucet only pays out once per account.");
|
|
711
|
+
}
|
|
712
|
+
const isStaleVersionRace = e instanceof Error && e.message.includes("Lock failure");
|
|
713
|
+
if (!isStaleVersionRace || attempt >= retries)
|
|
714
|
+
throw e;
|
|
715
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs * (attempt + 1)));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Shields (moves from revealed to private/confidential) `amount` of `resourceAddress`, held as
|
|
721
|
+
* a new stealth output owned by this same account. Uses an entirely separate submission
|
|
722
|
+
* pipeline from `execute()`: the SDK's `StealthTransfer` builder produces its own
|
|
723
|
+
* `TransactionEnvelope` directly (via `WalletStealthAuthorizer`/`AuthorizedTransfer`), not
|
|
724
|
+
* through `TransactionBuilder`/`resolveTransaction`/`signTransaction`/`sealTransaction`.
|
|
725
|
+
*
|
|
726
|
+
* The one genuinely new failure mode this adds beyond `send()`: if the extension's service
|
|
727
|
+
* worker is killed *after* the transaction finalizes on-chain but *before* the resulting
|
|
728
|
+
* commitment is written to storage, the shield succeeds financially but the wallet loses its
|
|
729
|
+
* only lead back to that output — worse than a failed `send()`, whose destination is always a
|
|
730
|
+
* known component address. `pendingShieldTransactionIds` (written before polling, cleared only
|
|
731
|
+
* after the commitment record is stored) narrows that window to "killed between finalization
|
|
732
|
+
* and one storage write" and gives `recoverPendingShields()` (background/index.ts) something
|
|
733
|
+
* to reconcile on next launch.
|
|
734
|
+
*
|
|
735
|
+
* `maxFee` defaults far higher than `send()`'s 5000 -- confirmed live that 5000 gets
|
|
736
|
+
* "AcceptFeeRejectRest" with `ExecutionFailure: "Insufficient fees to fund native
|
|
737
|
+
* verification"` (the stealth balance proof / range proof verification costs real compute
|
|
738
|
+
* points beyond the engine's free grace allowance). Unused fee is refunded on success, so
|
|
739
|
+
* erring high just wastes nothing; erring low burns the small fee-intent cost with nothing to
|
|
740
|
+
* show for it, since only the fee half of the transaction gets committed.
|
|
741
|
+
*/
|
|
742
|
+
async shield(resourceAddress, amount, maxFee = 50000n, memo, minimumValuePromise = 0n) {
|
|
743
|
+
assertValidMinimumValuePromise(minimumValuePromise, amount);
|
|
744
|
+
const accountId = localAccountId(this.index);
|
|
745
|
+
const provider = await this.getProvider();
|
|
746
|
+
const account = await this.getComponentAddress();
|
|
747
|
+
// Output.destination decodes as a bech32m wallet address (owner_key + view_key), NOT the
|
|
748
|
+
// on-chain component address -- passing `account` here throws "Bech32 decode error" (confirmed
|
|
749
|
+
// live). See getWalletAddress()'s doc comment for why these two addresses are easy to conflate.
|
|
750
|
+
const walletAddress = await this.getWalletAddress();
|
|
751
|
+
const spec = await new StealthTransfer(provider, resourceAddress)
|
|
752
|
+
// StealthTransfer.prepare() auto-adds the revealed account's own substate and any vault
|
|
753
|
+
// addresses embedded in its on-chain state, but never the resource's own substate -- fine
|
|
754
|
+
// for XTR (resource_0101...0101 is engine-special-cased and needs no lock), but any other
|
|
755
|
+
// resource's instructions fail with "SubstateNotFound: resource_... not found" at
|
|
756
|
+
// instruction #1 without it explicitly pinned here. Confirmed directly against prepare()'s
|
|
757
|
+
// source (node_modules/@tari-project/ootle/dist/index.js) -- not assumed from the docs.
|
|
758
|
+
.withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
|
|
759
|
+
.spendRevealedInput(account, amount)
|
|
760
|
+
.toStealthOutput(createOutput({ destination: walletAddress, amount, resourceAddress, memo: toMemo(memo), minimumValuePromise }))
|
|
761
|
+
.payFeeFromRevealed(maxFee)
|
|
762
|
+
.prepare();
|
|
763
|
+
const ownCommitment = extractOutputCommitment(spec, 0);
|
|
764
|
+
const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
|
|
765
|
+
// No stealth inputs to unblind for a shield (revealed-only source), so no viewSecret needed.
|
|
766
|
+
// `fromSpec`'s own default crypto is `new WasmStealthCrypto()` -- Network.LocalNet, NOT this
|
|
767
|
+
// account's real network -- which produced an "Invalid transaction signature" server-side
|
|
768
|
+
// rejection (confirmed live) since the balance proof it computes is network-domain-separated.
|
|
769
|
+
// Must match the network StealthTransfer itself used when it built the outputs statement.
|
|
770
|
+
const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
|
|
771
|
+
const envelope = await authorized.seal();
|
|
772
|
+
const transactionId = await submitTransaction(provider, envelope);
|
|
773
|
+
await addPendingShield({ transactionId, accountId, resourceAddress, amount: amount.toString(), ownCommitment, memo });
|
|
774
|
+
try {
|
|
775
|
+
const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the shield transaction");
|
|
776
|
+
await recordKnownVersions(response);
|
|
777
|
+
await recordKnownShieldedOutput(accountId, resourceAddress, ownCommitment, amount, transactionId, memo);
|
|
778
|
+
}
|
|
779
|
+
finally {
|
|
780
|
+
await removePendingShield(transactionId);
|
|
781
|
+
}
|
|
782
|
+
// The commitment and its substate id are returned, not just the transaction id, because they are
|
|
783
|
+
// the only durable handle on the output this created -- and with a non-zero
|
|
784
|
+
// `minimumValuePromise` that substate *is* the proof-of-funds artifact, which a caller cannot
|
|
785
|
+
// reconstruct from a transaction id alone. Returned unconditionally (a zero promise included)
|
|
786
|
+
// rather than only for the proof case, so callers get one shape to handle.
|
|
787
|
+
return { transactionId, commitment: ownCommitment, substateId: stealthUtxoSubstateId(resourceAddress, fromHex(ownCommitment)), minimumValuePromise: minimumValuePromise.toString() };
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Proves this account currently controls the stealth output at `substateId` -- e.g. a
|
|
791
|
+
* `minimumValuePromise` proof-of-funds output this account created earlier -- by Schnorr-signing
|
|
792
|
+
* `challenge` with the output's one-time spend key. Spends nothing and reveals nothing about the
|
|
793
|
+
* output beyond what `tari_getSubstate` already shows anyone.
|
|
794
|
+
*
|
|
795
|
+
* `challenge` is signed under a domain tag (`ownershipProof.ts`) disjoint from real transaction
|
|
796
|
+
* signing, so the resulting signature can never be replayed as spend authorization for this or
|
|
797
|
+
* any other output -- this is the entire reason a caller cannot just ask for a raw signature over
|
|
798
|
+
* an arbitrary message.
|
|
799
|
+
*
|
|
800
|
+
* Throws if the substate isn't a live, key-authorized stealth output, or if it isn't actually
|
|
801
|
+
* addressed to this account (checked by re-deriving the expected one-time public key and
|
|
802
|
+
* comparing it to the substate's own `auth.Key` -- the same check `scan_stealth_output` does
|
|
803
|
+
* receiver-side, just run here against a specific caller-supplied id instead of a scan).
|
|
804
|
+
*/
|
|
805
|
+
async signOwnershipProof(resourceAddress, substateId, challenge) {
|
|
806
|
+
const provider = await this.getProvider();
|
|
807
|
+
const response = await provider.getSubstate(substateId);
|
|
808
|
+
const substate = response.substate;
|
|
809
|
+
const utxo = substate.Utxo;
|
|
810
|
+
if (!utxo?.output) {
|
|
811
|
+
throw new Error("This output doesn't exist, isn't a stealth output, or has already been spent.");
|
|
812
|
+
}
|
|
813
|
+
const expectedAuthKeyHex = utxo.output.auth.Key;
|
|
814
|
+
if (!expectedAuthKeyHex) {
|
|
815
|
+
throw new Error("This output isn't key-authorized (it's spent by a script condition, not a signature) -- there's no key to prove ownership of.");
|
|
816
|
+
}
|
|
817
|
+
const senderPublicNonce = fromHex(utxo.output.output.public_nonce);
|
|
818
|
+
const oneTimeSecret = stealthDhSecret(this.network, this.ownerSecret, senderPublicNonce);
|
|
819
|
+
try {
|
|
820
|
+
const oneTimePublicKey = publicKeyFromSecretKey(oneTimeSecret);
|
|
821
|
+
if (toHex(oneTimePublicKey) !== expectedAuthKeyHex) {
|
|
822
|
+
throw new Error("This account doesn't control that output.");
|
|
823
|
+
}
|
|
824
|
+
const message = buildOwnershipProofMessage(this.network, resourceAddress, substateId, challenge);
|
|
825
|
+
const sig = schnorrSign(oneTimeSecret, message);
|
|
826
|
+
return { publicKey: toHex(oneTimePublicKey), publicNonce: toHex(sig.public_nonce), signature: toHex(sig.signature) };
|
|
827
|
+
}
|
|
828
|
+
finally {
|
|
829
|
+
oneTimeSecret.fill(0); // a derived per-output secret, not the account's own key -- zeroed anyway, on principle
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Proves this account holds a specific `otl_…` wallet address by Schnorr-signing `challenge`
|
|
834
|
+
* with the account's own persistent owner key -- not a per-output derived one, so unlike
|
|
835
|
+
* `signOwnershipProof` this needs no substate lookup and isn't tied to any particular output.
|
|
836
|
+
*
|
|
837
|
+
* Signs under a domain tag (`ownershipProof.ts`) disjoint from real transaction signing, for the
|
|
838
|
+
* same reason `signOwnershipProof` does: this exact key also signs real transactions
|
|
839
|
+
* (`SecretKeyWallet.signTransaction`), so a raw signature over caller-supplied bytes would risk
|
|
840
|
+
* being replayable as spend authorization.
|
|
841
|
+
*
|
|
842
|
+
* A verifier checks the result against the owner key decoded from the wallet address *they*
|
|
843
|
+
* already have in mind (`parseOotleAddress`) -- never against a value this call's caller reports
|
|
844
|
+
* about themselves.
|
|
845
|
+
*/
|
|
846
|
+
async signWalletOwnership(challenge) {
|
|
847
|
+
const walletAddress = await this.getWalletAddress();
|
|
848
|
+
const message = buildWalletOwnershipMessage(this.network, walletAddress, challenge);
|
|
849
|
+
const sig = schnorrSign(this.ownerSecret, message);
|
|
850
|
+
return { walletAddress, publicNonce: toHex(sig.public_nonce), signature: toHex(sig.signature) };
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Unshields (moves from private/confidential back to revealed) `revealedOutAmount` of this
|
|
854
|
+
* account's shielded balance for `resourceAddress` — see `ShieldedOutputRecord`'s doc comment
|
|
855
|
+
* for why a known local record is the only way to spend one (no client-side scan-by-view-key
|
|
856
|
+
* API exists for stealth UTXOs). Which specific output(s) to spend is decided internally by
|
|
857
|
+
* `resolveUnshieldPlan`'s coin selection (largest-first, spending more than one in the same
|
|
858
|
+
* transaction via repeated `spendStealthInput()` calls if a single output isn't enough) — the
|
|
859
|
+
* caller only supplies an amount, not a commitment.
|
|
860
|
+
*
|
|
861
|
+
* Two `StealthTransfer` builder requirements, confirmed directly against its `validate()`/
|
|
862
|
+
* `emitInstructions()` source (not assumed from the docs alone), shape this method:
|
|
863
|
+
*
|
|
864
|
+
* 1. `toRevealedOutput`/`payFeeFromRevealed` both require a revealed source account already
|
|
865
|
+
* registered via `spendRevealedInput`, which itself requires an amount `> 0` — there is no
|
|
866
|
+
* way to register just the account without withdrawing something. This method withdraws a
|
|
867
|
+
* trivial `1n`-unit "dust" amount for that sole purpose and folds it straight back into the
|
|
868
|
+
* revealed output (`revealedOutAmount + 1n`), so it costs nothing beyond the tx fee.
|
|
869
|
+
* 2. The builder always requires at least one stealth output — a "pure" 100%-revealed spend
|
|
870
|
+
* with zero private remainder cannot be constructed in one step. This method always creates
|
|
871
|
+
* a new stealth output for the selected inputs' total minus `revealedOutAmount` back to this
|
|
872
|
+
* same account, so `resolveUnshieldPlan` guarantees that remainder is always `> 0`.
|
|
873
|
+
*
|
|
874
|
+
* Shares `shield()`'s mid-flight crash safety: the pending-shield ledger entry carries
|
|
875
|
+
* `spentCommitments` so `recoverPendingShields()` can both record the new change output *and*
|
|
876
|
+
* mark the spent ones, even if the service worker dies between finalization and the storage
|
|
877
|
+
* writes.
|
|
878
|
+
*/
|
|
879
|
+
async unshield(resourceAddress, revealedOutAmount, maxFee = 100000n, memo) {
|
|
880
|
+
const accountId = localAccountId(this.index);
|
|
881
|
+
const records = await listShieldedOutputs(accountId);
|
|
882
|
+
const { commitments, remainder } = resolveUnshieldPlan(records, resourceAddress, revealedOutAmount);
|
|
883
|
+
const dust = 1n;
|
|
884
|
+
const provider = await this.getProvider();
|
|
885
|
+
const account = await this.getComponentAddress();
|
|
886
|
+
// See shield()'s comment: Output.destination needs the bech32m wallet address, not the
|
|
887
|
+
// on-chain component address.
|
|
888
|
+
const walletAddress = await this.getWalletAddress();
|
|
889
|
+
// See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
|
|
890
|
+
// never adds it on its own.
|
|
891
|
+
let builder = new StealthTransfer(provider, resourceAddress)
|
|
892
|
+
.withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
|
|
893
|
+
.spendRevealedInput(account, dust);
|
|
894
|
+
for (const commitment of commitments) {
|
|
895
|
+
builder = builder.spendStealthInput(account, fromHex(commitment));
|
|
896
|
+
}
|
|
897
|
+
const spec = await builder
|
|
898
|
+
.toStealthOutput(createOutput({ destination: walletAddress, amount: remainder, resourceAddress, memo: toMemo(memo) }))
|
|
899
|
+
.toRevealedOutput(revealedOutAmount + dust)
|
|
900
|
+
.payFeeFromRevealed(maxFee)
|
|
901
|
+
.prepare();
|
|
902
|
+
const ownCommitment = extractOutputCommitment(spec, 0);
|
|
903
|
+
const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
|
|
904
|
+
const viewSecret = await this.signer.getViewSecret();
|
|
905
|
+
// See shield()'s comment: must pass this account's real network, not fromSpec's LocalNet default.
|
|
906
|
+
const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { viewSecret, crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
|
|
907
|
+
const envelope = await authorized.seal();
|
|
908
|
+
const transactionId = await submitTransaction(provider, envelope);
|
|
909
|
+
await addPendingShield({
|
|
910
|
+
transactionId,
|
|
911
|
+
accountId,
|
|
912
|
+
resourceAddress,
|
|
913
|
+
amount: remainder.toString(),
|
|
914
|
+
spentCommitments: commitments,
|
|
915
|
+
ownCommitment,
|
|
916
|
+
memo,
|
|
917
|
+
});
|
|
918
|
+
try {
|
|
919
|
+
const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the unshield transaction");
|
|
920
|
+
await recordKnownVersions(response);
|
|
921
|
+
await recordKnownShieldedOutput(accountId, resourceAddress, ownCommitment, remainder, transactionId, memo);
|
|
922
|
+
for (const commitment of commitments) {
|
|
923
|
+
await markShieldedOutputSpent(accountId, commitment);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
finally {
|
|
927
|
+
await removePendingShield(transactionId);
|
|
928
|
+
}
|
|
929
|
+
return { transactionId };
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Withdraws `amount` of a Stealth-typed resource (e.g. XTR) from this account's revealed
|
|
933
|
+
* balance and feeds it, as a `Bucket` left on the workspace under `workspaceVarName`, into
|
|
934
|
+
* `followUpInstructions` — all in one signed transaction.
|
|
935
|
+
*
|
|
936
|
+
* This is the *only* way to move Stealth-typed funds into an arbitrary contract call. A plain
|
|
937
|
+
* `CallMethod withdraw` on a Stealth vault is not a standalone-valid instruction: confirmed
|
|
938
|
+
* empirically (isolated to a bare `withdraw` immediately followed by `deposit`, zero other
|
|
939
|
+
* instructions) that `account.execute()` fails it client-side, before ever reaching the
|
|
940
|
+
* network, with a generic `JSON deserialization failed: ... untagged enum TransactionInput`
|
|
941
|
+
* error. Moving Stealth funds anywhere always requires the native `StealthTransfer`
|
|
942
|
+
* instruction, signed via `WalletStealthAuthorizer` — which `execute()`/`TransactionBuilder`
|
|
943
|
+
* never invokes (see this file's own `execute()` doc comment, and the
|
|
944
|
+
* `isStealthTransferInstruction` guard + comment in `background/index.ts`). This method uses
|
|
945
|
+
* the *same* `StealthTransfer` builder + `WalletStealthAuthorizer` pipeline `shield()`/
|
|
946
|
+
* `unshield()` already use, extended with `toRevealedOutputAsBucket`/`andThen` (see
|
|
947
|
+
* `vendor/ootle-patched/README.md`) to leave the revealed output on the workspace instead of
|
|
948
|
+
* auto-depositing it back to this account, and to append the caller's own instructions after
|
|
949
|
+
* it in the same transaction.
|
|
950
|
+
*
|
|
951
|
+
* @param relatedComponents Every *other* component `followUpInstructions` touches (e.g. a
|
|
952
|
+
* DAO contract being called). The `StealthTransfer` builder auto-registers this account's
|
|
953
|
+
* own vaults as tx inputs, but has no way to know what the caller's own follow-up
|
|
954
|
+
* instructions reference — the engine rejects a `CallMethod` touching an unregistered
|
|
955
|
+
* substate with `SubstateNotFound`. Each is registered by address, plus every vault its own
|
|
956
|
+
* state references (the same vault-discovery this account's own address already gets).
|
|
957
|
+
*/
|
|
958
|
+
async withdrawStealthAndExecute(resourceAddress, amount, workspaceVarName, followUpInstructions, relatedComponents = [], maxFee = 100000n) {
|
|
959
|
+
if (amount <= 0n)
|
|
960
|
+
throw new Error(`withdrawStealthAndExecute amount must be > 0, got ${amount}`);
|
|
961
|
+
const provider = await this.getProvider();
|
|
962
|
+
const account = await this.getComponentAddress();
|
|
963
|
+
const walletAddress = await this.getWalletAddress();
|
|
964
|
+
// A zero-stealth-output StealthTransferStatement (pure revealed-in, revealed-out-as-bucket)
|
|
965
|
+
// isn't a shape the bundled ootle-wasm@0.37.0 signer can parse -- confirmed live: it throws
|
|
966
|
+
// the same generic "did not match any variant of untagged enum TransactionInput" error inside
|
|
967
|
+
// signTransaction's own WASM call, regardless of how balance_proof is represented (present,
|
|
968
|
+
// null, or omitted -- all three tried). Every other stealth-transfer path in this file
|
|
969
|
+
// (shield/unshield/sendPrivately) always includes >=1 real stealth output for exactly this
|
|
970
|
+
// reason. Rather than fight an apparent WASM-binary limitation, this keeps a tiny (1 µ-unit)
|
|
971
|
+
// stealth output back to this account -- the same proven-working shape -- alongside the real
|
|
972
|
+
// amount as a revealed bucket.
|
|
973
|
+
const dust = 1n;
|
|
974
|
+
let builder = new StealthTransfer(provider, resourceAddress)
|
|
975
|
+
.withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
|
|
976
|
+
.spendRevealedInput(account, amount + dust)
|
|
977
|
+
.toStealthOutput(createOutput({ destination: walletAddress, amount: dust, resourceAddress }))
|
|
978
|
+
.toRevealedOutputAsBucket(amount, workspaceVarName)
|
|
979
|
+
.andThen(followUpInstructions);
|
|
980
|
+
for (const component of relatedComponents) {
|
|
981
|
+
builder = builder.withBuilder((b) => b.addInput({ substate_id: component, version: null }));
|
|
982
|
+
for (const vaultId of await getVaultIdsForAccount(provider, component)) {
|
|
983
|
+
builder = builder.withBuilder((b) => b.addInput({ substate_id: vaultId, version: null }));
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
const spec = await builder.payFeeFromRevealed(maxFee).prepare();
|
|
987
|
+
const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
|
|
988
|
+
const viewSecret = await this.signer.getViewSecret();
|
|
989
|
+
const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { viewSecret, crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
|
|
990
|
+
const envelope = await authorized.seal();
|
|
991
|
+
const transactionId = await submitTransaction(provider, envelope);
|
|
992
|
+
const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the transaction");
|
|
993
|
+
await recordKnownVersions(response);
|
|
994
|
+
return { transactionId };
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Funds an HTLC (hashed timelock contract): creates a stealth output for `amount` of
|
|
998
|
+
* `resourceAddress`, gated not by a normal one-time stealth key but by a two-leaf TIP-0006
|
|
999
|
+
* condition tree (`htlcConditions` in `./htlc.ts`) — a claim path admissible only to
|
|
1000
|
+
* `claimantWalletAddress` (revealing the SHA-256 preimage of `hashLockHex`, before
|
|
1001
|
+
* `refundEpoch`), and a refund path admissible only to this account (at/after `refundEpoch`).
|
|
1002
|
+
* `htlcConditions`'s own doc comment covers the exact leaf shapes.
|
|
1003
|
+
*
|
|
1004
|
+
* This account never needs (and must never be given) the actual preimage — only its hash. The
|
|
1005
|
+
* caller resolves `refundEpoch` from a desired wall-clock deadline via the provider/network's
|
|
1006
|
+
* current epoch, the same as any other epoch-relative on-chain deadline.
|
|
1007
|
+
*
|
|
1008
|
+
* Returns the full `conditions` tree alongside the transaction id and this output's own
|
|
1009
|
+
* commitment: only the tree's **root** is committed on-chain, so the claimant needs the exact
|
|
1010
|
+
* leaves — out of band, via whatever swap protocol coordinates the two sides — to later reveal
|
|
1011
|
+
* the claim leaf.
|
|
1012
|
+
*
|
|
1013
|
+
* Also returns `outputMask` (hex): this account funded the output, so it's the one place the
|
|
1014
|
+
* blinding mask is ever available to it without decryption — `encrypted_data` is encrypted for
|
|
1015
|
+
* `claimantWalletAddress`'s view key, not this account's, so if the claim path is never taken
|
|
1016
|
+
* and this account later calls `htlcRefund`, it cannot decrypt the output the normal way (the
|
|
1017
|
+
* way `htlcClaim`'s claimant can, since the output is addressed to *them*). Retaining this mask
|
|
1018
|
+
* now — alongside the known `amount` — is what makes an undecryptable refund possible at all.
|
|
1019
|
+
* `htlcRefund`'s own doc comment covers the rest.
|
|
1020
|
+
*
|
|
1021
|
+
* `destination` is set to the claimant's own wallet address (not this account's) so the
|
|
1022
|
+
* claimant can independently decrypt and verify the funded amount themselves, the same as any
|
|
1023
|
+
* other stealth payment addressed to them — spend authority is governed entirely separately, by
|
|
1024
|
+
* `payTo`.
|
|
1025
|
+
*/
|
|
1026
|
+
async htlcFund(resourceAddress, amount, claimantWalletAddress, hashLockHex, refundEpoch, maxFee = 50000n) {
|
|
1027
|
+
const provider = await this.getProvider();
|
|
1028
|
+
const account = await this.getComponentAddress();
|
|
1029
|
+
const walletAddress = await this.getWalletAddress();
|
|
1030
|
+
const claimant = parseOotleAddress(claimantWalletAddress);
|
|
1031
|
+
const refunder = parseOotleAddress(walletAddress);
|
|
1032
|
+
const conditions = htlcConditions({
|
|
1033
|
+
hashLockHex,
|
|
1034
|
+
refundEpoch,
|
|
1035
|
+
claimantPublicKeyHex: toHex(claimant.owner_key),
|
|
1036
|
+
refunderPublicKeyHex: toHex(refunder.owner_key),
|
|
1037
|
+
});
|
|
1038
|
+
// See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
|
|
1039
|
+
// never adds it on its own.
|
|
1040
|
+
const spec = await new StealthTransfer(provider, resourceAddress)
|
|
1041
|
+
.withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
|
|
1042
|
+
.spendRevealedInput(account, amount)
|
|
1043
|
+
.toStealthOutput(createOutput({ destination: claimantWalletAddress, amount, resourceAddress, payTo: { Conditions: conditions } }))
|
|
1044
|
+
.payFeeFromRevealed(maxFee)
|
|
1045
|
+
.prepare();
|
|
1046
|
+
const ownCommitment = extractOutputCommitment(spec, 0);
|
|
1047
|
+
const outputMask = spec.outputMask.toHex();
|
|
1048
|
+
const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
|
|
1049
|
+
// No stealth inputs to unblind (revealed-only source), so no viewSecret needed -- same as shield().
|
|
1050
|
+
const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
|
|
1051
|
+
const envelope = await authorized.seal();
|
|
1052
|
+
const transactionId = await submitTransaction(provider, envelope);
|
|
1053
|
+
const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the HTLC funding transaction");
|
|
1054
|
+
await recordKnownVersions(response);
|
|
1055
|
+
return { transactionId, conditions, ownCommitment, outputMask };
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Claims a funded HTLC (the counterpart to `htlcFund`): reveals the claim leaf's SHA-256
|
|
1059
|
+
* preimage to spend the script-path-locked output, moving `amount` into a brand-new, normal
|
|
1060
|
+
* (freely key-spendable) stealth output owned by this account.
|
|
1061
|
+
*
|
|
1062
|
+
* This account must be the intended claimant — `htlcFund` addressed the output to this
|
|
1063
|
+
* account's own wallet address, so it can unblind it the normal way: fetch the `utxo_...`
|
|
1064
|
+
* substate and decrypt with this account's own view secret, exactly like `claimPrivatePayment`
|
|
1065
|
+
* does for any other incoming stealth payment. `conditions` must be the *exact* two-leaf tree
|
|
1066
|
+
* `htlcFund` returned (or was handed out of band) — the wrong tree produces a script-path
|
|
1067
|
+
* witness whose condition root won't match the output's committed `SpendAuthorization::Script`
|
|
1068
|
+
* root, and `buildStealthTransferStatement`/the network both reject the spend.
|
|
1069
|
+
*
|
|
1070
|
+
* Fees are paid from this account's own existing revealed balance via `pay_fee` (same as any
|
|
1071
|
+
* other transaction this wallet submits), not from the claimed HTLC funds — a transaction's fee
|
|
1072
|
+
* instructions execute in a separate phase *before* its main instructions, so the claim's own
|
|
1073
|
+
* `StealthTransfer` instruction (a main instruction) can't fund its own fee even in principle.
|
|
1074
|
+
*
|
|
1075
|
+
* Bypasses `StealthTransfer`/`WalletStealthAuthorizer` entirely — that builder pipeline only
|
|
1076
|
+
* ever produces key-path stealth inputs (a one-time DH signature per input), which doesn't
|
|
1077
|
+
* apply here: this spend's authorization is the revealed script-path leaf itself (its
|
|
1078
|
+
* `AccessRule` atom, satisfied by this account's normal transaction signature), not a stealth
|
|
1079
|
+
* spend key. `buildStealthTransferStatement` (tari-project/tari-ootle#2431) produces the
|
|
1080
|
+
* complete statement — inputs/outputs/balance proof/covenant claims — in one call from the
|
|
1081
|
+
* unblinded mask+value and the two witnesses below, so there is nothing left for the builder
|
|
1082
|
+
* pipeline to add.
|
|
1083
|
+
*/
|
|
1084
|
+
async htlcClaim(resourceAddress, commitmentHex, conditions, preimageHex, maxFee = 50000n) {
|
|
1085
|
+
if (!/^[0-9a-f]{64}$/i.test(preimageHex)) {
|
|
1086
|
+
throw new Error(`htlcClaim: preimageHex must be exactly 64 hex characters (32 bytes), got ${JSON.stringify(preimageHex)}`);
|
|
1087
|
+
}
|
|
1088
|
+
const provider = await this.getProvider();
|
|
1089
|
+
const account = await this.getComponentAddress();
|
|
1090
|
+
const walletAddress = await this.getWalletAddress();
|
|
1091
|
+
const commitment = fromHex(commitmentHex);
|
|
1092
|
+
const substateId = stealthUtxoSubstateId(resourceAddress, commitment);
|
|
1093
|
+
const substate = await provider.getSubstate(substateId);
|
|
1094
|
+
const viewSecret = await this.signer.getViewSecret();
|
|
1095
|
+
const decrypted = await decryptOwnedUtxo(new WasmStealthCrypto(this.network), viewSecret, substate, substateId);
|
|
1096
|
+
if (!decrypted) {
|
|
1097
|
+
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.");
|
|
1098
|
+
}
|
|
1099
|
+
const claimLeaf = conditions[0];
|
|
1100
|
+
if (!claimLeaf)
|
|
1101
|
+
throw new Error("htlcClaim: conditions must be the two-leaf [claim, refund] tree htlcFund returned");
|
|
1102
|
+
const statementJson = buildHtlcSpendStatement({
|
|
1103
|
+
network: this.network,
|
|
1104
|
+
conditions,
|
|
1105
|
+
leaf: claimLeaf,
|
|
1106
|
+
data: fromHex(preimageHex),
|
|
1107
|
+
mask: decrypted.mask.toHex(),
|
|
1108
|
+
value: decrypted.value,
|
|
1109
|
+
destinationWalletAddress: walletAddress,
|
|
1110
|
+
resourceAddress,
|
|
1111
|
+
});
|
|
1112
|
+
const transactionId = await submitHtlcSpend({
|
|
1113
|
+
provider,
|
|
1114
|
+
signer: this.signer,
|
|
1115
|
+
network: this.network,
|
|
1116
|
+
account,
|
|
1117
|
+
resourceAddress,
|
|
1118
|
+
substateId,
|
|
1119
|
+
statementJson,
|
|
1120
|
+
maxFee,
|
|
1121
|
+
timeoutLabel: "submitting the HTLC claim transaction",
|
|
1122
|
+
});
|
|
1123
|
+
return { transactionId };
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Refunds an HTLC this account itself funded (via `htlcFund`), once `refundEpoch` has passed:
|
|
1127
|
+
* reveals the refund leaf to spend the output back into a brand-new, normal (freely
|
|
1128
|
+
* key-spendable) stealth output owned by this account.
|
|
1129
|
+
*
|
|
1130
|
+
* Unlike `htlcClaim`, this account **cannot** decrypt the output on-chain — `htlcFund` addressed
|
|
1131
|
+
* it to the claimant's wallet, so `encrypted_data` is encrypted for the claimant's view key, not
|
|
1132
|
+
* this account's. `amount` and `outputMaskHex` must be exactly what `htlcFund` returned (or
|
|
1133
|
+
* otherwise already known — this account chose `amount` itself when funding, and `outputMaskHex`
|
|
1134
|
+
* is `htlcFund`'s own return value for exactly this purpose): reconstructing the commitment from
|
|
1135
|
+
* a wrong mask or amount fails cleanly (the resulting input just doesn't match the on-chain
|
|
1136
|
+
* commitment, so the network rejects the spend) rather than silently, but there is no recovery
|
|
1137
|
+
* path if this account discarded the mask — see `htlcFund`'s doc comment.
|
|
1138
|
+
*
|
|
1139
|
+
* Otherwise identical to `htlcClaim`: same fee-from-own-balance reasoning, same
|
|
1140
|
+
* `buildStealthTransferStatement`-in-one-call approach, same bypass of
|
|
1141
|
+
* `StealthTransfer`/`WalletStealthAuthorizer`.
|
|
1142
|
+
*/
|
|
1143
|
+
async htlcRefund(resourceAddress, commitmentHex, conditions, amount, outputMaskHex, maxFee = 50000n) {
|
|
1144
|
+
const provider = await this.getProvider();
|
|
1145
|
+
const account = await this.getComponentAddress();
|
|
1146
|
+
const walletAddress = await this.getWalletAddress();
|
|
1147
|
+
const commitment = fromHex(commitmentHex);
|
|
1148
|
+
const substateId = stealthUtxoSubstateId(resourceAddress, commitment);
|
|
1149
|
+
const refundLeaf = conditions[1];
|
|
1150
|
+
if (!refundLeaf)
|
|
1151
|
+
throw new Error("htlcRefund: conditions must be the two-leaf [claim, refund] tree htlcFund returned");
|
|
1152
|
+
const statementJson = buildHtlcSpendStatement({
|
|
1153
|
+
network: this.network,
|
|
1154
|
+
conditions,
|
|
1155
|
+
leaf: refundLeaf,
|
|
1156
|
+
data: new Uint8Array(0), // The refund leaf has no HashLock atom -- no witness data needed.
|
|
1157
|
+
mask: outputMaskHex,
|
|
1158
|
+
value: amount,
|
|
1159
|
+
destinationWalletAddress: walletAddress,
|
|
1160
|
+
resourceAddress,
|
|
1161
|
+
});
|
|
1162
|
+
const transactionId = await submitHtlcSpend({
|
|
1163
|
+
provider,
|
|
1164
|
+
signer: this.signer,
|
|
1165
|
+
network: this.network,
|
|
1166
|
+
account,
|
|
1167
|
+
resourceAddress,
|
|
1168
|
+
substateId,
|
|
1169
|
+
statementJson,
|
|
1170
|
+
maxFee,
|
|
1171
|
+
timeoutLabel: "submitting the HTLC refund transaction",
|
|
1172
|
+
});
|
|
1173
|
+
return { transactionId };
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Sends `amount` of this account's shielded balance for `resourceAddress` directly to
|
|
1177
|
+
* `recipientWalletAddress` — a private-to-private transfer, unlike `shield()` (which always
|
|
1178
|
+
* targets this same account's own wallet address). The recipient never appears on-chain in the
|
|
1179
|
+
* clear; only they (holding the matching view key) can discover the payment, and only by being
|
|
1180
|
+
* told the resulting commitment out of band — same "no scan API" caveat as everywhere else
|
|
1181
|
+
* stealth outputs are spent. Which specific output(s) to spend is decided internally by
|
|
1182
|
+
* `resolveSendPrivatelyPlan`'s coin selection (largest-first, spending more than one in the same
|
|
1183
|
+
* transaction via repeated `spendStealthInput()` calls if a single output isn't enough) — the
|
|
1184
|
+
* caller only supplies an amount, not a commitment.
|
|
1185
|
+
*
|
|
1186
|
+
* Unlike `unshield()`, sending the *entire* selected total with no remainder is possible in one
|
|
1187
|
+
* step here: the builder's "at least one stealth output" requirement is trivially satisfied by
|
|
1188
|
+
* the recipient's own output, with no need for a same-account dust/change output to satisfy it.
|
|
1189
|
+
* Change (if `amount` is less than the selected inputs' total) is a second stealth output back
|
|
1190
|
+
* to this account.
|
|
1191
|
+
*
|
|
1192
|
+
* The fee-paying "dust" trick from `unshield()` still applies for the same reason: pay_fee and
|
|
1193
|
+
* (when there's change) the *concept* of "this account received a new output" both need a
|
|
1194
|
+
* revealed source account registered via `spendRevealedInput`, which requires amount `> 0`.
|
|
1195
|
+
*
|
|
1196
|
+
* Crash-safety: this account's own change commitment (if any) is read directly from the
|
|
1197
|
+
* locally-built outputs statement, *not* inferred from the finalized transaction's
|
|
1198
|
+
* `up_substates` — those also contain the recipient's brand-new stealth output, and naively
|
|
1199
|
+
* grabbing "the first new stealth output" would risk recording the recipient's payment as our
|
|
1200
|
+
* own. See `PendingShield.ownCommitment`'s doc comment.
|
|
1201
|
+
*/
|
|
1202
|
+
async sendPrivately(resourceAddress, recipientWalletAddress, amount, maxFee = 100000n, memo, minimumValuePromise = 0n) {
|
|
1203
|
+
assertValidMinimumValuePromise(minimumValuePromise, amount);
|
|
1204
|
+
const accountId = localAccountId(this.index);
|
|
1205
|
+
const records = await listShieldedOutputs(accountId);
|
|
1206
|
+
const { commitments, changeAmount } = resolveSendPrivatelyPlan(records, resourceAddress, amount);
|
|
1207
|
+
const dust = 1n;
|
|
1208
|
+
const provider = await this.getProvider();
|
|
1209
|
+
const account = await this.getComponentAddress();
|
|
1210
|
+
const ownWalletAddress = await this.getWalletAddress();
|
|
1211
|
+
// See shield()'s comment: the resource's own substate must be pinned explicitly -- prepare()
|
|
1212
|
+
// never adds it on its own.
|
|
1213
|
+
let builder = new StealthTransfer(provider, resourceAddress)
|
|
1214
|
+
.withBuilder((b) => b.addInput({ substate_id: resourceAddress, version: null }))
|
|
1215
|
+
.spendRevealedInput(account, dust);
|
|
1216
|
+
for (const commitment of commitments) {
|
|
1217
|
+
builder = builder.spendStealthInput(account, fromHex(commitment));
|
|
1218
|
+
}
|
|
1219
|
+
// The promise rides on the recipient's output only. Putting one on the change output would
|
|
1220
|
+
// publish a floor on this account's own remaining private balance -- an unrelated disclosure the
|
|
1221
|
+
// caller never asked for, and one the recipient has no interest in.
|
|
1222
|
+
builder = builder.toStealthOutput(createOutput({ destination: recipientWalletAddress, amount, resourceAddress, memo: toMemo(memo), minimumValuePromise }));
|
|
1223
|
+
if (changeAmount > 0n) {
|
|
1224
|
+
builder = builder.toStealthOutput(createOutput({ destination: ownWalletAddress, amount: changeAmount, resourceAddress }));
|
|
1225
|
+
}
|
|
1226
|
+
const spec = await builder.toRevealedOutput(dust).payFeeFromRevealed(maxFee).prepare();
|
|
1227
|
+
// Output 0 is the recipient's; output 1 (only present when there's change) is ours. The
|
|
1228
|
+
// recipient has no way to discover their new output on their own (no scan-by-view-key API
|
|
1229
|
+
// exists) -- this commitment must be handed back to the caller so it can be shared with them
|
|
1230
|
+
// out of band; without it, the payment is invisible to them even though it succeeded on-chain.
|
|
1231
|
+
const recipientCommitment = extractOutputCommitment(spec, 0);
|
|
1232
|
+
const ownCommitment = changeAmount > 0n ? extractOutputCommitment(spec, 1) : undefined;
|
|
1233
|
+
const wallet = new OotleWallet().registerKeyProvider(account, this.signer).setDefaultSigner(account);
|
|
1234
|
+
const viewSecret = await this.signer.getViewSecret();
|
|
1235
|
+
const authorized = await WalletStealthAuthorizer.fromSpec(wallet, spec, { viewSecret, crypto: new WasmStealthCrypto(this.network) }).prepare(provider);
|
|
1236
|
+
const envelope = await authorized.seal();
|
|
1237
|
+
const transactionId = await submitTransaction(provider, envelope);
|
|
1238
|
+
await addPendingShield({
|
|
1239
|
+
transactionId,
|
|
1240
|
+
accountId,
|
|
1241
|
+
resourceAddress,
|
|
1242
|
+
amount: changeAmount.toString(),
|
|
1243
|
+
spentCommitments: commitments,
|
|
1244
|
+
ownCommitment,
|
|
1245
|
+
});
|
|
1246
|
+
try {
|
|
1247
|
+
const response = await withTimeout(pollTransactionResult(provider, transactionId), 60_000, "submitting the private send");
|
|
1248
|
+
await recordKnownVersions(response);
|
|
1249
|
+
if (ownCommitment)
|
|
1250
|
+
await recordKnownShieldedOutput(accountId, resourceAddress, ownCommitment, changeAmount, transactionId);
|
|
1251
|
+
for (const commitment of commitments) {
|
|
1252
|
+
await markShieldedOutputSpent(accountId, commitment);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
finally {
|
|
1256
|
+
await removePendingShield(transactionId);
|
|
1257
|
+
}
|
|
1258
|
+
return {
|
|
1259
|
+
transactionId,
|
|
1260
|
+
recipientCommitment,
|
|
1261
|
+
recipientSubstateId: stealthUtxoSubstateId(resourceAddress, fromHex(recipientCommitment)),
|
|
1262
|
+
minimumValuePromise: minimumValuePromise.toString(),
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* The recipient-side counterpart to `sendPrivately()`'s commitment hand-off: given a commitment
|
|
1267
|
+
* shared out of band, fetches the corresponding `utxo_{resource}_{commitment}` substate and
|
|
1268
|
+
* tries to decrypt it with this account's own view secret. `decryptOwnedUtxo` swallows the
|
|
1269
|
+
* "not ours" throw and returns `null` instead, so a wrong/foreign commitment fails cleanly here
|
|
1270
|
+
* rather than looking like a network error. Success both *proves* ownership and recovers the
|
|
1271
|
+
* amount in one step (no need to be told the amount separately) -- recorded exactly like a
|
|
1272
|
+
* self-shield's output, so it then just shows up in this account's private balance and
|
|
1273
|
+
* shielded-outputs picker.
|
|
1274
|
+
*/
|
|
1275
|
+
async claimPrivatePayment(resourceAddress, commitmentHex) {
|
|
1276
|
+
const accountId = localAccountId(this.index);
|
|
1277
|
+
const existing = await listShieldedOutputs(accountId);
|
|
1278
|
+
if (existing.some((r) => r.resourceAddress === resourceAddress && r.commitment === commitmentHex)) {
|
|
1279
|
+
throw new Error("You've already claimed this payment.");
|
|
1280
|
+
}
|
|
1281
|
+
const provider = await this.getProvider();
|
|
1282
|
+
const substateId = stealthUtxoSubstateId(resourceAddress, fromHex(commitmentHex));
|
|
1283
|
+
const substate = await provider.getSubstate(substateId);
|
|
1284
|
+
const viewSecret = await this.signer.getViewSecret();
|
|
1285
|
+
const decrypted = await decryptOwnedUtxo(new WasmStealthCrypto(this.network), viewSecret, substate, substateId);
|
|
1286
|
+
if (!decrypted) {
|
|
1287
|
+
throw new Error("This commitment doesn't belong to your account, or wasn't found on-chain.");
|
|
1288
|
+
}
|
|
1289
|
+
const memo = fromMemo(decrypted.memo);
|
|
1290
|
+
// No originating transaction id is available from a bare commitment -- the substate id is
|
|
1291
|
+
// itself a stable, deterministic reference back to this exact output, so it fills that slot.
|
|
1292
|
+
await recordKnownShieldedOutput(accountId, resourceAddress, commitmentHex, decrypted.value, substateId, memo);
|
|
1293
|
+
return { amount: decrypted.value, memo };
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* This account's unspent stealth outputs, newest first, optionally filtered to one resource.
|
|
1297
|
+
*
|
|
1298
|
+
* This local ledger *is* the account's private position -- see `ShieldedOutputRecord`'s doc
|
|
1299
|
+
* comment for why it has to be: a stealth output is a freestanding
|
|
1300
|
+
* `utxo_{resource}_{commitment}` substate, not an entry in any vault, and there is no
|
|
1301
|
+
* scan-by-view-key API that could rediscover one from the chain alone given only its commitment.
|
|
1302
|
+
* Anything not in here is, as far as this wallet is concerned, not spendable.
|
|
1303
|
+
*/
|
|
1304
|
+
async listUnspentShieldedOutputs(resourceAddress) {
|
|
1305
|
+
return selectUnspentShieldedOutputs(await listShieldedOutputs(localAccountId(this.index)), resourceAddress);
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Per-resource totals over `listUnspentShieldedOutputs()`, with each resource's real on-chain
|
|
1309
|
+
* `divisibility`/symbol/name folded in (the same lookup `getBalances()` does, via the shared
|
|
1310
|
+
* `fetchResourceMetadata` helper -- a private balance has to be renderable on its own, without a
|
|
1311
|
+
* caller cross-referencing `getBalances()` for the decimals).
|
|
1312
|
+
*
|
|
1313
|
+
* Distinct from `getBalances()`'s `confidentialAmount` in two ways worth being precise about:
|
|
1314
|
+
* it counts only these freestanding stealth outputs (not a Confidential *vault*'s own
|
|
1315
|
+
* commitments map, which `getBalances()` also decrypts and adds in), and it reports a resource
|
|
1316
|
+
* whose only holding is private, which `getBalances()` only surfaces via its
|
|
1317
|
+
* `synthesizeShieldedOnlyBalances` path. For "what can I spend privately right now", this is the
|
|
1318
|
+
* authoritative number, because it is exactly what `resolveUnshieldPlan`/
|
|
1319
|
+
* `resolveSendPrivatelyPlan` select from.
|
|
1320
|
+
*
|
|
1321
|
+
* One network round trip (the resource metadata batch); the amounts themselves are local.
|
|
1322
|
+
*/
|
|
1323
|
+
async getPrivateBalances() {
|
|
1324
|
+
const holdings = summarizePrivateHoldings(await this.listUnspentShieldedOutputs());
|
|
1325
|
+
if (holdings.length === 0)
|
|
1326
|
+
return [];
|
|
1327
|
+
const provider = await this.getProvider();
|
|
1328
|
+
const { divisibilityByResource, symbolByResource, nameByResource } = await fetchResourceMetadata(provider, holdings.map((h) => h.resourceAddress));
|
|
1329
|
+
return holdings.map((h) => ({
|
|
1330
|
+
...h,
|
|
1331
|
+
divisibility: divisibilityByResource.get(h.resourceAddress) ?? 0,
|
|
1332
|
+
symbol: symbolByResource.get(h.resourceAddress) ?? null,
|
|
1333
|
+
name: nameByResource.get(h.resourceAddress) ?? null,
|
|
1334
|
+
}));
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Automatic counterpart to `claimPrivatePayment()`: instead of requiring the recipient be told a
|
|
1338
|
+
* commitment out of band, walks the indexer's recent-transaction history looking for
|
|
1339
|
+
* `StealthTransfer` outputs this account can decrypt with its own view key (see
|
|
1340
|
+
* `scanTransactionsForOwnedOutputs`'s doc comment for why the data needed to do this is present
|
|
1341
|
+
* even in the *pruned* form `listRecentTransactions` returns).
|
|
1342
|
+
*
|
|
1343
|
+
* Cursor-based (`privatePaymentScanCursors[accountId]`, the newest transaction id seen last time)
|
|
1344
|
+
* so this can run cheaply and opportunistically on every `popup-get-status` — see
|
|
1345
|
+
* `buildStatus()`'s `recoverPendingShields()` call for the same pattern. Walks backward in pages
|
|
1346
|
+
* of `pageSize` (oldest-paging via `last_id`, per the indexer's own "recent" convention) up to
|
|
1347
|
+
* `maxPages`, stopping early once it reaches the transaction id it left off at last time. A very
|
|
1348
|
+
* active chain producing more than `maxPages * pageSize` new transactions between scans will
|
|
1349
|
+
* only be caught up partway; the next scan resumes from the same cursor and continues.
|
|
1350
|
+
*/
|
|
1351
|
+
async scanForPrivatePayments(maxPages, pageSize = 50) {
|
|
1352
|
+
const accountId = localAccountId(this.index);
|
|
1353
|
+
const provider = await this.getProvider();
|
|
1354
|
+
const viewSecret = await this.signer.getViewSecret();
|
|
1355
|
+
const crypto = new WasmStealthCrypto(this.network);
|
|
1356
|
+
const known = await listShieldedOutputs(accountId);
|
|
1357
|
+
const knownCommitments = new Set(known.map((r) => r.commitment));
|
|
1358
|
+
const previousCursor = await getPrivatePaymentScanCursor(accountId);
|
|
1359
|
+
let newestSeen = null;
|
|
1360
|
+
let lastId = null;
|
|
1361
|
+
const found = [];
|
|
1362
|
+
// Whether this pass actually walked all the way back to `previousCursor` (or there was none to
|
|
1363
|
+
// reach -- the very first scan ever). Only true in that case is it safe to advance the cursor:
|
|
1364
|
+
// if `maxPages` runs out first, advancing anyway would silently and PERMANENTLY skip the
|
|
1365
|
+
// unscanned gap between here and the old cursor -- the next scan would start from the new
|
|
1366
|
+
// (already-advanced) cursor and never revisit it. Leaving the cursor where it was instead means
|
|
1367
|
+
// the next opportunistic scan just re-tries the same catch-up (redundant work, not data loss);
|
|
1368
|
+
// see this method's own doc comment ("the next scan resumes from the same cursor").
|
|
1369
|
+
// "No cursor" does not mean "we have the whole history" — after a restore it means the
|
|
1370
|
+
// opposite, that nothing is known yet. Starting `true` let the cursor be advanced to the
|
|
1371
|
+
// newest transaction after only a few pages, writing off every stealth output older than that
|
|
1372
|
+
// window: the next scan starts from the new cursor and never looks back, so those outputs
|
|
1373
|
+
// become unrecoverable. Only reaching the old cursor, or running out of history, counts.
|
|
1374
|
+
const isFirstScan = previousCursor === null;
|
|
1375
|
+
const pageBudget = maxPages ?? (isFirstScan ? 400 : 3);
|
|
1376
|
+
let reachedCursor = false;
|
|
1377
|
+
pages: for (let page = 0; page < pageBudget; page++) {
|
|
1378
|
+
const { transactions } = await provider.listRecentTransactions({ limit: pageSize, last_id: lastId, source: null });
|
|
1379
|
+
if (transactions.length === 0) {
|
|
1380
|
+
reachedCursor = true; // the indexer's whole history fit before ever finding previousCursor
|
|
1381
|
+
break;
|
|
1382
|
+
}
|
|
1383
|
+
if (page === 0)
|
|
1384
|
+
newestSeen = transactions[0].transaction_id;
|
|
1385
|
+
for (const entry of transactions) {
|
|
1386
|
+
if (entry.transaction_id === previousCursor) {
|
|
1387
|
+
reachedCursor = true;
|
|
1388
|
+
break pages;
|
|
1389
|
+
}
|
|
1390
|
+
const newlyFound = await scanTransactionsForOwnedOutputs(crypto, viewSecret, [entry], knownCommitments);
|
|
1391
|
+
for (const raw of newlyFound) {
|
|
1392
|
+
// scanTransactionsForOwnedOutputs' `memo` is the raw JSON-encoded Memo union (see
|
|
1393
|
+
// ScannedStealthOutput's doc comment) -- decode once here so both the persisted record
|
|
1394
|
+
// and this method's own return value carry plain text, not JSON, memo consumers never
|
|
1395
|
+
// need to know about `fromMemo()` themselves.
|
|
1396
|
+
const output = { ...raw, memo: fromMemo(raw.memo) };
|
|
1397
|
+
await recordKnownShieldedOutput(accountId, output.resourceAddress, output.commitment, output.amount, output.transactionId, output.memo);
|
|
1398
|
+
knownCommitments.add(output.commitment);
|
|
1399
|
+
found.push(output);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
lastId = transactions[transactions.length - 1].transaction_id;
|
|
1403
|
+
}
|
|
1404
|
+
if (newestSeen && reachedCursor)
|
|
1405
|
+
await setPrivatePaymentScanCursor(accountId, newestSeen);
|
|
1406
|
+
return { claimed: found.length, found };
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Reads the commitment of the output at `outputIndex` (in the order `toStealthOutput()` was
|
|
1411
|
+
* called) directly from the outputs statement `StealthTransfer.prepare()` already built —
|
|
1412
|
+
* generated client-side, so it's known *before* submission, not inferred afterward by scanning
|
|
1413
|
+
* the finalized transaction's `up_substates`. That scan-based approach is unambiguous when a
|
|
1414
|
+
* transfer only ever creates one new stealth output belonging to this account (shield, unshield),
|
|
1415
|
+
* but breaks down the moment a transfer also creates a *different* account's new stealth output
|
|
1416
|
+
* in the same transaction (a private send's recipient output) — grabbing "the first new stealth
|
|
1417
|
+
* output" from `up_substates` would then risk attributing someone else's payment to us.
|
|
1418
|
+
*/
|
|
1419
|
+
/**
|
|
1420
|
+
* Builds a complete `StealthTransferStatement` (as its final compact JSON string) that spends one
|
|
1421
|
+
* script-path-locked HTLC UTXO into one brand-new, normal (key-spendable) stealth output — the
|
|
1422
|
+
* shared core of `htlcClaim`/`htlcRefund`, which differ only in which leaf they reveal and how
|
|
1423
|
+
* they come by the input's `mask`/`value` (on-chain decryption for a claim; retained from funding
|
|
1424
|
+
* time for a refund, since the funder can't decrypt an output addressed to the claimant).
|
|
1425
|
+
*
|
|
1426
|
+
* Every JSON fragment here is spliced from the raw strings `buildScriptPathWitness`/
|
|
1427
|
+
* `createStealthOutputWitness` themselves return, never `JSON.parse`d and rebuilt — both carry
|
|
1428
|
+
* u64 fields (amounts, epochs) that can exceed `Number.MAX_SAFE_INTEGER`, and a parse/restringify
|
|
1429
|
+
* round-trip through a JS number would silently corrupt them. `value` is spliced in from `params`
|
|
1430
|
+
* via template-literal interpolation of a `bigint`, which stringifies to its exact decimal digits
|
|
1431
|
+
* with no such risk.
|
|
1432
|
+
*/
|
|
1433
|
+
// Exported (unlike this file's other module-level helpers) specifically so its wasm-assembly
|
|
1434
|
+
// logic can be exercised directly in tests without needing a full account + fake provider --
|
|
1435
|
+
// see htlc.test.ts's real-crypto round-trip test.
|
|
1436
|
+
export function buildHtlcSpendStatement(params) {
|
|
1437
|
+
const witnessResultJson = buildScriptPathWitness(JSON.stringify(params.conditions), JSON.stringify(params.leaf), params.data);
|
|
1438
|
+
// `witnessResultJson` is exactly `{"witness":...,"condition_root":"..."}` -- strip the outer
|
|
1439
|
+
// braces and splice the inner `"witness":...,"condition_root":...` content directly into the
|
|
1440
|
+
// input entry below, rather than parsing and re-serializing the object.
|
|
1441
|
+
const witnessInner = witnessResultJson.slice(1, -1);
|
|
1442
|
+
const inputEntry = `{"mask_and_value":{"value":${params.value},"mask":"${params.mask}"},${witnessInner}}`;
|
|
1443
|
+
const destination = parseOotleAddress(params.destinationWalletAddress);
|
|
1444
|
+
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.
|
|
1445
|
+
0n);
|
|
1446
|
+
const statementJson = buildStealthTransferStatement(`[${inputEntry}]`, 0n, `[${outputWitnessJson}]`, 0n);
|
|
1447
|
+
// Fail fast locally rather than spend a network round trip on a malformed statement -- the same
|
|
1448
|
+
// check tari-ootle's own `build_stealth_transfer_statement` unit tests run before trusting its
|
|
1449
|
+
// output (crates/ootle_wasm/core/src/stealth/transfer.rs).
|
|
1450
|
+
validateStealthTransfer(statementJson, null);
|
|
1451
|
+
return statementJson;
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Submits a hand-built HTLC claim/refund transaction: one `StealthTransfer` instruction carrying
|
|
1455
|
+
* `statementJson` verbatim, spending `substateId`, fee paid from `account`'s own revealed balance.
|
|
1456
|
+
*
|
|
1457
|
+
* Deliberately bypasses `TransactionBuilder`'s usual `WalletStealthAuthorizer` companion — that
|
|
1458
|
+
* pipeline only ever produces key-path stealth-input signatures, which don't apply to a
|
|
1459
|
+
* script-path spend (whose authorization is the revealed leaf's own `AccessRule`, satisfied by
|
|
1460
|
+
* this account's ordinary transaction signature) — `signTransaction`/`sealTransaction` alone are
|
|
1461
|
+
* both necessary and sufficient here, the same pipeline any plain `CallMethod`-only transaction
|
|
1462
|
+
* uses.
|
|
1463
|
+
*/
|
|
1464
|
+
async function submitHtlcSpend(params) {
|
|
1465
|
+
const maxEpoch = await resolveMaxEpoch(params.provider);
|
|
1466
|
+
const builder = TransactionBuilder.new(params.network, maxEpoch)
|
|
1467
|
+
.addInput({ substate_id: params.substateId, version: null })
|
|
1468
|
+
.addInput({ substate_id: params.account, version: null })
|
|
1469
|
+
.feeTransactionPayFromComponent(params.account, params.maxFee)
|
|
1470
|
+
.addInstruction(
|
|
1471
|
+
// `statement` is spliced as a raw JSON fragment (see buildHtlcSpendStatement), the same
|
|
1472
|
+
// technique `@tari-project/ootle`'s own `statementAsWire` uses internally for the piecemeal
|
|
1473
|
+
// StealthTransfer builder path -- this cast mirrors that one exactly.
|
|
1474
|
+
{
|
|
1475
|
+
StealthTransfer: {
|
|
1476
|
+
resource_address_ref: { Address: params.resourceAddress },
|
|
1477
|
+
statement: { __ootleRawJson: params.statementJson },
|
|
1478
|
+
revealed_input_bucket: null,
|
|
1479
|
+
},
|
|
1480
|
+
});
|
|
1481
|
+
for (const vaultId of await getVaultIdsForAccount(params.provider, params.account)) {
|
|
1482
|
+
builder.addInput({ substate_id: vaultId, version: null });
|
|
1483
|
+
}
|
|
1484
|
+
const unsignedTx = await resolveTransaction(params.provider, builder.buildUnsignedTransaction());
|
|
1485
|
+
const signed = await signTransaction([params.signer], unsignedTx);
|
|
1486
|
+
const envelope = await sealTransaction(signed);
|
|
1487
|
+
const transactionId = await submitTransaction(params.provider, envelope);
|
|
1488
|
+
const response = await withTimeout(pollTransactionResult(params.provider, transactionId), 60_000, params.timeoutLabel);
|
|
1489
|
+
await recordKnownVersions(response);
|
|
1490
|
+
return transactionId;
|
|
1491
|
+
}
|
|
1492
|
+
function extractOutputCommitment(spec, outputIndex) {
|
|
1493
|
+
const parsed = spec.statement.outputsStatement.parsed();
|
|
1494
|
+
const commitment = parsed.outputs?.[outputIndex]?.output?.commitment;
|
|
1495
|
+
if (typeof commitment !== "string" || commitment.length === 0) {
|
|
1496
|
+
throw new Error(`Failed to read output ${outputIndex}'s commitment from the locally-built outputs statement.`);
|
|
1497
|
+
}
|
|
1498
|
+
return commitment;
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Synthesizes a `TokenBalance` entry for each resource whose only balance is a shielded output
|
|
1502
|
+
* with no on-chain vault at all (e.g. redeemed via the "Advanced" unshield flow from someone
|
|
1503
|
+
* else's shared commitment) -- these never appear in `getBalances()`'s vault-derived `parsed`
|
|
1504
|
+
* list, since they're freestanding `utxo_{resource}_{commitment}` substates, not vault entries.
|
|
1505
|
+
* Skips any resource already covered by `parsedResources` (that one gets its shielded amount
|
|
1506
|
+
* folded into its existing entry instead, in `getBalances()` itself).
|
|
1507
|
+
*/
|
|
1508
|
+
export function synthesizeShieldedOnlyBalances(parsedResources, shieldedByResource, divisibilityByResource, symbolByResource, nameByResource) {
|
|
1509
|
+
const balances = [];
|
|
1510
|
+
for (const [resourceAddress, amount] of shieldedByResource) {
|
|
1511
|
+
if (parsedResources.has(resourceAddress))
|
|
1512
|
+
continue;
|
|
1513
|
+
balances.push({
|
|
1514
|
+
resourceAddress,
|
|
1515
|
+
kind: "Stealth",
|
|
1516
|
+
amount: 0n,
|
|
1517
|
+
confidentialAmount: amount,
|
|
1518
|
+
confidentialDecryptFailures: 0,
|
|
1519
|
+
divisibility: divisibilityByResource.get(resourceAddress) ?? 0,
|
|
1520
|
+
symbol: symbolByResource.get(resourceAddress) ?? null,
|
|
1521
|
+
name: nameByResource.get(resourceAddress) ?? null,
|
|
1522
|
+
nonFungibleTokenIds: null,
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
return balances;
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* A display string for one `NonFungibleId` (the tagged union `{U256} | {String} | {Uint32} |
|
|
1529
|
+
* {Uint64}`, or occasionally a bare primitive) -- just the value, since the variant tag is rarely
|
|
1530
|
+
* interesting to a user looking at a list of tokens they hold. Falls back to the raw JSON for a
|
|
1531
|
+
* shape this doesn't recognise (a future `NonFungibleId` variant) rather than throwing partway
|
|
1532
|
+
* through a balance list.
|
|
1533
|
+
*/
|
|
1534
|
+
function stringifyNonFungibleId(id) {
|
|
1535
|
+
if (typeof id === "string" || typeof id === "number" || typeof id === "bigint")
|
|
1536
|
+
return String(id);
|
|
1537
|
+
if (id && typeof id === "object") {
|
|
1538
|
+
if ("U256" in id)
|
|
1539
|
+
return String(id.U256);
|
|
1540
|
+
if ("String" in id)
|
|
1541
|
+
return String(id.String);
|
|
1542
|
+
if ("Uint32" in id)
|
|
1543
|
+
return String(id.Uint32);
|
|
1544
|
+
if ("Uint64" in id)
|
|
1545
|
+
return String(id.Uint64);
|
|
1546
|
+
}
|
|
1547
|
+
return JSON.stringify(id);
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Coin selection for spending shielded outputs: picks this resource's unspent records
|
|
1551
|
+
* largest-first until their sum covers `targetAmount`, so a spend needing more than any single
|
|
1552
|
+
* output holds is satisfied by combining several in one transaction (the builder's
|
|
1553
|
+
* `spendStealthInput()` can be called once per input UTXO) rather than requiring the caller to
|
|
1554
|
+
* pick one record themselves. Largest-first minimizes the number of inputs spent (each one adds
|
|
1555
|
+
* real signature/proof-verification fee cost), rather than needlessly consolidating dust.
|
|
1556
|
+
*
|
|
1557
|
+
* Returns the unselected remainder too (still sorted largest-first) so callers that need "at
|
|
1558
|
+
* least one more unit available" (see `resolveUnshieldPlan`) can pull in exactly one more output
|
|
1559
|
+
* without re-deriving the candidate list.
|
|
1560
|
+
*/
|
|
1561
|
+
/**
|
|
1562
|
+
* The unspent subset of a shielded-output ledger, newest first, optionally narrowed to one
|
|
1563
|
+
* resource. Newest-first is a display order, deliberately *not* the spend order: coin selection is
|
|
1564
|
+
* `selectShieldedUtxosForAmount`'s largest-first job, and the two must not be conflated.
|
|
1565
|
+
*
|
|
1566
|
+
* Split out of `OotleAccount.listUnspentShieldedOutputs` (which is just this over the account's own
|
|
1567
|
+
* stored records) so the filtering rule -- what counts as spendable, and what a dApp with view
|
|
1568
|
+
* access is allowed to see -- is testable on its own.
|
|
1569
|
+
*/
|
|
1570
|
+
export function selectUnspentShieldedOutputs(records, resourceAddress) {
|
|
1571
|
+
return records
|
|
1572
|
+
.filter((r) => !r.spent && (resourceAddress === undefined || r.resourceAddress === resourceAddress))
|
|
1573
|
+
.sort((a, b) => b.createdAt - a.createdAt);
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Totals a set of already-unspent shielded outputs per resource. `outputCount` is carried
|
|
1577
|
+
* alongside the sum rather than left to be recomputed, because it is not cosmetic: each output is
|
|
1578
|
+
* spent whole, so the count is what bounds whether a given amount can actually be covered (see
|
|
1579
|
+
* `resolveUnshieldPlan`) -- a balance of 100 across one output and across fifty are very different
|
|
1580
|
+
* things to a caller planning a spend.
|
|
1581
|
+
*
|
|
1582
|
+
* Insertion-ordered by first appearance (Map iteration order), so a caller's rendering doesn't
|
|
1583
|
+
* reshuffle between calls for no reason.
|
|
1584
|
+
*/
|
|
1585
|
+
export function summarizePrivateHoldings(records) {
|
|
1586
|
+
const totals = new Map();
|
|
1587
|
+
for (const record of records) {
|
|
1588
|
+
const entry = totals.get(record.resourceAddress) ?? { amount: 0n, outputCount: 0 };
|
|
1589
|
+
entry.amount += BigInt(record.amount);
|
|
1590
|
+
entry.outputCount += 1;
|
|
1591
|
+
totals.set(record.resourceAddress, entry);
|
|
1592
|
+
}
|
|
1593
|
+
return [...totals].map(([resourceAddress, entry]) => ({ resourceAddress, ...entry }));
|
|
1594
|
+
}
|
|
1595
|
+
export function selectShieldedUtxosForAmount(records, resourceAddress, targetAmount) {
|
|
1596
|
+
const candidates = records
|
|
1597
|
+
.filter((r) => r.resourceAddress === resourceAddress && !r.spent)
|
|
1598
|
+
.sort((a, b) => {
|
|
1599
|
+
const diff = BigInt(b.amount) - BigInt(a.amount);
|
|
1600
|
+
return diff > 0n ? 1 : diff < 0n ? -1 : 0;
|
|
1601
|
+
});
|
|
1602
|
+
const selected = [];
|
|
1603
|
+
let total = 0n;
|
|
1604
|
+
let i = 0;
|
|
1605
|
+
for (; i < candidates.length; i++) {
|
|
1606
|
+
if (total >= targetAmount)
|
|
1607
|
+
break;
|
|
1608
|
+
selected.push(candidates[i]);
|
|
1609
|
+
total += BigInt(candidates[i].amount);
|
|
1610
|
+
}
|
|
1611
|
+
return { selected, total, unselected: candidates.slice(i) };
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Pure planning for `unshield()`: selects enough of the caller's unspent records for this
|
|
1615
|
+
* resource (via `selectShieldedUtxosForAmount`) to cover `revealedOutAmount`, and checks the
|
|
1616
|
+
* total leaves at least 1 unit as private remainder (see `unshield()`'s doc comment for why the
|
|
1617
|
+
* upper bound is strict, not `<=`). Extracted so this decision logic is unit-testable without a
|
|
1618
|
+
* live provider/WASM crypto, same reasoning as `pollTransactionResult`'s extraction.
|
|
1619
|
+
*
|
|
1620
|
+
* If the minimal selection's total lands *exactly* on `revealedOutAmount` (zero remainder), and
|
|
1621
|
+
* another unspent record for this resource exists, one more (the smallest available) is pulled
|
|
1622
|
+
* in purely to create a nonzero remainder — spending an extra UTXO is preferable to failing a
|
|
1623
|
+
* reveal that the account's total private balance could otherwise satisfy.
|
|
1624
|
+
*/
|
|
1625
|
+
export function resolveUnshieldPlan(records, resourceAddress, revealedOutAmount) {
|
|
1626
|
+
if (revealedOutAmount <= 0n)
|
|
1627
|
+
throw new Error("The amount to reveal must be greater than zero.");
|
|
1628
|
+
const { selected, total, unselected } = selectShieldedUtxosForAmount(records, resourceAddress, revealedOutAmount);
|
|
1629
|
+
if (total < revealedOutAmount) {
|
|
1630
|
+
throw new Error(`Amount exceeds your private balance (you have ${total}).`);
|
|
1631
|
+
}
|
|
1632
|
+
let finalSelected = selected;
|
|
1633
|
+
let finalTotal = total;
|
|
1634
|
+
if (finalTotal === revealedOutAmount) {
|
|
1635
|
+
const extra = unselected[unselected.length - 1]; // smallest remaining unspent record, if any
|
|
1636
|
+
if (!extra) {
|
|
1637
|
+
throw new Error("Can't unshield your full private balance in one transaction -- at least 1 unit must remain private as change.");
|
|
1638
|
+
}
|
|
1639
|
+
finalSelected = [...finalSelected, extra];
|
|
1640
|
+
finalTotal += BigInt(extra.amount);
|
|
1641
|
+
}
|
|
1642
|
+
return { commitments: finalSelected.map((r) => r.commitment), remainder: finalTotal - revealedOutAmount };
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Pure planning for `sendPrivately()`: selects enough of the caller's unspent records for this
|
|
1646
|
+
* resource to cover `amount` — unlike `resolveUnshieldPlan`, sending the *entire* selected total
|
|
1647
|
+
* with zero change is allowed, since the recipient's own stealth output already satisfies the
|
|
1648
|
+
* builder's "at least one stealth output" requirement (no same-account dust output is needed to
|
|
1649
|
+
* satisfy it, unlike unshield's revealed destination). Extracted for the same unit-testability
|
|
1650
|
+
* reasons as `resolveUnshieldPlan`.
|
|
1651
|
+
*/
|
|
1652
|
+
export function resolveSendPrivatelyPlan(records, resourceAddress, amount) {
|
|
1653
|
+
if (amount <= 0n)
|
|
1654
|
+
throw new Error("The amount to send must be greater than zero.");
|
|
1655
|
+
const { selected, total } = selectShieldedUtxosForAmount(records, resourceAddress, amount);
|
|
1656
|
+
if (total < amount) {
|
|
1657
|
+
throw new Error(`Amount exceeds your private balance (you have ${total}).`);
|
|
1658
|
+
}
|
|
1659
|
+
return { commitments: selected.map((r) => r.commitment), changeAmount: total - amount };
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Reconciles shields whose `pollTransactionResult` never got to finish (the extension's service
|
|
1663
|
+
* worker was killed between submission and the storage write) — see `PendingShield`'s doc
|
|
1664
|
+
* comment. Checks each pending shield's current status *once* (not a blocking poll-to-finalize
|
|
1665
|
+
* loop — if it's still genuinely pending, this leaves it for the next check rather than stalling
|
|
1666
|
+
* whatever triggered recovery, typically `popup-get-status`) and either completes the storage
|
|
1667
|
+
* write (finalized successfully), drops it (rejected — no stealth output was ever created, so
|
|
1668
|
+
* there's nothing to recover), or leaves it alone (still pending). One provider is reused across
|
|
1669
|
+
* every pending shield, whichever local account they belong to — this is a read-only connection,
|
|
1670
|
+
* not account/signing-specific.
|
|
1671
|
+
*/
|
|
1672
|
+
export async function recoverPendingShields(provider) {
|
|
1673
|
+
const pending = await listPendingShields();
|
|
1674
|
+
for (const p of pending) {
|
|
1675
|
+
try {
|
|
1676
|
+
const response = await provider.getTransactionResult(p.transactionId);
|
|
1677
|
+
const result = response.result;
|
|
1678
|
+
if (result === "Pending")
|
|
1679
|
+
continue; // still in flight -- leave it for next time
|
|
1680
|
+
if ("Rejected" in result) {
|
|
1681
|
+
await removePendingShield(p.transactionId);
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
const outcome = result.Finalized.execution_result?.finalize.result;
|
|
1685
|
+
const succeeded = outcome && typeof outcome === "object" && "Accept" in outcome;
|
|
1686
|
+
if (succeeded) {
|
|
1687
|
+
await recordKnownVersions(response);
|
|
1688
|
+
if (p.ownCommitment) {
|
|
1689
|
+
await recordKnownShieldedOutput(p.accountId, p.resourceAddress, p.ownCommitment, BigInt(p.amount), p.transactionId, p.memo);
|
|
1690
|
+
}
|
|
1691
|
+
if (p.spentCommitments) {
|
|
1692
|
+
for (const commitment of p.spentCommitments) {
|
|
1693
|
+
await markShieldedOutputSpent(p.accountId, commitment);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
// Reject / AcceptFeeRejectRest: no stealth output was created either way, nothing to
|
|
1698
|
+
// recover -- just stop waiting on it.
|
|
1699
|
+
await removePendingShield(p.transactionId);
|
|
1700
|
+
}
|
|
1701
|
+
catch {
|
|
1702
|
+
// A transient network/indexer error checking one pending shield must not stop the rest
|
|
1703
|
+
// from being reconciled -- leave this one in the list, it'll be retried next time.
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Persists a `ShieldedOutputRecord` for a commitment already known locally (see
|
|
1709
|
+
* `extractOutputCommitment`'s doc comment for why this is preferred over scanning the finalized
|
|
1710
|
+
* transaction's `up_substates`) — the only lead back to it, since there is no client-side
|
|
1711
|
+
* scan/list API for stealth UTXOs (see confidential.ts's module doc).
|
|
1712
|
+
*/
|
|
1713
|
+
async function recordKnownShieldedOutput(accountId, resourceAddress, commitment, amount, transactionId, memo) {
|
|
1714
|
+
await addShieldedOutput({
|
|
1715
|
+
accountId,
|
|
1716
|
+
resourceAddress,
|
|
1717
|
+
commitment,
|
|
1718
|
+
amount: amount.toString(),
|
|
1719
|
+
transactionId,
|
|
1720
|
+
createdAt: Date.now(),
|
|
1721
|
+
spent: false,
|
|
1722
|
+
memo,
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
// The indexer's own view of a substate an account keeps touching (above all its own fee vault,
|
|
1726
|
+
// referenced by nearly every transaction) can lag behind the version our *own* just-confirmed
|
|
1727
|
+
// transaction produced — confirmed empirically: even a brand-new account, on its second-ever
|
|
1728
|
+
// transaction, got "Lock failure: vault:1 is DOWN" from `resolveInputs()` handing back version 1
|
|
1729
|
+
// when the true current version (from our own prior transaction's `up_substates`) was already
|
|
1730
|
+
// higher, and this reproduced identically across multiple fresh accounts and reloads. A stateful
|
|
1731
|
+
// wallet daemon avoids this because it tracks its own substate versions locally instead of
|
|
1732
|
+
// re-asking a remote indexer for state it just changed itself. This is that same idea — persisted
|
|
1733
|
+
// (not just an in-memory Map) specifically because an in-memory-only cache loses exactly the
|
|
1734
|
+
// knowledge that matters most across a reload, which is the scenario this was written to fix.
|
|
1735
|
+
let knownVersionsPromise = null;
|
|
1736
|
+
async function loadKnownVersions() {
|
|
1737
|
+
if (!knownVersionsPromise) {
|
|
1738
|
+
knownVersionsPromise = getKnownVersions().then((raw) => new Map(Object.entries(raw)));
|
|
1739
|
+
}
|
|
1740
|
+
return knownVersionsPromise;
|
|
1741
|
+
}
|
|
1742
|
+
/** Prefers our own record of a substate's version over whatever the indexer just reported, when
|
|
1743
|
+
* we have a newer one — see the block comment above for why. */
|
|
1744
|
+
async function applyKnownVersions(inputs) {
|
|
1745
|
+
const known = await loadKnownVersions();
|
|
1746
|
+
return inputs.map((input) => {
|
|
1747
|
+
const version = known.get(input.substate_id);
|
|
1748
|
+
return version !== undefined && (input.version === null || version > input.version) ? { ...input, version } : input;
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
/** Remembers the post-transaction version of every substate a *confirmed* (never a dry-run —
|
|
1752
|
+
* those don't reflect real state) transaction touched, so the next transaction that references
|
|
1753
|
+
* one of them doesn't have to trust the indexer's possibly-stale view of it. */
|
|
1754
|
+
async function recordKnownVersions(response) {
|
|
1755
|
+
const result = response.result;
|
|
1756
|
+
if (result === "Pending" || "Rejected" in result)
|
|
1757
|
+
return;
|
|
1758
|
+
const outcome = result.Finalized.execution_result?.finalize.result;
|
|
1759
|
+
const upSubstates = outcome && typeof outcome === "object" && "Accept" in outcome
|
|
1760
|
+
? outcome.Accept.up_substates
|
|
1761
|
+
: outcome && typeof outcome === "object" && "AcceptFeeRejectRest" in outcome
|
|
1762
|
+
? outcome.AcceptFeeRejectRest[0].up_substates
|
|
1763
|
+
: undefined;
|
|
1764
|
+
if (!upSubstates || upSubstates.length === 0)
|
|
1765
|
+
return;
|
|
1766
|
+
const updates = new Map();
|
|
1767
|
+
for (const [id, substate] of upSubstates)
|
|
1768
|
+
updates.set(id, substate.version);
|
|
1769
|
+
// Serialized through the same write queue as every other storage mutation (see storage.ts's
|
|
1770
|
+
// `serialized`) so two concurrent confirmations can't interleave their read-modify-write on
|
|
1771
|
+
// this key and lose an update, and so this write can't race a wallet wipe to leave stale version
|
|
1772
|
+
// numbers behind.
|
|
1773
|
+
await serialized(async () => {
|
|
1774
|
+
const known = await loadKnownVersions();
|
|
1775
|
+
for (const [id, version] of updates)
|
|
1776
|
+
known.set(id, version);
|
|
1777
|
+
await setKnownVersions(Object.fromEntries(known));
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* Forgets what we thought we knew about these substates' versions, after the chain has told us a
|
|
1782
|
+
* remembered one is unusable. Keeping it would make `applyKnownVersions` reassert it over every
|
|
1783
|
+
* future resolve, turning one bad write into a permanently stuck wallet.
|
|
1784
|
+
*/
|
|
1785
|
+
export async function forgetKnownVersions(substateIds) {
|
|
1786
|
+
if (substateIds.length === 0)
|
|
1787
|
+
return;
|
|
1788
|
+
const known = await loadKnownVersions();
|
|
1789
|
+
let changed = false;
|
|
1790
|
+
for (const id of substateIds)
|
|
1791
|
+
if (known.delete(id))
|
|
1792
|
+
changed = true;
|
|
1793
|
+
if (changed)
|
|
1794
|
+
await setKnownVersions(Object.fromEntries(known));
|
|
1795
|
+
}
|
|
1796
|
+
/** Drops the in-memory known-versions cache. Call this on wallet reset so a freshly-created wallet
|
|
1797
|
+
* can't inherit the previous wallet's cached version numbers — the persisted side of that wipe is
|
|
1798
|
+
* the caller's own responsibility (a serialized clear of whatever `KeyValueStore` backs `storage.ts`). */
|
|
1799
|
+
export function resetKnownVersions() {
|
|
1800
|
+
knownVersionsPromise = null;
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* Polls a submitted transaction's result to finalization (or rejection/timeout). Extracted from
|
|
1804
|
+
* `OotleAccount.submitReal()` so `shield()`/`unshield()` — which submit via a completely
|
|
1805
|
+
* different pipeline (`StealthTransfer`/`WalletStealthAuthorizer`/`submitTransaction`, not
|
|
1806
|
+
* `TransactionBuilder`) — share the exact same hardened Reject/AcceptFeeRejectRest/timeout
|
|
1807
|
+
* handling instead of a second, potentially-drifting copy of it.
|
|
1808
|
+
*/
|
|
1809
|
+
export async function pollTransactionResult(provider, transactionId, timeoutMs = 60_000) {
|
|
1810
|
+
const deadline = Date.now() + timeoutMs;
|
|
1811
|
+
for (;;) {
|
|
1812
|
+
const response = await provider.getTransactionResult(transactionId);
|
|
1813
|
+
const result = response.result;
|
|
1814
|
+
if (result === "Pending") {
|
|
1815
|
+
if (Date.now() > deadline)
|
|
1816
|
+
throw new Error(`Timed out waiting for transaction ${transactionId} to finalize.`);
|
|
1817
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1818
|
+
continue;
|
|
1819
|
+
}
|
|
1820
|
+
if ("Rejected" in result) {
|
|
1821
|
+
throw new Error(`Transaction ${transactionId} was rejected: ${result.Rejected.details}`);
|
|
1822
|
+
}
|
|
1823
|
+
const outcome = result.Finalized.execution_result?.finalize.result;
|
|
1824
|
+
if (outcome && typeof outcome === "object") {
|
|
1825
|
+
if ("Reject" in outcome) {
|
|
1826
|
+
throw new Error(`Transaction ${transactionId} was rejected: ${JSON.stringify(outcome.Reject)}`);
|
|
1827
|
+
}
|
|
1828
|
+
if ("AcceptFeeRejectRest" in outcome) {
|
|
1829
|
+
throw new Error(`Transaction ${transactionId} accepted the fee but rejected the rest: ${JSON.stringify(outcome.AcceptFeeRejectRest)}`);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
return response;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
function bytesToHex(bytes) {
|
|
1836
|
+
let out = "";
|
|
1837
|
+
for (const byte of bytes)
|
|
1838
|
+
out += byte.toString(16).padStart(2, "0");
|
|
1839
|
+
return out;
|
|
1840
|
+
}
|
|
1841
|
+
// `resolveInputs()` throws "Failed to find input \"<id>\": ... Verify the substate id is correct
|
|
1842
|
+
// (typo? wrong network?) or wait for the producing transaction to finalize." — the indexer client's
|
|
1843
|
+
// own wording for a plain 404 on `substatesGet`. Confirmed empirically this fires on a substate that
|
|
1844
|
+
// exists moments later: the indexer's committed view lags the version this account's *own* prior
|
|
1845
|
+
// transaction (or a template it just called into, e.g. a newly-created pool) produced by more than
|
|
1846
|
+
// one round trip, the same race `claimTestnetXtr()`'s "Lock failure" backoff exists for. Unlike
|
|
1847
|
+
// "wrong network"/"typo'd address", a genuinely-missing substate never starts existing no matter how
|
|
1848
|
+
// long this waits — but there is no way to distinguish the two from the message alone, so this
|
|
1849
|
+
// retries a bounded number of times and lets a persistent 404 surface as a real error afterward.
|
|
1850
|
+
const RESOLVE_INPUTS_NOT_YET_FINALIZED_PATTERN = /Failed to find input/i;
|
|
1851
|
+
export async function resolveInputsWithRetry(provider, requirements, retries = 5, retryDelayMs = 500) {
|
|
1852
|
+
for (let attempt = 0;; attempt++) {
|
|
1853
|
+
try {
|
|
1854
|
+
return await withTimeout(provider.resolveInputs(requirements), 15_000, "resolving inputs");
|
|
1855
|
+
}
|
|
1856
|
+
catch (e) {
|
|
1857
|
+
const isNotYetFinalized = e instanceof Error && RESOLVE_INPUTS_NOT_YET_FINALIZED_PATTERN.test(e.message);
|
|
1858
|
+
if (!isNotYetFinalized || attempt >= retries)
|
|
1859
|
+
throw e;
|
|
1860
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs * (attempt + 1)));
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Checks whether `substateId` already exists on-chain, tolerating a couple of retries in case it
|
|
1866
|
+
* just hasn't finalized on the indexer yet (a much smaller budget than `resolveInputsWithRetry`'s
|
|
1867
|
+
* default — this is used to *decide* whether to create something, not to wait out a known producer,
|
|
1868
|
+
* so a persistent 404 should read as "doesn't exist" quickly rather than stall the caller). Only
|
|
1869
|
+
* `resolveInputsWithRetry`'s specific "not yet finalized" 404 is treated as "missing" — any other
|
|
1870
|
+
* failure (network error, indexer down) propagates rather than being silently read as nonexistence,
|
|
1871
|
+
* since that misread would make `send()` prepend a `CreateAccount` for an account that actually
|
|
1872
|
+
* exists, which the engine rejects outright ("is already UP and conflicts with an existing output").
|
|
1873
|
+
*/
|
|
1874
|
+
export async function substateExists(provider, substateId) {
|
|
1875
|
+
try {
|
|
1876
|
+
await resolveInputsWithRetry(provider, [{ substate_id: substateId, version: null }], 2, 300);
|
|
1877
|
+
return true;
|
|
1878
|
+
}
|
|
1879
|
+
catch (e) {
|
|
1880
|
+
if (e instanceof Error && RESOLVE_INPUTS_NOT_YET_FINALIZED_PATTERN.test(e.message))
|
|
1881
|
+
return false;
|
|
1882
|
+
throw e;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
// Matches the address this engine names in a "not found" rejection. Confirmed two distinct
|
|
1886
|
+
// phrasings for what is structurally the same SubstateNotFound rejection, depending on *how* the
|
|
1887
|
+
// missing reference was hit: a plain call-target miss reads "At instruction #1: component_...
|
|
1888
|
+
// not found" (address before the phrase), while a template-internal reference (e.g. a confidential
|
|
1889
|
+
// vault's withdraw() needing an extra substate the instructions never named directly) reads
|
|
1890
|
+
// "...Template referenced substate but it was not found: resource_..." (address after). A
|
|
1891
|
+
// cross-template call (e.g. the DEX router calling into a pool component internally) reads
|
|
1892
|
+
// "Substate 'component_...' not found or is not a transaction input" — address quoted, so the
|
|
1893
|
+
// optional `['"]?` matters: without it, the quote character sits between the address and "not
|
|
1894
|
+
// found" and the whitespace-only pattern silently fails to match. Any of the `<kind>_<hex>`
|
|
1895
|
+
// address forms this SDK uses (component/resource/vault/transaction_receipt/...), always hex
|
|
1896
|
+
// after the last `_`.
|
|
1897
|
+
const MISSING_SUBSTATE_PATTERNS = [
|
|
1898
|
+
/\b([a-z_]+_[0-9a-f]{16,})['"]?\s+not found\b/i,
|
|
1899
|
+
/not found:\s*([a-z_]+_[0-9a-f]{16,})\b/i,
|
|
1900
|
+
];
|
|
1901
|
+
export function extractMissingSubstateAddress(message) {
|
|
1902
|
+
for (const pattern of MISSING_SUBSTATE_PATTERNS) {
|
|
1903
|
+
const match = pattern.exec(message);
|
|
1904
|
+
if (match)
|
|
1905
|
+
return match[1];
|
|
1906
|
+
}
|
|
1907
|
+
return null;
|
|
1908
|
+
}
|
|
1909
|
+
// Matches "Lock failure: Substate <id>:<version> is DOWN" — the exact substate and version that
|
|
1910
|
+
// was just consumed, letting the caller compute the next version (version + 1) deterministically
|
|
1911
|
+
// instead of re-querying a possibly-still-lagging indexer.
|
|
1912
|
+
const STALE_LOCK_PATTERN = /Substate ([a-z_]+_[0-9a-f]{16,}):(\d+) is (?:not found or )?DOWN/i;
|
|
1913
|
+
export function extractStaleLockVersion(message) {
|
|
1914
|
+
const match = STALE_LOCK_PATTERN.exec(message);
|
|
1915
|
+
return match ? { substateId: match[1], version: Number(match[2]) } : null;
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* A compact description of a resolved transaction, for attaching to a wasm parse failure.
|
|
1919
|
+
*
|
|
1920
|
+
* Deliberately structural rather than a full dump: field names and the shape of `inputs` are what
|
|
1921
|
+
* identify a mismatch against `UnsignedTransactionV1`, and a whole transaction (range proofs,
|
|
1922
|
+
* blobs) is far too big to carry in an error message.
|
|
1923
|
+
*/
|
|
1924
|
+
function describeResolvedTx(resolved) {
|
|
1925
|
+
try {
|
|
1926
|
+
const tx = resolved;
|
|
1927
|
+
const keys = Object.keys(tx).join(",");
|
|
1928
|
+
const inputs = Array.isArray(tx.inputs) ? tx.inputs : [];
|
|
1929
|
+
const sample = inputs.slice(0, 3).map((i) => JSON.stringify(i)).join(" ");
|
|
1930
|
+
const missing = ["network", "fee_instructions", "instructions", "inputs", "max_epoch", "is_seal_signer_authorized", "dry_run"]
|
|
1931
|
+
.filter((k) => !(k in tx));
|
|
1932
|
+
return [
|
|
1933
|
+
`keys=[${keys}]`,
|
|
1934
|
+
missing.length ? `MISSING=[${missing.join(",")}]` : null,
|
|
1935
|
+
`inputs=${inputs.length}${sample ? ` ${sample}` : ""}`,
|
|
1936
|
+
].filter(Boolean).join(" ");
|
|
1937
|
+
}
|
|
1938
|
+
catch {
|
|
1939
|
+
return "unavailable";
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* The signed transaction's shape, for a `sealTransaction` parse failure.
|
|
1944
|
+
*
|
|
1945
|
+
* `signTransaction` wraps the transaction as `{transaction, signatures}` and the signatures are
|
|
1946
|
+
* generated inside it, so they are the half of the payload the caller never sees — and a malformed
|
|
1947
|
+
* one fails the parse identically to a malformed transaction.
|
|
1948
|
+
*/
|
|
1949
|
+
function describeSignedTx(signed) {
|
|
1950
|
+
try {
|
|
1951
|
+
const s = signed;
|
|
1952
|
+
const sigs = Array.isArray(s.signatures) ? s.signatures : [];
|
|
1953
|
+
const shape = sigs.slice(0, 2).map((sig) => {
|
|
1954
|
+
const inner = sig?.signature;
|
|
1955
|
+
return `{public_key:${typeof sig?.public_key} signature:{public_nonce:${typeof inner?.public_nonce},signature:${typeof inner?.signature}}}`;
|
|
1956
|
+
}).join(" ");
|
|
1957
|
+
const tx = s.transaction;
|
|
1958
|
+
return `keys=[${Object.keys(s).join(",")}] signatures=${sigs.length}${shape ? ` ${shape}` : ""}` +
|
|
1959
|
+
(tx ? ` tx=${describeResolvedTx(tx)}` : "");
|
|
1960
|
+
}
|
|
1961
|
+
catch {
|
|
1962
|
+
return "unavailable";
|
|
1963
|
+
}
|
|
1964
|
+
}
|