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
262 changes: 262 additions & 0 deletions src/tempo/server/Charge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1995,6 +1995,268 @@ 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: 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(
Expand Down
39 changes: 36 additions & 3 deletions src/tempo/server/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function charge<const parameters extends charge.Parameters>(
waitForConfirmation = true,
} = parameters
const store = (parameters.store ?? Store.memory()) as Store.Store<charge.StoreItemMap>
const proofStore = parameters.store as Store.Store<charge.StoreItemMap> | undefined

const { recipient, feePayer, feePayerUrl } = Account.resolve(parameters)

Expand Down Expand Up @@ -172,6 +173,11 @@ export function charge<const parameters extends charge.Parameters>(
})
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',
Expand Down Expand Up @@ -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
/**
Expand Down Expand Up @@ -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<charge.StoreItemMap>,
Expand All @@ -521,6 +537,23 @@ async function markHashUsed(
await store.put(getHashStoreKey(hash), Date.now())
}

/** @internal */
async function assertProofUnused(
store: Store.Store<charge.StoreItemMap>,
challengeId: string,
): Promise<void> {
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<charge.StoreItemMap>,
challengeId: string,
): Promise<void> {
await store.put(getProofStoreKey(challengeId), Date.now())
}

/** @internal */
function toReceipt(receipt: TransactionReceipt) {
const { status, transactionHash } = receipt
Expand Down
Loading