Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/server/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function charge(parameters: ServerParameters) {
store: storeInput,
submitter,
customToken,
maxReceiptAgeSeconds,
} = parameters

if (parameters.token && customToken) {
Expand Down Expand Up @@ -260,6 +261,7 @@ export function charge(parameters: ServerParameters) {
store,
confirmations,
expectedChainId: chainId,
maxReceiptAgeSeconds,
})
}

Expand Down
76 changes: 76 additions & 0 deletions src/server/verifiers/hash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ function stubClient(parameters: {
receiptBlock?: bigint
latestBlock?: bigint
logs?: ReturnType<typeof buildTransferLog>[]
blockTimestamp?: bigint
}): PublicClient {
const {
receiptStatus = 'success',
receiptBlock = 100n,
latestBlock = 110n,
logs = [buildTransferLog({})],
blockTimestamp = BigInt(Math.floor(Date.now() / 1000)),
} = parameters
return {
getTransactionReceipt: async () => ({
Expand All @@ -57,6 +59,7 @@ function stubClient(parameters: {
transactionHash: TX_HASH,
}),
getBlockNumber: async () => latestBlock,
getBlock: async () => ({ timestamp: blockTimestamp }),
} as unknown as PublicClient
}

Expand Down Expand Up @@ -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(
Expand Down
14 changes: 13 additions & 1 deletion src/server/verifiers/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ export async function verifyHash(parameters: {
store: ChargeStore
confirmations: number
expectedChainId: number
maxReceiptAgeSeconds?: number | undefined
}): Promise<Receipt.Receipt> {
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

Expand Down Expand Up @@ -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',
Expand Down
18 changes: 18 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,24 @@ export type ServerParameters = {
store?: import('mppx').Store.AtomicStore<ChargeReplayItemMap>
/** 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 = {
Expand Down
Loading