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/bright-machines-settle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Restored opt-in MACH funding for Tempo charges and sessions through canonical swapper routes while merchants continue to advertise their normal settlement currency, removed the dedicated direct-MACH charge path, and exposed immutable canonical machine-token deployment metadata.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const mppx = Mppx.create({
methods: [
tempo({
currency: '0x20c0000000000000000000000000000000000000',
machineTokenEnabled: true,
recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00',
}),
],
Expand Down
2 changes: 1 addition & 1 deletion scripts/check:package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-package-'
const limits = {
fileCount: 500,
packedBytes: 1_200_000,
unpackedBytes: 4_750_000,
unpackedBytes: 4_800_000,
}

/** Run a package validation command and fail with its exit status. */
Expand Down
10 changes: 6 additions & 4 deletions src/cli/sessions/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,20 @@ function assertCloseChallengeScope(challenge: TempoSessionChallenge, channel: Ch
const chainId = (challenge.request.methodDetails as { chainId?: unknown } | undefined)?.chainId
if (chainId !== undefined && chainId !== channel.chainId)
throw new Error('Close challenge changed the session chain.')
if (resolveEscrow(challenge, channel.escrow).toLowerCase() !== channel.escrow.toLowerCase())
throw new Error('Close challenge changed the session escrow.')
const payee = channel.paymentScope?.payee ?? channel.descriptor.payee
const token = channel.paymentScope?.token ?? channel.descriptor.token
if (
typeof challenge.request.recipient !== 'string' ||
challenge.request.recipient.toLowerCase() !== channel.descriptor.payee.toLowerCase()
challenge.request.recipient.toLowerCase() !== payee.toLowerCase()
)
throw new Error('Close challenge changed the session payee.')
if (
typeof challenge.request.currency !== 'string' ||
challenge.request.currency.toLowerCase() !== channel.descriptor.token.toLowerCase()
challenge.request.currency.toLowerCase() !== token.toLowerCase()
)
throw new Error('Close challenge changed the session token.')
if (resolveEscrow(challenge, channel.escrow).toLowerCase() !== channel.escrow.toLowerCase())
throw new Error('Close challenge changed the session escrow.')
const snapshot = getSessionSnapshot(challenge)
if (snapshot && snapshot.channelId.toLowerCase() !== channel.channelId.toLowerCase())
throw new Error('Close challenge changed the session channel.')
Expand Down
75 changes: 60 additions & 15 deletions src/cli/sessions/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ vi.mock('node:fs/promises', async (importOriginal) => ({
}))

import type * as Challenge from '../../Challenge.js'
import * as defaults from '../../tempo/internal/defaults.js'
import type { ChannelEntry } from '../../tempo/session/client/ChannelOps.js'
import { entryKey } from '../../tempo/session/client/ChannelStore.js'
import * as Channel from '../../tempo/session/precompile/Channel.js'
import type { SessionReceipt } from '../../tempo/session/precompile/Protocol.js'
import { tip20ChannelEscrow, type SessionReceipt } from '../../tempo/session/precompile/Protocol.js'
import sessions from './commands.js'
import {
createSessionRegistry,
Expand All @@ -30,6 +31,7 @@ const payee = '0x2222222222222222222222222222222222222222' as Address
const token = '0x3333333333333333333333333333333333333333' as Address
const escrow = '0x4444444444444444444444444444444444444444' as Address
const operator = '0x0000000000000000000000000000000000000000' as Address
const machinePayee = '0x7777777777777777777777777777777777777777' as Address
const descriptor = {
payer,
payee,
Expand All @@ -41,6 +43,12 @@ const descriptor = {
}
const channelId = Channel.computeId({ ...descriptor, escrow, chainId: 42431 })
const mainnetChannelId = Channel.computeId({ ...descriptor, escrow, chainId: 4217 })
const machineDescriptor = {
...descriptor,
operator: defaults.machineToken[42431].swap,
payee: machinePayee,
token: defaults.machineToken[42431].token,
}

let temporaryDirectory: string
let stateRoot: string
Expand Down Expand Up @@ -89,6 +97,28 @@ function challenge(id = 'challenge-1', chainId = 42431): Challenge.Challenge {
}
}

function machineChallenge(): Challenge.Challenge {
return {
...challenge('machine-challenge'),
request: {
...challenge('machine-challenge').request,
methodDetails: {
chainId: 42431,
escrowContract: tip20ChannelEscrow,
machineTokenEnabled: true,
},
},
}
}

function machineChannel(): ChannelEntry {
return channel({
descriptor: machineDescriptor,
escrow: tip20ChannelEscrow,
paymentScope: { payee, token },
})
}

function receipt(overrides: Partial<SessionReceipt> = {}): SessionReceipt {
return {
method: 'tempo',
Expand Down Expand Up @@ -277,20 +307,6 @@ describe('createSessionRegistry', () => {
expect(await fs.readFile(file, 'utf8')).toBe('{invalid json')
})

test('rejects a channel ID that does not match its descriptor', async () => {
const registry = createSessionRegistry(registryOptions())

await expect(
registry.upsert({
status: 'open',
channel: channel({ channelId: `0x${'ff'.repeat(32)}` }),
account: { address: payer },
endpoint: 'https://api.example.test/query',
challenge: challenge(),
}),
).rejects.toThrow('Session channel ID does not match its descriptor.')
})

test('rejects live and remote locks, then reclaims a dead same-host lock', async () => {
const first = createSessionRegistry(
registryOptions({ hostname: 'host-a', pid: 101, isProcessAlive: () => true }),
Expand Down Expand Up @@ -408,6 +424,35 @@ describe('createSessionRegistry', () => {
})

describe('toChannelStore', () => {
test('persists and reuses a machine channel under its logical challenge scope', async () => {
const registry = createSessionRegistry(registryOptions())
const logicalScope = scope({ escrow: tip20ChannelEscrow })
const machine = machineChannel()
const context = (): SessionPersistenceContext => ({
status: 'open',
account: { address: payer },
endpoint: 'https://api.example.test/query',
challenge: machineChallenge(),
})
const opened = toChannelStore(registry, {
scope: logicalScope,
selection: 'new',
context,
})
await opened.set(machine)

expect(await registry.getPreferred(logicalScope)).toBe(machine.channelId)
const automatic = toChannelStore(registry, {
scope: logicalScope,
selection: 'auto',
context,
})
expect(await automatic.get(entryKey(machine))).toEqual(machine)

await registry.remove(machine.channelId)
expect(await registry.getPreferred(logicalScope)).toBeUndefined()
})

test('uses preferred open sessions and never reuses opening sessions', async () => {
const registry = createSessionRegistry(registryOptions())
let status: SessionPersistenceContext['status'] = 'opening'
Expand Down
37 changes: 21 additions & 16 deletions src/cli/sessions/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ const storedChannelSchema = z.object({
escrow: z.address(),
chainId: z.number(),
opened: z.boolean(),
paymentScope: z.optional(
z.object({
payee: z.address(),
token: z.address(),
}),
),
})
const accountSchema = z.object({
name: z.optional(z.string()),
Expand Down Expand Up @@ -254,8 +260,8 @@ export function sessionScopeKey(scope: SessionScope): string {
export function sessionScope(channel: ChannelEntry): SessionScope {
return {
payer: channel.descriptor.payer,
payee: channel.descriptor.payee,
token: channel.descriptor.token,
payee: channel.paymentScope?.payee ?? channel.descriptor.payee,
token: channel.paymentScope?.token ?? channel.descriptor.token,
escrow: channel.escrow,
chainId: channel.chainId,
}
Expand Down Expand Up @@ -322,9 +328,8 @@ export function createSessionRegistry(options: CreateSessionRegistryOptions = {}
const previousValue = await readJson(file)
const previous =
previousValue === undefined ? undefined : parseStoredSession(previousValue, file)
if (previous) assertSameSession(previous, input, file)

const challenge = parseSessionChallenge(input.challenge, file)
if (previous) assertSameSession(previous, input, file)
assertChallengeMatchesChannel(challenge, channel, file)
const receipt = input.receipt
? sanitizeReceipt(input.receipt, channel.channelId, file)
Expand Down Expand Up @@ -744,6 +749,12 @@ function channelIdentity(channel: ChannelEntry): object {
),
escrow: channel.escrow.toLowerCase(),
chainId: channel.chainId,
paymentScope: channel.paymentScope
? {
payee: channel.paymentScope.payee.toLowerCase(),
token: channel.paymentScope.token.toLowerCase(),
}
: undefined,
}
}

Expand All @@ -761,32 +772,26 @@ function assertChallengeMatchesChannel(
throw stateError(file, 'Session channel ID does not match its descriptor.')
const payee = normalizeAddress(challenge.request.recipient, 'challenge recipient', file)
const token = normalizeAddress(challenge.request.currency, 'challenge currency', file)
if (payee !== channel.descriptor.payee.toLowerCase())
throw stateError(file, 'Session challenge payee does not match the channel.')
if (token !== channel.descriptor.token.toLowerCase())
throw stateError(file, 'Session challenge token does not match the channel.')
const scope = normalizeScope(sessionScope(channel))
if (resolveEscrow(challenge, channel.escrow).toLowerCase() !== channel.escrow.toLowerCase())
throw stateError(file, 'Session challenge escrow does not match the channel.')
if (isObject(challenge.request.methodDetails)) {
const methodDetails = challenge.request.methodDetails
if (methodDetails.chainId !== undefined && methodDetails.chainId !== channel.chainId)
throw stateError(file, 'Session challenge chain does not match the channel.')
}
if (payee !== scope.payee)
throw stateError(file, 'Session challenge payee does not match the channel.')
if (token !== scope.token)
throw stateError(file, 'Session challenge token does not match the channel.')
}

function assertChannelScope(
channel: ChannelEntry,
scope: SessionScope,
file?: string | undefined,
): void {
const normalized = normalizeScope(scope)
if (
channel.descriptor.payer.toLowerCase() !== normalized.payer ||
channel.descriptor.payee.toLowerCase() !== normalized.payee ||
channel.descriptor.token.toLowerCase() !== normalized.token ||
channel.escrow.toLowerCase() !== normalized.escrow ||
channel.chainId !== normalized.chainId
)
if (sessionScopeKey(sessionScope(channel)) !== sessionScopeKey(scope))
throw stateError(file, 'Session channel does not match the selected payment scope.')
}

Expand Down
7 changes: 7 additions & 0 deletions src/server/Methods.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { expectTypeOf, test } from 'vp/test'

import type { StripeClient } from '../stripe/internal/types.js'

test('accepts the server-only machine-token option on Tempo constructors', () => {
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', () => {
tempo.charge({
canOffer({ input, request }) {
Expand Down
11 changes: 11 additions & 0 deletions src/server/Methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ import { accounts } from '~test/tempo/viem.js'

const recipient = '0x0000000000000000000000000000000000000001'

describe('Tempo machine token', () => {
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 as { machineTokenEnabled?: boolean }).machineTokenEnabled).toBe(true)
})
})

describe('composable method hooks', () => {
test('all server method constructors forward canOffer', () => {
const canOffer = vi.fn(() => true)
Expand Down
52 changes: 47 additions & 5 deletions src/tempo/Methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,26 @@ describe('charge', () => {
expect(result.data.methodDetails?.supportedModes).toEqual(['pull'])
})

test('schema: serializes the machine-token hint into methodDetails', () => {
const currency = '0x20c0000000000000000000000000000000000001'
const recipient = '0x1234567890abcdef1234567890abcdef12345678'
const result = Methods.charge.schema.request.safeParse({
machineTokenEnabled: true,
amount: '1',
currency,
decimals: 6,
recipient,
})
expect(result.success).toBe(true)
if (!result.success) return

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

test('schema: rejects empty supportedModes', () => {
const result = Methods.charge.schema.request.safeParse({
amount: '1',
Expand Down Expand Up @@ -249,18 +269,40 @@ describe('session', () => {
expect(request.methodDetails?.minVoucherDelta).toBe('100000')
})

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

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

test('schema: preserves machine-token close refund authorization', () => {
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).toMatchObject({ authorizationSignature, refundSignature })
})

test('schema: preserves precompile session snapshots in method details', () => {
Expand Down
Loading
Loading