Skip to content
Closed
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/machine-sessions-route.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

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.
2 changes: 2 additions & 0 deletions src/server/Methods.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import type { StripeClient } from '../stripe/internal/types.js'

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

test('all server method constructors expose typed canOffer hooks', () => {
Expand Down
4 changes: 2 additions & 2 deletions src/server/Methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import { accounts } from '~test/tempo/viem.js'
const recipient = '0x0000000000000000000000000000000000000001'

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

expect((direct.defaults as { machineTokenEnabled?: boolean }).machineTokenEnabled).toBe(true)
expect((global.defaults as { machineTokenEnabled?: boolean }).machineTokenEnabled).toBe(true)
expect(session.defaults).not.toHaveProperty('machineTokenEnabled')
expect((session.defaults as { machineTokenEnabled?: boolean }).machineTokenEnabled).toBe(true)
})
})

Expand Down
35 changes: 35 additions & 0 deletions src/tempo/Methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,41 @@ describe('session', () => {
expect(request.methodDetails?.minVoucherDelta).toBe('100000')
})

test('schema: binds machine-token capability without changing logical payment fields', () => {
const currency = '0x20c0000000000000000000000000000000000001'
const recipient = '0x1234567890abcdef1234567890abcdef12345678'
const request = Methods.session.schema.request.parse({
amount: '1',
currency,
decimals: 6,
machineTokenEnabled: true,
recipient,
unitType: 'token',
})

expect(request).toMatchObject({
currency,
methodDetails: { machineTokenEnabled: true },
recipient,
})
})

test('schema: omits refund-only machine-token credential fields', () => {
const authorizationSignature = `0x${'44'.repeat(65)}`
const refundSignature = `0x${'22'.repeat(65)}`
const credential = Methods.session.schema.credential.payload.parse({
action: 'close',
authorizationSignature,
channelId: `0x${'11'.repeat(32)}`,
cumulativeAmount: '100000',
refundSignature,
signature: `0x${'33'.repeat(65)}`,
})

expect(credential).not.toHaveProperty('authorizationSignature')
expect(credential).not.toHaveProperty('refundSignature')
})

test('schema: preserves precompile session snapshots in method details', () => {
const sessionSnapshot = {
acceptedCumulative: '2',
Expand Down
3 changes: 3 additions & 0 deletions src/tempo/Methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ export const session = Method.from({
z.transform((v): boolean => (typeof v === 'object' ? true : v)),
),
),
machineTokenEnabled: z.optional(z.boolean()),
minVoucherDelta: z.optional(z.amount()),
operator: z.optional(z.address()),
recipient: z.optional(z.string()),
Expand All @@ -280,6 +281,7 @@ export const session = Method.from({
decimals,
escrowContract,
feePayer,
machineTokenEnabled,
minVoucherDelta,
operator,
sessionProtocol,
Expand All @@ -302,6 +304,7 @@ export const session = Method.from({
}),
...(chainId !== undefined && { chainId }),
...(feePayer !== undefined && { feePayer }),
...(machineTokenEnabled !== undefined && { machineTokenEnabled }),
...(operator !== undefined && { operator }),
...(sessionProtocol !== undefined && {
[Constants.MethodDetailKeys.sessionProtocol]: sessionProtocol,
Expand Down
13 changes: 10 additions & 3 deletions src/tempo/internal/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,17 @@ export const machineToken = {
token: '0x20C0000000000000000000003793c39601711f19',
},
[chainId.testnet]: {
swap: '0x07f1FE0467Ae01DE340024aa4b7DD9729b1c169b',
token: '0x20c000000000000000000000f85bbCa724044De0',
feeToken: tokens.pathUsd,
session: true,
swap: '0xD2E54024506079A62ceb2b698148D240036662E4',
token: '0x20C000000000000000000000785C8D7ebcC0b982',
},
} as const satisfies Partial<Record<ChainId, { swap: `0x${string}`; token: `0x${string}` }>>
} as const satisfies Partial<
Record<
ChainId,
{ feeToken?: `0x${string}`; session?: true; swap: `0x${string}`; token: `0x${string}` }
>
>

/**
* Default token decimals for TIP-20 stablecoins (e.g. pathUSD, USDC).
Expand Down
142 changes: 142 additions & 0 deletions src/tempo/internal/machine-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const deployment = defaults.machineToken[chainId]
const targetToken = '0x20c0000000000000000000000000000000000001'
const recipient = '0x2222222222222222222222222222222222222222'
const payer = '0x1111111111111111111111111111111111111111'
const sessionPayee = '0x44d7c1edfdfdfdfdfdfdfdfd0000000000000001'
const memo = `0x${'ab'.repeat(32)}` as const
const client = createClient({
transport: custom({ request: async () => undefined as never }),
Expand Down Expand Up @@ -176,4 +177,145 @@ describe('Tempo machine token', () => {
}),
).toBeUndefined()
})

test('resolves active session routes without exposing them to merchant configuration', async () => {
vi.resetModules()
vi.doMock('viem/actions', () => ({
call: vi.fn(),
readContract: vi.fn(async (_client, parameters: { functionName: string }) => {
if (parameters.functionName === 'sessionRouteFor') return sessionPayee
if (parameters.functionName === 'sessionRoutes') return [recipient, targetToken]
throw new Error(`unexpected function ${parameters.functionName}`)
}),
}))

try {
const MachineToken = await import('./machine-token.js')
await expect(
MachineToken.findSessionRoute(client, {
chainId,
merchant: recipient,
targetToken,
}),
).resolves.toEqual({
merchant: recipient,
operator: deployment.swap,
payee: sessionPayee,
targetToken,
token: deployment.token,
})
await expect(
MachineToken.getSessionRoute(client, { chainId, payee: sessionPayee }),
).resolves.toEqual({
merchant: recipient,
operator: deployment.swap,
payee: sessionPayee,
targetToken,
token: deployment.token,
})
await expect(
MachineToken.findVerifiedSessionRoute(client, {
chainId,
merchant: recipient,
targetToken,
}),
).resolves.toEqual(expect.objectContaining({ merchant: recipient, payee: sessionPayee }))
await expect(
MachineToken.resolveSessionRoute(client, { chainId, payee: sessionPayee }),
).resolves.toEqual({
merchant: recipient,
operator: deployment.swap,
payee: sessionPayee,
targetToken,
token: deployment.token,
})
await expect(
MachineToken.matchSessionRoute(client, {
chainId,
descriptor: {
operator: deployment.swap,
payee: sessionPayee,
token: deployment.token,
},
merchant: recipient,
targetToken,
}),
).resolves.toEqual(expect.objectContaining({ merchant: recipient, targetToken }))
await expect(
MachineToken.matchSessionRoute(client, {
chainId,
descriptor: {
operator: deployment.swap,
payee: sessionPayee,
token: deployment.token,
},
merchant: payer,
targetToken,
}),
).resolves.toBeUndefined()
expect(MachineToken.isSessionSupported(chainId)).toBe(true)
expect(MachineToken.isSessionSupported(defaults.chainId.mainnet)).toBe(false)
expect(MachineToken.getSessionFeeToken(chainId)).toBe(defaults.tokens.pathUsd)
expect(MachineToken.getSessionFeeToken(defaults.chainId.mainnet)).toBeUndefined()
} finally {
vi.doUnmock('viem/actions')
vi.resetModules()
}
})

test('separates the challenge capability flag from the trusted descriptor pair', async () => {
const MachineToken = await import('./machine-token.js')
expect(
MachineToken.isSessionEnabledChallenge({
request: { methodDetails: { chainId, machineTokenEnabled: true } },
}),
).toBe(true)
expect(
MachineToken.isSessionEnabledChallenge({
request: { methodDetails: { chainId, machineTokenEnabled: false } },
}),
).toBe(false)
expect(
MachineToken.matchSessionDescriptor({
chainId,
descriptor: { operator: deployment.swap, token: deployment.token },
}),
).toEqual(deployment)
expect(
MachineToken.matchSessionDescriptor({
chainId,
descriptor: { operator: recipient, token: deployment.token },
}),
).toBeUndefined()
})

test('rejects virtual payees that are no longer the active merchant route', async () => {
vi.resetModules()
vi.doMock('viem/actions', () => ({
call: vi.fn(),
readContract: vi.fn(async (_client, parameters: { functionName: string }) => {
if (parameters.functionName === 'sessionRoutes') return [recipient, targetToken]
if (parameters.functionName === 'sessionRouteFor')
return '0x0000000000000000000000000000000000000000'
throw new Error(`unexpected function ${parameters.functionName}`)
}),
}))

try {
const MachineToken = await import('./machine-token.js')
await expect(
MachineToken.resolveSessionRoute(client, { chainId, payee: sessionPayee }),
).resolves.toBeUndefined()
await expect(
MachineToken.resolveSessionRoute(client, {
active: false,
chainId,
payee: sessionPayee,
}),
).resolves.toEqual(expect.objectContaining({ merchant: recipient, targetToken }))
} finally {
vi.doUnmock('viem/actions')
vi.resetModules()
}
})
})
Loading
Loading