agent402-tollbooth 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/index.js +25 -5
- package/package.json +1 -1
- package/tempo.js +152 -5
package/README.md
CHANGED
|
@@ -256,6 +256,23 @@ body. Challenges are HMAC-bound to your `TOLLBOOTH_SECRET`; each credential is
|
|
|
256
256
|
single-use (share a `replayStore` across workers). No new dependency: the
|
|
257
257
|
relay is spoken to over plain `fetch`.
|
|
258
258
|
|
|
259
|
+
**Chain-truth confirm (0.9.2):** a relay can report settlement failure for a
|
|
260
|
+
payment that actually landed - measured live: a buyer whose packed signature
|
|
261
|
+
ends with a yParity-style v byte (0x00/0x01) gets normalized by the Tempo
|
|
262
|
+
node, so the canonical txid stops matching the hash of the submitted bytes
|
|
263
|
+
and the relay's post-broadcast check refuses a settled payment; the buyer is
|
|
264
|
+
told 402 and retries into a double charge. On any broadcast failure the gate
|
|
265
|
+
now derives the only txids the credential's own signed bytes could have
|
|
266
|
+
landed under (submitted form + v-swapped twin - the txid commits to the
|
|
267
|
+
bytes, so the binding is exact) and, if one of them succeeded on-chain paying
|
|
268
|
+
your recipient at least the challenge amount in the challenge currency,
|
|
269
|
+
serves the response as paid. Verification only - nothing is re-broadcast, so
|
|
270
|
+
it can never double-charge; every uncertainty fails closed to the 402.
|
|
271
|
+
Options: `confirm: false` disables, `confirmRpcUrl` overrides the Tempo RPC
|
|
272
|
+
(default `https://rpc.tempo.xyz`, env `TOLLBOOTH_TEMPO_RPC_URL`). Still
|
|
273
|
+
dependency-free (keccak-256 is implemented in-package and pinned against the
|
|
274
|
+
live incident transaction in the tests).
|
|
275
|
+
|
|
259
276
|
## Configuration
|
|
260
277
|
|
|
261
278
|
| Option | Default | What |
|
package/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import { createPow } from "./pow.js";
|
|
|
19
19
|
import { makeBotMatcher, AI_BOTS } from "./bots.js";
|
|
20
20
|
import { memorySink } from "./sinks.js";
|
|
21
21
|
import { challengesFromPaymentRequired, translateCredential, receiptFromPaymentResponse, isMppCredential, DEFAULT_MPP_CHAIN_IDS } from "./mpp.js";
|
|
22
|
-
import { tempoConfig, mintTempoChallenges, parseTempoCredential, checkTempoBinding, tempoRelay, relayInput, broadcastIdempotencyKey, tempoReceiptHeader, tempoProblem } from "./tempo.js";
|
|
22
|
+
import { tempoConfig, mintTempoChallenges, parseTempoCredential, checkTempoBinding, tempoRelay, relayInput, broadcastIdempotencyKey, tempoReceiptHeader, tempoProblem, confirmTempoSettlement } from "./tempo.js";
|
|
23
23
|
|
|
24
24
|
export { AI_BOTS, makeBotMatcher } from "./bots.js";
|
|
25
25
|
export { createPow, leadingZeroBits } from "./pow.js";
|
|
@@ -349,7 +349,12 @@ export function createTollbooth(config = {}) {
|
|
|
349
349
|
if (!claimed) return reject("invalid-challenge", "Challenge is invalid: this credential was already used or is in flight. Request the resource again for a fresh challenge.");
|
|
350
350
|
const input = relayInput(cred);
|
|
351
351
|
const v = await tempoRelayClient.validate(input).catch((e) => ({ ok: false, error: String(e?.message || e) }));
|
|
352
|
-
if (!v.ok)
|
|
352
|
+
if (!v.ok) {
|
|
353
|
+
// Operator log gets the relay's full message (`detail`); the buyer's
|
|
354
|
+
// 402 problem gets status + code only (`error`) - never an upstream body.
|
|
355
|
+
console.warn(`[agent402-tollbooth] tempo credential rejected by validate (${req.method} ${req.url}): ${v.detail || v.error}`);
|
|
356
|
+
return reject("verification-failed", `Payment verification failed: ${String(v.error || "the Tempo relay rejected the credential").slice(0, 160)}.`);
|
|
357
|
+
}
|
|
353
358
|
// Buffer the handler's response (writeHead/write/end/flushHeaders) -
|
|
354
359
|
// Node's real 'finish' never fires while end is buffered, so the sync
|
|
355
360
|
// primitive is a promise resolved inside the buffered end.
|
|
@@ -382,15 +387,30 @@ export function createTollbooth(config = {}) {
|
|
|
382
387
|
try { next(); } catch (err) { restore(); return next(err); }
|
|
383
388
|
await endPromise;
|
|
384
389
|
if (res.statusCode >= 400) { restore(); replay(); return; } // handler failed: never broadcast, nobody charged
|
|
385
|
-
|
|
390
|
+
let b = await tempoRelayClient.broadcast(input, { idempotencyKey: broadcastIdempotencyKey(input) }).catch((e) => ({ ok: false, error: String(e?.message || e) }));
|
|
391
|
+
if (!b.ok && tempoCfg.confirm) {
|
|
392
|
+
// The relay's verdict and the chain's truth can diverge (measured
|
|
393
|
+
// live 2026-08-20: a yParity-style v byte the node normalizes makes
|
|
394
|
+
// the relay's post-broadcast hash check fail a payment that SETTLED;
|
|
395
|
+
// the buyer retried into a double charge). Ask the chain whether this
|
|
396
|
+
// credential's own transaction landed before discarding the response —
|
|
397
|
+
// exact txid derivation from the signed bytes, verification never a
|
|
398
|
+
// re-broadcast, fails closed to the 402. See tempo.js.
|
|
399
|
+
const check = tempoCfg.confirmSettlement || ((c) => confirmTempoSettlement(c, { rpcUrl: tempoCfg.confirmRpcUrl, fetchImpl: tempoCfg.fetch }));
|
|
400
|
+
const confirmed = await Promise.resolve(check(cred)).catch(() => null);
|
|
401
|
+
if (confirmed?.txId) {
|
|
402
|
+
console.warn(`[agent402-tollbooth] tempo relay reported settlement failure but the credential's transaction SETTLED on-chain (${req.method} ${req.url} tx=${confirmed.txId}) - honouring the settlement (verified from the chain, nothing re-broadcast). Relay said: ${b.detail || b.error}`);
|
|
403
|
+
b = { ok: true, receipt: { method: "tempo", reference: confirmed.txId } };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
386
406
|
if (!b.ok) {
|
|
387
407
|
// Broadcast failed AFTER a successful handler: discard the body, 402
|
|
388
408
|
// (mirrors @x402/express's settle-failure path). Loud - this is the
|
|
389
409
|
// one path that can be our latency rather than the relay's verdict.
|
|
390
|
-
console.warn(`[agent402-tollbooth] tempo broadcast failed after a successful handler (${req.method} ${req.url}) - answered 402, not charged: ${b.error}`);
|
|
410
|
+
console.warn(`[agent402-tollbooth] tempo broadcast failed after a successful handler (${req.method} ${req.url}) - answered 402, not charged: ${b.detail || b.error}`);
|
|
391
411
|
buffered = [];
|
|
392
412
|
restore();
|
|
393
|
-
return reject("verification-failed", `Payment verification failed: Tempo settlement was not accepted (${String(b.error || "no relay detail").slice(0,
|
|
413
|
+
return reject("verification-failed", `Payment verification failed: Tempo settlement was not accepted (${String(b.error || "no relay detail").slice(0, 160)}).`);
|
|
394
414
|
}
|
|
395
415
|
incr("tempoPaid");
|
|
396
416
|
restore();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Agent402 tollbooth: open-source, self-hostable pay-per-crawl for x402 AND MPP (the sell side of Agentic Finance): charge AI bots per request (USDC via x402, or MPP - the Machine Payments Protocol - through the same settlement, or USDG on Robinhood Chain, or free proof-of-work) while humans browse free. Drop-in Express middleware or reverse proxy that settles both wires from env alone - no Cloudflare, no Stripe, no signup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/tempo.js
CHANGED
|
@@ -93,6 +93,12 @@ export function tempoConfig(cfg = {}) {
|
|
|
93
93
|
fetch: cfg.fetch || globalThis.fetch,
|
|
94
94
|
relay: cfg.relay || null, // { validate(input) -> {ok, error?}, broadcast(input, {idempotencyKey}) -> {ok, receipt?, error?} } (tests)
|
|
95
95
|
relayTimeoutMs: Number.isFinite(cfg.relayTimeoutMs) ? cfg.relayTimeoutMs : 20_000,
|
|
96
|
+
// Chain-truth confirm on broadcast failure (see confirmTempoSettlement):
|
|
97
|
+
// `confirm: false` disables; `confirmRpcUrl` overrides the Tempo RPC;
|
|
98
|
+
// `confirmSettlement` injects the whole check (tests).
|
|
99
|
+
confirm: cfg.confirm !== false,
|
|
100
|
+
confirmRpcUrl: String(cfg.confirmRpcUrl || process.env.TOLLBOOTH_TEMPO_RPC_URL || "https://rpc.tempo.xyz").replace(/\/$/, ""),
|
|
101
|
+
confirmSettlement: typeof cfg.confirmSettlement === "function" ? cfg.confirmSettlement : null,
|
|
96
102
|
};
|
|
97
103
|
}
|
|
98
104
|
|
|
@@ -167,10 +173,18 @@ export function checkTempoBinding(credential, { secretKey, realm, price, tempo,
|
|
|
167
173
|
return { ok: true, amount };
|
|
168
174
|
}
|
|
169
175
|
|
|
176
|
+
// Buyer-facing relay failure: status + the relay's error CODE only. The
|
|
177
|
+
// relay's free-text `message` is an upstream body and goes to the operator
|
|
178
|
+
// log (console.warn at the call sites), never into the 402 problem document
|
|
179
|
+
// a buyer reads - a relay that echoes a key or account detail in an error
|
|
180
|
+
// must not hand it to every buyer. (Leak audit 2026-08-19.)
|
|
170
181
|
function relayFailure(res, body) {
|
|
171
182
|
const code = body && typeof body === "object" && body.error && typeof body.error === "object" && typeof body.error.code === "string" ? body.error.code : null;
|
|
183
|
+
return `relay HTTP ${res?.status ?? "?"}${code ? ` ${code.slice(0, 60)}` : ""}`;
|
|
184
|
+
}
|
|
185
|
+
function relayFailureDetail(res, body) {
|
|
172
186
|
const msg = body && typeof body === "object" && body.error && typeof body.error.message === "string" ? body.error.message : "";
|
|
173
|
-
return
|
|
187
|
+
return `${relayFailure(res, body)}${msg ? ` ${msg.slice(0, 160)}` : ""}`;
|
|
174
188
|
}
|
|
175
189
|
|
|
176
190
|
/** The relay client. Injectable `relay` (tests) takes precedence. */
|
|
@@ -190,17 +204,17 @@ export function tempoRelay(tempo) {
|
|
|
190
204
|
}
|
|
191
205
|
let body = null;
|
|
192
206
|
try { body = await res.json(); } catch { body = null; }
|
|
193
|
-
if (!res.ok || !body || body.success !== true) return { ok: false, error: relayFailure(res, body), body };
|
|
207
|
+
if (!res.ok || !body || body.success !== true) return { ok: false, error: relayFailure(res, body), detail: relayFailureDetail(res, body), body };
|
|
194
208
|
return { ok: true, body };
|
|
195
209
|
};
|
|
196
210
|
return {
|
|
197
211
|
async validate(input) {
|
|
198
212
|
const r = await post("validate", input);
|
|
199
|
-
return r.ok ? { ok: true } : { ok: false, error: r.error };
|
|
213
|
+
return r.ok ? { ok: true } : { ok: false, error: r.error, detail: r.detail || r.error };
|
|
200
214
|
},
|
|
201
215
|
async broadcast(input, { idempotencyKey }) {
|
|
202
216
|
const r = await post("broadcast", input, { "idempotency-key": idempotencyKey });
|
|
203
|
-
if (!r.ok) return { ok: false, error: r.error };
|
|
217
|
+
if (!r.ok) return { ok: false, error: r.error, detail: r.detail || r.error };
|
|
204
218
|
const receipt = r.body.receipt;
|
|
205
219
|
if (!receipt || typeof receipt !== "object" || typeof receipt.reference !== "string" || receipt.method !== "tempo") return { ok: false, error: "relay broadcast answered success without a tempo receipt" };
|
|
206
220
|
return { ok: true, receipt };
|
|
@@ -209,7 +223,16 @@ export function tempoRelay(tempo) {
|
|
|
209
223
|
}
|
|
210
224
|
|
|
211
225
|
export function relayInput(credential) {
|
|
212
|
-
|
|
226
|
+
// The relay's input is mppx's DESERIALIZED credential (Relay.js
|
|
227
|
+
// toRelayInput): the challenge with `request` as the decoded object, not
|
|
228
|
+
// the base64url string that rides on the wire. The first live proof
|
|
229
|
+
// (run 32295980187, 2026-08-19) sent the wire string and the relay refused
|
|
230
|
+
// every credential while the stub relay in the offline suite accepted
|
|
231
|
+
// either - the suite now pins the decoded shape.
|
|
232
|
+
const { request, ...challenge } = credential.challenge;
|
|
233
|
+
let decoded = request;
|
|
234
|
+
if (typeof request === "string") { try { decoded = JSON.parse(unb64url(request)); } catch { decoded = request; } }
|
|
235
|
+
return { challenge: { ...challenge, request: decoded }, payload: credential.payload, ...(credential.source ? { source: credential.source } : {}) };
|
|
213
236
|
}
|
|
214
237
|
export function broadcastIdempotencyKey(input) {
|
|
215
238
|
return `mpp_${createHash("sha256").update(canonicalJson(input), "utf8").digest("hex")}`;
|
|
@@ -223,3 +246,127 @@ export function tempoProblem(kind, detail) {
|
|
|
223
246
|
const titles = { "invalid-challenge": "Invalid Challenge", "verification-failed": "Verification Failed", "malformed-credential": "Malformed Credential", "payment-insufficient": "Payment Insufficient" };
|
|
224
247
|
return { type: `https://paymentauth.org/problems/${kind}`, title: titles[kind] || kind, status: 402, detail };
|
|
225
248
|
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Chain-truth confirm on broadcast failure (2026-08-20) — the relay's verdict
|
|
252
|
+
// and the chain's truth can diverge: a buyer whose packed signature ends with
|
|
253
|
+
// a yParity-style v byte (0x00/0x01) gets normalized to 27/28 by the Tempo
|
|
254
|
+
// node, so the canonical txid stops matching keccak(submitted bytes) and the
|
|
255
|
+
// relay's post-broadcast hash check reports `invalid_payment` for a payment
|
|
256
|
+
// that SETTLED (measured live: two double charges in one buy attempt).
|
|
257
|
+
// Before answering 402, ask the chain whether this credential's OWN
|
|
258
|
+
// transaction landed. The txid commits to the signed bytes, so the candidates
|
|
259
|
+
// are exact — the submitted form and its v-swapped twin — never a time-window
|
|
260
|
+
// or payer heuristic. Fails closed on every uncertainty. Dependency-free like
|
|
261
|
+
// the rest of this file: keccak-256 is implemented below (BigInt lanes — it
|
|
262
|
+
// runs at most once per FAILED broadcast, so speed is irrelevant) and pinned
|
|
263
|
+
// against standard vectors plus the live incident transaction in the tests.
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
const KECCAK_RC = [
|
|
267
|
+
"0x0000000000000001", "0x0000000000008082", "0x800000000000808a", "0x8000000080008000",
|
|
268
|
+
"0x000000000000808b", "0x0000000080000001", "0x8000000080008081", "0x8000000000008009",
|
|
269
|
+
"0x000000000000008a", "0x0000000000000088", "0x0000000080008009", "0x000000008000000a",
|
|
270
|
+
"0x000000008000808b", "0x800000000000008b", "0x8000000000008089", "0x8000000000008003",
|
|
271
|
+
"0x8000000000008002", "0x8000000000000080", "0x000000000000800a", "0x800000008000000a",
|
|
272
|
+
"0x8000000080008081", "0x8000000000008080", "0x0000000080000001", "0x8000000080008008",
|
|
273
|
+
].map(BigInt);
|
|
274
|
+
const KECCAK_RHO = [0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14];
|
|
275
|
+
const U64 = (1n << 64n) - 1n;
|
|
276
|
+
|
|
277
|
+
function keccakF(A) {
|
|
278
|
+
const rotl = (v, n) => n === 0n ? v : (((v << n) & U64) | (v >> (64n - n)));
|
|
279
|
+
for (let round = 0; round < 24; round++) {
|
|
280
|
+
const C = [0, 1, 2, 3, 4].map((x) => A[x] ^ A[x + 5] ^ A[x + 10] ^ A[x + 15] ^ A[x + 20]);
|
|
281
|
+
for (let x = 0; x < 5; x++) {
|
|
282
|
+
const D = C[(x + 4) % 5] ^ rotl(C[(x + 1) % 5], 1n);
|
|
283
|
+
for (let y = 0; y < 5; y++) A[x + 5 * y] ^= D;
|
|
284
|
+
}
|
|
285
|
+
const B = new Array(25);
|
|
286
|
+
for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) B[y + 5 * ((2 * x + 3 * y) % 5)] = rotl(A[x + 5 * y], BigInt(KECCAK_RHO[x + 5 * y]));
|
|
287
|
+
for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) A[x + 5 * y] = B[x + 5 * y] ^ ((~B[((x + 1) % 5) + 5 * y] & U64) & B[((x + 2) % 5) + 5 * y]);
|
|
288
|
+
A[0] ^= KECCAK_RC[round];
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** keccak-256 of a Buffer, as a 0x hex string. Original Keccak padding
|
|
293
|
+
* (0x01 … 0x80), NOT SHA-3's 0x06 — Ethereum txids use the former. */
|
|
294
|
+
export function keccak256Hex(buf) {
|
|
295
|
+
const rate = 136;
|
|
296
|
+
const padded = Buffer.alloc(Math.ceil((buf.length + 1) / rate) * rate);
|
|
297
|
+
buf.copy(padded);
|
|
298
|
+
padded[buf.length] = 0x01;
|
|
299
|
+
padded[padded.length - 1] |= 0x80;
|
|
300
|
+
const A = new Array(25).fill(0n);
|
|
301
|
+
for (let off = 0; off < padded.length; off += rate) {
|
|
302
|
+
for (let i = 0; i < rate / 8; i++) A[i] ^= padded.readBigUInt64LE(off + i * 8);
|
|
303
|
+
keccakF(A);
|
|
304
|
+
}
|
|
305
|
+
const out = Buffer.alloc(32);
|
|
306
|
+
for (let i = 0; i < 4; i++) out.writeBigUInt64LE(A[i], i * 8);
|
|
307
|
+
return `0x${out.toString("hex")}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The txids this signed Tempo transaction could have landed under: the
|
|
311
|
+
* submitted bytes, plus (when the envelope verifiably ends with the 65-byte
|
|
312
|
+
* packed signature — RLP string header 0xb841 at the right offset) the
|
|
313
|
+
* v-swapped twin: yParity (0/1) <-> legacy (27/28). */
|
|
314
|
+
export function candidateTxIds(signedTx) {
|
|
315
|
+
const hex = String(signedTx || "").toLowerCase();
|
|
316
|
+
if (!/^0x76[0-9a-f]{2,}$/.test(hex) || hex.length % 2 !== 0) return [];
|
|
317
|
+
const out = [keccak256Hex(Buffer.from(hex.slice(2), "hex"))];
|
|
318
|
+
// b841 = RLP header for a 65-byte string; 134 hex chars = header + 65 bytes.
|
|
319
|
+
const sigIsLast = hex.length >= 138 && hex.slice(-134, -130) === "b841";
|
|
320
|
+
if (!sigIsLast) return out;
|
|
321
|
+
const swap = { "00": "1b", "01": "1c", "1b": "00", "1c": "01" }[hex.slice(-2)];
|
|
322
|
+
if (swap) out.push(keccak256Hex(Buffer.from(hex.slice(2, -2) + swap, "hex")));
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
327
|
+
|
|
328
|
+
/** Did this credential's transaction settle despite the relay's verdict?
|
|
329
|
+
* Returns { txId } when a candidate receipt exists, succeeded (status 0x1),
|
|
330
|
+
* and carries the challenge's transfer (currency + recipient + >= amount) —
|
|
331
|
+
* else null. Polls briefly (a just-mined tx may not be indexed when the
|
|
332
|
+
* relay answers). Never throws. */
|
|
333
|
+
export async function confirmTempoSettlement(credential, { rpcUrl, fetchImpl = globalThis.fetch, attempts = 4, delayMs = 2000 } = {}) {
|
|
334
|
+
try {
|
|
335
|
+
const payload = credential?.payload;
|
|
336
|
+
if (payload?.type !== "transaction" || typeof payload.signature !== "string") return null;
|
|
337
|
+
let request = credential?.challenge?.request;
|
|
338
|
+
if (typeof request === "string") { try { request = JSON.parse(unb64url(request)); } catch { return null; } }
|
|
339
|
+
const currency = String(request?.currency || "").toLowerCase();
|
|
340
|
+
const recipient = String(request?.recipient || "").toLowerCase();
|
|
341
|
+
let minAmount;
|
|
342
|
+
try { minAmount = BigInt(String(request?.amount)); } catch { return null; }
|
|
343
|
+
if (!currency.startsWith("0x") || !recipient.startsWith("0x") || !(minAmount > 0n)) return null;
|
|
344
|
+
const candidates = candidateTxIds(payload.signature);
|
|
345
|
+
if (!candidates.length) return null;
|
|
346
|
+
for (let i = 0; i < attempts; i++) {
|
|
347
|
+
if (i > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
348
|
+
for (const txId of candidates) {
|
|
349
|
+
let receipt;
|
|
350
|
+
try {
|
|
351
|
+
const res = await fetchImpl(rpcUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getTransactionReceipt", params: [txId] }) });
|
|
352
|
+
if (!res.ok) continue;
|
|
353
|
+
const body = await res.json();
|
|
354
|
+
if (body.error) continue;
|
|
355
|
+
receipt = body.result;
|
|
356
|
+
} catch { continue; }
|
|
357
|
+
if (!receipt || receipt.status !== "0x1") continue;
|
|
358
|
+
for (const log of receipt.logs || []) {
|
|
359
|
+
if (String(log.address || "").toLowerCase() !== currency) continue;
|
|
360
|
+
if ((log.topics || [])[0] !== TRANSFER_TOPIC) continue;
|
|
361
|
+
if (`0x${String(log.topics[2] || "").slice(-40)}`.toLowerCase() !== recipient) continue;
|
|
362
|
+
let value;
|
|
363
|
+
try { value = BigInt(log.data); } catch { continue; }
|
|
364
|
+
if (value >= minAmount) return { txId };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|