Skip to content

Commit 3c0500b

Browse files
committed
fix(tempo): keep selected session rail sticky
1 parent da860b5 commit 3c0500b

2 files changed

Lines changed: 214 additions & 5 deletions

File tree

src/tempo/session/client/Session.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,145 @@ describe('precompile client session', () => {
459459
expect(payload.authorizationSignature).toBeDefined()
460460
})
461461

462+
test.each([
463+
[
464+
'the route becomes inactive',
465+
{ active: false },
466+
'Machine-token channel is not bound to this merchant session challenge.',
467+
],
468+
['the route RPC fails', { routeError: true }, 'route RPC failed'],
469+
] as const)(
470+
'keeps a selected machine-token channel sticky when %s',
471+
async (_name, clientOptions, expectedError) => {
472+
const machineDescriptor = getMachineDescriptor()
473+
const machineKey = channelKey({
474+
chainId,
475+
escrow: tip20ChannelEscrow,
476+
payee: machineDescriptor.payee,
477+
token: machineDescriptor.token,
478+
})
479+
const channelId = Channel.computeId({
480+
...machineDescriptor,
481+
chainId,
482+
escrow: tip20ChannelEscrow,
483+
})
484+
const channelStore = createChannelStore()
485+
await channelStore.set({
486+
chainId,
487+
channelId,
488+
cumulativeAmount: 100n,
489+
deposit: 1_000n,
490+
descriptor: machineDescriptor,
491+
escrow: tip20ChannelEscrow,
492+
opened: true,
493+
})
494+
let currentClient = createMachineClient({ balance: 0n })
495+
const resolveAccount = vi.fn(() => account)
496+
const onChannelUpdate = vi.fn()
497+
const method = session({
498+
account,
499+
channelStore,
500+
decimals: 0,
501+
getClient: () => currentClient,
502+
onChannelUpdate,
503+
resolveAccount,
504+
})
505+
const challenge = () =>
506+
makeSessionChallenge({
507+
methodDetails: { escrowContract: tip20ChannelEscrow, machineTokenEnabled: true },
508+
})
509+
510+
expect(
511+
deserialize(await method.createCredential({ challenge: challenge(), context: {} })),
512+
).toMatchObject({
513+
action: 'voucher',
514+
channelId,
515+
cumulativeAmount: '200',
516+
})
517+
currentClient = createMachineClient(clientOptions)
518+
resolveAccount.mockClear()
519+
520+
await expect(
521+
method.createCredential({ challenge: challenge(), context: {} }),
522+
).rejects.toThrow(expectedError)
523+
expect(resolveAccount).not.toHaveBeenCalled()
524+
expect(await channelStore.get(defaultChannelKey)).toBeUndefined()
525+
expect(await channelStore.get(machineKey)).toMatchObject({
526+
channelId,
527+
cumulativeAmount: 200n,
528+
opened: true,
529+
})
530+
expect(onChannelUpdate).toHaveBeenCalledTimes(1)
531+
},
532+
)
533+
534+
test('keeps a selected direct channel direct when a machine channel later appears', async () => {
535+
const directChannelId = Channel.computeId({
536+
...descriptor,
537+
chainId,
538+
escrow: tip20ChannelEscrow,
539+
})
540+
const channelStore = createChannelStore()
541+
await channelStore.set({
542+
chainId,
543+
channelId: directChannelId,
544+
cumulativeAmount: 100n,
545+
deposit: 1_000n,
546+
descriptor,
547+
escrow: tip20ChannelEscrow,
548+
opened: true,
549+
})
550+
const method = session({
551+
account,
552+
channelStore,
553+
decimals: 0,
554+
getClient: () => machineClient,
555+
})
556+
const challenge = () =>
557+
makeSessionChallenge({
558+
methodDetails: { escrowContract: tip20ChannelEscrow, machineTokenEnabled: true },
559+
})
560+
561+
expect(
562+
deserialize(await method.createCredential({ challenge: challenge(), context: {} })),
563+
).toMatchObject({
564+
action: 'voucher',
565+
channelId: directChannelId,
566+
cumulativeAmount: '200',
567+
})
568+
569+
const machineDescriptor = getMachineDescriptor()
570+
const machineChannelId = Channel.computeId({
571+
...machineDescriptor,
572+
chainId,
573+
escrow: tip20ChannelEscrow,
574+
})
575+
const machineKey = channelKey({
576+
chainId,
577+
escrow: tip20ChannelEscrow,
578+
payee: machineDescriptor.payee,
579+
token: machineDescriptor.token,
580+
})
581+
await channelStore.set({
582+
chainId,
583+
channelId: machineChannelId,
584+
cumulativeAmount: 100n,
585+
deposit: 1_000n,
586+
descriptor: machineDescriptor,
587+
escrow: tip20ChannelEscrow,
588+
opened: true,
589+
})
590+
591+
expect(
592+
deserialize(await method.createCredential({ challenge: challenge(), context: {} })),
593+
).toMatchObject({
594+
action: 'voucher',
595+
channelId: directChannelId,
596+
cumulativeAmount: '300',
597+
})
598+
expect(await channelStore.get(machineKey)).toMatchObject({ cumulativeAmount: 100n })
599+
})
600+
462601
test('rejects an underfunded machine-token top-up without mutating channel state', async () => {
463602
const machineDescriptor = getMachineDescriptor()
464603
const channelId = Channel.computeId({

src/tempo/session/client/Session.ts

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export { sessionContextSchema, type SessionContext } from './CredentialState.js'
5656
/** Tail promise for each payment scope, used to serialize automatic opens per store. */
5757
const channelTails = new WeakMap<ChannelStore, Map<string, Promise<void>>>()
5858

59+
type SelectedRail = { type: 'direct' } | { type: 'machine'; route: MachineTokenSession.Route }
60+
61+
/** Selected rail for each logical payment scope, shared by methods using one store. */
62+
const selectedRails = new WeakMap<ChannelStore, Map<string, SelectedRail>>()
63+
5964
/** Serializes automatic opens for one payment scope across methods sharing a store. */
6065
async function lockChannel(store: ChannelStore, key: string) {
6166
const tails = channelTails.get(store) ?? new Map<string, Promise<void>>()
@@ -199,6 +204,8 @@ export function session(parameters: session.Parameters = {}) {
199204
topUpAmountParameter !== undefined ? parseUnits(topUpAmountParameter, decimals) : undefined
200205
const store = channelStore ?? createChannelStore()
201206
const sink = { store, notifyUpdate: (entry: ChannelEntry) => onChannelUpdate?.(entry) }
207+
const rails = selectedRails.get(store) ?? new Map<string, SelectedRail>()
208+
selectedRails.set(store, rails)
202209

203210
type ResolvedSelection = {
204211
account: ViemAccount
@@ -254,6 +261,48 @@ export function session(parameters: session.Parameters = {}) {
254261
resolved,
255262
})
256263

264+
const rememberRail = (
265+
direct: ChallengeContext,
266+
resolved: ChallengeContext,
267+
entry: ChannelEntry,
268+
) => {
269+
if (!entry.opened) return
270+
rails.set(
271+
direct.key,
272+
resolved.machineRoute
273+
? { type: 'machine', route: resolved.machineRoute }
274+
: { type: 'direct' },
275+
)
276+
}
277+
278+
const resolveSelectedChannel = async (
279+
direct: ChallengeContext,
280+
): Promise<{ channel: ChannelEntry; resolved: ChallengeContext } | undefined> => {
281+
const rail = rails.get(direct.key)
282+
if (!rail) return undefined
283+
const cached = rail.type === 'direct' ? direct : applyMachineTokenRoute(direct, rail.route)
284+
const channel = await store.get(cached.key)
285+
if (!channel?.opened) {
286+
rails.delete(direct.key)
287+
return undefined
288+
}
289+
if (rail.type === 'direct') return { channel, resolved: direct }
290+
if (
291+
!MachineTokenSession.isEnabledChallenge(direct.challenge) ||
292+
!isAddressEqual(direct.escrow, tip20ChannelEscrow)
293+
)
294+
throw new Error('Machine-token channel is not bound to this merchant session challenge.')
295+
const route = await MachineTokenSession.matchRoute(direct.client, {
296+
chainId: direct.chainId,
297+
descriptor: rail.route,
298+
merchant: direct.payee,
299+
targetToken: direct.token,
300+
})
301+
if (!route)
302+
throw new Error('Machine-token channel is not bound to this merchant session challenge.')
303+
return { channel, resolved: applyMachineTokenRoute(direct, route) }
304+
}
305+
257306
/** Selects one rail for this channel lifecycle before any credential is signed. */
258307
const resolveSelection = async (
259308
direct: ChallengeContext,
@@ -267,7 +316,13 @@ export function session(parameters: session.Parameters = {}) {
267316
return select(resolved, context, await store.get(resolved.key))
268317
}
269318

270-
const selectDirect = async () => select(direct, context, await store.get(direct.key))
319+
const selectDirect = async () => {
320+
const entry = await store.get(direct.key)
321+
if (entry?.opened) rememberRail(direct, direct, entry)
322+
return select(direct, context, entry)
323+
}
324+
const selected = await resolveSelectedChannel(direct)
325+
if (selected) return select(selected.resolved, context, selected.channel)
271326
if (
272327
!MachineTokenSession.isEnabledChallenge(direct.challenge) ||
273328
!isAddressEqual(direct.escrow, tip20ChannelEscrow)
@@ -283,10 +338,16 @@ export function session(parameters: session.Parameters = {}) {
283338

284339
const machine = applyMachineTokenRoute(direct, route)
285340
const machineEntry = await store.get(machine.key)
286-
if (machineEntry?.opened) return select(machine, context, machineEntry)
341+
if (machineEntry?.opened) {
342+
rememberRail(direct, machine, machineEntry)
343+
return select(machine, context, machineEntry)
344+
}
287345

288346
const directEntry = await store.get(direct.key)
289-
if (directEntry?.opened) return select(direct, context, directEntry)
347+
if (directEntry?.opened) {
348+
rememberRail(direct, direct, directEntry)
349+
return select(direct, context, directEntry)
350+
}
290351

291352
const account = await resolveCredentialAccount(machine, context, undefined)
292353
const openingDeposit = resolveOpeningDeposit({
@@ -320,6 +381,9 @@ export function session(parameters: session.Parameters = {}) {
320381
return { channel: await store.get(resolved.key), resolved }
321382
}
322383

384+
const selected = await resolveSelectedChannel(direct)
385+
if (selected) return selected
386+
323387
if (
324388
MachineTokenSession.isEnabledChallenge(direct.challenge) &&
325389
isAddressEqual(direct.escrow, tip20ChannelEscrow)
@@ -449,8 +513,13 @@ export function session(parameters: session.Parameters = {}) {
449513
payload = { ...payload, authorizationSignature }
450514
}
451515
if (pendingEntry && !(attempt && plan.type === 'open')) {
452-
if (pendingEntry.opened) await store.set(pendingEntry)
453-
else await store.delete(resolved.key)
516+
if (pendingEntry.opened) {
517+
await store.set(pendingEntry)
518+
rememberRail(direct, resolved, pendingEntry)
519+
} else {
520+
await store.delete(resolved.key)
521+
rails.delete(direct.key)
522+
}
454523
sink.notifyUpdate(pendingEntry)
455524
}
456525
const credential = await serializeCredential(
@@ -475,6 +544,7 @@ export function session(parameters: session.Parameters = {}) {
475544
await store.set(opened)
476545
sink.notifyUpdate(opened)
477546
}
547+
rememberRail(direct, resolved, opened)
478548
}
479549
return true
480550
} finally {

0 commit comments

Comments
 (0)