diff --git a/src/internal/NonceSet.test.ts b/src/internal/NonceSet.test.ts new file mode 100644 index 00000000..9eb7f488 --- /dev/null +++ b/src/internal/NonceSet.test.ts @@ -0,0 +1,35 @@ +import { NonceSet } from './NonceSet.js' + +describe('NonceSet', () => { + test('returns false for unknown nonce', () => { + const set = new NonceSet() + expect(set.has('unknown')).toBe(false) + }) + + test('returns true for added nonce', () => { + const set = new NonceSet() + set.add('nonce-1') + expect(set.has('nonce-1')).toBe(true) + }) + + test('returns false for expired nonce', () => { + const set = new NonceSet() + const pastExpires = new Date(Date.now() - 1000).toISOString() + set.add('expired', pastExpires) + expect(set.has('expired')).toBe(false) + }) + + test('returns true for non-expired nonce', () => { + const set = new NonceSet() + const futureExpires = new Date(Date.now() + 60_000).toISOString() + set.add('valid', futureExpires) + expect(set.has('valid')).toBe(true) + }) + + test('different nonces are independent', () => { + const set = new NonceSet() + set.add('nonce-a') + expect(set.has('nonce-a')).toBe(true) + expect(set.has('nonce-b')).toBe(false) + }) +}) diff --git a/src/internal/NonceSet.ts b/src/internal/NonceSet.ts new file mode 100644 index 00000000..87794bd7 --- /dev/null +++ b/src/internal/NonceSet.ts @@ -0,0 +1,45 @@ +/** Default TTL for nonces without an explicit expiration (5 minutes). */ +const DEFAULT_TTL = 5 * 60_000 + +/** Eviction threshold — run cleanup when map exceeds this size. */ +const EVICTION_THRESHOLD = 10_000 + +/** + * In-memory set for tracking used nonces with TTL-based eviction. + * + * Used for replay prevention — once a nonce is added, `has()` returns + * `true` until the TTL expires. + */ +export class NonceSet { + private entries = new Map() + + /** Returns `true` if the nonce has been recorded and has not expired. */ + has(nonce: string): boolean { + const expiry = this.entries.get(nonce) + if (expiry === undefined) return false + if (Date.now() > expiry) { + this.entries.delete(nonce) + return false + } + return true + } + + /** + * Records a nonce with an expiration time. + * + * @param nonce - The nonce to record. + * @param expires - Optional ISO 8601 expiration. If not provided, uses a default TTL. + */ + add(nonce: string, expires?: string): void { + const expiry = expires ? new Date(expires).getTime() : Date.now() + DEFAULT_TTL + this.entries.set(nonce, expiry) + if (this.entries.size > EVICTION_THRESHOLD) this.evict() + } + + private evict(): void { + const now = Date.now() + for (const [key, expiry] of this.entries) { + if (now > expiry) this.entries.delete(key) + } + } +} diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index a27b7075..bae6b8fb 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -11,6 +11,32 @@ const method = tempo({ getClient: () => client, }) +const replayBaseMethod = Method.from({ + name: 'mock', + intent: 'charge', + schema: { + credential: { + payload: z.object({ token: z.string() }), + }, + request: z.object({ + amount: z.string(), + currency: z.string(), + recipient: z.string(), + }), + }, +}) + +const replayMethod = Method.toServer(replayBaseMethod, { + async verify() { + return { + method: 'mock', + reference: 'tx-ref', + status: 'success' as const, + timestamp: new Date().toISOString(), + } + }, +}) + describe('create', () => { test('default', () => { const handler = Mppx.create({ methods: [method], realm, secretKey }) @@ -25,6 +51,39 @@ describe('create', () => { expect(handler.transport.name).toBe('mcp') }) + + test('injects a unique internal nonce into each issued challenge id', async () => { + const handler = Mppx.create({ methods: [replayMethod], realm, secretKey }) + + const issue = () => + handler.charge({ + amount: '1', + currency: asset, + recipient: accounts[0].address, + })(new Request('https://example.com/resource')) + + const [result1, result2] = await Promise.all([issue(), issue()]) + expect(result1.status).toBe(402) + expect(result2.status).toBe(402) + if (result1.status !== 402 || result2.status !== 402) throw new Error() + + const challenge1 = Challenge.fromResponse(result1.challenge) + const challenge2 = Challenge.fromResponse(result2.challenge) + expect(challenge1.id).not.toBe(challenge2.id) + }) + + test('rejects reserved internal meta key', () => { + const handler = Mppx.create({ methods: [replayMethod], realm, secretKey }) + + expect(() => + handler.charge({ + amount: '1', + currency: asset, + recipient: accounts[0].address, + meta: { mppx_challenge_nonce: 'reserved' }, + }), + ).toThrow('Reserved meta key "mppx_challenge_nonce" is not allowed.') + }) }) describe('request handler', () => { @@ -138,6 +197,59 @@ describe('request handler', () => { `) }) + test('returns 402 when the same challenge credential is replayed', async () => { + let verifyCount = 0 + const countedMethod = Method.toServer(replayBaseMethod, { + async verify() { + verifyCount += 1 + return { + method: 'mock', + reference: 'tx-ref', + status: 'success' as const, + timestamp: new Date().toISOString(), + } + }, + }) + const handler = Mppx.create({ methods: [countedMethod], realm, secretKey }) + const charge = handler.charge({ + amount: '1', + currency: asset, + recipient: accounts[0].address, + }) + + const challengeResponse = await charge(new Request('https://example.com/resource')) + expect(challengeResponse.status).toBe(402) + if (challengeResponse.status !== 402) throw new Error() + + const challenge = Challenge.fromResponse(challengeResponse.challenge) + const auth = Credential.serialize( + Credential.from({ + challenge, + payload: { token: 'valid' }, + }), + ) + + const success = await charge( + new Request('https://example.com/resource', { + headers: { Authorization: auth }, + }), + ) + expect(success.status).toBe(200) + expect(verifyCount).toBe(1) + + const replay = await charge( + new Request('https://example.com/resource', { + headers: { Authorization: auth }, + }), + ) + expect(replay.status).toBe(402) + expect(verifyCount).toBe(1) + if (replay.status !== 402) throw new Error() + + const body = (await replay.challenge.json()) as { detail: string } + expect(body.detail).toContain('credential has already been used') + }) + test('returns 402 when credential is from a different route (cross-route scope confusion)', async () => { const handler = Mppx.create({ methods: [method], realm, secretKey }) diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 9884b3c4..31fc8352 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -5,6 +5,7 @@ import * as Credential from '../Credential.js' import * as Errors from '../Errors.js' import * as Expires from '../Expires.js' import * as Env from '../internal/env.js' +import { NonceSet } from '../internal/NonceSet.js' import type * as Method from '../Method.js' import * as PaymentRequest from '../PaymentRequest.js' import type * as Receipt from '../Receipt.js' @@ -165,6 +166,7 @@ export function create< } const methods = config.methods.flat() as unknown as FlattenMethods + const nonceSet = new NonceSet() const handlers: Record = {} const intentCount: Record = {} @@ -174,6 +176,7 @@ export function create< handlers[`${mi.name}/${mi.intent}`] = createMethodFn({ defaults: mi.defaults, method: mi, + nonceSet, realm, request: mi.request as never, respond: mi.respond as never, @@ -253,10 +256,14 @@ function createMethodFn< ): createMethodFn.ReturnType // biome-ignore lint/correctness/noUnusedVariables: _ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.ReturnType { - const { defaults, method, realm, respond, secretKey, transport, verify } = parameters + const { defaults, method, nonceSet, realm, respond, secretKey, transport, verify } = parameters + + const internalMetaKey = 'mppx_challenge_nonce' return (options) => { const { description, meta, ...rest } = options + if (meta && internalMetaKey in meta) + throw new Error(`Reserved meta key "${internalMetaKey}" is not allowed.`) const merged = { ...defaults, ...rest } return Object.assign( @@ -289,7 +296,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R const challenge = Challenge.fromMethod(method, { description, expires, - meta, + meta: { + ...(meta ?? {}), + [internalMetaKey]: Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => + b.toString(16).padStart(2, '0'), + ).join(''), + }, realm, request, secretKey, @@ -315,6 +327,18 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R return { challenge: response, status: 402 } } + if (nonceSet.has(credential.challenge.id)) { + const response = await transport.respondChallenge({ + challenge, + input, + error: new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: 'credential has already been used', + }), + }) + return { challenge: response, status: 402 } + } + // Verify the echoed challenge was issued by us by recomputing its HMAC. // This is stateless—no database lookup needed. if (!Challenge.verify(credential.challenge, { secretKey })) { @@ -433,6 +457,8 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R return { challenge: response, status: 402 } } + nonceSet.add(credential.challenge.id, credential.challenge.expires) + // If the method's `respond` hook returns a Response, it means this // request is a management action (e.g. channel open, voucher POST) // and the user's route handler should NOT run. `withReceipt()` will @@ -483,6 +509,7 @@ declare namespace createMethodFn { > = { defaults?: defaults method: method + nonceSet: NonceSet realm: string request?: Method.RequestFn respond?: Method.RespondFn diff --git a/src/tempo/Attribution.test.ts b/src/tempo/Attribution.test.ts index 64804b8c..bb288c7a 100644 --- a/src/tempo/Attribution.test.ts +++ b/src/tempo/Attribution.test.ts @@ -1,8 +1,10 @@ -import { Bytes, Hash, Hex } from 'ox' +import { Base64, Bytes, Hash, Hex } from 'ox' import { describe, expect, test } from 'vitest' import * as Attribution from './Attribution.js' +const challengeId = 'aAY7_IEDzsznNYplhOSE8cERQxvjFcT4Lcn-7FHjLVE' + describe('Attribution', () => { describe('tag', () => { test('is a 4-byte hex string', () => { @@ -29,10 +31,11 @@ describe('Attribution', () => { expect(version).toBe('01') }) - test('generates unique memos (random nonce)', () => { - const a = Attribution.encode({ serverId: 'api.example.com' }) - const b = Attribution.encode({ serverId: 'api.example.com' }) - expect(a).not.toBe(b) + test('encodes challenge-bound suffix when challengeId is provided', () => { + const memo = Attribution.encode({ challengeId, serverId: 'api.example.com' }) + const suffix = `0x${memo.slice(52)}` as `0x${string}` + const expected = Hex.slice(Base64.toHex(challengeId), 0, 7) + expect(suffix.toLowerCase()).toBe(expected.toLowerCase()) }) test('encodes server fingerprint from serverId', () => { @@ -137,6 +140,20 @@ describe('Attribution', () => { }) }) + describe('verifyChallenge', () => { + test('returns true for matching challengeId', () => { + const memo = Attribution.encode({ challengeId, serverId: 'api.example.com' }) + expect(Attribution.verifyChallenge(memo, challengeId)).toBe(true) + }) + + test('returns false for wrong challengeId', () => { + const memo = Attribution.encode({ challengeId, serverId: 'api.example.com' }) + expect(Attribution.verifyChallenge(memo, 'QNLtjAvrKKR0VlEGSIowhULqcGlCDU4fjrP-O7js8XE')).toBe( + false, + ) + }) + }) + describe('decode', () => { test('decodes an encoded memo with serverId and clientId', () => { const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' }) @@ -162,10 +179,13 @@ describe('Attribution', () => { expect(Attribution.decode(arbitrary)).toBeNull() }) - test('different encodes produce different nonces', () => { - const a = Attribution.decode(Attribution.encode({ serverId: 'api.example.com' })) - const b = Attribution.decode(Attribution.encode({ serverId: 'api.example.com' })) - expect(a!.nonce).not.toBe(b!.nonce) + test('decodes the challenge-bound suffix into the trailing field', () => { + const memo = Attribution.encode({ challengeId, serverId: 'api.example.com' }) + const decoded = Attribution.decode(memo) + expect(decoded).not.toBeNull() + expect(decoded!.nonce.toLowerCase()).toBe( + Attribution.challengeSuffix(challengeId).toLowerCase(), + ) }) test('serverId fingerprint matches expected keccak hash', () => { diff --git a/src/tempo/Attribution.ts b/src/tempo/Attribution.ts index dc697cd7..eca23810 100644 --- a/src/tempo/Attribution.ts +++ b/src/tempo/Attribution.ts @@ -1,4 +1,4 @@ -import { Bytes, Hash, Hex } from 'ox' +import { Base64, Bytes, Hash, Hex } from 'ox' /** * MPP attribution memo encoding for TIP-20 `transferWithMemo`. @@ -14,7 +14,7 @@ import { Bytes, Hash, Hex } from 'ox' * | 4 | 1 | version (0x01) | * | 5..14 | 10 | serverId = keccak256(serverId)[0..9] | * | 15..24 | 10 | clientId = keccak256(clientId)[0..9] or 0s | - * | 25..31 | 7 | nonce (random bytes) | + * | 25..31 | 7 | opaque trailing field | * * The TAG prefix makes MPP transactions trivially distinguishable * from arbitrary memos via `TransferWithMemo` event topic filtering. @@ -43,7 +43,8 @@ function fingerprint(value: string): Uint8Array { /** * Encodes an MPP attribution memo as a `bytes32` hex string. * - * @param parameters - The serverId (server identity) and optional clientId. + * @param parameters - The serverId (server identity), optional clientId, + * and optional challengeId for challenge-bound encoding. * @returns A `0x`-prefixed 64-char hex string (32 bytes). * * @example @@ -54,7 +55,7 @@ function fingerprint(value: string): Uint8Array { * ``` */ export function encode(parameters: encode.Parameters) { - const { serverId, clientId } = parameters + const { challengeId, clientId, serverId } = parameters const buf = new Uint8Array(32) buf.set(Hex.toBytes(tag), 0) @@ -62,14 +63,18 @@ export function encode(parameters: encode.Parameters) { buf.set(fingerprint(serverId), 5) if (clientId) buf.set(fingerprint(clientId), 15) - const nonce = crypto.getRandomValues(new Uint8Array(7)) - buf.set(nonce, 25) + const trailing = challengeId + ? Hex.toBytes(challengeSuffix(challengeId)) + : crypto.getRandomValues(new Uint8Array(7)) + buf.set(trailing, 25) return Hex.fromBytes(buf) } export declare namespace encode { type Parameters = { + /** Optional challenge ID used to bind the trailing 7 bytes to a specific challenge. */ + challengeId?: string | undefined /** Server identity used to derive the server fingerprint. */ serverId: string /** Optional client identity used to derive the client fingerprint. */ @@ -77,6 +82,11 @@ export declare namespace encode { } } +/** Returns the 7-byte challenge-bound suffix derived from a base64url challenge ID. */ +export function challengeSuffix(challengeId: string): `0x${string}` { + return Hex.slice(Base64.toHex(challengeId), 0, 7) as `0x${string}` +} + /** * Checks whether a memo was generated by the MPP attribution system. * @@ -114,6 +124,13 @@ export function verifyServer(memo: `0x${string}`, serverId: string): boolean { return memoServerHex.toLowerCase() === expectedHex.toLowerCase() } +/** Verifies that a memo's trailing 7 bytes match the given challenge ID. */ +export function verifyChallenge(memo: `0x${string}`, challengeId: string): boolean { + if (!isMppMemo(memo)) return false + const memoSuffix = `0x${memo.slice(52)}` as `0x${string}` + return memoSuffix.toLowerCase() === challengeSuffix(challengeId).toLowerCase() +} + /** * Decodes an MPP attribution memo into its constituent parts. * @@ -150,7 +167,7 @@ export declare namespace decode { serverFingerprint: `0x${string}` /** 10-byte client fingerprint hex, or `null` if anonymous. */ clientFingerprint: `0x${string}` | null - /** 7-byte random nonce hex. */ + /** 7-byte trailing field hex. */ nonce: `0x${string}` } } diff --git a/src/tempo/client/Charge.ts b/src/tempo/client/Charge.ts index a3ed4d49..90cdf47a 100644 --- a/src/tempo/client/Charge.ts +++ b/src/tempo/client/Charge.ts @@ -58,7 +58,7 @@ export function charge(parameters: charge.Parameters = {}) { const memo = methodDetails?.memo ? (methodDetails.memo as Hex.Hex) - : Attribution.encode({ serverId: challenge.realm, clientId }) + : Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm, clientId }) const transferCall = Actions.token.transfer.call({ amount: BigInt(amount), diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index 48415654..ac122cc2 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -829,7 +829,7 @@ describe('tempo', () => { }) expect(replayResponse.status).toBe(402) const replayBody = (await replayResponse.json()) as { detail: string } - expect(replayBody.detail).toContain('Transaction hash has already been used.') + expect(replayBody.detail).toContain('attribution memo does not match this challenge') httpServer.close() }) @@ -2023,9 +2023,14 @@ describe('tempo', () => { expect(challenge.request.methodDetails?.memo).toBeUndefined() - const memo = Attribution.encode({ serverId: challenge.realm, clientId: 'test-app' }) + const memo = Attribution.encode({ + challengeId: challenge.id, + serverId: challenge.realm, + clientId: 'test-app', + }) expect(Attribution.isMppMemo(memo)).toBe(true) expect(Attribution.verifyServer(memo, realm)).toBe(true) + expect(Attribution.verifyChallenge(memo, challenge.id)).toBe(true) const { receipt } = await Actions.token.transferSync(client, { account: accounts[1], @@ -2069,7 +2074,7 @@ describe('tempo', () => { methods: [tempo_client.charge()], }) - const memo = Attribution.encode({ serverId: challenge.realm }) + const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm }) const decoded = Attribution.decode(memo) expect(decoded).not.toBeNull() expect(decoded!.clientFingerprint).toBeNull() @@ -2097,6 +2102,69 @@ describe('tempo', () => { httpServer.close() }) + test('replayed hash against a second challenge is rejected without store state', async () => { + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + server.charge({ amount: '1', decimals: 6 }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const challengeResponse1 = await fetch(httpServer.url) + expect(challengeResponse1.status).toBe(402) + const challenge1 = Challenge.fromResponse(challengeResponse1, { + methods: [tempo_client.charge()], + }) + + const memo = Attribution.encode({ + challengeId: challenge1.id, + serverId: challenge1.realm, + clientId: 'test-app', + }) + const { receipt } = await Actions.token.transferSync(client, { + account: accounts[1], + amount: BigInt(challenge1.request.amount), + memo: memo as Hex.Hex, + to: challenge1.request.recipient as Hex.Hex, + token: challenge1.request.currency as Hex.Hex, + }) + + const response1 = await fetch(httpServer.url, { + headers: { + Authorization: Credential.serialize( + Credential.from({ + challenge: challenge1, + payload: { hash: receipt.transactionHash, type: 'hash' as const }, + }), + ), + }, + }) + expect(response1.status).toBe(200) + + const challengeResponse2 = await fetch(httpServer.url) + expect(challengeResponse2.status).toBe(402) + const challenge2 = Challenge.fromResponse(challengeResponse2, { + methods: [tempo_client.charge()], + }) + + const response2 = await fetch(httpServer.url, { + headers: { + Authorization: Credential.serialize( + Credential.from({ + challenge: challenge2, + payload: { hash: receipt.transactionHash, type: 'hash' as const }, + }), + ), + }, + }) + expect(response2.status).toBe(402) + const body = (await response2.json()) as { detail: string } + expect(body.detail).toContain('attribution memo does not match this challenge') + + httpServer.close() + }) + test('client generates memo for transaction credential via Mppx', async () => { const mppx = Mppx_client.create({ polyfill: false, @@ -2129,6 +2197,64 @@ describe('tempo', () => { httpServer.close() }) + test('re-wrapped transaction credential fails against a second challenge without store state', async () => { + const pullClient = Mppx_client.create({ + polyfill: false, + methods: [ + tempo_client({ + account: accounts[1], + mode: 'pull', + getClient() { + return client + }, + }), + ], + }) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + server.charge({ + amount: '1', + currency: asset, + recipient: accounts[0].address, + }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const challengeResponse1 = await fetch(httpServer.url) + const challengeResponse2 = await fetch(httpServer.url) + expect(challengeResponse1.status).toBe(402) + expect(challengeResponse2.status).toBe(402) + + const credential1 = await pullClient.createCredential(challengeResponse1) + const decoded1 = Credential.deserialize(credential1) + const challenge2 = Challenge.fromResponse(challengeResponse2, { + methods: [tempo_client.charge()], + }) + const credential2 = Credential.serialize( + Credential.from({ + challenge: challenge2, + payload: decoded1.payload, + }), + ) + + const response1 = await fetch(httpServer.url, { + headers: { Authorization: credential1 }, + }) + expect(response1.status).toBe(200) + + const response2 = await fetch(httpServer.url, { + headers: { Authorization: credential2 }, + }) + expect(response2.status).toBe(402) + const body = (await response2.json()) as { detail: string } + expect(body.detail).toContain('attribution memo does not match this challenge') + + httpServer.close() + }) + test('server accepts plain transfer without memo', 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 645e6965..4971007d 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -10,11 +10,12 @@ import { import { tempo as tempo_chain } from 'viem/chains' import { Abis, Transaction } from 'viem/tempo' -import { PaymentExpiredError } from '../../Errors.js' +import { PaymentExpiredError, VerificationFailedError } from '../../Errors.js' import type { LooseOmit } from '../../internal/types.js' import * as Method from '../../Method.js' import * as Store from '../../Store.js' import * as Client from '../../viem/Client.js' +import * as Attribution from '../Attribution.js' import * as Account from '../internal/account.js' import * as TempoAddress from '../internal/address.js' import * as defaults from '../internal/defaults.js' @@ -124,21 +125,25 @@ export function charge( switch (payload.type) { case 'hash': { const hash = payload.hash as `0x${string}` - await assertHashUnused(store, hash) const receipt = await getTransactionReceipt(client, { hash, }) - assertTransferLog(receipt, { + const replayProtection = assertTransferLog(receipt, { amount, + challengeId: challenge.id, currency, from: receipt.from, memo, recipient, + serverId: challenge.realm, }) - await markHashUsed(store, hash) + if (replayProtection === 'store') { + await assertHashUnused(store, hash) + await markHashUsed(store, hash) + } return toReceipt(receipt) } @@ -146,11 +151,6 @@ export function charge( case 'transaction': { const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo - // Pre-broadcast dedup: catch exact byte-for-byte replays early. - const hash = keccak256(serializedTransaction) - await assertHashUnused(store, hash) - await markHashUsed(store, hash) - if (!FeePayer.isTempoTransaction(serializedTransaction)) throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {}) @@ -161,49 +161,15 @@ export function charge( {}, ) - const call = transaction.calls.find((call) => { - if (!call.to || !TempoAddress.isEqual(call.to, currency)) return false - if (!call.data) return false - - const selector = call.data.slice(0, 10) - - if (memo) { - if (selector !== Selectors.transferWithMemo) return false - try { - const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data }) - const [to, amount_, memo_] = args as [`0x${string}`, bigint, `0x${string}`] - return ( - TempoAddress.isEqual(to, recipient) && - amount_.toString() === amount && - memo_.toLowerCase() === memo.toLowerCase() - ) - } catch { - return false - } - } - - if (selector === Selectors.transfer) { - try { - const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data }) - const [to, amount_] = args as [`0x${string}`, bigint] - return TempoAddress.isEqual(to, recipient) && amount_.toString() === amount - } catch { - return false - } - } - - if (selector === Selectors.transferWithMemo) { - try { - const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data }) - const [to, amount_] = args as [`0x${string}`, bigint, `0x${string}`] - return TempoAddress.isEqual(to, recipient) && amount_.toString() === amount - } catch { - return false - } - } - - return false + const matchedCall = findMatchingPaymentCall(transaction.calls, { + amount, + challengeId: challenge.id, + currency, + memo, + recipient, + serverId: challenge.realm, }) + const { call, replayProtection } = matchedCall ?? {} if (!call) throw new MismatchError('Invalid transaction: no matching payment call found', { @@ -212,6 +178,12 @@ export function charge( recipient, }) + const hash = replayProtection === 'store' ? keccak256(serializedTransaction) : undefined + if (hash) { + await assertHashUnused(store, hash) + await markHashUsed(store, hash) + } + if ((feePayer || feePayerUrl) && methodDetails?.feePayer !== false) FeePayer.validateCalls(transaction.calls, { amount, currency, recipient }) @@ -236,16 +208,18 @@ export function charge( }) assertTransferLog(receipt, { amount, + challengeId: challenge.id, currency, from: transaction.from, memo, recipient, + serverId: challenge.realm, }) // Post-broadcast dedup: catch malleable input variants // (different serialized bytes, same underlying tx) that // bypass the pre-broadcast check. Skip if the broadcast // hash matches the input hash (already stored above). - if (receipt.transactionHash.toLowerCase() !== hash.toLowerCase()) { + if (hash && receipt.transactionHash.toLowerCase() !== hash.toLowerCase()) { await assertHashUnused(store, receipt.transactionHash) await markHashUsed(store, receipt.transactionHash) } @@ -264,7 +238,7 @@ export function charge( serializedTransaction: serializedTransaction_final, }) // Post-broadcast dedup: same - if (reference.toLowerCase() !== hash.toLowerCase()) { + if (hash && reference.toLowerCase() !== hash.toLowerCase()) { await assertHashUnused(store, reference) await markHashUsed(store, reference) } @@ -295,8 +269,8 @@ export declare namespace charge { /** * Store for transaction hash replay protection. * - * Use a shared store in multi-instance deployments so consumed hashes are - * visible across all server instances. + * Use a shared store in multi-instance deployments so fallback-path + * consumed hashes are visible across all server instances. */ store?: Store.Store | undefined /** @@ -328,13 +302,15 @@ function assertTransferLog( receipt: TransactionReceipt, parameters: { amount: string + challengeId: string currency: TempoAddress_types.Address from: TempoAddress_types.Address memo: `0x${string}` | undefined recipient: TempoAddress_types.Address + serverId: string }, -): void { - const { amount, currency, from, memo, recipient } = parameters +): 'challenge' | 'store' { + const { amount, challengeId, currency, from, memo, recipient, serverId } = parameters if (memo) { const memoLogs = parseEventLogs({ @@ -362,6 +338,7 @@ function assertTransferLog( recipient, }, ) + return 'store' } else { const transferLogs = parseEventLogs({ abi: Abis.tip20, @@ -375,7 +352,40 @@ function assertTransferLog( logs: receipt.logs, }) - const match = [...transferLogs, ...memoLogs].find( + let fallbackMatch = false + let sawMppMemoMismatch = false + + for (const log of memoLogs) { + if ( + !TempoAddress.isEqual(log.address, currency) || + !TempoAddress.isEqual(log.args.from, from) || + !TempoAddress.isEqual(log.args.to, recipient) || + log.args.amount.toString() !== amount + ) + continue + + if (!Attribution.isMppMemo(log.args.memo)) { + fallbackMatch = true + continue + } + + if ( + Attribution.verifyServer(log.args.memo, serverId) && + Attribution.verifyChallenge(log.args.memo, challengeId) + ) + return 'challenge' + + sawMppMemoMismatch = true + } + + if (sawMppMemoMismatch) + throw new VerificationFailedError({ + reason: 'attribution memo does not match this challenge', + }) + + if (fallbackMatch) return 'store' + + const transferMatch = transferLogs.some( (log) => TempoAddress.isEqual(log.address, currency) && TempoAddress.isEqual(log.args.from, from) && @@ -383,15 +393,102 @@ function assertTransferLog( log.args.amount.toString() === amount, ) - if (!match) - throw new MismatchError('Payment verification failed: no matching transfer found.', { - amount, - currency, - recipient, - }) + if (transferMatch) return 'store' + + throw new MismatchError('Payment verification failed: no matching transfer found.', { + amount, + currency, + recipient, + }) } } +function findMatchingPaymentCall( + calls: NonNullable['calls']>, + parameters: { + amount: string + challengeId: string + currency: TempoAddress_types.Address + memo: `0x${string}` | undefined + recipient: TempoAddress_types.Address + serverId: string + }, +): + | { + call: NonNullable['calls']>[number] + replayProtection: 'challenge' | 'store' + } + | undefined { + const { amount, challengeId, currency, memo, recipient, serverId } = parameters + let fallbackCall: + | { + call: NonNullable['calls']>[number] + replayProtection: 'store' + } + | undefined + let sawMppMemoMismatch = false + + for (const call of calls) { + if (!call.to || !TempoAddress.isEqual(call.to, currency)) continue + if (!call.data) continue + + const selector = call.data.slice(0, 10) + + if (memo) { + if (selector !== Selectors.transferWithMemo) continue + try { + const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data }) + const [to, amount_, memo_] = args as [`0x${string}`, bigint, `0x${string}`] + if ( + TempoAddress.isEqual(to, recipient) && + amount_.toString() === amount && + memo_.toLowerCase() === memo.toLowerCase() + ) + return { call, replayProtection: 'store' } + } catch {} + continue + } + + if (selector === Selectors.transferWithMemo) { + try { + const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data }) + const [to, amount_, memo_] = args as [`0x${string}`, bigint, `0x${string}`] + if (!TempoAddress.isEqual(to, recipient) || amount_.toString() !== amount) continue + + if (!Attribution.isMppMemo(memo_)) { + fallbackCall ??= { call, replayProtection: 'store' } + continue + } + + if ( + Attribution.verifyServer(memo_, serverId) && + Attribution.verifyChallenge(memo_, challengeId) + ) + return { call, replayProtection: 'challenge' } + + sawMppMemoMismatch = true + } catch {} + continue + } + + if (selector === Selectors.transfer) { + try { + const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data }) + const [to, amount_] = args as [`0x${string}`, bigint] + if (TempoAddress.isEqual(to, recipient) && amount_.toString() === amount) + fallbackCall ??= { call, replayProtection: 'store' } + } catch {} + } + } + + if (sawMppMemoMismatch) + throw new VerificationFailedError({ + reason: 'attribution memo does not match this challenge', + }) + if (fallbackCall) return fallbackCall + return undefined +} + /** @internal */ function getHashStoreKey(hash: `0x${string}`): `mppx:charge:${string}` { return `mppx:charge:${hash.toLowerCase()}`