Skip to content

Commit 8d991bc

Browse files
committed
fix: harden session hint reconciliation
1 parent 8bcc48c commit 8d991bc

File tree

6 files changed

+194
-48
lines changed

6 files changed

+194
-48
lines changed

src/tempo/client/ChannelOps.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,23 @@ describe('createClosePayload', () => {
172172
})
173173

174174
describe('reconcileChannelEntry', () => {
175+
test('hinted entries do not treat server snapshots as local authorization', () => {
176+
const entry = createHintedChannelEntry({
177+
chainId,
178+
channelId,
179+
escrowContract,
180+
hints: {
181+
acceptedCumulative: '6000000',
182+
deposit: '10000000',
183+
spent: '4000000',
184+
},
185+
})
186+
187+
expect(entry.acceptedCumulative).toBe(6_000_000n)
188+
expect(entry.cumulativeAmount).toBe(0n)
189+
expect(entry.spent).toBe(4_000_000n)
190+
})
191+
175192
test('does not move channel state backwards for stale snapshots', () => {
176193
const entry = createHintedChannelEntry({
177194
chainId,

src/tempo/client/ChannelOps.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,18 @@ import type {
2929
import { signVoucher } from '../session/Voucher.js'
3030

3131
export type ChannelEntry = {
32+
/** Highest voucher amount observed from server accounting hints or receipts. */
3233
acceptedCumulative: bigint
3334
chainId: number
3435
channelId: Hex.Hex
36+
/** Highest cumulative amount the client itself has signed for this channel. */
3537
cumulativeAmount: bigint
38+
/** Latest known deposit ceiling. */
3639
deposit?: bigint | undefined
3740
escrowContract: Address
3841
opened: boolean
3942
salt: Hex.Hex
43+
/** Latest server-reported spent amount for the session. */
4044
spent: bigint
4145
}
4246

@@ -77,6 +81,7 @@ export function serializeCredential(
7781
}
7882

7983
type ChannelSnapshot = {
84+
/** Advisory server snapshot: not safe to treat as client authorization. */
8085
acceptedCumulative?: bigint | string | undefined
8186
deposit?: bigint | string | undefined
8287
spent?: bigint | string | undefined
@@ -103,7 +108,8 @@ export function createHintedChannelEntry(options: {
103108
acceptedCumulative,
104109
chainId: options.chainId,
105110
channelId: options.channelId,
106-
cumulativeAmount: acceptedCumulative,
111+
// Hints are advisory only. Start signing from locally authorized state.
112+
cumulativeAmount: 0n,
107113
...(options.hints.deposit !== undefined && { deposit: BigInt(options.hints.deposit) }),
108114
escrowContract: options.escrowContract,
109115
opened: true,
@@ -121,10 +127,6 @@ export function reconcileChannelEntry(entry: ChannelEntry, snapshot: ChannelSnap
121127
entry.acceptedCumulative = acceptedCumulative
122128
changed = true
123129
}
124-
if (acceptedCumulative > entry.cumulativeAmount) {
125-
entry.cumulativeAmount = acceptedCumulative
126-
changed = true
127-
}
128130
}
129131

130132
if (snapshot.spent !== undefined) {
@@ -137,10 +139,6 @@ export function reconcileChannelEntry(entry: ChannelEntry, snapshot: ChannelSnap
137139
entry.acceptedCumulative = spent
138140
changed = true
139141
}
140-
if (snapshot.acceptedCumulative === undefined && entry.cumulativeAmount < spent) {
141-
entry.cumulativeAmount = spent
142-
changed = true
143-
}
144142
}
145143

146144
if (snapshot.deposit !== undefined) {

src/tempo/client/Session.test.ts

Lines changed: 117 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe('session (pure)', () => {
6969
describe('server-authored hints', () => {
7070
const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
7171

72-
test('prefers requiredCumulative and hydrates channel from challenge hints', async () => {
72+
test('hydrates accounting hints without inflating the next signed voucher', async () => {
7373
const method = session({
7474
getClient: () => pureClient,
7575
account: pureAccount,
@@ -95,11 +95,11 @@ describe('session (pure)', () => {
9595
expect(cred.payload.action).toBe('voucher')
9696
if (cred.payload.action === 'voucher') {
9797
expect(cred.payload.channelId).toBe(channelId)
98-
expect(cred.payload.cumulativeAmount).toBe('6000000')
98+
expect(cred.payload.cumulativeAmount).toBe('1000000')
9999
}
100100
})
101101

102-
test('does not let stale requiredCumulative move local cumulative backwards', async () => {
102+
test('keeps cumulative strictly local across repeated hinted requests', async () => {
103103
const method = session({
104104
getClient: () => pureClient,
105105
account: pureAccount,
@@ -124,10 +124,49 @@ describe('session (pure)', () => {
124124
expect(first.payload.action).toBe('voucher')
125125
expect(second.payload.action).toBe('voucher')
126126
if (first.payload.action === 'voucher') {
127-
expect(first.payload.cumulativeAmount).toBe('6000000')
127+
expect(first.payload.cumulativeAmount).toBe('1000000')
128128
}
129129
if (second.payload.action === 'voucher') {
130-
expect(second.payload.cumulativeAmount).toBe('7000000')
130+
expect(second.payload.cumulativeAmount).toBe('2000000')
131+
}
132+
})
133+
134+
test('keeps the current local channel when a server-supplied replacement cannot be verified', async () => {
135+
const channelIdA = '0x00000000000000000000000000000000000000000000000000000000000000aa' as Hex
136+
const channelIdB = '0x00000000000000000000000000000000000000000000000000000000000000bb' as Hex
137+
const method = session({
138+
getClient: () => pureClient,
139+
account: pureAccount,
140+
deposit: '10',
141+
})
142+
143+
const challengeA = makeChallenge({
144+
methodDetails: {
145+
acceptedCumulative: '5000000',
146+
chainId: 42431,
147+
channelId: channelIdA,
148+
deposit: '10000000',
149+
escrowContract: escrowAddress,
150+
spent: '4000000',
151+
},
152+
})
153+
const challengeB = makeChallenge({
154+
methodDetails: {
155+
chainId: 42431,
156+
channelId: channelIdB,
157+
escrowContract: escrowAddress,
158+
},
159+
})
160+
161+
await method.createCredential({ challenge: challengeA, context: {} })
162+
const result = deserializePayload(
163+
await method.createCredential({ challenge: challengeB, context: {} }),
164+
)
165+
166+
expect(result.payload.action).toBe('voucher')
167+
if (result.payload.action === 'voucher') {
168+
expect(result.payload.channelId).toBe(channelIdA)
169+
expect(result.payload.cumulativeAmount).toBe('2000000')
131170
}
132171
})
133172
})
@@ -483,7 +522,7 @@ describe.runIf(isLocalnet)('session (on-chain)', () => {
483522
).rejects.toThrow('cannot be reused')
484523
})
485524

486-
test('falls back to opening a new channel when hints omit cumulative state', async () => {
525+
test('throws when a server-supplied channelId cannot be recovered', async () => {
487526
const hintedChannelId =
488527
'0x0000000000000000000000000000000000000000000000000000000000000bad' as Hex
489528
const method = session({
@@ -502,12 +541,77 @@ describe.runIf(isLocalnet)('session (on-chain)', () => {
502541
},
503542
})
504543

505-
const result = await method.createCredential({ challenge, context: {} })
506-
const cred = deserializePayload(result)
544+
await expect(method.createCredential({ challenge, context: {} })).rejects.toThrow(
545+
'cannot be reused',
546+
)
547+
})
507548

508-
expect(cred.payload.action).toBe('open')
509-
if (cred.payload.action === 'open') {
510-
expect(cred.payload.channelId).not.toBe(hintedChannelId)
549+
test('ignores stale receipts after rebinding to a newly recovered channel', async () => {
550+
const { channelId: channelIdA } = await openChannel({
551+
escrow: escrowContract,
552+
payer,
553+
payee,
554+
token: asset,
555+
deposit: 10_000_000n,
556+
salt: nextSalt(),
557+
})
558+
const { channelId: channelIdB } = await openChannel({
559+
escrow: escrowContract,
560+
payer,
561+
payee,
562+
token: asset,
563+
deposit: 10_000_000n,
564+
salt: nextSalt(),
565+
})
566+
567+
const method = session({
568+
getClient: () => client,
569+
account: payer,
570+
deposit: '10',
571+
escrowContract,
572+
})
573+
574+
const challengeA = makeLiveChallenge({
575+
methodDetails: {
576+
chainId: chain.id,
577+
escrowContract,
578+
channelId: channelIdA,
579+
},
580+
})
581+
const challengeB = makeLiveChallenge({
582+
methodDetails: {
583+
chainId: chain.id,
584+
escrowContract,
585+
channelId: channelIdB,
586+
},
587+
})
588+
589+
await method.createCredential({ challenge: challengeA, context: {} })
590+
await method.createCredential({ challenge: challengeB, context: {} })
591+
592+
method.onResponse(
593+
new Response(null, {
594+
headers: {
595+
'Payment-Receipt': serializeSessionReceipt(
596+
createSessionReceipt({
597+
challengeId: challengeA.id,
598+
channelId: channelIdA,
599+
acceptedCumulative: 9_000_000n,
600+
spent: 9_000_000n,
601+
}),
602+
),
603+
},
604+
}),
605+
)
606+
607+
const result = deserializePayload(
608+
await method.createCredential({ challenge: challengeB, context: {} }),
609+
)
610+
611+
expect(result.payload.action).toBe('voucher')
612+
if (result.payload.action === 'voucher') {
613+
expect(result.payload.channelId).toBe(channelIdB)
614+
expect(result.payload.cumulativeAmount).toBe('2000000')
511615
}
512616
})
513617
})
@@ -547,7 +651,7 @@ describe.runIf(isLocalnet)('session (on-chain)', () => {
547651
expect(updates[1]!.cumulativeAmount).toBe(2_000_000n)
548652
})
549653

550-
test('reconciles local cumulative from Payment-Receipt before the next voucher', async () => {
654+
test('does not let Payment-Receipt inflate the next voucher amount', async () => {
551655
const method = session({
552656
getClient: () => client,
553657
account: payer,
@@ -579,7 +683,7 @@ describe.runIf(isLocalnet)('session (on-chain)', () => {
579683
const secondCred = deserializePayload(second)
580684
expect(secondCred.payload.action).toBe('voucher')
581685
if (secondCred.payload.action === 'voucher') {
582-
expect(secondCred.payload.cumulativeAmount).toBe('6000000')
686+
expect(secondCred.payload.cumulativeAmount).toBe('2000000')
583687
}
584688
})
585689
})

0 commit comments

Comments
 (0)