Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/tempo-charge-sequential-nonces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added a Tempo charge client option for sequential pull-mode nonces.
5 changes: 3 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
116 changes: 116 additions & 0 deletions src/tempo/client/Charge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type ChargeRequest = ReturnType<typeof Methods.charge.schema.request.parse>

function createChallenge(
overrides: Partial<Parameters<typeof Methods.charge.schema.request.parse>[0]> = {},
options: { expires?: string | undefined } = {},
): Challenge.Challenge<ChargeRequest, 'charge', 'tempo'> {
const request = Methods.charge.schema.request.parse({
amount: '0',
Expand All @@ -32,6 +33,7 @@ function createChallenge(
method: 'tempo',
realm: 'api.example.com',
request,
...options,
}) as Challenge.Challenge<ChargeRequest, 'charge', 'tempo'>
}

Expand Down Expand Up @@ -178,6 +180,120 @@ describe('tempo.charge client', () => {
}
})

describe('nonce strategy', () => {
async function createWithMockedTransaction(
parameters: Parameters<typeof charge>[0],
challenge: Challenge.Challenge<ChargeRequest, 'charge', 'tempo'>,
) {
vi.resetModules()
const prepareTransactionRequest = vi.fn(
async (_client: unknown, _parameters: Record<string, unknown>) => ({}),
)
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<string, unknown>
expect(request.nonceKey).toBeUndefined()
expect(request.validBefore).toBeUndefined()
} finally {
vi.doUnmock('viem/actions')
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', () => {
const client = createClient({
account,
Expand Down
35 changes: 26 additions & 9 deletions src/tempo/client/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
Expand All @@ -194,11 +189,24 @@ 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'
? {}
: {
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.
Expand Down Expand Up @@ -251,6 +259,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
}
Loading