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
35 changes: 34 additions & 1 deletion src/tempo/client/ChannelOps.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Hex } from 'ox'
import { type Address, createClient } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { waitForTransactionReceipt } from 'viem/actions'
import { Addresses } from 'viem/tempo'
import { beforeAll, describe, expect, test } from 'vitest'
import { deployEscrow, openChannel } from '~test/tempo/session.js'
Expand All @@ -11,7 +12,8 @@ import {
chainId as chainIdDefaults,
escrowContract as escrowContractDefaults,
} from '../internal/defaults.js'
import { verifyVoucher } from '../session/Voucher.js'
import { settleOnChain } from '../session/Chain.js'
import { signVoucher, verifyVoucher } from '../session/Voucher.js'
import {
createClosePayload,
createOpenPayload,
Expand Down Expand Up @@ -291,4 +293,35 @@ describe('tryRecoverChannel', () => {
const result = await tryRecoverChannel(client, escrow, fakeChannelId, chain.id)
expect(result).toBeUndefined()
})

test('returns undefined when available balance is below the requested amount', async () => {
const salt = Hex.random(32) as `0x${string}`
const deposit = 10_000_000n
const settled = 9_500_000n
const { channelId } = await openChannel({
escrow,
payer,
payee,
token: currency,
deposit,
salt,
})

const signature = await signVoucher(
client,
payer,
{ channelId, cumulativeAmount: settled },
escrow,
chain.id,
)
const txHash = await settleOnChain(client, escrow, {
channelId,
cumulativeAmount: settled,
signature,
})
await waitForTransactionReceipt(client, { hash: txHash })

const result = await tryRecoverChannel(client, escrow, channelId, chain.id, 1_000_000n)
expect(result).toBeUndefined()
})
})
6 changes: 5 additions & 1 deletion src/tempo/client/ChannelOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,18 +207,22 @@ export async function createOpenPayload(
* amount (the safe starting point for new vouchers).
*
* Returns `undefined` if the channel doesn't exist, has zero deposit,
* or is already finalized.
* is already finalized, or lacks enough available balance.
*/
export async function tryRecoverChannel(
client: viem_Client,
escrowContract: Address,
channelId: Hex.Hex,
chainId: number,
minAvailable?: bigint,
): Promise<ChannelEntry | undefined> {
try {
const onChain = await getOnChainChannel(client, escrowContract, channelId)

if (onChain.deposit > 0n && !onChain.finalized) {
if (minAvailable !== undefined && onChain.deposit - onChain.settled < minAvailable)
return undefined

return {
channelId,
salt: '0x' as Hex.Hex,
Expand Down
50 changes: 50 additions & 0 deletions src/tempo/client/Session.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { type Address, createClient, type Hex, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { waitForTransactionReceipt } from 'viem/actions'
import { Addresses } from 'viem/tempo'
import { beforeAll, describe, expect, test } from 'vitest'
import { deployEscrow, openChannel } from '~test/tempo/session.js'
import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
import * as Challenge from '../../Challenge.js'
import * as Credential from '../../Credential.js'
import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
import { settleOnChain } from '../session/Chain.js'
import type { SessionCredentialPayload } from '../session/Types.js'
import { signVoucher } from '../session/Voucher.js'
import { session } from './Session.js'

function deserializePayload(result: string) {
Expand Down Expand Up @@ -411,6 +414,53 @@ describe('session (on-chain)', () => {
}),
).rejects.toThrow('cannot be reused')
})

test('opens a new channel when suggestedChannelId lacks available balance', async () => {
const salt = nextSalt()
const deposit = 1_500_000n
const settled = 1_000_000n
const { channelId } = await openChannel({
escrow: escrowContract,
payer,
payee,
token: asset,
deposit,
salt,
})
const signature = await signVoucher(
client,
payer,
{ channelId, cumulativeAmount: settled },
escrowContract,
chain.id,
)
const txHash = await settleOnChain(client, escrowContract, {
channelId,
cumulativeAmount: settled,
signature,
})
await waitForTransactionReceipt(client, { hash: txHash })

const method = session({
getClient: () => client,
account: payer,
deposit: '10',
escrowContract,
})
const challenge = makeLiveChallenge({
methodDetails: {
chainId: chain.id,
escrowContract,
channelId,
},
})

const result = await method.createCredential({ challenge, context: {} })

const cred = deserializePayload(result)
expect(cred.payload.action).toBe('open')
expect(cred.payload.channelId).not.toBe(channelId)
})
})

describe('cumulative tracking in auto mode', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/tempo/client/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export function session(parameters: session.Parameters = {}) {
escrowContract,
suggestedChannelId,
chainId,
amount,
)
if (recovered) {
const contextCumulative = context?.cumulativeAmountRaw
Expand All @@ -168,7 +169,7 @@ export function session(parameters: session.Parameters = {}) {
notifyUpdate(entry)
} else if (context?.channelId) {
throw new Error(
`Channel ${context.channelId} cannot be reused (closed or not found on-chain).`,
`Channel ${context.channelId} cannot be reused (closed, not found, or lacking available balance on-chain).`,
)
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/tempo/client/SessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ export type PaymentResponse = Response & {
*
* When the server includes a `channelId` in the 402 challenge `methodDetails`,
* the client will attempt to recover the channel by reading its on-chain state
* via `getOnChainChannel()`. If the channel has a positive deposit and is not
* finalized, it resumes from the on-chain settled amount.
* via `getOnChainChannel()`. If the channel has enough available balance and is
* not finalized, it resumes from the on-chain settled amount. Otherwise, it
* opens a new channel for the next payment.
*/
export function sessionManager(parameters: sessionManager.Parameters): SessionManager {
const fetchFn = parameters.fetch ?? globalThis.fetch
Expand Down