Skip to content

Commit 225798e

Browse files
committed
feat(tempo): support machine-token sessions without refunds
1 parent 4c9ce16 commit 225798e

26 files changed

Lines changed: 1436 additions & 88 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'mppx': patch
3+
---
4+
5+
Added opt-in machine-token settlement for Tempo sessions while preserving the merchant's configured payout currency. Machine-token channels can close after the full deposit is spent; partial close remains disabled until the reserve supports returning restricted escrow.

src/server/Methods.test-d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import type { StripeClient } from '../stripe/internal/types.js'
55

66
test('accepts the machine-token option on Tempo charges and the global constructor', () => {
77
expectTypeOf(tempo.charge({ machineTokenEnabled: true })).toHaveProperty('verify')
8+
expectTypeOf(tempo.session({ machineTokenEnabled: true })).toHaveProperty('verify')
89
expectTypeOf(tempo({ machineTokenEnabled: true })[0]).toHaveProperty('verify')
10+
expectTypeOf(tempo({ machineTokenEnabled: true })[1]).toHaveProperty('verify')
911
})
1012

1113
test('all server method constructors expose typed canOffer hooks', () => {

src/server/Methods.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import { accounts } from '~test/tempo/viem.js'
66
const recipient = '0x0000000000000000000000000000000000000001'
77

88
describe('Tempo machine token', () => {
9-
test('preserves the option on direct and global charge methods', () => {
9+
test('preserves the option on charge and session methods', () => {
1010
const direct = tempo.charge({ machineTokenEnabled: true })
1111
const [global, session] = tempo({ machineTokenEnabled: true })
1212

1313
expect((direct.defaults as { machineTokenEnabled?: boolean }).machineTokenEnabled).toBe(true)
1414
expect((global.defaults as { machineTokenEnabled?: boolean }).machineTokenEnabled).toBe(true)
15-
expect(session.defaults).not.toHaveProperty('machineTokenEnabled')
15+
expect((session.defaults as { machineTokenEnabled?: boolean }).machineTokenEnabled).toBe(true)
1616
})
1717
})
1818

src/tempo/Methods.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,41 @@ describe('session', () => {
263263
expect(request.methodDetails?.minVoucherDelta).toBe('100000')
264264
})
265265

266+
test('schema: binds machine-token capability without changing logical payment fields', () => {
267+
const currency = '0x20c0000000000000000000000000000000000001'
268+
const recipient = '0x1234567890abcdef1234567890abcdef12345678'
269+
const request = Methods.session.schema.request.parse({
270+
amount: '1',
271+
currency,
272+
decimals: 6,
273+
machineTokenEnabled: true,
274+
recipient,
275+
unitType: 'token',
276+
})
277+
278+
expect(request).toMatchObject({
279+
currency,
280+
methodDetails: { machineTokenEnabled: true },
281+
recipient,
282+
})
283+
})
284+
285+
test('schema: omits refund-only machine-token credential fields', () => {
286+
const authorizationSignature = `0x${'44'.repeat(65)}`
287+
const refundSignature = `0x${'22'.repeat(65)}`
288+
const credential = Methods.session.schema.credential.payload.parse({
289+
action: 'close',
290+
authorizationSignature,
291+
channelId: `0x${'11'.repeat(32)}`,
292+
cumulativeAmount: '100000',
293+
refundSignature,
294+
signature: `0x${'33'.repeat(65)}`,
295+
})
296+
297+
expect(credential).not.toHaveProperty('authorizationSignature')
298+
expect(credential).not.toHaveProperty('refundSignature')
299+
})
300+
266301
test('schema: preserves precompile session snapshots in method details', () => {
267302
const sessionSnapshot = {
268303
acceptedCumulative: '2',

src/tempo/Methods.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ export const session = Method.from({
256256
z.transform((v): boolean => (typeof v === 'object' ? true : v)),
257257
),
258258
),
259+
machineTokenEnabled: z.optional(z.boolean()),
259260
minVoucherDelta: z.optional(z.amount()),
260261
operator: z.optional(z.address()),
261262
recipient: z.optional(z.string()),
@@ -280,6 +281,7 @@ export const session = Method.from({
280281
decimals,
281282
escrowContract,
282283
feePayer,
284+
machineTokenEnabled,
283285
minVoucherDelta,
284286
operator,
285287
sessionProtocol,
@@ -302,6 +304,7 @@ export const session = Method.from({
302304
}),
303305
...(chainId !== undefined && { chainId }),
304306
...(feePayer !== undefined && { feePayer }),
307+
...(machineTokenEnabled !== undefined && { machineTokenEnabled }),
305308
...(operator !== undefined && { operator }),
306309
...(sessionProtocol !== undefined && {
307310
[Constants.MethodDetailKeys.sessionProtocol]: sessionProtocol,

src/tempo/internal/defaults.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,17 @@ export const machineToken = {
2727
token: '0x20C0000000000000000000003793c39601711f19',
2828
},
2929
[chainId.testnet]: {
30-
swap: '0x07f1FE0467Ae01DE340024aa4b7DD9729b1c169b',
31-
token: '0x20c000000000000000000000f85bbCa724044De0',
30+
feeToken: tokens.pathUsd,
31+
session: true,
32+
swap: '0x0EdC4d63Daf0FdeA89C95357eC85D6Fc2eD41cB8',
33+
token: '0x20c00000000000000000000004645c8f96629cE6',
3234
},
33-
} as const satisfies Partial<Record<ChainId, { swap: `0x${string}`; token: `0x${string}` }>>
35+
} as const satisfies Partial<
36+
Record<
37+
ChainId,
38+
{ feeToken?: `0x${string}`; session?: true; swap: `0x${string}`; token: `0x${string}` }
39+
>
40+
>
3441

3542
/**
3643
* Default token decimals for TIP-20 stablecoins (e.g. pathUSD, USDC).

src/tempo/internal/machine-token.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const deployment = defaults.machineToken[chainId]
1010
const targetToken = '0x20c0000000000000000000000000000000000001'
1111
const recipient = '0x2222222222222222222222222222222222222222'
1212
const payer = '0x1111111111111111111111111111111111111111'
13+
const sessionPayee = '0x44d7c1edfdfdfdfdfdfdfdfd0000000000000001'
1314
const memo = `0x${'ab'.repeat(32)}` as const
1415
const client = createClient({
1516
transport: custom({ request: async () => undefined as never }),
@@ -176,4 +177,145 @@ describe('Tempo machine token', () => {
176177
}),
177178
).toBeUndefined()
178179
})
180+
181+
test('resolves active session routes without exposing them to merchant configuration', async () => {
182+
vi.resetModules()
183+
vi.doMock('viem/actions', () => ({
184+
call: vi.fn(),
185+
readContract: vi.fn(async (_client, parameters: { functionName: string }) => {
186+
if (parameters.functionName === 'sessionRouteFor') return sessionPayee
187+
if (parameters.functionName === 'sessionRoutes') return [recipient, targetToken]
188+
throw new Error(`unexpected function ${parameters.functionName}`)
189+
}),
190+
}))
191+
192+
try {
193+
const MachineToken = await import('./machine-token.js')
194+
await expect(
195+
MachineToken.findSessionRoute(client, {
196+
chainId,
197+
merchant: recipient,
198+
targetToken,
199+
}),
200+
).resolves.toEqual({
201+
merchant: recipient,
202+
operator: deployment.swap,
203+
payee: sessionPayee,
204+
targetToken,
205+
token: deployment.token,
206+
})
207+
await expect(
208+
MachineToken.getSessionRoute(client, { chainId, payee: sessionPayee }),
209+
).resolves.toEqual({
210+
merchant: recipient,
211+
operator: deployment.swap,
212+
payee: sessionPayee,
213+
targetToken,
214+
token: deployment.token,
215+
})
216+
await expect(
217+
MachineToken.findVerifiedSessionRoute(client, {
218+
chainId,
219+
merchant: recipient,
220+
targetToken,
221+
}),
222+
).resolves.toEqual(expect.objectContaining({ merchant: recipient, payee: sessionPayee }))
223+
await expect(
224+
MachineToken.resolveSessionRoute(client, { chainId, payee: sessionPayee }),
225+
).resolves.toEqual({
226+
merchant: recipient,
227+
operator: deployment.swap,
228+
payee: sessionPayee,
229+
targetToken,
230+
token: deployment.token,
231+
})
232+
await expect(
233+
MachineToken.matchSessionRoute(client, {
234+
chainId,
235+
descriptor: {
236+
operator: deployment.swap,
237+
payee: sessionPayee,
238+
token: deployment.token,
239+
},
240+
merchant: recipient,
241+
targetToken,
242+
}),
243+
).resolves.toEqual(expect.objectContaining({ merchant: recipient, targetToken }))
244+
await expect(
245+
MachineToken.matchSessionRoute(client, {
246+
chainId,
247+
descriptor: {
248+
operator: deployment.swap,
249+
payee: sessionPayee,
250+
token: deployment.token,
251+
},
252+
merchant: payer,
253+
targetToken,
254+
}),
255+
).resolves.toBeUndefined()
256+
expect(MachineToken.isSessionSupported(chainId)).toBe(true)
257+
expect(MachineToken.isSessionSupported(defaults.chainId.mainnet)).toBe(false)
258+
expect(MachineToken.getSessionFeeToken(chainId)).toBe(defaults.tokens.pathUsd)
259+
expect(MachineToken.getSessionFeeToken(defaults.chainId.mainnet)).toBeUndefined()
260+
} finally {
261+
vi.doUnmock('viem/actions')
262+
vi.resetModules()
263+
}
264+
})
265+
266+
test('separates the challenge capability flag from the trusted descriptor pair', async () => {
267+
const MachineToken = await import('./machine-token.js')
268+
expect(
269+
MachineToken.isSessionEnabledChallenge({
270+
request: { methodDetails: { chainId, machineTokenEnabled: true } },
271+
}),
272+
).toBe(true)
273+
expect(
274+
MachineToken.isSessionEnabledChallenge({
275+
request: { methodDetails: { chainId, machineTokenEnabled: false } },
276+
}),
277+
).toBe(false)
278+
expect(
279+
MachineToken.matchSessionDescriptor({
280+
chainId,
281+
descriptor: { operator: deployment.swap, token: deployment.token },
282+
}),
283+
).toEqual(deployment)
284+
expect(
285+
MachineToken.matchSessionDescriptor({
286+
chainId,
287+
descriptor: { operator: recipient, token: deployment.token },
288+
}),
289+
).toBeUndefined()
290+
})
291+
292+
test('rejects virtual payees that are no longer the active merchant route', async () => {
293+
vi.resetModules()
294+
vi.doMock('viem/actions', () => ({
295+
call: vi.fn(),
296+
readContract: vi.fn(async (_client, parameters: { functionName: string }) => {
297+
if (parameters.functionName === 'sessionRoutes') return [recipient, targetToken]
298+
if (parameters.functionName === 'sessionRouteFor')
299+
return '0x0000000000000000000000000000000000000000'
300+
throw new Error(`unexpected function ${parameters.functionName}`)
301+
}),
302+
}))
303+
304+
try {
305+
const MachineToken = await import('./machine-token.js')
306+
await expect(
307+
MachineToken.resolveSessionRoute(client, { chainId, payee: sessionPayee }),
308+
).resolves.toBeUndefined()
309+
await expect(
310+
MachineToken.resolveSessionRoute(client, {
311+
active: false,
312+
chainId,
313+
payee: sessionPayee,
314+
}),
315+
).resolves.toEqual(expect.objectContaining({ merchant: recipient, targetToken }))
316+
} finally {
317+
vi.doUnmock('viem/actions')
318+
vi.resetModules()
319+
}
320+
})
179321
})

0 commit comments

Comments
 (0)