mtok-verify 0.1.0 → 0.1.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/onchain.js +60 -5
- package/package.json +1 -1
package/onchain.js
CHANGED
|
@@ -97,6 +97,7 @@ export function createOnchainVerifier({ rpcUrl, rpcUrls, usdcAddress, expectedCh
|
|
|
97
97
|
// chunk/settlement is wrongly rejected as tx_not_found_or_pending. Retry the receipt
|
|
98
98
|
// a few times before giving up. Bounded so a genuinely-missing tx still fails fast.
|
|
99
99
|
receiptRetries = 3, receiptRetryMs = 700, sleepImpl = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
100
|
+
nowMs = () => Date.now(), // injectable clock for the optional draw-age guard (verifyDrawPaid maxPaidAgeMs).
|
|
100
101
|
} = {}) {
|
|
101
102
|
// Accept one URL, a comma-separated list, or an array — rpc() rotates across them
|
|
102
103
|
// so a single rate-limited or down RPC can't strand a settlement verification (#108).
|
|
@@ -178,7 +179,7 @@ export function createOnchainVerifier({ rpcUrl, rpcUrls, usdcAddress, expectedCh
|
|
|
178
179
|
for (let i = 0; i < logs.length; i++) {
|
|
179
180
|
if (consumed && consumed.has(i)) continue;
|
|
180
181
|
const l = logs[i];
|
|
181
|
-
if (lc(l.address) !== usdc || l.topics?.[0] !== TRANSFER_TOPIC || l.topics.length !== 3) continue;
|
|
182
|
+
if (lc(l.address) !== usdc || lc(l.topics?.[0]) !== TRANSFER_TOPIC || l.topics.length !== 3) continue;
|
|
182
183
|
if (topicToAddress(l.topics[2]) !== want) continue;
|
|
183
184
|
if (wantFrom && topicToAddress(l.topics[1]) !== wantFrom) continue;
|
|
184
185
|
sawRecipient = true;
|
|
@@ -217,6 +218,13 @@ export function createOnchainVerifier({ rpcUrl, rpcUrls, usdcAddress, expectedCh
|
|
|
217
218
|
sellerWallet,
|
|
218
219
|
feeRecipient,
|
|
219
220
|
minSellerAtomic = 0n,
|
|
221
|
+
// Optional draw-age guard (#580). A DrawPaid receipt is a PERMANENT chain fact, so a
|
|
222
|
+
// buyer who holds the request preimage can replay the same /chunk body long after
|
|
223
|
+
// paying; if the relay's local redemption record was pruned (retention lapse) or lost
|
|
224
|
+
// (in-memory restart), served.has() misses and a stale payment buys a fresh inference.
|
|
225
|
+
// When set, refuse a payment whose block is older than maxPaidAgeMs: a legitimate
|
|
226
|
+
// first-serve or honest retry happens seconds-to-minutes after payDraw, never days.
|
|
227
|
+
maxPaidAgeMs,
|
|
220
228
|
} = {}) {
|
|
221
229
|
if (!(await assertChain())) return { ok: false, reason: 'wrong_chain' };
|
|
222
230
|
const contract = lc(contractAddress);
|
|
@@ -224,14 +232,35 @@ export function createOnchainVerifier({ rpcUrl, rpcUrls, usdcAddress, expectedCh
|
|
|
224
232
|
const got = await fetchReceipt(txHash);
|
|
225
233
|
if (got.error) return { ok: false, reason: got.error };
|
|
226
234
|
|
|
227
|
-
const
|
|
235
|
+
const drawLogs = (got.receipt.logs || []).filter((l) =>
|
|
228
236
|
lc(l.address) === contract
|
|
229
237
|
&& isDrawPaidTopic(l.topics?.[0])
|
|
230
238
|
);
|
|
231
|
-
if (!
|
|
239
|
+
if (!drawLogs.length) return { ok: false, reason: 'no_draw_paid_event' };
|
|
232
240
|
|
|
233
|
-
|
|
234
|
-
|
|
241
|
+
// A single tx can carry MORE than one DrawPaid (a smart-contract buyer wallet that
|
|
242
|
+
// batches payDraw calls). Pick the log matching THIS draw's identity (bookingId + n)
|
|
243
|
+
// rather than the first: otherwise draw #2's fields get checked against draw #1's log
|
|
244
|
+
// and a fully-paid draw reads as booking_mismatch after the money already moved.
|
|
245
|
+
let event = null;
|
|
246
|
+
let matchedLog = null;
|
|
247
|
+
let sawDecodable = false;
|
|
248
|
+
for (const l of drawLogs) {
|
|
249
|
+
let e;
|
|
250
|
+
try { e = decodeDrawPaidLog(l); } catch { continue; }
|
|
251
|
+
sawDecodable = true;
|
|
252
|
+
if (bookingId != null && e.bookingId !== String(bookingId)) continue;
|
|
253
|
+
if (n != null && e.n !== Number(n)) continue;
|
|
254
|
+
event = e; matchedLog = l;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
if (!event) {
|
|
258
|
+
// Nothing matched (bookingId, n). If nothing even decoded, it's malformed; else fall
|
|
259
|
+
// back to the first decodable log so the field checks below still return a precise
|
|
260
|
+
// mismatch reason (back-compat for callers that omit bookingId/n).
|
|
261
|
+
if (!sawDecodable) return { ok: false, reason: 'malformed_draw_paid_event' };
|
|
262
|
+
for (const l of drawLogs) { try { event = decodeDrawPaidLog(l); matchedLog = l; break; } catch { /* keep looking */ } }
|
|
263
|
+
}
|
|
235
264
|
if (buyerAgentId != null && event.buyerAgentId !== String(buyerAgentId)) return { ok: false, reason: 'buyer_agent_mismatch' };
|
|
236
265
|
if (sellerAgentId != null && event.sellerAgentId !== String(sellerAgentId)) return { ok: false, reason: 'seller_agent_mismatch' };
|
|
237
266
|
if (bookingId != null && event.bookingId !== String(bookingId)) return { ok: false, reason: 'booking_mismatch' };
|
|
@@ -241,6 +270,32 @@ export function createOnchainVerifier({ rpcUrl, rpcUrls, usdcAddress, expectedCh
|
|
|
241
270
|
if (requestHash != null && lc(event.requestHash) !== lc(requestHash)) return { ok: false, reason: 'request_hash_mismatch' };
|
|
242
271
|
if (BigInt(event.sellerUsdAtomic) < BigInt(minSellerAtomic)) return { ok: false, reason: 'amount_too_low' };
|
|
243
272
|
|
|
273
|
+
// #580 draw-age guard: refuse a payment older than maxPaidAgeMs so a stale replay
|
|
274
|
+
// can't buy a fresh serve after the relay's local redemption record lapsed or was
|
|
275
|
+
// lost. This is DEFENSE-IN-DEPTH on top of the relay's served.has() one-serve guard,
|
|
276
|
+
// so it only REJECTS when it actually reads an age past the bound. If the paid block
|
|
277
|
+
// can't be read (missing blockNumber, or a transient RPC failure even after retries),
|
|
278
|
+
// it SKIPS the freshness check rather than reject: the payment was already fully
|
|
279
|
+
// verified above, and rejecting would make the SDK auto-DISPUTE a good draw over an RPC
|
|
280
|
+
// blip, burning an honest buyer's USDC and busting an innocent seller's reputation.
|
|
281
|
+
// Opt-in: unset => no extra RPC, exact current behavior.
|
|
282
|
+
const maxAge = Number(maxPaidAgeMs);
|
|
283
|
+
if (Number.isFinite(maxAge) && maxAge > 0) {
|
|
284
|
+
const bn = matchedLog?.blockNumber;
|
|
285
|
+
let paidAtMs = null;
|
|
286
|
+
if (bn != null) {
|
|
287
|
+
const blockTag = (typeof bn === 'string' && bn.startsWith('0x')) ? bn : '0x' + BigInt(bn).toString(16);
|
|
288
|
+
for (let i = 0; i <= receiptRetries; i++) {
|
|
289
|
+
try {
|
|
290
|
+
const block = await rpc('eth_getBlockByNumber', [blockTag, false]);
|
|
291
|
+
if (block?.timestamp != null) { paidAtMs = Number(BigInt(block.timestamp)) * 1000; break; }
|
|
292
|
+
} catch { /* transient: fall through to retry */ }
|
|
293
|
+
if (i < receiptRetries) await sleepImpl(receiptRetryMs);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (paidAtMs != null && nowMs() - paidAtMs > maxAge) return { ok: false, reason: 'payment_too_old' };
|
|
297
|
+
}
|
|
298
|
+
|
|
244
299
|
// #(fable review): bind each leg's `from` to the pay-time buyer (event.buyer,
|
|
245
300
|
// emitted on v2; undefined on v1 -> no from-check, backward-compatible), and
|
|
246
301
|
// CONSUME the seller-leg log so the fee leg can't be satisfied by the SAME transfer
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mtok-verify",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "The on-chain verify core for mtok.market sellers: decode + verify a MtokDripLedger DrawPaid receipt (and its USDC transfer legs) against a paid draw, with the canonical DrawPaid topic set. Dependency-free, runs anywhere fetch runs (node, a CF Worker). The SSOT the platform + reference relay + house seller all share.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|