diff --git a/src/tempo/client/ChannelOps.test.ts b/src/tempo/client/ChannelOps.test.ts index e93a7d89..63539d8f 100644 --- a/src/tempo/client/ChannelOps.test.ts +++ b/src/tempo/client/ChannelOps.test.ts @@ -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' @@ -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, @@ -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() + }) }) diff --git a/src/tempo/client/ChannelOps.ts b/src/tempo/client/ChannelOps.ts index 4879b8f6..7b74ccb2 100644 --- a/src/tempo/client/ChannelOps.ts +++ b/src/tempo/client/ChannelOps.ts @@ -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 { 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, diff --git a/src/tempo/client/Session.test.ts b/src/tempo/client/Session.test.ts index d59e0dcb..ae4b2e86 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -1,5 +1,6 @@ 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' @@ -7,7 +8,9 @@ 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) { @@ -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', () => { diff --git a/src/tempo/client/Session.ts b/src/tempo/client/Session.ts index aaee3e1f..b58fda20 100644 --- a/src/tempo/client/Session.ts +++ b/src/tempo/client/Session.ts @@ -153,6 +153,7 @@ export function session(parameters: session.Parameters = {}) { escrowContract, suggestedChannelId, chainId, + amount, ) if (recovered) { const contextCumulative = context?.cumulativeAmountRaw @@ -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).`, ) } } diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index c0e33427..a7f8bf27 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -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