From 074a4ef23262c12b7e99b6e04676595abbb68a03 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 31 Mar 2026 09:00:40 -0700 Subject: [PATCH 1/3] fix: add configurable zero-dollar proof replay protection --- README.md | 27 +++++++++ src/tempo/server/Charge.test.ts | 104 ++++++++++++++++++++++++++++++++ src/tempo/server/Charge.ts | 39 +++++++++++- 3 files changed, 167 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a625c045..575ce46b 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,33 @@ Mppx.create({ const res = await fetch('https://mpp.dev/api/ping/paid') ``` +## Replay Protection for $0 Auth + +`tempo.charge({ amount: '0' })` uses a signed proof credential instead of an on-chain transfer. By default, `mppx` does not persist proof usage, so a valid zero-dollar proof can be replayed until the challenge expires. + +If you want single-use zero-dollar auth, provide a `store` and `mppx` will consume the challenge ID after the first successful proof verification: + +```ts +import { Mppx, Store, tempo } from 'mppx/server' + +const replayStore = Store.memory() + +const mppx = Mppx.create({ + methods: [ + tempo.charge({ + currency: '0x20c0000000000000000000000000000000000000', + recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + store: replayStore, + }), + ], +}) +``` + +- `Store.memory()` is a good fit for local development, tests, or a single long-lived server process. Replay prevention only applies inside that process and is lost on restart. +- Use `Store.redis()`, `Store.upstash()`, or `Store.cloudflare()` when you need replay prevention to survive restarts or apply across multiple server instances. +- If no store is configured for zero-dollar auth, the proof remains reusable until expiry. Existing challenge binding and route/request verification still apply, but the proof is not treated as single-use. +- Multi-instance deployments that want cross-instance replay prevention should use a shared store so consumed proofs are visible everywhere. + ## Examples | Example | Description | diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index 1d3cc2ec..9c19387b 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -1995,6 +1995,110 @@ describe('tempo', () => { httpServer.close() }) + test('behavior: proof credential remains reusable until expiry without store', async () => { + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + server.charge({ amount: '0', decimals: 6 }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response1 = await fetch(httpServer.url) + expect(response1.status).toBe(402) + + const challenge = Challenge.fromResponse(response1, { + methods: [tempo_client.charge()], + }) + + const signature = await signTypedData(client, { + account: accounts[1], + domain: Proof.domain(chain.id), + types: Proof.types, + primaryType: 'Proof', + message: Proof.message(challenge.id), + }) + + const credential = Credential.from({ + challenge, + payload: { signature, type: 'proof' as const }, + source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`, + }) + + const response2 = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(response2.status).toBe(200) + + const replayResponse = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(replayResponse.status).toBe(200) + + httpServer.close() + }) + + test('behavior: rejects replayed proof credential when store is configured', async () => { + const replayStore = Store.memory() + const server_ = Mppx_server.create({ + methods: [ + tempo_server.charge({ + getClient() { + return client + }, + currency: asset, + account: accounts[0], + store: replayStore, + }), + ], + realm, + secretKey, + }) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + server_.charge({ amount: '0', decimals: 6 }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response1 = await fetch(httpServer.url) + expect(response1.status).toBe(402) + + const challenge = Challenge.fromResponse(response1, { + methods: [tempo_client.charge()], + }) + + const signature = await signTypedData(client, { + account: accounts[1], + domain: Proof.domain(chain.id), + types: Proof.types, + primaryType: 'Proof', + message: Proof.message(challenge.id), + }) + + const credential = Credential.from({ + challenge, + payload: { signature, type: 'proof' as const }, + source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`, + }) + + const response2 = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(response2.status).toBe(200) + + const replayResponse = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(replayResponse.status).toBe(402) + const replayBody = (await replayResponse.json()) as { detail: string } + expect(replayBody.detail).toContain('Proof credential has already been used.') + + httpServer.close() + }) + test('behavior: rejects proof with wrong signer', async () => { const httpServer = await Http.createServer(async (req, res) => { const result = await Mppx_server.toNodeListener( diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index ecf44f37..f40acc35 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -51,6 +51,7 @@ export function charge( waitForConfirmation = true, } = parameters const store = (parameters.store ?? Store.memory()) as Store.Store + const proofStore = parameters.store as Store.Store | undefined const { recipient, feePayer, feePayerUrl } = Account.resolve(parameters) @@ -172,6 +173,11 @@ export function charge( }) if (!valid) throw new MismatchError('Proof signature does not match source.', {}) + if (proofStore) { + await assertProofUnused(proofStore, challenge.id) + await markProofUsed(proofStore, challenge.id) + } + return { method: 'tempo', status: 'success', @@ -285,10 +291,15 @@ export declare namespace charge { /** Testnet mode. */ testnet?: boolean | undefined /** - * Store for transaction hash replay protection. + * Store for charge replay protection. * - * Use a shared store in multi-instance deployments so consumed hashes are - * visible across all server instances. + * Non-zero charge flows default to an in-memory store if omitted. For + * zero-dollar proof auth, replay prevention is enabled only when a store + * is explicitly provided; otherwise proofs remain reusable until the + * challenge expires. + * + * Use a shared store in multi-instance deployments so consumed hashes and + * proofs are visible across all server instances. */ store?: Store.Store | undefined /** @@ -504,6 +515,11 @@ function getHashStoreKey(hash: `0x${string}`): `mppx:charge:${string}` { return `mppx:charge:${hash.toLowerCase()}` } +/** @internal */ +function getProofStoreKey(challengeId: string): `mppx:charge:${string}` { + return `mppx:charge:proof:${challengeId}` +} + /** @internal */ async function assertHashUnused( store: Store.Store, @@ -521,6 +537,23 @@ async function markHashUsed( await store.put(getHashStoreKey(hash), Date.now()) } +/** @internal */ +async function assertProofUnused( + store: Store.Store, + challengeId: string, +): Promise { + const seen = await store.get(getProofStoreKey(challengeId)) + if (seen !== null) throw new Error('Proof credential has already been used.') +} + +/** @internal */ +async function markProofUsed( + store: Store.Store, + challengeId: string, +): Promise { + await store.put(getProofStoreKey(challengeId), Date.now()) +} + /** @internal */ function toReceipt(receipt: TransactionReceipt) { const { status, transactionHash } = receipt From 1e93aba5859c9f1c4204509662c784b5cb08f67e Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 31 Mar 2026 09:06:04 -0700 Subject: [PATCH 2/3] docs: move zero-dollar replay docs to mpp site --- README.md | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/README.md b/README.md index 575ce46b..a625c045 100644 --- a/README.md +++ b/README.md @@ -78,33 +78,6 @@ Mppx.create({ const res = await fetch('https://mpp.dev/api/ping/paid') ``` -## Replay Protection for $0 Auth - -`tempo.charge({ amount: '0' })` uses a signed proof credential instead of an on-chain transfer. By default, `mppx` does not persist proof usage, so a valid zero-dollar proof can be replayed until the challenge expires. - -If you want single-use zero-dollar auth, provide a `store` and `mppx` will consume the challenge ID after the first successful proof verification: - -```ts -import { Mppx, Store, tempo } from 'mppx/server' - -const replayStore = Store.memory() - -const mppx = Mppx.create({ - methods: [ - tempo.charge({ - currency: '0x20c0000000000000000000000000000000000000', - recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', - store: replayStore, - }), - ], -}) -``` - -- `Store.memory()` is a good fit for local development, tests, or a single long-lived server process. Replay prevention only applies inside that process and is lost on restart. -- Use `Store.redis()`, `Store.upstash()`, or `Store.cloudflare()` when you need replay prevention to survive restarts or apply across multiple server instances. -- If no store is configured for zero-dollar auth, the proof remains reusable until expiry. Existing challenge binding and route/request verification still apply, but the proof is not treated as single-use. -- Multi-instance deployments that want cross-instance replay prevention should use a shared store so consumed proofs are visible everywhere. - ## Examples | Example | Description | From 851ac5f7c6046c14c65de216097a45569aca2513 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 31 Mar 2026 09:08:32 -0700 Subject: [PATCH 3/3] test: cover proof replay store boundaries --- src/tempo/server/Charge.test.ts | 158 ++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index 9c19387b..e1426e12 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -2099,6 +2099,164 @@ describe('tempo', () => { httpServer.close() }) + test('behavior: shared store rejects proof replay across server instances', async () => { + const replayStore = Store.memory() + const serverA = Mppx_server.create({ + methods: [ + tempo_server.charge({ + getClient() { + return client + }, + currency: asset, + account: accounts[0], + store: replayStore, + }), + ], + realm, + secretKey, + }) + const serverB = Mppx_server.create({ + methods: [ + tempo_server.charge({ + getClient() { + return client + }, + currency: asset, + account: accounts[0], + store: replayStore, + }), + ], + realm, + secretKey, + }) + + const httpServer = await Http.createServer(async (req, res) => { + const route = new URL(req.url!, 'https://example.com').pathname + const handler = route === '/a' ? serverA : serverB + const result = await Mppx_server.toNodeListener( + handler.charge({ amount: '0', decimals: 6 }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response1 = await fetch(`${httpServer.url}/a`) + expect(response1.status).toBe(402) + + const challenge = Challenge.fromResponse(response1, { + methods: [tempo_client.charge()], + }) + + const signature = await signTypedData(client, { + account: accounts[1], + domain: Proof.domain(chain.id), + types: Proof.types, + primaryType: 'Proof', + message: Proof.message(challenge.id), + }) + + const credential = Credential.from({ + challenge, + payload: { signature, type: 'proof' as const }, + source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`, + }) + + const response2 = await fetch(`${httpServer.url}/a`, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(response2.status).toBe(200) + + const replayResponse = await fetch(`${httpServer.url}/b`, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(replayResponse.status).toBe(402) + const replayBody = (await replayResponse.json()) as { detail: string } + expect(replayBody.detail).toContain('Proof credential has already been used.') + + httpServer.close() + }) + + test('behavior: store keys proof replay protection by challenge ID', async () => { + const replayStore = Store.memory() + const server_ = Mppx_server.create({ + methods: [ + tempo_server.charge({ + getClient() { + return client + }, + currency: asset, + account: accounts[0], + store: replayStore, + }), + ], + realm, + secretKey, + }) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + server_.charge({ amount: '0', decimals: 6 }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response1 = await fetch(httpServer.url) + expect(response1.status).toBe(402) + + const challenge1 = Challenge.fromResponse(response1, { + methods: [tempo_client.charge()], + }) + + const signature1 = await signTypedData(client, { + account: accounts[1], + domain: Proof.domain(chain.id), + types: Proof.types, + primaryType: 'Proof', + message: Proof.message(challenge1.id), + }) + + const credential1 = Credential.from({ + challenge: challenge1, + payload: { signature: signature1, type: 'proof' as const }, + source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`, + }) + + const response2 = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential1) }, + }) + expect(response2.status).toBe(200) + + const response3 = await fetch(httpServer.url) + expect(response3.status).toBe(402) + + const challenge2 = Challenge.fromResponse(response3, { + methods: [tempo_client.charge()], + }) + expect(challenge2.id).not.toBe(challenge1.id) + + const signature2 = await signTypedData(client, { + account: accounts[1], + domain: Proof.domain(chain.id), + types: Proof.types, + primaryType: 'Proof', + message: Proof.message(challenge2.id), + }) + + const credential2 = Credential.from({ + challenge: challenge2, + payload: { signature: signature2, type: 'proof' as const }, + source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`, + }) + + const response4 = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential2) }, + }) + expect(response4.status).toBe(200) + + httpServer.close() + }) + test('behavior: rejects proof with wrong signer', async () => { const httpServer = await Http.createServer(async (req, res) => { const result = await Mppx_server.toNodeListener(