diff --git a/README.md b/README.md index 3e3e2e0..8fe4a7b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ with all three non-trivial credential types: | `authorization` | Strong (on-chain nonce) | Server pays | One signature, USDC / EIP-3009 tokens | | `hash` | Weakest (post-hoc receipt match) | Client pays | Client broadcasts + waits | +> [!CAUTION] +> The `hash` credential is **post-hoc receipt matching only** — it binds nothing to the specific challenge. Any historical Transfer to the recipient that matches the requested token + amount can be claimed as proof of payment, once each. To narrow the replay window, set `maxReceiptAgeSeconds` on the server (see [Configuration](#configuration)). Even then, concurrent third-party payments to the same recipient for the same amount within the window can still leak through. For payments where stronger binding matters, prefer `permit2` or `authorization`. + ## Contents - [Install](#install) @@ -135,6 +138,7 @@ To avoid the limit entirely, pass your own `rpcUrl` from any Quicknode plan. | `token` | | `'USDC'` | Curated symbol: `USDC \| EURC \| WETH \| USDT`. Mutually exclusive with `customToken`. | | `customToken` | | — | Caller-supplied `{ address, decimals, symbol?, name?, version?, credentialTypes? }`. Use for any ERC-20 by address, or for native (zero-address). See below. | | `confirmations` | | per-chain default | Block-depth check for `hash` credential | +| `maxReceiptAgeSeconds` | | — | If set, rejects `hash` credentials whose receipt block is older than N seconds at verification time. Closes the historical-Transfer-replay class. Recommended ≥ slowest expected confirmation window (e.g. 600 for L1, 60 for fast L2). | | `store` | | `Store.memory()` | Any mppx `AtomicStore` (Cloudflare KV, Redis, Upstash) | ### `evm.charge` (client) diff --git a/src/server/Charge.ts b/src/server/Charge.ts index 21582d5..632bbf6 100644 --- a/src/server/Charge.ts +++ b/src/server/Charge.ts @@ -56,6 +56,7 @@ export function charge(parameters: ServerParameters) { store: storeInput, submitter, customToken, + maxReceiptAgeSeconds, } = parameters if (parameters.token && customToken) { @@ -260,6 +261,7 @@ export function charge(parameters: ServerParameters) { store, confirmations, expectedChainId: chainId, + maxReceiptAgeSeconds, }) } diff --git a/src/server/verifiers/hash.test.ts b/src/server/verifiers/hash.test.ts index e2d9842..10c8f53 100644 --- a/src/server/verifiers/hash.test.ts +++ b/src/server/verifiers/hash.test.ts @@ -42,12 +42,14 @@ function stubClient(parameters: { receiptBlock?: bigint latestBlock?: bigint logs?: ReturnType[] + blockTimestamp?: bigint }): PublicClient { const { receiptStatus = 'success', receiptBlock = 100n, latestBlock = 110n, logs = [buildTransferLog({})], + blockTimestamp = BigInt(Math.floor(Date.now() / 1000)), } = parameters return { getTransactionReceipt: async () => ({ @@ -57,6 +59,7 @@ function stubClient(parameters: { transactionHash: TX_HASH, }), getBlockNumber: async () => latestBlock, + getBlock: async () => ({ timestamp: blockTimestamp }), } as unknown as PublicClient } @@ -185,6 +188,79 @@ test('verifyHash replay-rejects the same txHash on second call', async () => { ) }) +test('verifyHash without maxReceiptAgeSeconds accepts a years-old receipt (current behavior preserved)', async () => { + const store = Store.memory() as ChargeStore + const ancientTimestamp = BigInt(Math.floor(Date.now() / 1000) - 3 * 365 * 24 * 60 * 60) + const receipt = await verifyHash({ + payload: { type: 'hash', txHash: TX_HASH, chainId: 84532 }, + request: baseRequest(), + client: stubClient({ blockTimestamp: ancientTimestamp }), + store, + confirmations: 1, + expectedChainId: 84532, + }) + assert.equal(receipt.status, 'success') +}) + +test('verifyHash with maxReceiptAgeSeconds accepts a fresh receipt within the window', async () => { + const store = Store.memory() as ChargeStore + const fiveSecondsAgo = BigInt(Math.floor(Date.now() / 1000) - 5) + const receipt = await verifyHash({ + payload: { type: 'hash', txHash: TX_HASH, chainId: 84532 }, + request: baseRequest(), + client: stubClient({ blockTimestamp: fiveSecondsAgo }), + store, + confirmations: 1, + expectedChainId: 84532, + maxReceiptAgeSeconds: 600, + }) + assert.equal(receipt.status, 'success') +}) + +test('verifyHash with maxReceiptAgeSeconds rejects a receipt older than the window', async () => { + const store = Store.memory() as ChargeStore + const tooOld = BigInt(Math.floor(Date.now() / 1000) - 3600) + await assert.rejects( + verifyHash({ + payload: { type: 'hash', txHash: TX_HASH, chainId: 84532 }, + request: baseRequest(), + client: stubClient({ blockTimestamp: tooOld }), + store, + confirmations: 1, + expectedChainId: 84532, + maxReceiptAgeSeconds: 600, + }), + /maxReceiptAgeSeconds/i, + ) +}) + +test('verifyHash releases reservation when maxReceiptAgeSeconds rejects, so a later fresh retry works', async () => { + const store = Store.memory() as ChargeStore + const tooOld = BigInt(Math.floor(Date.now() / 1000) - 3600) + await assert.rejects( + verifyHash({ + payload: { type: 'hash', txHash: TX_HASH, chainId: 84532 }, + request: baseRequest(), + client: stubClient({ blockTimestamp: tooOld }), + store, + confirmations: 1, + expectedChainId: 84532, + maxReceiptAgeSeconds: 600, + }), + ) + const fresh = BigInt(Math.floor(Date.now() / 1000) - 5) + const receipt = await verifyHash({ + payload: { type: 'hash', txHash: TX_HASH, chainId: 84532 }, + request: baseRequest(), + client: stubClient({ blockTimestamp: fresh }), + store, + confirmations: 1, + expectedChainId: 84532, + maxReceiptAgeSeconds: 600, + }) + assert.equal(receipt.status, 'success') +}) + test('verifyHash releases reservation on rejection (so a later valid retry works)', async () => { const store = Store.memory() as ChargeStore await assert.rejects( diff --git a/src/server/verifiers/hash.ts b/src/server/verifiers/hash.ts index 027d773..07e7e75 100644 --- a/src/server/verifiers/hash.ts +++ b/src/server/verifiers/hash.ts @@ -17,8 +17,10 @@ export async function verifyHash(parameters: { store: ChargeStore confirmations: number expectedChainId: number + maxReceiptAgeSeconds?: number | undefined }): Promise { - const { payload, request, client, store, confirmations, expectedChainId } = parameters + const { payload, request, client, store, confirmations, expectedChainId, maxReceiptAgeSeconds } = + parameters const { txHash, chainId } = payload const { amount, currency, recipient, externalId } = request @@ -90,6 +92,16 @@ export async function verifyHash(parameters: { }) } + if (maxReceiptAgeSeconds !== undefined) { + const block = await client.getBlock({ blockNumber: receipt.blockNumber }) + const ageSec = Math.floor(Date.now() / 1000) - Number(block.timestamp) + if (ageSec > maxReceiptAgeSeconds) { + throw new Errors.VerificationFailedError({ + reason: `Receipt is ${ageSec}s old; exceeds maxReceiptAgeSeconds=${maxReceiptAgeSeconds}`, + }) + } + } + return Receipt.from({ method: 'evm', status: 'success', diff --git a/src/types.ts b/src/types.ts index 6910cee..ad6e65a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -118,6 +118,24 @@ export type ServerParameters = { store?: import('mppx').Store.AtomicStore /** Block confirmations required for the hash credential. @default per-chain value in DEFAULT_CONFIRMATIONS */ confirmations?: number + /** + * Reject `hash` credentials whose on-chain receipt is older than this many + * seconds at verification time (block timestamp vs `Date.now()`). + * + * Without this set, the verifier accepts any historical Transfer to the + * recipient that matches token + amount — including transfers that + * occurred long before the challenge was issued, since the credential + * binds nothing to the specific challenge. Setting a window closes that + * historical-replay class. + * + * Pick a value larger than your slowest expected confirmation window + * (e.g. ≥ 600s for L1 mainnet at 12 confirmations, ~60s for fast L2s). + * Too tight rejects legitimate payments that took unusually long to + * confirm; too loose widens the replay window. + * + * @default undefined (no age limit) + */ + maxReceiptAgeSeconds?: number } export type ClientParameters = {