mtok-verify 0.1.0 → 0.1.1

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.
Files changed (2) hide show
  1. package/onchain.js +52 -5
  2. 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 log = (got.receipt.logs || []).find((l) =>
235
+ const drawLogs = (got.receipt.logs || []).filter((l) =>
228
236
  lc(l.address) === contract
229
237
  && isDrawPaidTopic(l.topics?.[0])
230
238
  );
231
- if (!log) return { ok: false, reason: 'no_draw_paid_event' };
239
+ if (!drawLogs.length) return { ok: false, reason: 'no_draw_paid_event' };
232
240
 
233
- let event;
234
- try { event = decodeDrawPaidLog(log); } catch { return { ok: false, reason: 'malformed_draw_paid_event' }; }
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,24 @@ 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. Fail CLOSED on an unreadable block (the receipt just read fine on this same
276
+ // RPC pool, so this is a money guard, not a liveness knob). Opt-in: unset => no
277
+ // extra RPC, exact current behavior.
278
+ const maxAge = Number(maxPaidAgeMs);
279
+ if (Number.isFinite(maxAge) && maxAge > 0) {
280
+ const bn = matchedLog?.blockNumber;
281
+ if (bn == null) return { ok: false, reason: 'paid_block_unknown' };
282
+ let paidAtMs;
283
+ try {
284
+ const blockTag = (typeof bn === 'string' && bn.startsWith('0x')) ? bn : '0x' + BigInt(bn).toString(16);
285
+ const block = await rpc('eth_getBlockByNumber', [blockTag, false]);
286
+ paidAtMs = Number(BigInt(block.timestamp)) * 1000;
287
+ } catch { return { ok: false, reason: 'paid_block_unreadable' }; }
288
+ if (nowMs() - paidAtMs > maxAge) return { ok: false, reason: 'payment_too_old' };
289
+ }
290
+
244
291
  // #(fable review): bind each leg's `from` to the pay-time buyer (event.buyer,
245
292
  // emitted on v2; undefined on v1 -> no from-check, backward-compatible), and
246
293
  // 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.0",
3
+ "version": "0.1.1",
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": {