From 1f981a67f554ef06f8f93608dea1a3fbd72af322 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:34:49 +0200 Subject: [PATCH 1/3] feat: add sequential nonce option for tempo charge --- .changeset/tempo-charge-sequential-nonces.md | 5 + src/tempo/client/Charge.test.ts | 102 +++++++++++++++++++ src/tempo/client/Charge.ts | 33 ++++-- 3 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 .changeset/tempo-charge-sequential-nonces.md diff --git a/.changeset/tempo-charge-sequential-nonces.md b/.changeset/tempo-charge-sequential-nonces.md new file mode 100644 index 00000000..0c9a4420 --- /dev/null +++ b/.changeset/tempo-charge-sequential-nonces.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added a Tempo charge client option for sequential pull-mode nonces. diff --git a/src/tempo/client/Charge.test.ts b/src/tempo/client/Charge.test.ts index e828f319..d8066977 100644 --- a/src/tempo/client/Charge.test.ts +++ b/src/tempo/client/Charge.test.ts @@ -18,6 +18,7 @@ type ChargeRequest = ReturnType function createChallenge( overrides: Partial[0]> = {}, + options: { expires?: string | undefined } = {}, ): Challenge.Challenge { const request = Methods.charge.schema.request.parse({ amount: '0', @@ -32,6 +33,7 @@ function createChallenge( method: 'tempo', realm: 'api.example.com', request, + ...options, }) as Challenge.Challenge } @@ -178,6 +180,106 @@ describe('tempo.charge client', () => { } }) + describe('nonce strategy', () => { + async function createWithMockedTransaction( + parameters: Parameters[0], + challenge: Challenge.Challenge, + ) { + vi.resetModules() + const prepareTransactionRequest = vi.fn( + async (_client: unknown, _parameters: Record) => ({}), + ) + const signTransaction = vi.fn(async (_client: unknown, _parameters: unknown) => '0xdeadbeef') + vi.doMock('viem/actions', () => ({ + prepareTransactionRequest, + sendCallsSync: vi.fn(), + signTransaction, + signTypedData: vi.fn(), + })) + + const { charge: chargeWithMockedActions } = await import('./Charge.js') + const client = createClient({ + account, + chain: tempoLocalnet, + transport: http('http://127.0.0.1'), + }) + const method = chargeWithMockedActions({ + account, + getClient: () => client, + ...parameters, + }) + + await method.createCredential({ challenge, context: {} }) + + return { prepareTransactionRequest, signTransaction } + } + + test('uses expiring nonce parameters by default', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-01-01T00:00:00Z')) + + try { + const { prepareTransactionRequest, signTransaction } = await createWithMockedTransaction( + {}, + createChallenge({ amount: '1', supportedModes: ['pull'] }), + ) + + expect(prepareTransactionRequest).toHaveBeenCalledOnce() + expect(signTransaction).toHaveBeenCalledOnce() + expect(prepareTransactionRequest.mock.calls[0]?.[1]).toMatchObject({ + nonceKey: 'expiring', + validBefore: 1_735_689_625, + }) + } finally { + vi.doUnmock('viem/actions') + vi.resetModules() + vi.useRealTimers() + } + }) + + test('clamps expiring nonce validity to challenge expiration', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-01-01T00:00:00Z')) + + try { + const { prepareTransactionRequest } = await createWithMockedTransaction( + {}, + createChallenge( + { amount: '1', supportedModes: ['pull'] }, + { expires: '2025-01-01T00:00:10Z' }, + ), + ) + + expect(prepareTransactionRequest).toHaveBeenCalledOnce() + expect(prepareTransactionRequest.mock.calls[0]?.[1]).toMatchObject({ + nonceKey: 'expiring', + validBefore: 1_735_689_610, + }) + } finally { + vi.doUnmock('viem/actions') + vi.resetModules() + vi.useRealTimers() + } + }) + + test('omits expiring nonce parameters for sequential nonces', async () => { + try { + const { prepareTransactionRequest } = await createWithMockedTransaction( + { nonceStrategy: 'sequential' }, + createChallenge({ amount: '1', supportedModes: ['pull'] }), + ) + + expect(prepareTransactionRequest).toHaveBeenCalledOnce() + const request = prepareTransactionRequest.mock.calls[0]?.[1] as Record + expect(request.nonceKey).toBeUndefined() + expect(request.validBefore).toBeUndefined() + } finally { + vi.doUnmock('viem/actions') + vi.resetModules() + } + }) + }) + describe('chain pinning', () => { const client = createClient({ account, diff --git a/src/tempo/client/Charge.ts b/src/tempo/client/Charge.ts index a4f6f4cc..9c44c4da 100644 --- a/src/tempo/client/Charge.ts +++ b/src/tempo/client/Charge.ts @@ -22,6 +22,8 @@ import * as Proof from '../internal/proof.js' import * as Wallet from '../internal/wallet.js' import * as Methods from '../Methods.js' +const defaultExpiringNonceTtlSeconds = 25 + /** * Creates a Tempo charge method intent for usage on the client. * @@ -172,13 +174,6 @@ export function charge(parameters: charge.Parameters = {}) { const calls = [...(swapCalls ?? []), ...transferCalls] - const validBefore = (() => { - const defaultExpiry = Math.floor(Date.now() / 1000) + 25 - if (!challenge.expires) return defaultExpiry - const challengeExpiry = Math.floor(new Date(challenge.expires).getTime() / 1000) - return Math.min(defaultExpiry, challengeExpiry) - })() - if (mode === 'push') { const { receipts } = await sendCallsSync(client, { account, @@ -194,11 +189,22 @@ export function charge(parameters: charge.Parameters = {}) { }) } + const nonceOptions = + parameters.nonceStrategy === 'sequential' + ? {} + : { + nonceKey: 'expiring', + validBefore: (() => { + const defaultExpiry = Math.floor(Date.now() / 1000) + defaultExpiringNonceTtlSeconds + if (!challenge.expires) return defaultExpiry + const challengeExpiry = Math.floor(new Date(challenge.expires).getTime() / 1000) + return Math.min(defaultExpiry, challengeExpiry) + })(), + } const prepared = await prepareTransactionRequest(client, { account, calls, - nonceKey: 'expiring', - validBefore, + ...nonceOptions, } as never) // Estimate before enabling fee-payer mode so Tempo includes sender // signature and access-key verification costs in the gas budget. @@ -251,6 +257,15 @@ export declare namespace charge { * @default `'push'` for JSON-RPC accounts, `'pull'` for local accounts. */ mode?: Methods.ChargeMode | undefined + /** + * Controls which nonce type pull-mode charge transactions use. + * + * - `'expiring'`: Uses Tempo expiring nonces with a short `validBefore`. + * - `'sequential'`: Uses the account's standard sequential nonce. + * + * @default 'expiring' + */ + nonceStrategy?: 'expiring' | 'sequential' | undefined } & Account.getResolver.Parameters & Client.getResolver.Parameters } From 62d24993da55c9a581ff164fdd4e5c87dd7f4e96 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:33:04 +0200 Subject: [PATCH 2/3] fix: reject sequential nonces for sponsored tempo charges --- src/tempo/client/Charge.test.ts | 14 ++++++++++++++ src/tempo/client/Charge.ts | 2 ++ 2 files changed, 16 insertions(+) diff --git a/src/tempo/client/Charge.test.ts b/src/tempo/client/Charge.test.ts index d8066977..a6849c30 100644 --- a/src/tempo/client/Charge.test.ts +++ b/src/tempo/client/Charge.test.ts @@ -278,6 +278,20 @@ describe('tempo.charge client', () => { vi.resetModules() } }) + + test('rejects sequential nonces for fee-sponsored charges', async () => { + try { + await expect( + createWithMockedTransaction( + { nonceStrategy: 'sequential' }, + createChallenge({ amount: '1', feePayer: true, supportedModes: ['pull'] }), + ), + ).rejects.toThrow('Sequential nonces are not supported for fee-sponsored charges.') + } finally { + vi.doUnmock('viem/actions') + vi.resetModules() + } + }) }) describe('chain pinning', () => { diff --git a/src/tempo/client/Charge.ts b/src/tempo/client/Charge.ts index 9c44c4da..c467d85d 100644 --- a/src/tempo/client/Charge.ts +++ b/src/tempo/client/Charge.ts @@ -189,6 +189,8 @@ export function charge(parameters: charge.Parameters = {}) { }) } + if (parameters.nonceStrategy === 'sequential' && methodDetails?.feePayer) + throw new Error('Sequential nonces are not supported for fee-sponsored charges.') const nonceOptions = parameters.nonceStrategy === 'sequential' ? {} From 5e2d943c1f04e1ea4db8e400a65d40c6ef5d436d Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:38:34 +0200 Subject: [PATCH 3/3] ci: resolve hono audit advisory --- pnpm-lock.yaml | 5 +++-- pnpm-workspace.yaml | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa2a7cd0..bfff189e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,7 @@ overrides: typescript: ~5.9.3 ox: 0.14.24 viem: ^2.51.3 + hono@<4.12.25: 4.12.25 path-to-regexp@<8.4.0: 8.4.0 tar@<=7.5.15: 7.5.16 '@modelcontextprotocol/sdk@>=1.10.0 <=1.25.3': 1.26.0 @@ -724,13 +725,13 @@ packages: resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: 4.12.25 '@hono/node-server@2.0.4': resolution: {integrity: sha512-Ut3y0dMMPWy6bZ2kVfx25EOVbZlm15dhF4mOsezMlhpNHy+4MkU1qN9Y6lnruYi4wPmFzimGX2X7LF/FwHli4A==} engines: {node: '>=20'} peerDependencies: - hono: ^4 + hono: 4.12.25 '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ecca811f..e5c34bfc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,6 +15,7 @@ overrides: typescript: '~5.9.3' ox: '0.14.24' viem: '^2.51.3' + hono@<4.12.25: '4.12.25' path-to-regexp@<8.4.0: '8.4.0' tar@<=7.5.15: '7.5.16' '@modelcontextprotocol/sdk@>=1.10.0 <=1.25.3': '1.26.0'