From 1f820d042b6adb3b253bc2b6ac8f8a0fb94604c9 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 13 May 2026 16:33:15 +0200 Subject: [PATCH 01/26] Add Tempo precompile channel primitives --- .changeset/tip-1034-precompile-hash.md | 5 + src/tempo/client/Methods.ts | 3 + src/tempo/index.ts | 1 + src/tempo/precompile/Chain.test.ts | 193 +++++++++++++++++ src/tempo/precompile/Chain.ts | 198 ++++++++++++++++++ src/tempo/precompile/Channel.test.ts | 109 ++++++++++ src/tempo/precompile/Channel.ts | 89 ++++++++ src/tempo/precompile/Constants.ts | 2 + src/tempo/precompile/Types.test.ts | 29 +++ src/tempo/precompile/Types.ts | 21 ++ src/tempo/precompile/Voucher.test.ts | 239 ++++++++++++++++++++++ src/tempo/precompile/Voucher.ts | 108 ++++++++++ src/tempo/precompile/client/ChannelOps.ts | 82 ++++++++ src/tempo/precompile/client/index.ts | 1 + src/tempo/precompile/escrow.abi.ts | 226 ++++++++++++++++++++ src/tempo/precompile/index.ts | 8 + src/tempo/precompile/server/ChannelOps.ts | 73 +++++++ src/tempo/precompile/server/index.ts | 1 + src/tempo/server/Methods.ts | 3 + 19 files changed, 1391 insertions(+) create mode 100644 .changeset/tip-1034-precompile-hash.md create mode 100644 src/tempo/precompile/Chain.test.ts create mode 100644 src/tempo/precompile/Chain.ts create mode 100644 src/tempo/precompile/Channel.test.ts create mode 100644 src/tempo/precompile/Channel.ts create mode 100644 src/tempo/precompile/Constants.ts create mode 100644 src/tempo/precompile/Types.test.ts create mode 100644 src/tempo/precompile/Types.ts create mode 100644 src/tempo/precompile/Voucher.test.ts create mode 100644 src/tempo/precompile/Voucher.ts create mode 100644 src/tempo/precompile/client/ChannelOps.ts create mode 100644 src/tempo/precompile/client/index.ts create mode 100644 src/tempo/precompile/escrow.abi.ts create mode 100644 src/tempo/precompile/index.ts create mode 100644 src/tempo/precompile/server/ChannelOps.ts create mode 100644 src/tempo/precompile/server/index.ts diff --git a/.changeset/tip-1034-precompile-hash.md b/.changeset/tip-1034-precompile-hash.md new file mode 100644 index 00000000..f23f7871 --- /dev/null +++ b/.changeset/tip-1034-precompile-hash.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added Tempo TIP-1034 precompile channel helpers for computing channel IDs, expiring nonce hashes, vouchers, ABI calldata, and open validation. diff --git a/src/tempo/client/Methods.ts b/src/tempo/client/Methods.ts index a46908d8..ab09b66b 100644 --- a/src/tempo/client/Methods.ts +++ b/src/tempo/client/Methods.ts @@ -1,3 +1,4 @@ +import * as Precompile_ from '../precompile/index.js' import { charge as charge_ } from './Charge.js' import { session as sessionIntent_ } from './Session.js' import { sessionManager as session_ } from './SessionManager.js' @@ -26,6 +27,8 @@ export namespace tempo { export const charge = charge_ /** Creates a client-side streaming session for managing payment channels. */ export const session = session_ + /** TIP-1034 precompile primitives for opt-in session implementations. */ + export const precompile = Precompile_ /** Creates a Tempo `subscription` client method for recurring TIP-20 payments. */ export const subscription = subscription_ } diff --git a/src/tempo/index.ts b/src/tempo/index.ts index 65875ac7..d13fb128 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -1,4 +1,5 @@ export * as Proof from './Proof.js' export * as Methods from './Methods.js' +export * as Precompile from './precompile/index.js' export * as Session from './session/index.js' export * as Subscription from './subscription/index.js' diff --git a/src/tempo/precompile/Chain.test.ts b/src/tempo/precompile/Chain.test.ts new file mode 100644 index 00000000..9f63a980 --- /dev/null +++ b/src/tempo/precompile/Chain.test.ts @@ -0,0 +1,193 @@ +import { decodeFunctionData, encodeFunctionData, erc20Abi } from 'viem' +import { describe, expect, test } from 'vp/test' + +import * as Chain from './Chain.js' +import { escrowAbi } from './escrow.abi.js' +import * as ServerChannelOps from './server/ChannelOps.js' +import * as Types from './Types.js' + +const descriptor = { + payer: '0x1111111111111111111111111111111111111111', + payee: '0x2222222222222222222222222222222222222222', + operator: '0x3333333333333333333333333333333333333333', + token: '0x4444444444444444444444444444444444444444', + salt: '0x0000000000000000000000000000000000000000000000000000000000000001', + authorizedSigner: '0x5555555555555555555555555555555555555555', + expiringNonceHash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', +} as const + +const deposit = Types.uint96(1_000_000n) +const cumulativeAmount = Types.uint96(500_000n) +const captureAmount = Types.uint96(400_000n) +const signature = '0x1234' as const + +function expectDescriptor(actual: unknown) { + expect(actual).toEqual(descriptor) +} + +describe('precompile Chain encoders', () => { + test('encodeOpen round-trips through parseOpenCall', () => { + const data = Chain.encodeOpen({ + authorizedSigner: descriptor.authorizedSigner, + deposit, + operator: descriptor.operator, + payee: descriptor.payee, + salt: descriptor.salt, + token: descriptor.token, + }) + const open = ServerChannelOps.parseOpenCall({ + data, + expected: { + authorizedSigner: descriptor.authorizedSigner, + deposit, + operator: descriptor.operator, + payee: descriptor.payee, + token: descriptor.token, + }, + }) + expect(open).toEqual({ + authorizedSigner: descriptor.authorizedSigner, + deposit, + operator: descriptor.operator, + payee: descriptor.payee, + salt: descriptor.salt, + token: descriptor.token, + }) + }) + + test('parseOpenCall rejects non-open calldata and expected mismatches', () => { + const approve = encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [descriptor.payee, deposit], + }) + expect(() => ServerChannelOps.parseOpenCall({ data: approve })).toThrow( + 'Expected TIP-1034 open calldata', + ) + + const data = Chain.encodeOpen({ + authorizedSigner: descriptor.authorizedSigner, + deposit, + operator: descriptor.operator, + payee: descriptor.payee, + salt: descriptor.salt, + token: descriptor.token, + }) + expect(() => + ServerChannelOps.parseOpenCall({ + data, + expected: { payee: '0xffffffffffffffffffffffffffffffffffffffff' }, + }), + ).toThrow('payee does not match') + expect(() => + ServerChannelOps.parseOpenCall({ + data, + expected: { operator: '0xffffffffffffffffffffffffffffffffffffffff' }, + }), + ).toThrow('operator does not match') + expect(() => + ServerChannelOps.parseOpenCall({ + data, + expected: { token: '0xffffffffffffffffffffffffffffffffffffffff' }, + }), + ).toThrow('token does not match') + expect(() => + ServerChannelOps.parseOpenCall({ + data, + expected: { authorizedSigner: '0xffffffffffffffffffffffffffffffffffffffff' }, + }), + ).toThrow('authorizedSigner does not match') + expect(() => + ServerChannelOps.parseOpenCall({ data, expected: { deposit: Types.uint96(1n) } }), + ).toThrow('deposit does not match') + }) + + test('encodes descriptor-based lifecycle calls', () => { + const settle = decodeFunctionData({ + abi: escrowAbi, + data: Chain.encodeSettle(descriptor, cumulativeAmount, signature), + }) + expect(settle.functionName).toBe('settle') + expectDescriptor(settle.args[0]) + expect(settle.args[1]).toBe(cumulativeAmount) + expect(settle.args[2]).toBe(signature) + + const topUp = decodeFunctionData({ + abi: escrowAbi, + data: Chain.encodeTopUp(descriptor, deposit), + }) + expect(topUp.functionName).toBe('topUp') + expectDescriptor(topUp.args[0]) + expect(topUp.args[1]).toBe(deposit) + + const close = decodeFunctionData({ + abi: escrowAbi, + data: Chain.encodeClose(descriptor, cumulativeAmount, captureAmount, signature), + }) + expect(close.functionName).toBe('close') + expectDescriptor(close.args[0]) + expect(close.args[1]).toBe(cumulativeAmount) + expect(close.args[2]).toBe(captureAmount) + expect(close.args[3]).toBe(signature) + + const requestClose = decodeFunctionData({ + abi: escrowAbi, + data: Chain.encodeRequestClose(descriptor), + }) + expect(requestClose.functionName).toBe('requestClose') + expectDescriptor(requestClose.args[0]) + + const withdraw = decodeFunctionData({ abi: escrowAbi, data: Chain.encodeWithdraw(descriptor) }) + expect(withdraw.functionName).toBe('withdraw') + expectDescriptor(withdraw.args[0]) + }) +}) + +describe('precompile escrowAbi parity', () => { + test('contains all TIP-1034 functions and events', () => { + const functions = escrowAbi.filter((item) => item.type === 'function').map((item) => item.name) + expect(functions).toEqual([ + 'CLOSE_GRACE_PERIOD', + 'VOUCHER_TYPEHASH', + 'open', + 'settle', + 'topUp', + 'close', + 'requestClose', + 'withdraw', + 'getChannel', + 'getChannelState', + 'getChannelStatesBatch', + 'computeChannelId', + 'getVoucherDigest', + 'domainSeparator', + ]) + + const events = escrowAbi.filter((item) => item.type === 'event').map((item) => item.name) + expect(events).toEqual([ + 'ChannelOpened', + 'Settled', + 'TopUp', + 'CloseRequested', + 'ChannelClosed', + 'CloseRequestCancelled', + ]) + }) + + test('keeps ChannelDescriptor component order and ChannelOpened expiringNonceHash', () => { + const settle = escrowAbi.find((item) => item.type === 'function' && item.name === 'settle')! + const descriptorInput = settle.inputs[0] + expect(descriptorInput.components.map((component) => component.name)).toEqual([ + 'payer', + 'payee', + 'operator', + 'token', + 'salt', + 'authorizedSigner', + 'expiringNonceHash', + ]) + + const opened = escrowAbi.find((item) => item.type === 'event' && item.name === 'ChannelOpened')! + expect(opened.inputs.map((input) => input.name)).toContain('expiringNonceHash') + }) +}) diff --git a/src/tempo/precompile/Chain.ts b/src/tempo/precompile/Chain.ts new file mode 100644 index 00000000..cd79a12b --- /dev/null +++ b/src/tempo/precompile/Chain.ts @@ -0,0 +1,198 @@ +import type { Address, Client, Hex } from 'viem' +import { encodeFunctionData } from 'viem' +import { readContract, sendTransaction } from 'viem/actions' + +import type { ChannelDescriptor } from './Channel.js' +import { tip20ChannelEscrow } from './Constants.js' +import { escrowAbi } from './escrow.abi.js' +import type { Uint96 } from './Types.js' +import { uint96 } from './Types.js' + +export type ChannelState = { + settled: Uint96 + deposit: Uint96 + closeRequestedAt: number +} + +export type Channel = { + descriptor: ChannelDescriptor + state: ChannelState +} + +function stateFromTuple(state: { + settled: bigint + deposit: bigint + closeRequestedAt: number +}): ChannelState { + return { + settled: uint96(state.settled), + deposit: uint96(state.deposit), + closeRequestedAt: state.closeRequestedAt, + } +} + +function descriptorTuple(descriptor: ChannelDescriptor) { + return { + payer: descriptor.payer, + payee: descriptor.payee, + operator: descriptor.operator, + token: descriptor.token, + salt: descriptor.salt, + authorizedSigner: descriptor.authorizedSigner, + expiringNonceHash: descriptor.expiringNonceHash, + } as const +} + +/** Encodes a TIP-1034 approve-less `open` call. */ +export function encodeOpen(parameters: { + payee: Address + operator: Address + token: Address + deposit: Uint96 + salt: Hex + authorizedSigner: Address +}): Hex { + return encodeFunctionData({ + abi: escrowAbi, + functionName: 'open', + args: [ + parameters.payee, + parameters.operator, + parameters.token, + parameters.deposit, + parameters.salt, + parameters.authorizedSigner, + ], + }) +} + +/** Encodes a descriptor-based TIP-1034 `settle` call. */ +export function encodeSettle( + descriptor: ChannelDescriptor, + cumulativeAmount: Uint96, + signature: Hex, +): Hex { + return encodeFunctionData({ + abi: escrowAbi, + functionName: 'settle', + args: [descriptorTuple(descriptor), cumulativeAmount, signature], + }) +} + +/** Encodes a descriptor-based TIP-1034 `topUp` call. */ +export function encodeTopUp(descriptor: ChannelDescriptor, additionalDeposit: Uint96): Hex { + return encodeFunctionData({ + abi: escrowAbi, + functionName: 'topUp', + args: [descriptorTuple(descriptor), additionalDeposit], + }) +} + +/** Encodes a descriptor-based TIP-1034 `close` call. */ +export function encodeClose( + descriptor: ChannelDescriptor, + cumulativeAmount: Uint96, + captureAmount: Uint96, + signature: Hex, +): Hex { + return encodeFunctionData({ + abi: escrowAbi, + functionName: 'close', + args: [descriptorTuple(descriptor), cumulativeAmount, captureAmount, signature], + }) +} + +/** Encodes a descriptor-based TIP-1034 `requestClose` call. */ +export function encodeRequestClose(descriptor: ChannelDescriptor): Hex { + return encodeFunctionData({ + abi: escrowAbi, + functionName: 'requestClose', + args: [descriptorTuple(descriptor)], + }) +} + +/** Encodes a descriptor-based TIP-1034 `withdraw` call. */ +export function encodeWithdraw(descriptor: ChannelDescriptor): Hex { + return encodeFunctionData({ + abi: escrowAbi, + functionName: 'withdraw', + args: [descriptorTuple(descriptor)], + }) +} + +/** Reads immutable descriptor and mutable state for a TIP-1034 channel. */ +export async function getChannel( + client: Client, + descriptor: ChannelDescriptor, + escrow: Address = tip20ChannelEscrow, +): Promise { + const channel = await readContract(client, { + address: escrow, + abi: escrowAbi, + functionName: 'getChannel', + args: [descriptorTuple(descriptor)], + }) + return { + descriptor: channel.descriptor, + state: stateFromTuple(channel.state), + } +} + +/** Reads mutable state for a TIP-1034 channel ID. */ +export async function getChannelState( + client: Client, + channelId: Hex, + escrow: Address = tip20ChannelEscrow, +): Promise { + const state = await readContract(client, { + address: escrow, + abi: escrowAbi, + functionName: 'getChannelState', + args: [channelId], + }) + return stateFromTuple(state) +} + +/** Reads mutable states for TIP-1034 channel IDs in one precompile call. */ +export async function getChannelStatesBatch( + client: Client, + channelIds: readonly Hex[], + escrow: Address = tip20ChannelEscrow, +): Promise { + const states = await readContract(client, { + address: escrow, + abi: escrowAbi, + functionName: 'getChannelStatesBatch', + args: [channelIds], + }) + return states.map(stateFromTuple) +} + +/** Broadcasts a descriptor-based TIP-1034 settle transaction with the client's account. */ +export async function settle( + client: Client, + descriptor: ChannelDescriptor, + cumulativeAmount: Uint96, + signature: Hex, + escrow: Address = tip20ChannelEscrow, +): Promise { + return sendTransaction(client, { + to: escrow, + data: encodeSettle(descriptor, cumulativeAmount, signature), + } as never) +} + +/** Broadcasts a descriptor-based TIP-1034 close transaction with the client's account. */ +export async function close( + client: Client, + descriptor: ChannelDescriptor, + cumulativeAmount: Uint96, + captureAmount: Uint96, + signature: Hex, + escrow: Address = tip20ChannelEscrow, +): Promise { + return sendTransaction(client, { + to: escrow, + data: encodeClose(descriptor, cumulativeAmount, captureAmount, signature), + } as never) +} diff --git a/src/tempo/precompile/Channel.test.ts b/src/tempo/precompile/Channel.test.ts new file mode 100644 index 00000000..76422100 --- /dev/null +++ b/src/tempo/precompile/Channel.test.ts @@ -0,0 +1,109 @@ +import { AbiParameters, Hash } from 'ox' +import { describe, expect, test } from 'vp/test' + +import * as Channel from './Channel.js' +import { tip20ChannelEscrow } from './Constants.js' + +const descriptor = { + payer: '0x1111111111111111111111111111111111111111', + payee: '0x2222222222222222222222222222222222222222', + operator: '0x3333333333333333333333333333333333333333', + token: '0x4444444444444444444444444444444444444444', + salt: '0x0000000000000000000000000000000000000000000000000000000000000001', + authorizedSigner: '0x5555555555555555555555555555555555555555', + expiringNonceHash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', +} as const satisfies Channel.ChannelDescriptor + +const chainId = 42431 + +describe('precompile Channel.computeId', () => { + test('returns deterministic 32-byte hash for fixed inputs', () => { + const id = Channel.computeId(descriptor, { chainId }) + expect(Channel.computeId(descriptor, { chainId })).toBe(id) + expect(id).toMatch(/^0x[0-9a-f]{64}$/) + }) + + test('matches manual keccak256(abi.encode(...))', () => { + const encoded = AbiParameters.encode( + AbiParameters.from([ + 'address payer', + 'address payee', + 'address operator', + 'address token', + 'bytes32 salt', + 'address authorizedSigner', + 'bytes32 expiringNonceHash', + 'address escrow', + 'uint256 chainId', + ]), + [ + descriptor.payer, + descriptor.payee, + descriptor.operator, + descriptor.token, + descriptor.salt, + descriptor.authorizedSigner, + descriptor.expiringNonceHash, + tip20ChannelEscrow, + BigInt(chainId), + ], + ) + expect(Channel.computeId(descriptor, { chainId })).toBe(Hash.keccak256(encoded)) + }) + + test.each([ + ['payer', '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], + ['payee', '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'], + ['operator', '0xcccccccccccccccccccccccccccccccccccccccc'], + ['token', '0xdddddddddddddddddddddddddddddddddddddddd'], + ['salt', '0x0000000000000000000000000000000000000000000000000000000000000002'], + ['authorizedSigner', '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'], + ['expiringNonceHash', '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'], + ] as const)('changes when %s changes', (key, value) => { + expect(Channel.computeId({ ...descriptor, [key]: value }, { chainId })).not.toBe( + Channel.computeId(descriptor, { chainId }), + ) + }) + + test('changes when escrow or chainId changes', () => { + expect( + Channel.computeId(descriptor, { + chainId, + escrow: '0xffffffffffffffffffffffffffffffffffffffff', + }), + ).not.toBe(Channel.computeId(descriptor, { chainId })) + expect(Channel.computeId(descriptor, { chainId: 1 })).not.toBe( + Channel.computeId(descriptor, { chainId }), + ) + }) + + test('encodes chainId as uint256', () => { + const largeChainId = 2 ** 32 + const id = Channel.computeId(descriptor, { chainId: largeChainId }) + const encoded = AbiParameters.encode( + AbiParameters.from([ + 'address payer', + 'address payee', + 'address operator', + 'address token', + 'bytes32 salt', + 'address authorizedSigner', + 'bytes32 expiringNonceHash', + 'address escrow', + 'uint256 chainId', + ]), + [ + descriptor.payer, + descriptor.payee, + descriptor.operator, + descriptor.token, + descriptor.salt, + descriptor.authorizedSigner, + descriptor.expiringNonceHash, + tip20ChannelEscrow, + BigInt(largeChainId), + ], + ) + expect(id).toBe(Hash.keccak256(encoded)) + }) +}) diff --git a/src/tempo/precompile/Channel.ts b/src/tempo/precompile/Channel.ts new file mode 100644 index 00000000..ffa594da --- /dev/null +++ b/src/tempo/precompile/Channel.ts @@ -0,0 +1,89 @@ +import type { Hex } from 'ox' +import { encodeAbiParameters, keccak256, type Account, type Address } from 'viem' +import { + Transaction, + type z_TransactionRequestTempo, + type z_TransactionSerializableTempo, +} from 'viem/tempo' + +import { tip20ChannelEscrow } from './Constants.js' + +export type ExpiringNonceTransaction = ( + | z_TransactionSerializableTempo + | z_TransactionRequestTempo +) & { + feePayer?: Account | true | undefined +} + +export type ChannelDescriptor = { + payer: Address + payee: Address + operator: Address + token: Address + salt: Hex.Hex + authorizedSigner: Address + expiringNonceHash: Hex.Hex +} + +/** Computes the TIP-1034 channel ID for a precompile channel descriptor. */ +export function computeId( + descriptor: ChannelDescriptor, + parameters: { chainId: number; escrow?: Address | undefined }, +): Hex.Hex { + return keccak256( + encodeAbiParameters( + [ + { type: 'address' }, + { type: 'address' }, + { type: 'address' }, + { type: 'address' }, + { type: 'bytes32' }, + { type: 'address' }, + { type: 'bytes32' }, + { type: 'address' }, + { type: 'uint256' }, + ], + [ + descriptor.payer, + descriptor.payee, + descriptor.operator, + descriptor.token, + descriptor.salt, + descriptor.authorizedSigner, + descriptor.expiringNonceHash, + parameters.escrow ?? tip20ChannelEscrow, + BigInt(parameters.chainId), + ], + ), + ) +} + +/** + * Computes the TIP-1034 `expiringNonceHash` for a channel-opening Tempo transaction. + * + * This delegates to viem's Tempo sender-scoped hash helper, which matches the node's + * `keccak256(encodeForSigning || sender)` consensus preimage. mppx intentionally does + * not duplicate Tempo transaction encoding logic here. + */ +export function computeExpiringNonceHash( + transaction: ExpiringNonceTransaction, + parameters: { sender: Address }, +): Hex.Hex { + const transactionModule = Transaction as unknown as Record + const getChannelOpenContextHash = + transactionModule['getChannelOpenContextHash'] ?? + transactionModule['getExpiringNonceHash'] ?? + transactionModule['getSenderScopedHash'] + + if (!getChannelOpenContextHash) + throw new Error( + 'viem/tempo Transaction.getChannelOpenContextHash is required to compute TIP-1034 expiringNonceHash.', + ) + + return ( + getChannelOpenContextHash as ( + transaction: ExpiringNonceTransaction, + options: { sender: Address }, + ) => Hex.Hex + )(transaction, parameters) +} diff --git a/src/tempo/precompile/Constants.ts b/src/tempo/precompile/Constants.ts new file mode 100644 index 00000000..6503c094 --- /dev/null +++ b/src/tempo/precompile/Constants.ts @@ -0,0 +1,2 @@ +/** Canonical TIP-1034 TIP-20 Channel Escrow precompile address. */ +export const tip20ChannelEscrow = '0x4d50500000000000000000000000000000000000' diff --git a/src/tempo/precompile/Types.test.ts b/src/tempo/precompile/Types.test.ts new file mode 100644 index 00000000..19f25335 --- /dev/null +++ b/src/tempo/precompile/Types.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'vp/test' + +import * as Types from './Types.js' + +const maxUint96 = (1n << 96n) - 1n + +describe('precompile Uint96', () => { + test('accepts lower and upper bounds', () => { + expect(Types.uint96(0n)).toBe(0n) + expect(Types.uint96(maxUint96)).toBe(maxUint96) + expect(Types.isUint96(0n)).toBe(true) + expect(Types.isUint96(maxUint96)).toBe(true) + }) + + test('rejects values outside uint96 bounds', () => { + expect(() => Types.uint96(-1n)).toThrow('outside uint96 bounds') + expect(() => Types.uint96(maxUint96 + 1n)).toThrow('outside uint96 bounds') + expect(Types.isUint96(-1n)).toBe(false) + expect(Types.isUint96(maxUint96 + 1n)).toBe(false) + }) + + test('assertUint96 narrows valid values and throws for invalid values', () => { + let amount: bigint = 1n + Types.assertUint96(amount) + const branded: Types.Uint96 = amount + expect(branded).toBe(1n) + expect(() => Types.assertUint96(maxUint96 + 1n)).toThrow('outside uint96 bounds') + }) +}) diff --git a/src/tempo/precompile/Types.ts b/src/tempo/precompile/Types.ts new file mode 100644 index 00000000..1f536156 --- /dev/null +++ b/src/tempo/precompile/Types.ts @@ -0,0 +1,21 @@ +const maxUint96 = (1n << 96n) - 1n +declare const uint96Brand: unique symbol + +/** Bigint branded as already validated to fit the TIP-1034 `uint96` amount width. */ +export type Uint96 = bigint & { readonly [uint96Brand]: true } + +/** Returns whether a bigint can be encoded as a TIP-1034 `uint96` amount. */ +export function isUint96(value: bigint): value is Uint96 { + return value >= 0n && value <= maxUint96 +} + +/** Converts a bigint into a branded TIP-1034 `uint96` amount. */ +export function uint96(value: bigint): Uint96 { + if (!isUint96(value)) throw new Error(`Value ${value} is outside uint96 bounds.`) + return value +} + +/** Asserts that a bigint can be encoded as a TIP-1034 `uint96` amount. */ +export function assertUint96(value: bigint): asserts value is Uint96 { + uint96(value) +} diff --git a/src/tempo/precompile/Voucher.test.ts b/src/tempo/precompile/Voucher.test.ts new file mode 100644 index 00000000..06713c35 --- /dev/null +++ b/src/tempo/precompile/Voucher.test.ts @@ -0,0 +1,239 @@ +import { SignatureEnvelope } from 'ox/tempo' +import { createClient, hashTypedData, http } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { signTypedData } from 'viem/actions' +import { describe, expect, test } from 'vp/test' + +import { uint96 } from './Types.js' +import { domain, parseVoucherFromPayload, sign, types, verify } from './Voucher.js' + +const account = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const escrowContract = '0x1234567890abcdef1234567890abcdef12345678' as const +const chainId = 42431 + +const client = createClient({ + account, + transport: http('http://127.0.0.1'), // only used for local signTypedData +}) + +const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as const +const cumulativeAmount = uint96(1_000_000n) + +describe('Precompile Voucher', () => { + test('sign and verify round-trip', async () => { + const signature = await sign( + client, + account, + { channelId, cumulativeAmount }, + { + chainId, + verifyingContract: escrowContract, + }, + ) + expect(signature).toMatch(/^0x/) + expect(signature.length).toBe(132) + + const isValid = verify({ channelId, cumulativeAmount, signature }, account.address, { + chainId, + verifyingContract: escrowContract, + }) + expect(isValid).toBe(true) + }) + + test('verify rejects wrong signer', async () => { + const signature = await sign( + client, + account, + { channelId, cumulativeAmount }, + { + chainId, + verifyingContract: escrowContract, + }, + ) + + const wrongAddress = '0x0000000000000000000000000000000000000001' as const + const isValid = verify({ channelId, cumulativeAmount, signature }, wrongAddress, { + chainId, + verifyingContract: escrowContract, + }) + expect(isValid).toBe(false) + }) + + test('verify rejects tampered amount', async () => { + const signature = await sign( + client, + account, + { channelId, cumulativeAmount }, + { + chainId, + verifyingContract: escrowContract, + }, + ) + + const isValid = verify( + { channelId, cumulativeAmount: uint96(9_999_999n), signature }, + account.address, + { chainId, verifyingContract: escrowContract }, + ) + expect(isValid).toBe(false) + }) + + test('verify rejects tampered channelId', async () => { + const signature = await sign( + client, + account, + { channelId, cumulativeAmount }, + { + chainId, + verifyingContract: escrowContract, + }, + ) + + const wrongChannelId = + '0x0000000000000000000000000000000000000000000000000000000000000099' as const + const isValid = verify( + { channelId: wrongChannelId, cumulativeAmount, signature }, + account.address, + { chainId, verifyingContract: escrowContract }, + ) + expect(isValid).toBe(false) + }) + + test('verify rejects wrong chain ID', async () => { + const signature = await sign( + client, + account, + { channelId, cumulativeAmount }, + { + chainId, + verifyingContract: escrowContract, + }, + ) + + const isValid = verify({ channelId, cumulativeAmount, signature }, account.address, { + chainId: 99999, + verifyingContract: escrowContract, + }) + expect(isValid).toBe(false) + }) + + test('verify returns false for invalid signature', () => { + const isValid = verify( + { channelId, cumulativeAmount, signature: '0xdeadbeef' }, + account.address, + { chainId, verifyingContract: escrowContract }, + ) + expect(isValid).toBe(false) + }) + + test('parseVoucherFromPayload', () => { + const signature = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab' as const + const voucher = parseVoucherFromPayload(channelId, '5000000', signature) + expect(voucher.channelId).toBe(channelId) + expect(voucher.cumulativeAmount).toBe(5_000_000n) + expect(voucher.signature).toBe(signature) + }) + + test('parseVoucherFromPayload with zero amount', () => { + const signature = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab' as const + const voucher = parseVoucherFromPayload(channelId, '0', signature) + expect(voucher.cumulativeAmount).toBe(0n) + }) + + test('parseVoucherFromPayload rejects amounts outside uint96 bounds', () => { + const signature = '0xdeadbeef' as const + expect(() => parseVoucherFromPayload(channelId, '-1', signature)).toThrow( + 'outside uint96 bounds', + ) + expect(() => parseVoucherFromPayload(channelId, (1n << 96n).toString(), signature)).toThrow( + 'outside uint96 bounds', + ) + }) + + test('verify rejects wrong escrow contract', async () => { + const signature = await sign( + client, + account, + { channelId, cumulativeAmount }, + { + chainId, + verifyingContract: escrowContract, + }, + ) + + const wrongEscrow = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as const + const isValid = verify({ channelId, cumulativeAmount, signature }, account.address, { + chainId, + verifyingContract: wrongEscrow, + }) + expect(isValid).toBe(false) + }) + + test('sign and verify round-trip with zero amount', async () => { + const zeroAmount = uint96(0n) + const signature = await sign( + client, + account, + { channelId, cumulativeAmount: zeroAmount }, + { + chainId, + verifyingContract: escrowContract, + }, + ) + expect(signature).toMatch(/^0x/) + + const isValid = verify( + { channelId, cumulativeAmount: zeroAmount, signature }, + account.address, + { chainId, verifyingContract: escrowContract }, + ) + expect(isValid).toBe(true) + }) + + test('verify rejects direct keychain wrapper signatures', async () => { + const signature = await signTypedData(client, { + account, + domain: domain(chainId, escrowContract), + types, + primaryType: 'Voucher', + message: { channelId, cumulativeAmount }, + }) + const envelope = SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) + const wrapped = SignatureEnvelope.serialize( + { inner: envelope, type: 'keychain', userAddress: account.address, version: 'v1' }, + { magic: true }, + ) + + expect( + verify({ channelId, cumulativeAmount, signature: wrapped }, account.address, { + chainId, + verifyingContract: escrowContract, + }), + ).toBe(false) + }) + + test('domain and type match TIP-1034', () => { + expect(domain(chainId, escrowContract)).toEqual({ + name: 'TIP20 Channel Escrow', + version: '1', + chainId, + verifyingContract: escrowContract, + }) + expect(types.Voucher).toEqual([ + { name: 'channelId', type: 'bytes32' }, + { name: 'cumulativeAmount', type: 'uint96' }, + ]) + expect( + hashTypedData({ + domain: domain(chainId, escrowContract), + types, + primaryType: 'Voucher', + message: { channelId, cumulativeAmount }, + }), + ).toMatch(/^0x[0-9a-f]{64}$/) + }) +}) diff --git a/src/tempo/precompile/Voucher.ts b/src/tempo/precompile/Voucher.ts new file mode 100644 index 00000000..c77fc8e7 --- /dev/null +++ b/src/tempo/precompile/Voucher.ts @@ -0,0 +1,108 @@ +import { Signature } from 'ox' +import { SignatureEnvelope } from 'ox/tempo' +import type { Account, Address, Client, Hex } from 'viem' +import { hashTypedData } from 'viem' +import { signTypedData } from 'viem/actions' + +import * as TempoAddress from '../internal/address.js' +import { tip20ChannelEscrow } from './Constants.js' +import type { Uint96 } from './Types.js' +import { uint96 } from './Types.js' + +const domainName = 'TIP20 Channel Escrow' +const domainVersion = '1' + +export type Voucher = { + channelId: Hex + cumulativeAmount: Uint96 +} + +export type SignedVoucher = Voucher & { signature: Hex } + +/** Parses a signed TIP-1034 voucher payload and brands its uint96 cumulative amount. */ +export function parseVoucherFromPayload( + channelId: Hex, + cumulativeAmount: string, + signature: Hex, +): SignedVoucher { + return { + channelId, + cumulativeAmount: uint96(BigInt(cumulativeAmount)), + signature, + } +} + +/** EIP-712 domain for TIP-1034 channel escrow vouchers. */ +export function domain(chainId: number, verifyingContract: Address = tip20ChannelEscrow) { + return { + name: domainName, + version: domainVersion, + chainId, + verifyingContract, + } as const +} + +/** EIP-712 voucher type for TIP-1034 channel escrow vouchers. */ +export const types = { + Voucher: [ + { name: 'channelId', type: 'bytes32' }, + { name: 'cumulativeAmount', type: 'uint96' }, + ], +} as const + +/** Signs a TIP-1034 voucher and unwraps keychain signatures for delegated secp256k1 signers. */ +export async function sign( + client: Client, + account: Account, + voucher: Voucher, + parameters: { + chainId: number + verifyingContract?: Address | undefined + authorizedSigner?: Address | undefined + }, +): Promise { + const signature = await signTypedData(client, { + account, + domain: domain(parameters.chainId, parameters.verifyingContract), + types, + primaryType: 'Voucher', + message: voucher, + }) + + if (parameters.authorizedSigner) { + try { + const envelope = SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) + if (envelope.type === 'keychain' && envelope.inner.type === 'secp256k1') + return Signature.toHex(envelope.inner.signature) + } catch {} + } + + return signature +} + +/** Verifies a direct TIP-1034 voucher signature and rejects keychain wrapper signatures. */ +export function verify( + voucher: SignedVoucher, + expectedSigner: Address, + parameters: { chainId: number; verifyingContract?: Address | undefined }, +): boolean { + try { + const envelope = SignatureEnvelope.from(voucher.signature as SignatureEnvelope.Serialized) + if (envelope.type === 'keychain') return false + + const payload = hashTypedData({ + domain: domain(parameters.chainId, parameters.verifyingContract), + types, + primaryType: 'Voucher', + message: { + channelId: voucher.channelId, + cumulativeAmount: voucher.cumulativeAmount, + }, + }) + const signer = SignatureEnvelope.extractAddress({ payload, signature: envelope }) + const valid = SignatureEnvelope.verify(envelope, { address: signer, payload }) + return valid && TempoAddress.isEqual(signer, expectedSigner) + } catch { + return false + } +} diff --git a/src/tempo/precompile/client/ChannelOps.ts b/src/tempo/precompile/client/ChannelOps.ts new file mode 100644 index 00000000..bcfc9ddb --- /dev/null +++ b/src/tempo/precompile/client/ChannelOps.ts @@ -0,0 +1,82 @@ +import { Hex } from 'ox' +import type { Account, Address, Client } from 'viem' +import { prepareTransactionRequest, signTransaction } from 'viem/actions' + +import * as Chain from '../Chain.js' +import * as Channel from '../Channel.js' +import { tip20ChannelEscrow } from '../Constants.js' +import type { Uint96 } from '../Types.js' +import * as Voucher from '../Voucher.js' + +export type OpenResult = { + channelId: Hex.Hex + descriptor: Channel.ChannelDescriptor + transaction: Hex.Hex + voucherSignature: Hex.Hex +} + +/** + * Prepares and signs a one-call TIP-1034 channel-open transaction, computes the + * transaction-bound `expiringNonceHash` via viem, and signs the initial voucher. + */ +export async function createOpen( + client: Client, + account: Account, + parameters: { + authorizedSigner?: Address | undefined + chainId: number + deposit: Uint96 + escrow?: Address | undefined + initialAmount: Uint96 + operator?: Address | undefined + payee: Address + token: Address + }, +): Promise { + const escrow = parameters.escrow ?? tip20ChannelEscrow + const authorizedSigner = parameters.authorizedSigner ?? account.address + const operator = parameters.operator ?? '0x0000000000000000000000000000000000000000' + const salt = Hex.random(32) + + const openData = Chain.encodeOpen({ + authorizedSigner, + deposit: parameters.deposit, + operator, + payee: parameters.payee, + salt, + token: parameters.token, + }) + const prepared = await prepareTransactionRequest(client, { + account, + calls: [{ to: escrow, data: openData }], + feeToken: parameters.token, + } as never) + + const expiringNonceHash = Channel.computeExpiringNonceHash( + prepared as Channel.ExpiringNonceTransaction, + { sender: account.address }, + ) + const descriptor = { + authorizedSigner, + expiringNonceHash, + operator, + payee: parameters.payee, + payer: account.address, + salt, + token: parameters.token, + } satisfies Channel.ChannelDescriptor + const channelId = Channel.computeId(descriptor, { chainId: parameters.chainId, escrow }) + const voucherSignature = await Voucher.sign( + client, + account, + { channelId, cumulativeAmount: parameters.initialAmount }, + { + authorizedSigner: parameters.authorizedSigner, + chainId: parameters.chainId, + verifyingContract: escrow, + }, + ) + const transaction = (await signTransaction(client, prepared as never)) as Hex.Hex + + return { channelId, descriptor, transaction, voucherSignature } +} diff --git a/src/tempo/precompile/client/index.ts b/src/tempo/precompile/client/index.ts new file mode 100644 index 00000000..68a40620 --- /dev/null +++ b/src/tempo/precompile/client/index.ts @@ -0,0 +1 @@ +export * as ChannelOps from './ChannelOps.js' diff --git a/src/tempo/precompile/escrow.abi.ts b/src/tempo/precompile/escrow.abi.ts new file mode 100644 index 00000000..49e1ad1a --- /dev/null +++ b/src/tempo/precompile/escrow.abi.ts @@ -0,0 +1,226 @@ +const channelDescriptorComponents = [ + { name: 'payer', type: 'address' }, + { name: 'payee', type: 'address' }, + { name: 'operator', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'salt', type: 'bytes32' }, + { name: 'authorizedSigner', type: 'address' }, + { name: 'expiringNonceHash', type: 'bytes32' }, +] as const + +const channelStateComponents = [ + { name: 'settled', type: 'uint96' }, + { name: 'deposit', type: 'uint96' }, + { name: 'closeRequestedAt', type: 'uint32' }, +] as const + +const channelDescriptorInput = { + name: 'descriptor', + type: 'tuple', + components: channelDescriptorComponents, +} as const + +/** ABI for the TIP-1034 TIP-20 Channel Escrow precompile. */ +export const escrowAbi = [ + { + type: 'function', + name: 'CLOSE_GRACE_PERIOD', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'uint64' }], + }, + { + type: 'function', + name: 'VOUCHER_TYPEHASH', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'bytes32' }], + }, + { + type: 'function', + name: 'open', + stateMutability: 'nonpayable', + inputs: [ + { name: 'payee', type: 'address' }, + { name: 'operator', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'deposit', type: 'uint96' }, + { name: 'salt', type: 'bytes32' }, + { name: 'authorizedSigner', type: 'address' }, + ], + outputs: [{ name: 'channelId', type: 'bytes32' }], + }, + { + type: 'function', + name: 'settle', + stateMutability: 'nonpayable', + inputs: [ + channelDescriptorInput, + { name: 'cumulativeAmount', type: 'uint96' }, + { name: 'signature', type: 'bytes' }, + ], + outputs: [], + }, + { + type: 'function', + name: 'topUp', + stateMutability: 'nonpayable', + inputs: [channelDescriptorInput, { name: 'additionalDeposit', type: 'uint96' }], + outputs: [], + }, + { + type: 'function', + name: 'close', + stateMutability: 'nonpayable', + inputs: [ + channelDescriptorInput, + { name: 'cumulativeAmount', type: 'uint96' }, + { name: 'captureAmount', type: 'uint96' }, + { name: 'signature', type: 'bytes' }, + ], + outputs: [], + }, + { + type: 'function', + name: 'requestClose', + stateMutability: 'nonpayable', + inputs: [channelDescriptorInput], + outputs: [], + }, + { + type: 'function', + name: 'withdraw', + stateMutability: 'nonpayable', + inputs: [channelDescriptorInput], + outputs: [], + }, + { + type: 'function', + name: 'getChannel', + stateMutability: 'view', + inputs: [channelDescriptorInput], + outputs: [ + { + type: 'tuple', + components: [ + { name: 'descriptor', type: 'tuple', components: channelDescriptorComponents }, + { name: 'state', type: 'tuple', components: channelStateComponents }, + ], + }, + ], + }, + { + type: 'function', + name: 'getChannelState', + stateMutability: 'view', + inputs: [{ name: 'channelId', type: 'bytes32' }], + outputs: [{ type: 'tuple', components: channelStateComponents }], + }, + { + type: 'function', + name: 'getChannelStatesBatch', + stateMutability: 'view', + inputs: [{ name: 'channelIds', type: 'bytes32[]' }], + outputs: [{ type: 'tuple[]', components: channelStateComponents }], + }, + { + type: 'function', + name: 'computeChannelId', + stateMutability: 'view', + inputs: [ + { name: 'payer', type: 'address' }, + { name: 'payee', type: 'address' }, + { name: 'operator', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'salt', type: 'bytes32' }, + { name: 'authorizedSigner', type: 'address' }, + { name: 'expiringNonceHash', type: 'bytes32' }, + ], + outputs: [{ type: 'bytes32' }], + }, + { + type: 'function', + name: 'getVoucherDigest', + stateMutability: 'view', + inputs: [ + { name: 'channelId', type: 'bytes32' }, + { name: 'cumulativeAmount', type: 'uint96' }, + ], + outputs: [{ type: 'bytes32' }], + }, + { + type: 'function', + name: 'domainSeparator', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'bytes32' }], + }, + { + type: 'event', + name: 'ChannelOpened', + inputs: [ + { name: 'channelId', type: 'bytes32', indexed: true }, + { name: 'payer', type: 'address', indexed: true }, + { name: 'payee', type: 'address', indexed: true }, + { name: 'operator', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'authorizedSigner', type: 'address' }, + { name: 'salt', type: 'bytes32' }, + { name: 'expiringNonceHash', type: 'bytes32' }, + { name: 'deposit', type: 'uint96' }, + ], + }, + { + type: 'event', + name: 'Settled', + inputs: [ + { name: 'channelId', type: 'bytes32', indexed: true }, + { name: 'payer', type: 'address', indexed: true }, + { name: 'payee', type: 'address', indexed: true }, + { name: 'cumulativeAmount', type: 'uint96' }, + { name: 'deltaPaid', type: 'uint96' }, + { name: 'newSettled', type: 'uint96' }, + ], + }, + { + type: 'event', + name: 'TopUp', + inputs: [ + { name: 'channelId', type: 'bytes32', indexed: true }, + { name: 'payer', type: 'address', indexed: true }, + { name: 'payee', type: 'address', indexed: true }, + { name: 'additionalDeposit', type: 'uint96' }, + { name: 'newDeposit', type: 'uint96' }, + ], + }, + { + type: 'event', + name: 'CloseRequested', + inputs: [ + { name: 'channelId', type: 'bytes32', indexed: true }, + { name: 'payer', type: 'address', indexed: true }, + { name: 'payee', type: 'address', indexed: true }, + { name: 'closeGraceEnd', type: 'uint256' }, + ], + }, + { + type: 'event', + name: 'ChannelClosed', + inputs: [ + { name: 'channelId', type: 'bytes32', indexed: true }, + { name: 'payer', type: 'address', indexed: true }, + { name: 'payee', type: 'address', indexed: true }, + { name: 'settledToPayee', type: 'uint96' }, + { name: 'refundedToPayer', type: 'uint96' }, + ], + }, + { + type: 'event', + name: 'CloseRequestCancelled', + inputs: [ + { name: 'channelId', type: 'bytes32', indexed: true }, + { name: 'payer', type: 'address', indexed: true }, + { name: 'payee', type: 'address', indexed: true }, + ], + }, +] as const diff --git a/src/tempo/precompile/index.ts b/src/tempo/precompile/index.ts new file mode 100644 index 00000000..271a4682 --- /dev/null +++ b/src/tempo/precompile/index.ts @@ -0,0 +1,8 @@ +export * as Client from './client/index.js' +export * as Chain from './Chain.js' +export * as Channel from './Channel.js' +export * as Constants from './Constants.js' +export * as Server from './server/index.js' +export * as Types from './Types.js' +export * as Voucher from './Voucher.js' +export { escrowAbi } from './escrow.abi.js' diff --git a/src/tempo/precompile/server/ChannelOps.ts b/src/tempo/precompile/server/ChannelOps.ts new file mode 100644 index 00000000..f94f396b --- /dev/null +++ b/src/tempo/precompile/server/ChannelOps.ts @@ -0,0 +1,73 @@ +import type { Address, Hex } from 'viem' +import { decodeFunctionData, isAddressEqual } from 'viem' + +import * as Channel from '../Channel.js' +import { tip20ChannelEscrow } from '../Constants.js' +import { escrowAbi } from '../escrow.abi.js' +import type { Uint96 } from '../Types.js' +import { uint96 } from '../Types.js' + +/** Validates that calldata contains exactly one TIP-1034 approve-less `open` call. */ +export function parseOpenCall(parameters: { + data: Hex + expected?: + | { + authorizedSigner?: Address | undefined + deposit?: Uint96 | undefined + operator?: Address | undefined + payee?: Address | undefined + token?: Address | undefined + } + | undefined +}) { + let decoded: ReturnType> + try { + decoded = decodeFunctionData({ abi: escrowAbi, data: parameters.data }) + } catch { + throw new Error('Expected TIP-1034 open calldata.') + } + if (decoded.functionName !== 'open') throw new Error('Expected TIP-1034 open calldata.') + const [payee, operator, token, deposit, salt, authorizedSigner] = decoded.args + const expected = parameters.expected + if (expected?.payee && !isAddressEqual(payee, expected.payee)) + throw new Error('TIP-1034 open payee does not match challenge.') + if (expected?.operator && !isAddressEqual(operator, expected.operator)) + throw new Error('TIP-1034 open operator does not match challenge.') + if (expected?.token && !isAddressEqual(token, expected.token)) + throw new Error('TIP-1034 open token does not match challenge.') + if (expected?.authorizedSigner && !isAddressEqual(authorizedSigner, expected.authorizedSigner)) + throw new Error('TIP-1034 open authorizedSigner does not match credential.') + const validatedDeposit = uint96(deposit) + if (expected?.deposit !== undefined && validatedDeposit !== expected.deposit) + throw new Error('TIP-1034 open deposit does not match challenge.') + return { payee, operator, token, deposit: validatedDeposit, salt, authorizedSigner } +} + +/** Builds and validates a descriptor from an accepted open call and event expiring nonce hash. */ +export function descriptorFromOpen(parameters: { + chainId: number + escrow?: Address | undefined + expiringNonceHash: Hex + payer: Address + open: ReturnType + channelId?: Hex | undefined +}): Channel.ChannelDescriptor { + const descriptor = { + authorizedSigner: parameters.open.authorizedSigner, + expiringNonceHash: parameters.expiringNonceHash, + operator: parameters.open.operator, + payee: parameters.open.payee, + payer: parameters.payer, + salt: parameters.open.salt, + token: parameters.open.token, + } satisfies Channel.ChannelDescriptor + if (parameters.channelId) { + const computed = Channel.computeId(descriptor, { + chainId: parameters.chainId, + escrow: parameters.escrow ?? tip20ChannelEscrow, + }) + if (computed.toLowerCase() !== parameters.channelId.toLowerCase()) + throw new Error('TIP-1034 ChannelOpened channelId does not match descriptor.') + } + return descriptor +} diff --git a/src/tempo/precompile/server/index.ts b/src/tempo/precompile/server/index.ts new file mode 100644 index 00000000..68a40620 --- /dev/null +++ b/src/tempo/precompile/server/index.ts @@ -0,0 +1 @@ +export * as ChannelOps from './ChannelOps.js' diff --git a/src/tempo/server/Methods.ts b/src/tempo/server/Methods.ts index d8ab70a4..6578e894 100644 --- a/src/tempo/server/Methods.ts +++ b/src/tempo/server/Methods.ts @@ -1,3 +1,4 @@ +import * as Precompile_ from '../precompile/index.js' import * as Ws_ from '../session/Ws.js' import { charge as charge_ } from './Charge.js' import { session as session_, settle as settle_ } from './Session.js' @@ -29,6 +30,8 @@ export namespace tempo { export const charge = charge_ /** Creates a Tempo `session` method for session-based TIP-20 token payments. */ export const session = session_ + /** TIP-1034 precompile primitives for opt-in session implementations. */ + export const precompile = Precompile_ /** Creates a Tempo `subscription` method for recurring TIP-20 token payments. */ export const subscription = subscription_ /** Renews an overdue Tempo subscription outside of the HTTP request path. */ From e9b948ec987532f5bcf4f62f2d57906037f86726 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 13 May 2026 17:35:06 +0200 Subject: [PATCH 02/26] Wire Tempo precompile client sessions --- .changeset/tip-1034-precompile-hash.md | 2 +- src/tempo/precompile/Chain.ts | 37 +++ src/tempo/precompile/Types.test.ts | 76 +++++ src/tempo/precompile/Types.ts | 107 ++++++ .../precompile/client/ChannelOps.test.ts | 161 +++++++++ src/tempo/precompile/client/ChannelOps.ts | 142 +++++++- src/tempo/precompile/client/Session.test.ts | 104 ++++++ src/tempo/precompile/client/Session.ts | 310 ++++++++++++++++++ src/tempo/precompile/client/index.ts | 1 + src/tempo/precompile/index.ts | 1 + src/tempo/server/Session.test.ts | 52 +-- src/tempo/session/ChannelStore.test.ts | 81 ++++- src/tempo/session/ChannelStore.ts | 37 ++- 13 files changed, 1078 insertions(+), 33 deletions(-) create mode 100644 src/tempo/precompile/client/ChannelOps.test.ts create mode 100644 src/tempo/precompile/client/Session.test.ts create mode 100644 src/tempo/precompile/client/Session.ts diff --git a/.changeset/tip-1034-precompile-hash.md b/.changeset/tip-1034-precompile-hash.md index f23f7871..bf65b942 100644 --- a/.changeset/tip-1034-precompile-hash.md +++ b/.changeset/tip-1034-precompile-hash.md @@ -2,4 +2,4 @@ 'mppx': patch --- -Added Tempo TIP-1034 precompile channel helpers for computing channel IDs, expiring nonce hashes, vouchers, ABI calldata, and open validation. +Added Tempo TIP-1034 precompile channel helpers for computing channel IDs, expiring nonce hashes, vouchers, ABI calldata, open validation, descriptor persistence, precompile session credential payload parsing, client credential builders, and an opt-in precompile client session method. diff --git a/src/tempo/precompile/Chain.ts b/src/tempo/precompile/Chain.ts index cd79a12b..4c2cbfab 100644 --- a/src/tempo/precompile/Chain.ts +++ b/src/tempo/precompile/Chain.ts @@ -182,6 +182,43 @@ export async function settle( } as never) } +/** Broadcasts a descriptor-based TIP-1034 top-up transaction with the client's account. */ +export async function topUp( + client: Client, + descriptor: ChannelDescriptor, + additionalDeposit: Uint96, + escrow: Address = tip20ChannelEscrow, +): Promise { + return sendTransaction(client, { + to: escrow, + data: encodeTopUp(descriptor, additionalDeposit), + } as never) +} + +/** Broadcasts a descriptor-based TIP-1034 request-close transaction with the client's account. */ +export async function requestClose( + client: Client, + descriptor: ChannelDescriptor, + escrow: Address = tip20ChannelEscrow, +): Promise { + return sendTransaction(client, { + to: escrow, + data: encodeRequestClose(descriptor), + } as never) +} + +/** Broadcasts a descriptor-based TIP-1034 withdraw transaction with the client's account. */ +export async function withdraw( + client: Client, + descriptor: ChannelDescriptor, + escrow: Address = tip20ChannelEscrow, +): Promise { + return sendTransaction(client, { + to: escrow, + data: encodeWithdraw(descriptor), + } as never) +} + /** Broadcasts a descriptor-based TIP-1034 close transaction with the client's account. */ export async function close( client: Client, diff --git a/src/tempo/precompile/Types.test.ts b/src/tempo/precompile/Types.test.ts index 19f25335..c8533c99 100644 --- a/src/tempo/precompile/Types.test.ts +++ b/src/tempo/precompile/Types.test.ts @@ -4,6 +4,16 @@ import * as Types from './Types.js' const maxUint96 = (1n << 96n) - 1n +const descriptor = { + payer: '0x0000000000000000000000000000000000000001', + payee: '0x0000000000000000000000000000000000000002', + operator: '0x0000000000000000000000000000000000000003', + token: '0x0000000000000000000000000000000000000004', + salt: `0x${'11'.repeat(32)}`, + authorizedSigner: '0x0000000000000000000000000000000000000005', + expiringNonceHash: `0x${'22'.repeat(32)}`, +} as const + describe('precompile Uint96', () => { test('accepts lower and upper bounds', () => { expect(Types.uint96(0n)).toBe(0n) @@ -27,3 +37,69 @@ describe('precompile Uint96', () => { expect(() => Types.assertUint96(maxUint96 + 1n)).toThrow('outside uint96 bounds') }) }) + +describe('precompile session credential payloads', () => { + test('brands open cumulative amounts at the payload boundary', () => { + const parsed = Types.parseCredentialPayload({ + action: 'open', + type: 'transaction', + channelId: `0x${'33'.repeat(32)}`, + transaction: '0x1234', + signature: '0xabcd', + descriptor, + cumulativeAmount: '10', + }) + + expect(parsed.action).toBe('open') + expect(parsed.cumulativeAmount).toBe(10n) + expect(Types.isUint96(parsed.cumulativeAmount)).toBe(true) + }) + + test('brands top-up additional deposits at the payload boundary', () => { + const parsed = Types.parseCredentialPayload({ + action: 'topUp', + type: 'transaction', + channelId: `0x${'44'.repeat(32)}`, + transaction: '0x1234', + descriptor, + additionalDeposit: maxUint96.toString(), + }) + + expect(parsed.action).toBe('topUp') + expect(parsed.additionalDeposit).toBe(maxUint96) + }) + + test('brands voucher cumulative amounts at the payload boundary', () => { + const parsed = Types.parseCredentialPayload({ + action: 'voucher', + channelId: `0x${'55'.repeat(32)}`, + signature: '0xabcd', + descriptor, + cumulativeAmount: maxUint96.toString(), + }) + + expect(parsed.action).toBe('voucher') + expect(parsed.cumulativeAmount).toBe(maxUint96) + }) + + test('brands close cumulative amounts at the payload boundary', () => { + const parsed = Types.parseCredentialPayload({ + action: 'close', + channelId: `0x${'66'.repeat(32)}`, + signature: '0xabcd', + descriptor, + cumulativeAmount: '1', + }) + + expect(parsed.action).toBe('close') + expect(parsed.cumulativeAmount).toBe(1n) + }) + + test('rejects malformed or overflowing cumulative amounts', () => { + expect(() => Types.parseUint96Amount('1.5')).toThrow('decimal string') + expect(() => Types.parseUint96Amount('-1')).toThrow('decimal string') + expect(() => Types.parseUint96Amount((maxUint96 + 1n).toString())).toThrow( + 'outside uint96 bounds', + ) + }) +}) diff --git a/src/tempo/precompile/Types.ts b/src/tempo/precompile/Types.ts index 1f536156..204e952e 100644 --- a/src/tempo/precompile/Types.ts +++ b/src/tempo/precompile/Types.ts @@ -1,3 +1,7 @@ +import type { Address, Hex } from 'viem' + +import type * as Channel from './Channel.js' + const maxUint96 = (1n << 96n) - 1n declare const uint96Brand: unique symbol @@ -19,3 +23,106 @@ export function uint96(value: bigint): Uint96 { export function assertUint96(value: bigint): asserts value is Uint96 { uint96(value) } + +/** TIP-1034 precompile open credential payload before amount branding. */ +export type OpenCredentialPayload = { + action: 'open' + type: 'transaction' + channelId: Hex + transaction: Hex + signature: Hex + descriptor: Channel.ChannelDescriptor + cumulativeAmount: string + authorizedSigner?: Address | undefined +} + +/** TIP-1034 precompile top-up credential payload before amount branding. */ +export type TopUpCredentialPayload = { + action: 'topUp' + type: 'transaction' + channelId: Hex + transaction: Hex + descriptor: Channel.ChannelDescriptor + additionalDeposit: string +} + +/** TIP-1034 precompile voucher credential payload before amount branding. */ +export type VoucherCredentialPayload = { + action: 'voucher' + channelId: Hex + descriptor: Channel.ChannelDescriptor + cumulativeAmount: string + signature: Hex +} + +/** TIP-1034 precompile close credential payload before amount branding. */ +export type CloseCredentialPayload = { + action: 'close' + channelId: Hex + descriptor: Channel.ChannelDescriptor + cumulativeAmount: string + signature: Hex +} + +/** TIP-1034 precompile session credential payload before amount branding. */ +export type SessionCredentialPayload = + | OpenCredentialPayload + | TopUpCredentialPayload + | VoucherCredentialPayload + | CloseCredentialPayload + +export type ParsedOpenCredentialPayload = Omit & { + cumulativeAmount: Uint96 +} + +export type ParsedTopUpCredentialPayload = Omit & { + additionalDeposit: Uint96 +} + +export type ParsedVoucherCredentialPayload = Omit & { + cumulativeAmount: Uint96 +} + +export type ParsedCloseCredentialPayload = Omit & { + cumulativeAmount: Uint96 +} + +/** TIP-1034 precompile session credential payload after boundary validation. */ +export type ParsedSessionCredentialPayload = + | ParsedOpenCredentialPayload + | ParsedTopUpCredentialPayload + | ParsedVoucherCredentialPayload + | ParsedCloseCredentialPayload + +export function parseCredentialPayload(payload: OpenCredentialPayload): ParsedOpenCredentialPayload +export function parseCredentialPayload( + payload: TopUpCredentialPayload, +): ParsedTopUpCredentialPayload +export function parseCredentialPayload( + payload: VoucherCredentialPayload, +): ParsedVoucherCredentialPayload +export function parseCredentialPayload( + payload: CloseCredentialPayload, +): ParsedCloseCredentialPayload +/** Parses and brands decimal string amounts from a precompile session credential payload. */ +export function parseCredentialPayload( + payload: SessionCredentialPayload, +): ParsedSessionCredentialPayload { + if (payload.action === 'topUp') { + return { + ...payload, + additionalDeposit: parseUint96Amount(payload.additionalDeposit), + } + } + + return { + ...payload, + cumulativeAmount: parseUint96Amount(payload.cumulativeAmount), + } +} + +/** Parses a decimal string into a TIP-1034 `uint96` amount. */ +export function parseUint96Amount(value: string): Uint96 { + if (!/^\d+$/.test(value)) throw new Error('Expected uint96 amount as a decimal string.') + return uint96(BigInt(value)) +} diff --git a/src/tempo/precompile/client/ChannelOps.test.ts b/src/tempo/precompile/client/ChannelOps.test.ts new file mode 100644 index 00000000..9e99beb1 --- /dev/null +++ b/src/tempo/precompile/client/ChannelOps.test.ts @@ -0,0 +1,161 @@ +import { Hex } from 'ox' +import { type Address, createClient, custom, zeroAddress } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { describe, expect, test } from 'vp/test' + +import * as Channel from '../Channel.js' +import { tip20ChannelEscrow } from '../Constants.js' +import * as Types from '../Types.js' +import * as Voucher from '../Voucher.js' +import * as ChannelOps from './ChannelOps.js' + +const account = privateKeyToAccount( + '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const client = createClient({ + account, + transport: custom({ + async request() { + throw new Error('unexpected rpc request') + }, + }), +}) +const chainId = 42431 + +const descriptor = { + payer: account.address, + payee: '0x0000000000000000000000000000000000000002' as Address, + operator: '0x0000000000000000000000000000000000000000' as Address, + token: '0x0000000000000000000000000000000000000003' as Address, + salt: `0x${'11'.repeat(32)}` as Hex.Hex, + authorizedSigner: account.address, + expiringNonceHash: `0x${'22'.repeat(32)}` as Hex.Hex, +} satisfies Channel.ChannelDescriptor + +const channelId = Channel.computeId(descriptor, { chainId, escrow: tip20ChannelEscrow }) + +describe('precompile client ChannelOps credential builders', () => { + test('creates an open credential from a signed open result', () => { + const initialAmount = Types.uint96(100n) + const payload = ChannelOps.createOpenCredential( + { + channelId, + descriptor, + transaction: '0x1234', + voucherSignature: '0xabcd', + }, + initialAmount, + ) + + expect(payload).toEqual({ + action: 'open', + type: 'transaction', + channelId, + transaction: '0x1234', + signature: '0xabcd', + descriptor, + cumulativeAmount: '100', + authorizedSigner: descriptor.authorizedSigner, + }) + }) + + test('creates a verifiable voucher credential for an existing precompile channel', async () => { + const cumulativeAmount = Types.uint96(250n) + const payload = await ChannelOps.createVoucherCredential(client, account, { + chainId, + cumulativeAmount, + descriptor, + escrow: tip20ChannelEscrow, + }) + + expect(payload.action).toBe('voucher') + expect(payload.channelId).toBe(channelId) + expect(payload.descriptor).toEqual(descriptor) + expect(payload.cumulativeAmount).toBe('250') + expect( + Voucher.verify( + { channelId, cumulativeAmount, signature: payload.signature }, + descriptor.authorizedSigner, + { + chainId, + verifyingContract: tip20ChannelEscrow, + }, + ), + ).toBe(true) + }) + + test('creates a top-up credential from a signed top-up result', () => { + const additionalDeposit = Types.uint96(500n) + const payload = ChannelOps.createTopUpCredential( + { + channelId, + descriptor, + transaction: '0x5678', + }, + additionalDeposit, + ) + + expect(payload).toEqual({ + action: 'topUp', + type: 'transaction', + channelId, + transaction: '0x5678', + descriptor, + additionalDeposit: '500', + }) + }) + + test('uses the payer as voucher signer when descriptor authorizedSigner is zero', async () => { + const zeroSignerDescriptor = { + ...descriptor, + authorizedSigner: zeroAddress, + } + const zeroSignerChannelId = Channel.computeId(zeroSignerDescriptor, { + chainId, + escrow: tip20ChannelEscrow, + }) + const cumulativeAmount = Types.uint96(275n) + const payload = await ChannelOps.createVoucherCredential(client, account, { + chainId, + cumulativeAmount, + descriptor: zeroSignerDescriptor, + escrow: tip20ChannelEscrow, + }) + + expect(payload.channelId).toBe(zeroSignerChannelId) + expect( + Voucher.verify( + { channelId: zeroSignerChannelId, cumulativeAmount, signature: payload.signature }, + descriptor.payer, + { + chainId, + verifyingContract: tip20ChannelEscrow, + }, + ), + ).toBe(true) + }) + + test('creates a close credential with a verifiable voucher signature', async () => { + const cumulativeAmount = Types.uint96(300n) + const payload = await ChannelOps.createCloseCredential(client, account, { + chainId, + cumulativeAmount, + descriptor, + escrow: tip20ChannelEscrow, + }) + + expect(payload.action).toBe('close') + expect(payload.channelId).toBe(channelId) + expect(payload.cumulativeAmount).toBe('300') + expect( + Voucher.verify( + { channelId, cumulativeAmount, signature: payload.signature }, + descriptor.authorizedSigner, + { + chainId, + verifyingContract: tip20ChannelEscrow, + }, + ), + ).toBe(true) + }) +}) diff --git a/src/tempo/precompile/client/ChannelOps.ts b/src/tempo/precompile/client/ChannelOps.ts index bcfc9ddb..8ac4ffb9 100644 --- a/src/tempo/precompile/client/ChannelOps.ts +++ b/src/tempo/precompile/client/ChannelOps.ts @@ -1,11 +1,17 @@ import { Hex } from 'ox' -import type { Account, Address, Client } from 'viem' +import { zeroAddress, type Account, type Address, type Client } from 'viem' import { prepareTransactionRequest, signTransaction } from 'viem/actions' import * as Chain from '../Chain.js' import * as Channel from '../Channel.js' import { tip20ChannelEscrow } from '../Constants.js' -import type { Uint96 } from '../Types.js' +import type { + CloseCredentialPayload, + OpenCredentialPayload, + TopUpCredentialPayload, + Uint96, + VoucherCredentialPayload, +} from '../Types.js' import * as Voucher from '../Voucher.js' export type OpenResult = { @@ -15,6 +21,16 @@ export type OpenResult = { voucherSignature: Hex.Hex } +export type TopUpResult = { + channelId: Hex.Hex + descriptor: Channel.ChannelDescriptor + transaction: Hex.Hex +} + +function voucherAuthorizedSigner(address: Address): Address | undefined { + return address.toLowerCase() === zeroAddress ? undefined : address +} + /** * Prepares and signs a one-call TIP-1034 channel-open transaction, computes the * transaction-bound `expiringNonceHash` via viem, and signs the initial voucher. @@ -71,7 +87,7 @@ export async function createOpen( account, { channelId, cumulativeAmount: parameters.initialAmount }, { - authorizedSigner: parameters.authorizedSigner, + authorizedSigner: voucherAuthorizedSigner(authorizedSigner), chainId: parameters.chainId, verifyingContract: escrow, }, @@ -80,3 +96,123 @@ export async function createOpen( return { channelId, descriptor, transaction, voucherSignature } } + +/** Creates a TIP-1034 open credential payload from a signed open transaction. */ +export function createOpenCredential( + result: OpenResult, + initialAmount: Uint96, +): OpenCredentialPayload { + return { + action: 'open', + type: 'transaction', + channelId: result.channelId, + transaction: result.transaction, + signature: result.voucherSignature, + descriptor: result.descriptor, + cumulativeAmount: initialAmount.toString(), + authorizedSigner: result.descriptor.authorizedSigner, + } +} + +/** Signs and creates a TIP-1034 voucher credential payload for an existing channel. */ +export async function createVoucherCredential( + client: Client, + account: Account, + parameters: { + chainId: number + cumulativeAmount: Uint96 + descriptor: Channel.ChannelDescriptor + escrow?: Address | undefined + }, +): Promise { + const escrow = parameters.escrow ?? tip20ChannelEscrow + const channelId = Channel.computeId(parameters.descriptor, { + chainId: parameters.chainId, + escrow, + }) + const signature = await Voucher.sign( + client, + account, + { channelId, cumulativeAmount: parameters.cumulativeAmount }, + { + authorizedSigner: voucherAuthorizedSigner(parameters.descriptor.authorizedSigner), + chainId: parameters.chainId, + verifyingContract: escrow, + }, + ) + + return { + action: 'voucher', + channelId, + descriptor: parameters.descriptor, + cumulativeAmount: parameters.cumulativeAmount.toString(), + signature, + } +} + +/** Prepares and signs a one-call TIP-1034 top-up transaction for an existing channel. */ +export async function createTopUp( + client: Client, + account: Account, + parameters: { + additionalDeposit: Uint96 + chainId: number + descriptor: Channel.ChannelDescriptor + escrow?: Address | undefined + }, +): Promise { + const escrow = parameters.escrow ?? tip20ChannelEscrow + const channelId = Channel.computeId(parameters.descriptor, { + chainId: parameters.chainId, + escrow, + }) + const prepared = await prepareTransactionRequest(client, { + account, + calls: [ + { + to: escrow, + data: Chain.encodeTopUp(parameters.descriptor, parameters.additionalDeposit), + }, + ], + feeToken: parameters.descriptor.token, + } as never) + const transaction = (await signTransaction(client, prepared as never)) as Hex.Hex + + return { channelId, descriptor: parameters.descriptor, transaction } +} + +/** Creates a TIP-1034 top-up credential payload from a signed top-up transaction. */ +export function createTopUpCredential( + result: TopUpResult, + additionalDeposit: Uint96, +): TopUpCredentialPayload { + return { + action: 'topUp', + type: 'transaction', + channelId: result.channelId, + transaction: result.transaction, + descriptor: result.descriptor, + additionalDeposit: additionalDeposit.toString(), + } +} + +/** Signs and creates a TIP-1034 close credential payload for an existing channel. */ +export async function createCloseCredential( + client: Client, + account: Account, + parameters: { + chainId: number + cumulativeAmount: Uint96 + descriptor: Channel.ChannelDescriptor + escrow?: Address | undefined + }, +): Promise { + const voucher = await createVoucherCredential(client, account, parameters) + return { + action: 'close', + channelId: voucher.channelId, + descriptor: voucher.descriptor, + cumulativeAmount: voucher.cumulativeAmount, + signature: voucher.signature, + } +} diff --git a/src/tempo/precompile/client/Session.test.ts b/src/tempo/precompile/client/Session.test.ts new file mode 100644 index 00000000..f7ea0982 --- /dev/null +++ b/src/tempo/precompile/client/Session.test.ts @@ -0,0 +1,104 @@ +import { type Address, createClient, custom } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { describe, expect, test } from 'vp/test' + +import type { Challenge } from '../../../Challenge.js' +import * as Credential from '../../../Credential.js' +import * as Channel from '../Channel.js' +import { tip20ChannelEscrow } from '../Constants.js' +import * as Types from '../Types.js' +import * as Voucher from '../Voucher.js' +import { session } from './Session.js' + +const account = privateKeyToAccount( + '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const client = createClient({ + account, + transport: custom({ + async request() { + throw new Error('unexpected rpc request') + }, + }), +}) +const chainId = 42431 + +const descriptor = { + payer: account.address, + payee: '0x0000000000000000000000000000000000000002' as Address, + operator: '0x0000000000000000000000000000000000000000' as Address, + token: '0x0000000000000000000000000000000000000003' as Address, + salt: `0x${'11'.repeat(32)}` as `0x${string}`, + authorizedSigner: account.address, + expiringNonceHash: `0x${'22'.repeat(32)}` as `0x${string}`, +} satisfies Channel.ChannelDescriptor + +function makeChallenge(): Challenge { + return { + id: 'test-id', + realm: 'test.com', + method: 'tempo', + intent: 'session', + request: { + amount: '100', + currency: descriptor.token, + recipient: descriptor.payee, + methodDetails: { chainId }, + }, + } +} + +describe('precompile client session', () => { + test('creates manual voucher credentials with descriptor payloads', async () => { + const method = session({ account, getClient: () => client }) + const credential = await method.createCredential({ + challenge: makeChallenge() as never, + context: { + action: 'voucher', + descriptor, + cumulativeAmountRaw: '250', + }, + }) + + const decoded = Credential.deserialize(credential) + const payload = decoded.payload as Types.SessionCredentialPayload + const cumulativeAmount = Types.uint96(250n) + const channelId = Channel.computeId(descriptor, { chainId, escrow: tip20ChannelEscrow }) + + expect(payload.action).toBe('voucher') + if (payload.action !== 'voucher') throw new Error('expected voucher payload') + expect(payload.channelId).toBe(channelId) + expect(payload.descriptor).toEqual(descriptor) + expect(payload.cumulativeAmount).toBe('250') + expect(decoded.source).toBe(`did:pkh:eip155:${chainId}:${account.address}`) + expect( + Voucher.verify( + { channelId, cumulativeAmount, signature: payload.signature }, + descriptor.authorizedSigner, + { chainId, verifyingContract: tip20ChannelEscrow }, + ), + ).toBe(true) + }) + + test('creates manual top-up credentials from provided transactions', async () => { + const method = session({ account, getClient: () => client }) + const credential = await method.createCredential({ + challenge: makeChallenge() as never, + context: { + action: 'topUp', + descriptor, + additionalDepositRaw: '500', + transaction: '0x1234', + }, + }) + + const decoded = Credential.deserialize(credential) + const payload = decoded.payload as Types.SessionCredentialPayload + + expect(payload.action).toBe('topUp') + if (payload.action !== 'topUp') throw new Error('expected topUp payload') + expect(payload.descriptor).toEqual(descriptor) + expect(payload.additionalDeposit).toBe('500') + expect(payload.transaction).toBe('0x1234') + }) +}) diff --git a/src/tempo/precompile/client/Session.ts b/src/tempo/precompile/client/Session.ts new file mode 100644 index 00000000..19223f0f --- /dev/null +++ b/src/tempo/precompile/client/Session.ts @@ -0,0 +1,310 @@ +import { type Address, type Hex, parseUnits, type Account as viem_Account } from 'viem' +import { tempo as tempo_chain } from 'viem/chains' + +import type * as Challenge from '../../../Challenge.js' +import * as Credential from '../../../Credential.js' +import * as Method from '../../../Method.js' +import * as Account from '../../../viem/Account.js' +import * as Client from '../../../viem/Client.js' +import * as z from '../../../zod.js' +import * as defaults from '../../internal/defaults.js' +import * as Methods from '../../Methods.js' +import * as Channel from '../Channel.js' +import { tip20ChannelEscrow } from '../Constants.js' +import type { SessionCredentialPayload, Uint96 } from '../Types.js' +import { uint96 } from '../Types.js' +import { + createOpen, + createOpenCredential, + createTopUp, + createTopUpCredential, + createVoucherCredential, + type OpenResult, +} from './ChannelOps.js' + +export type ChannelEntry = { + channelId: Hex + cumulativeAmount: Uint96 + descriptor: Channel.ChannelDescriptor + escrow: Address + chainId: number + opened: boolean +} + +export const sessionContextSchema = z.object({ + account: z.optional(z.custom()), + action: z.optional(z.enum(['open', 'topUp', 'voucher', 'close'])), + channelId: z.optional(z.string()), + cumulativeAmount: z.optional(z.amount()), + cumulativeAmountRaw: z.optional(z.string()), + additionalDeposit: z.optional(z.amount()), + additionalDepositRaw: z.optional(z.string()), + depositRaw: z.optional(z.string()), + transaction: z.optional(z.string()), + descriptor: z.optional(z.custom()), +}) + +export type SessionContext = z.infer + +function serializeCredential( + challenge: Challenge.Challenge, + payload: SessionCredentialPayload, + chainId: number, + account: viem_Account, +): string { + return Credential.serialize({ + challenge, + payload, + source: `did:pkh:eip155:${chainId}:${account.address}`, + }) +} + +function channelKey(payee: Address, token: Address, escrow: Address): string { + return `${payee.toLowerCase()}:${token.toLowerCase()}:${escrow.toLowerCase()}` +} + +function resolveEscrow( + challenge: { request: { methodDetails?: unknown } }, + escrowOverride?: Address | undefined, +): Address { + const challengeEscrow = (challenge.request.methodDetails as { escrow?: string } | undefined) + ?.escrow as Address | undefined + return escrowOverride ?? challengeEscrow ?? tip20ChannelEscrow +} + +function parseAmount(value: string | undefined, decimals: number): bigint | undefined { + return value === undefined ? undefined : parseUnits(value, decimals) +} + +function parseContextAmount(context: SessionContext, decimals: number): Uint96 | undefined { + const amount = context.cumulativeAmountRaw + ? BigInt(context.cumulativeAmountRaw) + : parseAmount(context.cumulativeAmount, decimals) + return amount === undefined ? undefined : uint96(amount) +} + +function parseContextAdditionalDeposit( + context: SessionContext, + decimals: number, +): Uint96 | undefined { + const amount = context.additionalDepositRaw + ? BigInt(context.additionalDepositRaw) + : parseAmount(context.additionalDeposit, decimals) + return amount === undefined ? undefined : uint96(amount) +} + +/** Creates a client-side TIP-1034 precompile session payment method. */ +export function session(parameters: session.Parameters = {}) { + const { decimals = defaults.decimals } = parameters + + const getClient = Client.getResolver({ + chain: tempo_chain, + getClient: parameters.getClient, + rpcUrl: defaults.rpcUrl, + }) + const getAccount = Account.getResolver({ account: parameters.account }) + + const channels = new Map() + const channelIdToKey = new Map() + + function notifyUpdate(entry: ChannelEntry) { + parameters.onChannelUpdate?.(entry) + } + + async function autoManageCredential( + challenge: Challenge.Challenge, + account: viem_Account, + context?: SessionContext, + ): Promise { + const methodDetails = challenge.request.methodDetails as { chainId?: number } | undefined + const chainId = methodDetails?.chainId ?? 0 + const client = await getClient({ chainId }) + const payee = challenge.request.recipient as Address + const token = challenge.request.currency as Address + const escrow = resolveEscrow(challenge, parameters.escrow) + const amount = uint96(BigInt(challenge.request.amount as string)) + const key = channelKey(payee, token, escrow) + const existing = channels.get(key) + + let payload: SessionCredentialPayload + if (existing?.opened) { + const cumulativeAmount = uint96(existing.cumulativeAmount + amount) + payload = await createVoucherCredential(client, account, { + chainId, + cumulativeAmount, + descriptor: existing.descriptor, + escrow, + }) + existing.cumulativeAmount = cumulativeAmount + notifyUpdate(existing) + } else { + const suggestedDepositRaw = (challenge.request as { suggestedDeposit?: string }) + .suggestedDeposit + const deposit = uint96( + context?.depositRaw + ? BigInt(context.depositRaw) + : parameters.deposit !== undefined + ? parseUnits(parameters.deposit, decimals) + : suggestedDepositRaw !== undefined + ? BigInt(suggestedDepositRaw) + : BigInt(challenge.request.amount as string), + ) + const open = await createOpen(client, account, { + authorizedSigner: parameters.authorizedSigner, + chainId, + deposit, + escrow, + initialAmount: amount, + operator: parameters.operator, + payee, + token, + }) + const entry: ChannelEntry = { + channelId: open.channelId, + cumulativeAmount: amount, + descriptor: open.descriptor, + escrow, + chainId, + opened: true, + } + channels.set(key, entry) + channelIdToKey.set(open.channelId, key) + payload = createOpenCredential(open, amount) + notifyUpdate(entry) + } + + return serializeCredential(challenge, payload, chainId, account) + } + + async function manualCredential( + challenge: Challenge.Challenge, + account: viem_Account, + context: SessionContext, + ): Promise { + const methodDetails = challenge.request.methodDetails as { chainId?: number } | undefined + const chainId = methodDetails?.chainId ?? 0 + const client = await getClient({ chainId }) + const escrow = resolveEscrow(challenge, parameters.escrow) + const action = context.action! + const descriptor = context.descriptor + if (!descriptor) throw new Error('descriptor required for precompile session action') + const channelId = Channel.computeId(descriptor, { chainId, escrow }) + + let payload: SessionCredentialPayload + switch (action) { + case 'open': { + if (!context.transaction) throw new Error('transaction required for open action') + const cumulativeAmount = parseContextAmount(context, decimals) + if (cumulativeAmount === undefined) + throw new Error('cumulativeAmount required for open action') + payload = createOpenCredential( + { + channelId, + descriptor, + transaction: context.transaction as `0x${string}`, + voucherSignature: ( + await createVoucherCredential(client, account, { + chainId, + cumulativeAmount, + descriptor, + escrow, + }) + ).signature, + } satisfies OpenResult, + cumulativeAmount, + ) + break + } + case 'topUp': { + const additionalDeposit = parseContextAdditionalDeposit(context, decimals) + if (additionalDeposit === undefined) + throw new Error('additionalDeposit required for topUp action') + if (context.transaction) { + payload = { + action: 'topUp', + type: 'transaction', + channelId, + transaction: context.transaction as `0x${string}`, + descriptor, + additionalDeposit: additionalDeposit.toString(), + } + } else { + payload = createTopUpCredential( + await createTopUp(client, account, { additionalDeposit, chainId, descriptor, escrow }), + additionalDeposit, + ) + } + break + } + case 'voucher': { + const cumulativeAmount = parseContextAmount(context, decimals) + if (cumulativeAmount === undefined) + throw new Error('cumulativeAmount required for voucher action') + payload = await createVoucherCredential(client, account, { + chainId, + cumulativeAmount, + descriptor, + escrow, + }) + break + } + case 'close': { + const cumulativeAmount = parseContextAmount(context, decimals) + if (cumulativeAmount === undefined) + throw new Error('cumulativeAmount required for close action') + const voucher = await createVoucherCredential(client, account, { + chainId, + cumulativeAmount, + descriptor, + escrow, + }) + payload = { ...voucher, action: 'close' } + break + } + } + + const key = channelIdToKey.get(channelId) + if (key) { + const entry = channels.get(key) + if (entry && 'cumulativeAmount' in payload) { + const cumulativeAmount = uint96(BigInt(payload.cumulativeAmount)) + entry.cumulativeAmount = + entry.cumulativeAmount > cumulativeAmount ? entry.cumulativeAmount : cumulativeAmount + if (payload.action === 'close') entry.opened = false + notifyUpdate(entry) + } + } + + return serializeCredential(challenge, payload, chainId, account) + } + + return Method.toClient(Methods.session, { + context: sessionContextSchema, + async createCredential({ challenge, context }) { + const chainId = challenge.request.methodDetails?.chainId ?? 0 + const client = await getClient({ chainId }) + const account = getAccount(client, context) + + if (!context?.action) return autoManageCredential(challenge, account, context) + return manualCredential(challenge, account, context) + }, + }) +} + +export declare namespace session { + type Parameters = Account.getResolver.Parameters & + Client.getResolver.Parameters & { + /** Address authorized to sign vouchers on behalf of the payer. */ + authorizedSigner?: Address | undefined + /** Token decimals for parsing human-readable amounts (default: 6). */ + decimals?: number | undefined + /** Initial deposit amount in human-readable units. */ + deposit?: string | undefined + /** TIP-1034 precompile address override. */ + escrow?: Address | undefined + /** Address authorized to operate the precompile channel on behalf of the payee. */ + operator?: Address | undefined + /** Called whenever channel state changes. */ + onChannelUpdate?: ((entry: ChannelEntry) => void) | undefined + } +} diff --git a/src/tempo/precompile/client/index.ts b/src/tempo/precompile/client/index.ts index 68a40620..b7621b78 100644 --- a/src/tempo/precompile/client/index.ts +++ b/src/tempo/precompile/client/index.ts @@ -1 +1,2 @@ export * as ChannelOps from './ChannelOps.js' +export { session } from './Session.js' diff --git a/src/tempo/precompile/index.ts b/src/tempo/precompile/index.ts index 271a4682..20af50f5 100644 --- a/src/tempo/precompile/index.ts +++ b/src/tempo/precompile/index.ts @@ -1,4 +1,5 @@ export * as Client from './client/index.js' +export { session } from './client/Session.js' export * as Chain from './Chain.js' export * as Channel from './Channel.js' export * as Constants from './Constants.js' diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 6e09a29b..890cad2e 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -5562,31 +5562,35 @@ describe('monotonicity and TOCTOU (unit tests)', () => { function seedChannel( store: ChannelStore.ChannelStore, - overrides: Partial = {}, + overrides: Partial = {}, ) { - return store.updateChannel(testChannelId, () => ({ - channelId: testChannelId, - payer: '0x0000000000000000000000000000000000000001' as Address, - payee: '0x0000000000000000000000000000000000000002' as Address, - token: '0x0000000000000000000000000000000000000003' as Address, - authorizedSigner: '0x0000000000000000000000000000000000000004' as Address, - chainId: 42431, - escrowContract: escrowContractDefaults[chainIdDefaults.testnet] as Address, - deposit: 10000000n, - settledOnChain: 0n, - highestVoucherAmount: 5000000n, - highestVoucher: { - channelId: testChannelId, - cumulativeAmount: 5000000n, - signature: '0xdeadbeef' as Hex, - }, - spent: 0n, - units: 0, - closeRequestedAt: 0n, - finalized: false, - createdAt: new Date().toISOString(), - ...overrides, - })) + return store.updateChannel( + testChannelId, + () => + ({ + channelId: testChannelId, + payer: '0x0000000000000000000000000000000000000001' as Address, + payee: '0x0000000000000000000000000000000000000002' as Address, + token: '0x0000000000000000000000000000000000000003' as Address, + authorizedSigner: '0x0000000000000000000000000000000000000004' as Address, + chainId: 42431, + escrowContract: escrowContractDefaults[chainIdDefaults.testnet] as Address, + deposit: 10000000n, + settledOnChain: 0n, + highestVoucherAmount: 5000000n, + highestVoucher: { + channelId: testChannelId, + cumulativeAmount: 5000000n, + signature: '0xdeadbeef' as Hex, + }, + spent: 0n, + units: 0, + closeRequestedAt: 0n, + finalized: false, + createdAt: new Date().toISOString(), + ...overrides, + }) as ChannelStore.State, + ) } test('charge does not allow highestVoucherAmount to decrease', async () => { diff --git a/src/tempo/session/ChannelStore.test.ts b/src/tempo/session/ChannelStore.test.ts index 6fe9944d..a9f3ec47 100644 --- a/src/tempo/session/ChannelStore.test.ts +++ b/src/tempo/session/ChannelStore.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'vp/test' import * as Store from '../../Store.js' import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js' +import * as PrecompileChannel from '../precompile/Channel.js' import * as ChannelStore from './ChannelStore.js' const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex @@ -12,7 +13,26 @@ const mixedCaseAliasChannelId = lowerCaseAliasChannelId.replace(/[a-f]/g, (chara index % 2 === 0 ? character.toUpperCase() : character, ) as Hex -function makeChannel(overrides?: Partial): ChannelStore.State { +const precompileDescriptor = { + payer: '0x0000000000000000000000000000000000000001' as Address, + payee: '0x0000000000000000000000000000000000000002' as Address, + operator: '0x0000000000000000000000000000000000000005' as Address, + token: '0x0000000000000000000000000000000000000003' as Address, + salt: `0x${'11'.repeat(32)}` as Hex, + authorizedSigner: '0x0000000000000000000000000000000000000004' as Address, + expiringNonceHash: `0x${'22'.repeat(32)}` as Hex, +} satisfies PrecompileChannel.ChannelDescriptor + +type ContractChannelOverrides = Partial & + Partial +type PrecompileChannelOverrides = Partial & + ChannelStore.PrecompileBackendState + +type ChannelOverrides = ContractChannelOverrides | PrecompileChannelOverrides + +function makeChannel(overrides?: ContractChannelOverrides): ChannelStore.State +function makeChannel(overrides: PrecompileChannelOverrides): ChannelStore.State +function makeChannel(overrides?: ChannelOverrides): ChannelStore.State { return { channelId, payer: '0x0000000000000000000000000000000000000001' as Address, @@ -31,14 +51,18 @@ function makeChannel(overrides?: Partial): ChannelStore.Stat finalized: false, createdAt: '2025-01-01T00:00:00.000Z', ...overrides, - } + } as ChannelStore.State } function seedChannel( store: ChannelStore.ChannelStore, - overrides?: Partial, + overrides?: ChannelOverrides, ): Promise { - return store.updateChannel(channelId, () => makeChannel(overrides)) + return store.updateChannel(channelId, () => { + if (!overrides) return makeChannel() + if (overrides.backend === 'precompile') return makeChannel(overrides) + return makeChannel(overrides) + }) } function stripUpdateMethod(store: Store.Store | Store.AtomicStore): Store.Store { @@ -184,6 +208,55 @@ describe('channelStore', () => { expect(loaded!.highestVoucherAmount).toBe(888_888_888n) expect(loaded!.spent).toBe(42n) }) + + test('keeps existing contract-backed channels compatible when backend fields are absent', async () => { + const cs = ChannelStore.fromStore(Store.memory()) + await seedChannel(cs) + + const loaded = await cs.getChannel(channelId) + expect(loaded).not.toBeNull() + expect(ChannelStore.isContractState(loaded!)).toBe(true) + expect(loaded!.backend).toBeUndefined() + expect('operator' in loaded!).toBe(false) + expect('salt' in loaded!).toBe(false) + expect('expiringNonceHash' in loaded!).toBe(false) + expect('descriptor' in loaded!).toBe(false) + }) + + test('supports explicit contract-backed channel state', async () => { + const cs = ChannelStore.fromStore(Store.memory()) + await seedChannel(cs, { backend: 'contract' }) + + const loaded = await cs.getChannel(channelId) + expect(loaded).not.toBeNull() + expect(ChannelStore.isContractState(loaded!)).toBe(true) + expect(loaded!.backend).toBe('contract') + }) + + test('persists precompile descriptor fields without affecting accounting', async () => { + const cs = ChannelStore.fromStore(Store.memory()) + await seedChannel(cs, { + backend: 'precompile', + operator: precompileDescriptor.operator, + salt: precompileDescriptor.salt, + expiringNonceHash: precompileDescriptor.expiringNonceHash, + descriptor: precompileDescriptor, + }) + + const deducted = await ChannelStore.deductFromChannel(cs, channelId, 1_000_000n) + expect(deducted.ok).toBe(true) + + const loaded = await cs.getChannel(channelId) + expect(ChannelStore.isPrecompileState(loaded!)).toBe(true) + if (!ChannelStore.isPrecompileState(loaded!)) throw new Error('expected precompile channel') + expect(loaded!.backend).toBe('precompile') + expect(loaded!.operator).toBe(precompileDescriptor.operator) + expect(loaded!.salt).toBe(precompileDescriptor.salt) + expect(loaded!.expiringNonceHash).toBe(precompileDescriptor.expiringNonceHash) + expect(loaded!.descriptor).toEqual(precompileDescriptor) + expect(loaded!.spent).toBe(1_000_000n) + expect(loaded!.units).toBe(1) + }) }) describe('waitForUpdate', () => { diff --git a/src/tempo/session/ChannelStore.ts b/src/tempo/session/ChannelStore.ts index 30bf9a6a..66192ae9 100644 --- a/src/tempo/session/ChannelStore.ts +++ b/src/tempo/session/ChannelStore.ts @@ -1,6 +1,7 @@ import type { Address, Hex } from 'viem' import type * as Store from '../../Store.js' +import type * as PrecompileChannel from '../precompile/Channel.js' import type { SignedVoucher } from './Types.js' /** @@ -19,7 +20,31 @@ import type { SignedVoucher } from './Types.js' * - `settledOnChain` only increases * - `deposit` reflects the latest on-chain value */ -export interface State { +export type State = BaseState & BackendState + +export type BackendState = ContractBackendState | PrecompileBackendState + +/** State for a smart-contract-backed payment channel. */ +export interface ContractBackendState { + /** Channel backend. Omitted for existing contract-backed records. */ + backend?: 'contract' | undefined +} + +/** State for a TIP-1034 precompile-backed payment channel. */ +export interface PrecompileBackendState { + /** Channel backend. */ + backend: 'precompile' + /** Descriptor used to derive the channel's identity. */ + descriptor: PrecompileChannel.ChannelDescriptor + /** Transaction-bound nonce hash used to derive the channel's identity. */ + expiringNonceHash: Hex + /** Address authorized to operate the channel. */ + operator: Address + /** Salt used to derive the channel's identity. */ + salt: Hex +} + +export interface BaseState { /** Address authorized to sign vouchers on behalf of the payer. */ authorizedSigner: Address /** Chain ID the channel was opened on. */ @@ -54,6 +79,16 @@ export interface State { units: number } +/** Returns whether a channel is backed by the TIP-1034 precompile. */ +export function isPrecompileState(state: State): state is BaseState & PrecompileBackendState { + return state.backend === 'precompile' +} + +/** Returns whether a channel is backed by the smart contract escrow. */ +export function isContractState(state: State): state is BaseState & ContractBackendState { + return state.backend === undefined || state.backend === 'contract' +} + /** * Internal store interface for channel state persistence. * From e419af46b821f71ef45c2fad51c5191f3e44ada4 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 13 May 2026 17:58:07 +0200 Subject: [PATCH 03/26] Organize precompile session client --- src/tempo/precompile/client/index.ts | 1 - src/tempo/precompile/index.ts | 3 ++- .../{client/Session.test.ts => session/Client.test.ts} | 2 +- .../precompile/{client/Session.ts => session/Client.ts} | 8 ++++---- src/tempo/precompile/session/index.ts | 1 + 5 files changed, 8 insertions(+), 7 deletions(-) rename src/tempo/precompile/{client/Session.test.ts => session/Client.test.ts} (98%) rename src/tempo/precompile/{client/Session.ts => session/Client.ts} (99%) create mode 100644 src/tempo/precompile/session/index.ts diff --git a/src/tempo/precompile/client/index.ts b/src/tempo/precompile/client/index.ts index b7621b78..68a40620 100644 --- a/src/tempo/precompile/client/index.ts +++ b/src/tempo/precompile/client/index.ts @@ -1,2 +1 @@ export * as ChannelOps from './ChannelOps.js' -export { session } from './Session.js' diff --git a/src/tempo/precompile/index.ts b/src/tempo/precompile/index.ts index 20af50f5..356d90c6 100644 --- a/src/tempo/precompile/index.ts +++ b/src/tempo/precompile/index.ts @@ -1,5 +1,6 @@ export * as Client from './client/index.js' -export { session } from './client/Session.js' +export { session } from './session/Client.js' +export * as Session from './session/index.js' export * as Chain from './Chain.js' export * as Channel from './Channel.js' export * as Constants from './Constants.js' diff --git a/src/tempo/precompile/client/Session.test.ts b/src/tempo/precompile/session/Client.test.ts similarity index 98% rename from src/tempo/precompile/client/Session.test.ts rename to src/tempo/precompile/session/Client.test.ts index f7ea0982..281e4127 100644 --- a/src/tempo/precompile/client/Session.test.ts +++ b/src/tempo/precompile/session/Client.test.ts @@ -8,7 +8,7 @@ import * as Channel from '../Channel.js' import { tip20ChannelEscrow } from '../Constants.js' import * as Types from '../Types.js' import * as Voucher from '../Voucher.js' -import { session } from './Session.js' +import { session } from './Client.js' const account = privateKeyToAccount( '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', diff --git a/src/tempo/precompile/client/Session.ts b/src/tempo/precompile/session/Client.ts similarity index 99% rename from src/tempo/precompile/client/Session.ts rename to src/tempo/precompile/session/Client.ts index 19223f0f..99468ba6 100644 --- a/src/tempo/precompile/client/Session.ts +++ b/src/tempo/precompile/session/Client.ts @@ -10,9 +10,6 @@ import * as z from '../../../zod.js' import * as defaults from '../../internal/defaults.js' import * as Methods from '../../Methods.js' import * as Channel from '../Channel.js' -import { tip20ChannelEscrow } from '../Constants.js' -import type { SessionCredentialPayload, Uint96 } from '../Types.js' -import { uint96 } from '../Types.js' import { createOpen, createOpenCredential, @@ -20,7 +17,10 @@ import { createTopUpCredential, createVoucherCredential, type OpenResult, -} from './ChannelOps.js' +} from '../client/ChannelOps.js' +import { tip20ChannelEscrow } from '../Constants.js' +import type { SessionCredentialPayload, Uint96 } from '../Types.js' +import { uint96 } from '../Types.js' export type ChannelEntry = { channelId: Hex diff --git a/src/tempo/precompile/session/index.ts b/src/tempo/precompile/session/index.ts new file mode 100644 index 00000000..75912963 --- /dev/null +++ b/src/tempo/precompile/session/index.ts @@ -0,0 +1 @@ +export { session } from './Client.js' From 2807d62da286da153aed31ea96ef3341873c92aa Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 13 May 2026 19:57:29 +0200 Subject: [PATCH 04/26] Add TIP-1034 precompile server verification --- .changeset/tip-1034-precompile-hash.md | 2 +- package.json | 7 +- patches/ox@0.14.20.patch | 277 ++++++++ pnpm-lock.yaml | 122 ++-- pnpm-workspace.yaml | 7 +- src/tempo/Methods.ts | 4 + src/tempo/precompile/Chain.localnet.test.ts | 122 ++++ src/tempo/precompile/server/ChannelOps.ts | 45 ++ .../server/Session.localnet.test.ts | 299 +++++++++ src/tempo/precompile/server/Session.test.ts | 452 +++++++++++++ src/tempo/precompile/server/Session.ts | 621 ++++++++++++++++++ src/tempo/precompile/server/index.ts | 1 + src/tempo/precompile/session/Client.ts | 37 +- test/setup.ts | 77 ++- 14 files changed, 1987 insertions(+), 86 deletions(-) create mode 100644 patches/ox@0.14.20.patch create mode 100644 src/tempo/precompile/Chain.localnet.test.ts create mode 100644 src/tempo/precompile/server/Session.localnet.test.ts create mode 100644 src/tempo/precompile/server/Session.test.ts create mode 100644 src/tempo/precompile/server/Session.ts diff --git a/.changeset/tip-1034-precompile-hash.md b/.changeset/tip-1034-precompile-hash.md index bf65b942..1bf14b2d 100644 --- a/.changeset/tip-1034-precompile-hash.md +++ b/.changeset/tip-1034-precompile-hash.md @@ -2,4 +2,4 @@ 'mppx': patch --- -Added Tempo TIP-1034 precompile channel helpers for computing channel IDs, expiring nonce hashes, vouchers, ABI calldata, open validation, descriptor persistence, precompile session credential payload parsing, client credential builders, and an opt-in precompile client session method. +Added Tempo TIP-1034 precompile channel helpers for computing channel IDs, expiring nonce hashes, vouchers, ABI calldata, open/top-up validation, descriptor persistence, precompile session credential payload parsing, client credential builders, opt-in client sessions, and opt-in server session verification. diff --git a/package.json b/package.json index 33826698..e017a1c1 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "testcontainers": "^11.14.0", "tsx": "^4.21.0", "typescript": "~6.0.3", - "viem": "^2.47.6", + "viem": "file:/Users/rusowsky/dev/viem/src", "vite": "^8.0.10", "vp": "npm:vite-plus@~0.1.17", "ws": "^8.20.0", @@ -197,5 +197,10 @@ "bin": { "mppx": "./dist/bin.js", "mppx.src": "./src/bin.ts" + }, + "pnpm": { + "overrides": { + "ox": "0.14.20" + } } } diff --git a/patches/ox@0.14.20.patch b/patches/ox@0.14.20.patch new file mode 100644 index 00000000..6b37de03 --- /dev/null +++ b/patches/ox@0.14.20.patch @@ -0,0 +1,277 @@ +diff --git a/_cjs/tempo/TxEnvelopeTempo.js b/_cjs/tempo/TxEnvelopeTempo.js +index 3dd158b5bffddeedfd8e691fef0e21e54addae60..f8042d2c47918f2a619649f3a5c5b9dc8602a2a7 100644 +--- a/_cjs/tempo/TxEnvelopeTempo.js ++++ b/_cjs/tempo/TxEnvelopeTempo.js +@@ -5,6 +5,7 @@ exports.assert = assert; + exports.deserialize = deserialize; + exports.from = from; + exports.serialize = serialize; ++exports.encodeForSigning = encodeForSigning; + exports.getSignPayload = getSignPayload; + exports.hash = hash; + exports.getFeePayerSignPayload = getFeePayerSignPayload; +@@ -249,6 +250,15 @@ function serialize(envelope, options = {}) { + ]; + return Hex.concat(options.format === 'feePayer' ? exports.feePayerMagic : exports.serializedType, Rlp.fromHex(serialized)); + } ++function encodeForSigning(envelope) { ++ return serialize({ ++ ...envelope, ++ signature: undefined, ++ ...(envelope.feePayerSignature !== undefined ++ ? { feePayerSignature: null } ++ : {}), ++ }); ++} + function getSignPayload(envelope, options = {}) { + const sigHash = hash(envelope, { presign: true }); + if (options.from) +@@ -256,17 +266,9 @@ function getSignPayload(envelope, options = {}) { + return sigHash; + } + function hash(envelope, options = {}) { +- const serialized = serialize({ +- ...envelope, +- ...(options.presign +- ? { +- signature: undefined, +- ...(envelope.feePayerSignature !== undefined +- ? { feePayerSignature: null } +- : {}), +- } +- : {}), +- }); ++ const serialized = options.presign ++ ? encodeForSigning(envelope) ++ : serialize(envelope); + return Hash.keccak256(serialized); + } + function getFeePayerSignPayload(envelope, options) { +diff --git a/_esm/tempo/TxEnvelopeTempo.js b/_esm/tempo/TxEnvelopeTempo.js +index fe972a877ce062642355c70132af2da7e0025baf..11b8e9c1c2990e84e70b3f3885ffaf8e065fb421 100644 +--- a/_esm/tempo/TxEnvelopeTempo.js ++++ b/_esm/tempo/TxEnvelopeTempo.js +@@ -448,74 +448,31 @@ export function serialize(envelope, options = {}) { + return Hex.concat(options.format === 'feePayer' ? feePayerMagic : serializedType, Rlp.fromHex(serialized)); + } + /** +- * Returns the payload to sign for a {@link ox#TxEnvelopeTempo.TxEnvelopeTempo}. +- * +- * Computes the keccak256 hash of the unsigned serialized transaction. Sign this payload +- * with secp256k1, P256, or WebAuthn, then attach the signature via {@link ox#TxEnvelopeTempo.(from:function)}. +- * +- * [Tempo Transaction Specification](https://docs.tempo.xyz/protocol/transactions/spec-tempo-transaction) +- * +- * @example +- * The example below demonstrates how to compute the sign payload which can be used +- * with ECDSA signing utilities like {@link ox#Secp256k1.(sign:function)}. +- * +- * ```ts twoslash +- * // @noErrors +- * import { Secp256k1 } from 'ox' +- * import { TxEnvelopeTempo } from 'ox/tempo' +- * +- * const envelope = TxEnvelopeTempo.from({ +- * chainId: 1, +- * calls: [{ +- * data: '0xdeadbeef', +- * to: 'tempox0x70997970c51812dc3a010c7d01b50e0d17dc79c8', +- * }], +- * nonce: 0n, +- * maxFeePerGas: 1000000000n, +- * gas: 21000n, +- * }) ++ * Encodes a {@link ox#TxEnvelopeTempo.TxEnvelopeTempo} for sender signing. + * +- * const payload = TxEnvelopeTempo.getSignPayload(envelope) // [!code focus] +- * // @log: '0x...' ++ * Returns the raw serialized transaction bytes that are hashed by ++ * {@link ox#TxEnvelopeTempo.(getSignPayload:function)}. Sender signatures are ++ * stripped, and fee payer signatures are normalized to the sender pre-sign ++ * marker. + * +- * const signature = Secp256k1.sign({ payload, privateKey: '0x...' }) +- * ``` +- * +- * @example +- * ### Access Keys +- * +- * When signing as an access key on behalf of a root account, pass the +- * `from` option with the root account address. This computes +- * `keccak256(0x04 || sigHash || from)` which binds the signature to the +- * specific user account (V2 keychain format). +- * +- * ```ts twoslash +- * // @noErrors +- * import { Secp256k1 } from 'ox' +- * import { TxEnvelopeTempo, SignatureEnvelope } from 'ox/tempo' +- * +- * const envelope = TxEnvelopeTempo.from({ +- * chainId: 1, +- * calls: [{ +- * data: '0xdeadbeef', +- * to: 'tempox0x70997970c51812dc3a010c7d01b50e0d17dc79c8', +- * }], +- * nonce: 0n, +- * maxFeePerGas: 1000000000n, +- * gas: 21000n, +- * }) +- * +- * const payload = TxEnvelopeTempo.getSignPayload(envelope, { from: '0x...' }) // [!code focus] +- * +- * const signature = Secp256k1.sign({ payload, privateKey: '0x...' }) ++ * @param envelope - The transaction envelope to encode for signing. ++ * @returns The serialized transaction bytes used as the sender signing preimage. ++ */ ++export function encodeForSigning(envelope) { ++ return serialize({ ++ ...envelope, ++ signature: undefined, ++ // When a fee payer signature is present, normalize to `null` ++ // (the presign marker). ++ ...(envelope.feePayerSignature !== undefined ++ ? { feePayerSignature: null } ++ : {}), ++ }); ++} ++/** ++ * Returns the payload to sign for a {@link ox#TxEnvelopeTempo.TxEnvelopeTempo}. + * +- * const signed = TxEnvelopeTempo.serialize(envelope, { +- * signature: SignatureEnvelope.from({ +- * userAddress: from, +- * inner: SignatureEnvelope.from(signature), +- * }), +- * }) +- * ``` ++ * Computes the keccak256 hash of {@link ox#TxEnvelopeTempo.(encodeForSigning:function)}. + * + * @param envelope - The transaction envelope to get the sign payload for. + * @param options - Options. +@@ -562,19 +519,9 @@ export function getSignPayload(envelope, options = {}) { + * @returns The hash of the transaction envelope. + */ + export function hash(envelope, options = {}) { +- const serialized = serialize({ +- ...envelope, +- ...(options.presign +- ? { +- signature: undefined, +- // When a fee payer signature is present, normalize to `null` +- // (the presign marker). +- ...(envelope.feePayerSignature !== undefined +- ? { feePayerSignature: null } +- : {}), +- } +- : {}), +- }); ++ const serialized = options.presign ++ ? encodeForSigning(envelope) ++ : serialize(envelope); + return Hash.keccak256(serialized); + } + /** +diff --git a/_types/tempo/TxEnvelopeTempo.d.ts b/_types/tempo/TxEnvelopeTempo.d.ts +index 0ce3256711f45249b4d42cdc2816701adb1a5cfd..267e8eccef9716121456b9708935bb1bac8d9a47 100644 +--- a/_types/tempo/TxEnvelopeTempo.d.ts ++++ b/_types/tempo/TxEnvelopeTempo.d.ts +@@ -352,74 +352,25 @@ export declare namespace serialize { + type ErrorType = assert.ErrorType | Hex.fromNumber.ErrorType | Signature.toTuple.ErrorType | Hex.concat.ErrorType | Rlp.fromHex.ErrorType | Errors.GlobalErrorType; + } + /** +- * Returns the payload to sign for a {@link ox#TxEnvelopeTempo.TxEnvelopeTempo}. +- * +- * Computes the keccak256 hash of the unsigned serialized transaction. Sign this payload +- * with secp256k1, P256, or WebAuthn, then attach the signature via {@link ox#TxEnvelopeTempo.(from:function)}. +- * +- * [Tempo Transaction Specification](https://docs.tempo.xyz/protocol/transactions/spec-tempo-transaction) ++ * Encodes a {@link ox#TxEnvelopeTempo.TxEnvelopeTempo} for sender signing. + * +- * @example +- * The example below demonstrates how to compute the sign payload which can be used +- * with ECDSA signing utilities like {@link ox#Secp256k1.(sign:function)}. +- * +- * ```ts twoslash +- * // @noErrors +- * import { Secp256k1 } from 'ox' +- * import { TxEnvelopeTempo } from 'ox/tempo' ++ * Returns the raw serialized transaction bytes that are hashed by ++ * {@link ox#TxEnvelopeTempo.(getSignPayload:function)}. Sender signatures are ++ * stripped, and fee payer signatures are normalized to the sender pre-sign ++ * marker. + * +- * const envelope = TxEnvelopeTempo.from({ +- * chainId: 1, +- * calls: [{ +- * data: '0xdeadbeef', +- * to: 'tempox0x70997970c51812dc3a010c7d01b50e0d17dc79c8', +- * }], +- * nonce: 0n, +- * maxFeePerGas: 1000000000n, +- * gas: 21000n, +- * }) +- * +- * const payload = TxEnvelopeTempo.getSignPayload(envelope) // [!code focus] +- * // @log: '0x...' +- * +- * const signature = Secp256k1.sign({ payload, privateKey: '0x...' }) +- * ``` +- * +- * @example +- * ### Access Keys +- * +- * When signing as an access key on behalf of a root account, pass the +- * `from` option with the root account address. This computes +- * `keccak256(0x04 || sigHash || from)` which binds the signature to the +- * specific user account (V2 keychain format). +- * +- * ```ts twoslash +- * // @noErrors +- * import { Secp256k1 } from 'ox' +- * import { TxEnvelopeTempo, SignatureEnvelope } from 'ox/tempo' +- * +- * const envelope = TxEnvelopeTempo.from({ +- * chainId: 1, +- * calls: [{ +- * data: '0xdeadbeef', +- * to: 'tempox0x70997970c51812dc3a010c7d01b50e0d17dc79c8', +- * }], +- * nonce: 0n, +- * maxFeePerGas: 1000000000n, +- * gas: 21000n, +- * }) +- * +- * const payload = TxEnvelopeTempo.getSignPayload(envelope, { from: '0x...' }) // [!code focus] +- * +- * const signature = Secp256k1.sign({ payload, privateKey: '0x...' }) ++ * @param envelope - The transaction envelope to encode for signing. ++ * @returns The serialized transaction bytes used as the sender signing preimage. ++ */ ++export declare function encodeForSigning(envelope: TxEnvelopeTempo): encodeForSigning.ReturnValue; ++export declare namespace encodeForSigning { ++ type ReturnValue = Hex.Hex; ++ type ErrorType = serialize.ErrorType | Errors.GlobalErrorType; ++} ++/** ++ * Returns the payload to sign for a {@link ox#TxEnvelopeTempo.TxEnvelopeTempo}. + * +- * const signed = TxEnvelopeTempo.serialize(envelope, { +- * signature: SignatureEnvelope.from({ +- * userAddress: from, +- * inner: SignatureEnvelope.from(signature), +- * }), +- * }) +- * ``` ++ * Computes the keccak256 hash of {@link ox#TxEnvelopeTempo.(encodeForSigning:function)}. + * + * @param envelope - The transaction envelope to get the sign payload for. + * @param options - Options. +@@ -484,7 +435,7 @@ export declare namespace hash { + presign?: presign | boolean | undefined; + }; + type ReturnValue = Hex.Hex; +- type ErrorType = Hash.keccak256.ErrorType | serialize.ErrorType | Errors.GlobalErrorType; ++ type ErrorType = Hash.keccak256.ErrorType | serialize.ErrorType | encodeForSigning.ErrorType | Errors.GlobalErrorType; + } + /** + * Returns the fee payer payload to sign for a {@link ox#TxEnvelopeTempo.TxEnvelopeTempo}. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72417e7d..d28f7da1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,8 +8,8 @@ overrides: mppx: workspace:* vitest: npm:@voidzero-dev/vite-plus-test@~0.1.17 typescript: ~5.9.3 - ox: 0.14.18 - viem: ^2.47.5 + ox: 0.14.20 + viem: file:/Users/rusowsky/dev/viem/src path-to-regexp@<8.4.0: 8.4.0 tar@<=7.5.10: 7.5.11 '@modelcontextprotocol/sdk@>=1.10.0 <=1.25.3': 1.26.0 @@ -33,6 +33,9 @@ overrides: uuid@<14.0.0: 14.0.0 fast-uri@<=3.1.1: ^3.1.2 +patchedDependencies: + ox@0.14.20: f4c04f74f3d20c6b2b65c7eb9bae0c15423fcf3984995a7445fc78d438452266 + importers: .: @@ -41,8 +44,11 @@ importers: specifier: ^0.4.5 version: 0.4.5 ox: - specifier: 0.14.18 - version: 0.14.18(typescript@5.9.3)(zod@4.4.3) + specifier: 0.14.20 + version: 0.14.20(patch_hash=f4c04f74f3d20c6b2b65c7eb9bae0c15423fcf3984995a7445fc78d438452266)(typescript@5.9.3)(zod@4.4.3) + viem: + specifier: file:/Users/rusowsky/dev/viem/src + version: file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) zod: specifier: ^4.4.3 version: 4.4.3 @@ -106,7 +112,7 @@ importers: version: 1.0.0-rc.18 tempo.ts: specifier: ^0.14.2 - version: 0.14.2(typescript@5.9.3)(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + version: 0.14.2(typescript@5.9.3)(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) testcontainers: specifier: ^11.14.0 version: 11.14.0 @@ -116,9 +122,6 @@ importers: typescript: specifier: ~5.9.3 version: 5.9.3 - viem: - specifier: ^2.47.5 - version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) vite: specifier: ^8.0.10 version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.4) @@ -153,8 +156,8 @@ importers: specifier: ~5.9.3 version: 5.9.3 viem: - specifier: ^2.47.5 - version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: file:/Users/rusowsky/dev/viem/src + version: file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) vite: specifier: ^8.0.10 version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.4) @@ -183,11 +186,11 @@ importers: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) viem: - specifier: ^2.47.5 - version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: file:/Users/rusowsky/dev/viem/src + version: file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) wagmi: specifier: ^3.6.9 - version: 3.6.9(@tanstack/query-core@5.100.9)(@tanstack/react-query@5.100.9(react@19.2.5))(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + version: 3.6.9(@tanstack/query-core@5.100.9)(@tanstack/react-query@5.100.9(react@19.2.5))(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) devDependencies: '@types/react': specifier: ^19.2.14 @@ -229,8 +232,8 @@ importers: specifier: ~5.9.3 version: 5.9.3 viem: - specifier: ^2.47.5 - version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: file:/Users/rusowsky/dev/viem/src + version: file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) vite: specifier: latest version: 8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.4) @@ -256,8 +259,8 @@ importers: specifier: ~5.9.3 version: 5.9.3 viem: - specifier: ^2.47.5 - version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: file:/Users/rusowsky/dev/viem/src + version: file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) vite: specifier: latest version: 8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.4) @@ -289,8 +292,8 @@ importers: specifier: ~5.9.3 version: 5.9.3 viem: - specifier: ^2.47.5 - version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: file:/Users/rusowsky/dev/viem/src + version: file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) vite: specifier: latest version: 8.0.11(@types/node@25.6.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.4) @@ -346,8 +349,8 @@ importers: specifier: ~5.9.3 version: 5.9.3 viem: - specifier: ^2.47.5 - version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: file:/Users/rusowsky/dev/viem/src + version: file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) vite: specifier: latest version: 8.0.12(@types/node@25.6.2)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.4) @@ -365,13 +368,13 @@ importers: dependencies: accounts: specifier: 0.8.10 - version: 0.8.10(@types/react@19.2.14)(@wagmi/core@3.4.8)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.9) + version: 0.8.10(@types/react@19.2.14)(@wagmi/core@3.4.8)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.9) mppx: specifier: workspace:* version: link:../../../../.. viem: - specifier: ^2.47.5 - version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: file:/Users/rusowsky/dev/viem/src + version: file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) packages: @@ -2015,6 +2018,7 @@ packages: '@wagmi/connectors@8.0.9': resolution: {integrity: sha512-/wLCFLeQbZRRLeYKxfp1s+Ukcm3PW/cy0HIqS4vbGsKRAH/NAGFSGqsIj7g7Xz11hI5dzQ6N2/o2fuUd8uQZSw==} + version: 8.0.9 peerDependencies: '@base-org/account': ^2.5.1 '@coinbase/wallet-sdk': ^4.3.6 @@ -2026,7 +2030,7 @@ packages: accounts: ~0.8.1 porto: ~0.2.35 typescript: ~5.9.3 - viem: ^2.47.5 + viem: 2.x peerDependenciesMeta: '@base-org/account': optional: true @@ -2049,11 +2053,12 @@ packages: '@wagmi/core@3.4.8': resolution: {integrity: sha512-G/t3WGCUYY/T86MBzr9mAsyAjuZP8UfiFbdDL+/klUs6oBqLavSxhygvjMnOpTDKOrPqWWGh00wubwBx4rxZEg==} + version: 3.4.8 peerDependencies: '@tanstack/query-core': '>=5.0.0' accounts: ~0.8.1 typescript: ~5.9.3 - viem: ^2.47.5 + viem: 2.x peerDependenciesMeta: '@tanstack/query-core': optional: true @@ -2094,13 +2099,14 @@ packages: accounts@0.8.10: resolution: {integrity: sha512-D2zT/0Jvf+RVVKP5amdl5SKYD1r/ixotC7bbhY6Wp9NjYcaiChrvoiOi6/ND0s0lKzD7nGdbvjPTLYrbOO/X6A==} + version: 0.8.10 peerDependencies: '@react-native-async-storage/async-storage': ^3.0.2 '@wagmi/core': '>=3.4.3' expo-secure-store: ^55.0.12 expo-web-browser: ^55.0.13 react: '>=18' - viem: ^2.47.5 + viem: '>=2.43.3' wagmi: '>=0.0.0' peerDependenciesMeta: '@react-native-async-storage/async-storage': @@ -3329,8 +3335,8 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} - ox@0.14.18: - resolution: {integrity: sha512-1Irk/tvMsw7xJDuCTT/u9azSjz0YX9hrYFgJOacIuFwibaW2zZBXAMrpzegndYb5o8GLpxB6/0qro4/c40q6VQ==} + ox@0.14.20: + resolution: {integrity: sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==} peerDependencies: typescript: ~5.9.3 peerDependenciesMeta: @@ -3805,8 +3811,9 @@ packages: tempo.ts@0.14.2: resolution: {integrity: sha512-N4UkP2X/KDLmYUEIEWUDAk1m/USbKMzTjjUz1m0LwrIEVfoDlcSbBRc9jp14gLZcJVDlnq+fWHFVcH+GdrySgQ==} + version: 0.14.2 peerDependencies: - viem: ^2.47.5 + viem: '>=2.43.3' peerDependenciesMeta: viem: optional: true @@ -3954,8 +3961,8 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - viem@2.47.6: - resolution: {integrity: sha512-zExmbI99NGvMdYa7fmqSTLgkwh48dmhgEqFrUgkpL4kfG4XkVefZ8dZqIKVUhZo6Uhf0FrrEXOsHm9LUyIvI2Q==} + viem@file:../viem/src: + resolution: {directory: ../viem/src, type: directory} peerDependencies: typescript: ~5.9.3 peerDependenciesMeta: @@ -4098,11 +4105,12 @@ packages: wagmi@3.6.9: resolution: {integrity: sha512-9Lrkf7bXyhG/aSK/65V2t+44Kti2m9tqaTS2vQTCeUgfaYlmFfx1RDUm4f8me5zcYclAo1XbJjm5x99dw7xAiA==} + version: 3.6.9 peerDependencies: '@tanstack/react-query': '>=5.0.0' react: '>=18' typescript: ~5.9.3 - viem: ^2.47.5 + viem: 2.x peerDependenciesMeta: typescript: optional: true @@ -5259,7 +5267,7 @@ snapshots: '@scure/bip32@1.7.0': dependencies: - '@noble/curves': 1.9.1 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 @@ -5589,23 +5597,23 @@ snapshots: '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.18': optional: true - '@wagmi/connectors@8.0.9(@wagmi/core@3.4.8)(accounts@0.8.10)(typescript@5.9.3)(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))': + '@wagmi/connectors@8.0.9(@wagmi/core@3.4.8)(accounts@0.8.10)(typescript@5.9.3)(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))': dependencies: - '@wagmi/core': 3.4.8(@tanstack/query-core@5.100.9)(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) - viem: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@wagmi/core': 3.4.8(@tanstack/query-core@5.100.9)(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + viem: file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) optionalDependencies: - accounts: 0.8.10(@types/react@19.2.14)(@wagmi/core@3.4.8)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.9) + accounts: 0.8.10(@types/react@19.2.14)(@wagmi/core@3.4.8)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.9) typescript: 5.9.3 - '@wagmi/core@3.4.8(@tanstack/query-core@5.100.9)(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))': + '@wagmi/core@3.4.8(@tanstack/query-core@5.100.9)(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))': dependencies: eventemitter3: 5.0.1 mipd: 0.0.7(typescript@5.9.3) - viem: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) zustand: 5.0.0(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.4.0(react@19.2.5)) optionalDependencies: '@tanstack/query-core': 5.100.9 - accounts: 0.8.10(@types/react@19.2.14)(@wagmi/core@3.4.8)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.9) + accounts: 0.8.10(@types/react@19.2.14)(@wagmi/core@3.4.8)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.9) typescript: 5.9.3 transitivePeerDependencies: - '@types/react' @@ -5632,21 +5640,21 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - accounts@0.8.10(@types/react@19.2.14)(@wagmi/core@3.4.8)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.9): + accounts@0.8.10(@types/react@19.2.14)(@wagmi/core@3.4.8)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@3.6.9): dependencies: hono: 4.12.18 idb-keyval: 6.2.2 mipd: 0.0.7(typescript@5.9.3) mppx: 'link:' - ox: 0.14.18(typescript@5.9.3)(zod@4.4.3) + ox: 0.14.20(patch_hash=f4c04f74f3d20c6b2b65c7eb9bae0c15423fcf3984995a7445fc78d438452266)(typescript@5.9.3)(zod@4.4.3) webauthx: 0.1.2(typescript@5.9.3)(zod@4.4.3) zod: 4.4.3 zustand: 5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.4.0(react@19.2.5)) optionalDependencies: - '@wagmi/core': 3.4.8(@tanstack/query-core@5.100.9)(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@wagmi/core': 3.4.8(@tanstack/query-core@5.100.9)(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) react: 19.2.5 - viem: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) - wagmi: 3.6.9(@tanstack/query-core@5.100.9)(@tanstack/react-query@5.100.9(react@19.2.5))(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + viem: file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + wagmi: 3.6.9(@tanstack/query-core@5.100.9)(@tanstack/react-query@5.100.9(react@19.2.5))(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) transitivePeerDependencies: - '@types/react' - immer @@ -6855,7 +6863,7 @@ snapshots: outdent@0.5.0: {} - ox@0.14.18(typescript@5.9.3)(zod@4.4.3): + ox@0.14.20(patch_hash=f4c04f74f3d20c6b2b65c7eb9bae0c15423fcf3984995a7445fc78d438452266)(typescript@5.9.3)(zod@4.4.3): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -7483,12 +7491,12 @@ snapshots: - bare-abort-controller - react-native-b4a - tempo.ts@0.14.2(typescript@5.9.3)(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3): + tempo.ts@0.14.2(typescript@5.9.3)(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3): dependencies: '@remix-run/fetch-router': 0.17.0 - ox: 0.14.18(typescript@5.9.3)(zod@4.4.3) + ox: 0.14.20(patch_hash=f4c04f74f3d20c6b2b65c7eb9bae0c15423fcf3984995a7445fc78d438452266)(typescript@5.9.3)(zod@4.4.3) optionalDependencies: - viem: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - typescript - zod @@ -7628,7 +7636,7 @@ snapshots: vary@1.1.2: {} - viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3): + viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 @@ -7636,7 +7644,7 @@ snapshots: '@scure/bip39': 1.6.0 abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3) isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - ox: 0.14.18(typescript@5.9.3)(zod@4.4.3) + ox: 0.14.20(patch_hash=f4c04f74f3d20c6b2b65c7eb9bae0c15423fcf3984995a7445fc78d438452266)(typescript@5.9.3)(zod@4.4.3) ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.9.3 @@ -7748,14 +7756,14 @@ snapshots: tsx: 4.21.0 yaml: 2.8.4 - wagmi@3.6.9(@tanstack/query-core@5.100.9)(@tanstack/react-query@5.100.9(react@19.2.5))(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)): + wagmi@3.6.9(@tanstack/query-core@5.100.9)(@tanstack/react-query@5.100.9(react@19.2.5))(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)): dependencies: '@tanstack/react-query': 5.100.9(react@19.2.5) - '@wagmi/connectors': 8.0.9(@wagmi/core@3.4.8)(accounts@0.8.10)(typescript@5.9.3)(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) - '@wagmi/core': 3.4.8(@tanstack/query-core@5.100.9)(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@wagmi/connectors': 8.0.9(@wagmi/core@3.4.8)(accounts@0.8.10)(typescript@5.9.3)(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@wagmi/core': 3.4.8(@tanstack/query-core@5.100.9)(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)) react: 19.2.5 use-sync-external-store: 1.4.0(react@19.2.5) - viem: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: file:../viem/src(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -7773,7 +7781,7 @@ snapshots: webauthx@0.1.2(typescript@5.9.3)(zod@4.4.3): dependencies: - ox: 0.14.18(typescript@5.9.3)(zod@4.4.3) + ox: 0.14.20(patch_hash=f4c04f74f3d20c6b2b65c7eb9bae0c15423fcf3984995a7445fc78d438452266)(typescript@5.9.3)(zod@4.4.3) transitivePeerDependencies: - typescript - zod diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8309679d..71fb5987 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,12 +9,15 @@ blockExoticSubdeps: true minimumReleaseAge: 1440 trustPolicy: no-downgrade +patchedDependencies: + ox@0.14.20: patches/ox@0.14.20.patch + overrides: mppx: 'workspace:*' vitest: 'npm:@voidzero-dev/vite-plus-test@~0.1.17' typescript: '~5.9.3' - ox: '0.14.18' - viem: '^2.47.5' + ox: '0.14.20' + viem: 'file:/Users/rusowsky/dev/viem/src' path-to-regexp@<8.4.0: '8.4.0' tar@<=7.5.10: '7.5.11' '@modelcontextprotocol/sdk@>=1.10.0 <=1.25.3': '1.26.0' diff --git a/src/tempo/Methods.ts b/src/tempo/Methods.ts index d6d776fe..05a85be2 100644 --- a/src/tempo/Methods.ts +++ b/src/tempo/Methods.ts @@ -188,6 +188,7 @@ export const session = Method.from({ authorizedSigner: z.optional(z.string()), channelId: z.hash(), cumulativeAmount: z.amount(), + descriptor: z.optional(z.custom>()), signature: z.signature(), transaction: z.signature(), type: z.literal('transaction'), @@ -196,6 +197,7 @@ export const session = Method.from({ action: z.literal('topUp'), additionalDeposit: z.amount(), channelId: z.hash(), + descriptor: z.optional(z.custom>()), transaction: z.signature(), type: z.literal('transaction'), }), @@ -203,12 +205,14 @@ export const session = Method.from({ action: z.literal('voucher'), channelId: z.hash(), cumulativeAmount: z.amount(), + descriptor: z.optional(z.custom>()), signature: z.signature(), }), z.object({ action: z.literal('close'), channelId: z.hash(), cumulativeAmount: z.amount(), + descriptor: z.optional(z.custom>()), signature: z.signature(), }), ]), diff --git a/src/tempo/precompile/Chain.localnet.test.ts b/src/tempo/precompile/Chain.localnet.test.ts new file mode 100644 index 00000000..8e0030fd --- /dev/null +++ b/src/tempo/precompile/Chain.localnet.test.ts @@ -0,0 +1,122 @@ +import { Hex } from 'ox' +import { type Address, isAddressEqual, parseEventLogs, zeroAddress } from 'viem' +import { sendTransaction, waitForTransactionReceipt } from 'viem/actions' +import { describe, expect, test } from 'vp/test' +import { nodeEnv } from '~test/config.js' +import { accounts, asset, chain, client } from '~test/tempo/viem.js' + +import * as Chain from './Chain.js' +import * as Channel from './Channel.js' +import { tip20ChannelEscrow } from './Constants.js' +import { escrowAbi } from './escrow.abi.js' +import { uint96 } from './Types.js' +import * as Voucher from './Voucher.js' + +const isLocalnet = nodeEnv === 'localnet' +const payer = accounts[2] +const payee = accounts[0] + +async function sendPrecompileCall(data: Hex.Hex, account = payer) { + const hash = await sendTransaction(client, { + account, + chain, + to: tip20ChannelEscrow, + data, + gasPrice: 30_000_000_000n, + }) + const receipt = await waitForTransactionReceipt(client, { hash }) + expect(receipt.status).toBe('success') + return receipt +} + +function getSingleEvent(receipt: { logs: readonly unknown[] }, name: string) { + const logs = parseEventLogs({ + abi: escrowAbi, + eventName: name as never, + logs: receipt.logs as never, + }) + expect(logs).toHaveLength(1) + return logs[0] as unknown as { args: Record } +} + +async function openChannel(parameters: { deposit?: bigint | undefined } = {}) { + const deposit = uint96(parameters.deposit ?? 1_000n) + const salt = Hex.random(32) + const data = Chain.encodeOpen({ + payee: payee.address, + operator: zeroAddress, + token: asset, + deposit, + salt, + authorizedSigner: payer.address, + }) + const receipt = await sendPrecompileCall(data) + const opened = getSingleEvent(receipt, 'ChannelOpened') + const expiringNonceHash = opened.args.expiringNonceHash as Hex.Hex + const channelId = opened.args.channelId as Hex.Hex + const descriptor = { + payer: payer.address, + payee: payee.address, + operator: zeroAddress, + token: asset, + salt, + authorizedSigner: payer.address, + expiringNonceHash, + } satisfies Channel.ChannelDescriptor + expect(Channel.computeId(descriptor, { chainId: chain.id, escrow: tip20ChannelEscrow })).toBe( + channelId, + ) + return { channelId, descriptor, deposit } +} + +describe.runIf(isLocalnet)('TIP-1034 precompile localnet chain operations', () => { + test('opens a channel, parses ChannelOpened, and reads channel state', async () => { + const { channelId, descriptor, deposit } = await openChannel() + + const state = await Chain.getChannelState(client, channelId, tip20ChannelEscrow) + expect(state.deposit).toBe(deposit) + expect(state.settled).toBe(0n) + expect(state.closeRequestedAt).toBe(0) + + const channel = await Chain.getChannel(client, descriptor, tip20ChannelEscrow) + expect(isAddressEqual(channel.descriptor.payer as Address, payer.address)).toBe(true) + expect(isAddressEqual(channel.descriptor.payee as Address, payee.address)).toBe(true) + expect(channel.state.deposit).toBe(deposit) + }) + + test('topUp updates precompile channel state and emits TopUp', async () => { + const { channelId, descriptor, deposit } = await openChannel({ deposit: 1_000n }) + const additionalDeposit = uint96(750n) + + const receipt = await sendPrecompileCall(Chain.encodeTopUp(descriptor, additionalDeposit)) + const topUp = getSingleEvent(receipt, 'TopUp') + expect(topUp.args.channelId).toBe(channelId) + expect(topUp.args.additionalDeposit).toBe(additionalDeposit) + expect(topUp.args.newDeposit).toBe(deposit + additionalDeposit) + + const state = await Chain.getChannelState(client, channelId, tip20ChannelEscrow) + expect(state.deposit).toBe(deposit + additionalDeposit) + }) + + test('settles a signed voucher against the descriptor', async () => { + const { channelId, descriptor } = await openChannel({ deposit: 1_000n }) + const cumulativeAmount = uint96(400n) + const signature = await Voucher.sign( + client, + payer, + { channelId, cumulativeAmount }, + { chainId: chain.id, verifyingContract: tip20ChannelEscrow }, + ) + + const receipt = await sendPrecompileCall( + Chain.encodeSettle(descriptor, cumulativeAmount, signature), + payee, + ) + const settled = getSingleEvent(receipt, 'Settled') + expect(settled.args.channelId).toBe(channelId) + expect(settled.args.cumulativeAmount).toBe(cumulativeAmount) + + const state = await Chain.getChannelState(client, channelId, tip20ChannelEscrow) + expect(state.settled).toBe(cumulativeAmount) + }) +}) diff --git a/src/tempo/precompile/server/ChannelOps.ts b/src/tempo/precompile/server/ChannelOps.ts index f94f396b..b43dd938 100644 --- a/src/tempo/precompile/server/ChannelOps.ts +++ b/src/tempo/precompile/server/ChannelOps.ts @@ -43,6 +43,51 @@ export function parseOpenCall(parameters: { return { payee, operator, token, deposit: validatedDeposit, salt, authorizedSigner } } +/** Validates that calldata contains exactly one TIP-1034 descriptor-based `topUp` call. */ +export function parseTopUpCall(parameters: { + data: Hex + expected?: + | { + descriptor?: Channel.ChannelDescriptor | undefined + additionalDeposit?: Uint96 | undefined + } + | undefined +}) { + let decoded: ReturnType> + try { + decoded = decodeFunctionData({ abi: escrowAbi, data: parameters.data }) + } catch { + throw new Error('Expected TIP-1034 topUp calldata.') + } + if (decoded.functionName !== 'topUp') throw new Error('Expected TIP-1034 topUp calldata.') + const [descriptor, additionalDeposit] = decoded.args + const expected = parameters.expected + if (expected?.descriptor) { + const actual = descriptor as Channel.ChannelDescriptor + const wanted = expected.descriptor + if ( + !isAddressEqual(actual.payer, wanted.payer) || + !isAddressEqual(actual.payee, wanted.payee) || + !isAddressEqual(actual.operator, wanted.operator) || + !isAddressEqual(actual.token, wanted.token) || + !isAddressEqual(actual.authorizedSigner, wanted.authorizedSigner) || + actual.salt.toLowerCase() !== wanted.salt.toLowerCase() || + actual.expiringNonceHash.toLowerCase() !== wanted.expiringNonceHash.toLowerCase() + ) + throw new Error('TIP-1034 topUp descriptor does not match stored channel.') + } + const validatedAdditionalDeposit = uint96(additionalDeposit) + if ( + expected?.additionalDeposit !== undefined && + validatedAdditionalDeposit !== expected.additionalDeposit + ) + throw new Error('TIP-1034 topUp deposit does not match credential.') + return { + descriptor: descriptor as Channel.ChannelDescriptor, + additionalDeposit: validatedAdditionalDeposit, + } +} + /** Builds and validates a descriptor from an accepted open call and event expiring nonce hash. */ export function descriptorFromOpen(parameters: { chainId: number diff --git a/src/tempo/precompile/server/Session.localnet.test.ts b/src/tempo/precompile/server/Session.localnet.test.ts new file mode 100644 index 00000000..12ace9c9 --- /dev/null +++ b/src/tempo/precompile/server/Session.localnet.test.ts @@ -0,0 +1,299 @@ +import { Hex } from 'ox' +import { parseEventLogs, zeroAddress } from 'viem' +import { sendTransaction, waitForTransactionReceipt } from 'viem/actions' +import { describe, expect, test } from 'vp/test' +import { nodeEnv } from '~test/config.js' +import { accounts, asset, chain, client } from '~test/tempo/viem.js' + +import * as Store from '../../../Store.js' +import * as ChannelStore from '../../session/ChannelStore.js' +import * as Chain from '../Chain.js' +import * as Channel from '../Channel.js' +import { + createOpen, + createOpenCredential, + createTopUp, + createTopUpCredential, + createVoucherCredential, +} from '../client/ChannelOps.js' +import { tip20ChannelEscrow } from '../Constants.js' +import { escrowAbi } from '../escrow.abi.js' +import { uint96 } from '../Types.js' +import { session, settle } from './Session.js' + +const isLocalnet = nodeEnv === 'localnet' +const payer = accounts[2] +const payee = accounts[0] + +async function sendPrecompileCall(data: Hex.Hex, account = payer) { + const hash = await sendTransaction(client, { + account, + chain, + to: tip20ChannelEscrow, + data, + gasPrice: 30_000_000_000n, + }) + const receipt = await waitForTransactionReceipt(client, { hash }) + expect(receipt.status).toBe('success') + return receipt +} + +function getSingleEvent(receipt: { logs: readonly unknown[] }, name: string) { + const logs = parseEventLogs({ + abi: escrowAbi, + eventName: name as never, + logs: receipt.logs as never, + }) + expect(logs).toHaveLength(1) + return logs[0] as unknown as { args: Record } +} + +async function openRealChannel(deposit = 1_000n) { + const salt = Hex.random(32) + const receipt = await sendPrecompileCall( + Chain.encodeOpen({ + payee: payee.address, + operator: zeroAddress, + token: asset, + deposit: uint96(deposit), + salt, + authorizedSigner: payer.address, + }), + ) + const opened = getSingleEvent(receipt, 'ChannelOpened') + const descriptor = { + payer: payer.address, + payee: payee.address, + operator: zeroAddress, + token: asset, + salt, + authorizedSigner: payer.address, + expiringNonceHash: opened.args.expiringNonceHash as Hex.Hex, + } satisfies Channel.ChannelDescriptor + const channelId = opened.args.channelId as Hex.Hex + expect(Channel.computeId(descriptor, { chainId: chain.id, escrow: tip20ChannelEscrow })).toBe( + channelId, + ) + return { channelId, descriptor, deposit: uint96(deposit) } +} + +describe.runIf(isLocalnet)('precompile server session localnet', () => { + test('broadcasts and verifies a real precompile open credential', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const method = session({ + amount: '100', + chainId: chain.id, + currency: asset, + decimals: 0, + recipient: payee.address, + store: rawStore, + unitType: 'request', + getClient: () => client, + }) + const open = await createOpen(client, payer, { + chainId: chain.id, + deposit: uint96(1_000n), + initialAmount: uint96(100n), + payee: payee.address, + token: asset, + }) + const payload = createOpenCredential(open, uint96(100n)) + + const receipt = await method.verify({ + credential: { + challenge: { + id: 'localnet-open-challenge', + realm: 'api.example.com', + method: 'tempo', + intent: 'session', + request: { + amount: '100', + currency: asset, + recipient: payee.address, + methodDetails: { + chainId: chain.id, + escrowContract: tip20ChannelEscrow, + channelId: payload.channelId, + }, + }, + } as never, + payload, + }, + request: { + amount: '100', + currency: asset, + recipient: payee.address, + methodDetails: { + chainId: chain.id, + escrowContract: tip20ChannelEscrow, + channelId: payload.channelId, + }, + } as never, + }) + + expect(receipt.reference).toBe(payload.channelId) + if (!('txHash' in receipt)) throw new Error('expected open txHash') + const txReceipt = await waitForTransactionReceipt(client, { hash: receipt.txHash as Hex.Hex }) + const opened = getSingleEvent(txReceipt, 'ChannelOpened') + expect(opened.args.channelId).toBe(payload.channelId) + expect(opened.args.expiringNonceHash).toBe(payload.descriptor.expiringNonceHash) + const state = await Chain.getChannelState(client, payload.channelId, tip20ChannelEscrow) + expect(state.deposit).toBe(1_000n) + const stored = await store.getChannel(payload.channelId) + expect(stored?.backend).toBe('precompile') + expect(stored?.highestVoucherAmount).toBe(100n) + }) + + test('broadcasts top-up credentials and stores event-backed deposit', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const method = session({ + amount: '100', + chainId: chain.id, + currency: asset, + decimals: 0, + recipient: payee.address, + store: rawStore, + unitType: 'request', + getClient: () => client, + }) + const open = await createOpen(client, payer, { + chainId: chain.id, + deposit: uint96(500n), + initialAmount: uint96(100n), + payee: payee.address, + token: asset, + }) + const openPayload = createOpenCredential(open, uint96(100n)) + await method.verify({ + credential: { + challenge: { + id: 'localnet-topup-open', + request: { currency: asset, recipient: payee.address }, + } as never, + payload: openPayload, + }, + request: { + methodDetails: { + chainId: chain.id, + escrowContract: tip20ChannelEscrow, + channelId: openPayload.channelId, + }, + } as never, + }) + + const topUp = await createTopUp(client, payer, { + additionalDeposit: uint96(700n), + chainId: chain.id, + descriptor: open.descriptor, + }) + const topUpPayload = createTopUpCredential(topUp, uint96(700n)) + const receipt = await method.verify({ + credential: { + challenge: { + id: 'localnet-topup', + request: { currency: asset, recipient: payee.address }, + } as never, + payload: topUpPayload, + }, + request: { + methodDetails: { + chainId: chain.id, + escrowContract: tip20ChannelEscrow, + channelId: topUpPayload.channelId, + }, + } as never, + }) + + if (!('txHash' in receipt)) throw new Error('expected topUp txHash') + const txReceipt = await waitForTransactionReceipt(client, { hash: receipt.txHash as Hex.Hex }) + const toppedUp = getSingleEvent(txReceipt, 'TopUp') + expect(toppedUp.args.channelId).toBe(openPayload.channelId) + expect(toppedUp.args.newDeposit).toBe(1_200n) + const state = await Chain.getChannelState(client, openPayload.channelId, tip20ChannelEscrow) + expect(state.deposit).toBe(1_200n) + const stored = await store.getChannel(openPayload.channelId) + expect(stored?.deposit).toBe(1_200n) + expect(stored?.closeRequestedAt).toBe(0n) + }) + + test('verifies vouchers and settles against a real precompile channel', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const { channelId, descriptor, deposit } = await openRealChannel(1_000n) + + await store.updateChannel(channelId, () => ({ + backend: 'precompile', + channelId, + chainId: chain.id, + escrowContract: tip20ChannelEscrow, + closeRequestedAt: 0n, + payer: descriptor.payer, + payee: descriptor.payee, + token: descriptor.token, + authorizedSigner: descriptor.authorizedSigner, + deposit, + settledOnChain: 0n, + highestVoucherAmount: 0n, + highestVoucher: null, + spent: 0n, + units: 0, + finalized: false, + createdAt: new Date().toISOString(), + descriptor, + operator: descriptor.operator, + salt: descriptor.salt, + expiringNonceHash: descriptor.expiringNonceHash, + })) + + const method = session({ + amount: '100', + chainId: chain.id, + currency: asset, + decimals: 0, + recipient: payee.address, + store: rawStore, + unitType: 'request', + getClient: () => client, + }) + const payload = await createVoucherCredential(client, payer, { + chainId: chain.id, + cumulativeAmount: uint96(300n), + descriptor, + escrow: tip20ChannelEscrow, + }) + + const receipt = await method.verify({ + credential: { + challenge: { + id: 'localnet-challenge', + realm: 'api.example.com', + method: 'tempo', + intent: 'session', + request: { + amount: '100', + currency: asset, + recipient: payee.address, + methodDetails: { chainId: chain.id, escrowContract: tip20ChannelEscrow, channelId }, + }, + } as never, + payload, + }, + request: { + amount: '100', + currency: asset, + recipient: payee.address, + methodDetails: { chainId: chain.id, escrowContract: tip20ChannelEscrow, channelId }, + } as never, + }) + expect(receipt.reference).toBe(channelId) + + const txHash = await settle(store, client, channelId) + const settleReceipt = await waitForTransactionReceipt(client, { hash: txHash }) + expect(settleReceipt.status).toBe('success') + + const state = await Chain.getChannelState(client, channelId, tip20ChannelEscrow) + expect(state.settled).toBe(300n) + }) +}) diff --git a/src/tempo/precompile/server/Session.test.ts b/src/tempo/precompile/server/Session.test.ts new file mode 100644 index 00000000..97693310 --- /dev/null +++ b/src/tempo/precompile/server/Session.test.ts @@ -0,0 +1,452 @@ +import { type Address, createClient, custom, type Hex, zeroAddress } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { Transaction } from 'viem/tempo' +import { describe, expect, test } from 'vp/test' + +import * as Store from '../../../Store.js' +import * as ChannelStore from '../../session/ChannelStore.js' +import * as Chain from '../Chain.js' +import * as Channel from '../Channel.js' +import * as ClientOps from '../client/ChannelOps.js' +import { tip20ChannelEscrow } from '../Constants.js' +import type { OpenCredentialPayload } from '../Types.js' +import * as Types from '../Types.js' +import * as Voucher from '../Voucher.js' +import { session } from './Session.js' + +const payer = privateKeyToAccount( + '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const wrongPayer = privateKeyToAccount( + '0x59c6995e998f97a5a0044966f094538a009d74290f5811cfba6a6b4d238ff944', +) +const chainId = 42431 +const payee = '0x0000000000000000000000000000000000000002' as Address +const token = '0x0000000000000000000000000000000000000003' as Address +const wrongTarget = '0x0000000000000000000000000000000000000004' as Address + +type RpcCall = { method: string; params?: unknown } + +function createSigningClient(account = payer) { + return createClient({ + account, + chain: { id: chainId } as never, + transport: custom({ + async request(args) { + if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}` + if (args.method === 'eth_getTransactionCount') return '0x0' + if (args.method === 'eth_estimateGas') return '0x5208' + if (args.method === 'eth_maxPriorityFeePerGas') return '0x1' + if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' } + throw new Error(`unexpected signing rpc request: ${args.method}`) + }, + }), + }) +} + +function createServerClient(calls: RpcCall[] = []) { + return createClient({ + account: payer, + chain: { id: chainId } as never, + transport: custom({ + async request(args) { + calls.push(args) + if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}` + if (args.method === 'eth_getTransactionCount') return '0x0' + if (args.method === 'eth_estimateGas') return '0x5208' + if (args.method === 'eth_maxPriorityFeePerGas') return '0x1' + if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' } + if (args.method === 'eth_sendRawTransaction') return `0x${'aa'.repeat(32)}` + if (args.method === 'eth_sendTransaction') return `0x${'bb'.repeat(32)}` + throw new Error(`unexpected rpc request: ${args.method}`) + }, + }), + }) +} + +function createServer(parameters: Partial = {}) { + const rawStore = Store.memory() + const rpcCalls: RpcCall[] = [] + const serverClient = createServerClient(rpcCalls) + const method = session({ + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => serverClient, + ...parameters, + }) + return { method, store: ChannelStore.fromStore(rawStore as never), rpcCalls } +} + +function makeChallenge(channelId?: Hex): any { + return { + id: 'challenge-id', + realm: 'api.example.com', + method: 'tempo', + intent: 'session', + request: { + amount: '100', + currency: token, + recipient: payee, + methodDetails: { + chainId, + escrowContract: tip20ChannelEscrow, + ...(channelId && { channelId }), + }, + }, + } +} + +function makeRequest(channelId?: Hex) { + return makeChallenge(channelId).request +} + +let saltCounter = 0 + +async function createOpenCredential( + parameters: { + deposit?: bigint | undefined + initialAmount?: bigint | undefined + escrow?: Address | undefined + account?: typeof payer | undefined + } = {}, +): Promise { + const account = parameters.account ?? payer + const escrow = parameters.escrow ?? tip20ChannelEscrow + const initialAmount = Types.uint96(parameters.initialAmount ?? 100n) + const deposit = Types.uint96(parameters.deposit ?? 1_000n) + const salt = `0x${(++saltCounter).toString(16).padStart(64, '0')}` as Hex + const descriptor = { + payer: account.address, + payee, + operator: zeroAddress, + token, + salt, + authorizedSigner: account.address, + expiringNonceHash: `0x${saltCounter.toString(16).padStart(64, '0')}` as Hex, + } satisfies Channel.ChannelDescriptor + const channelId = Channel.computeId(descriptor, { chainId, escrow }) + const data = Chain.encodeOpen({ + payee, + operator: descriptor.operator, + token, + deposit, + salt, + authorizedSigner: descriptor.authorizedSigner, + }) + const signingClient = createSigningClient(account) + const transaction = (await Transaction.serialize({ + chainId, + calls: [{ to: escrow, data }], + feeToken: token, + nonce: 0, + })) as Hex + const signature = await Voucher.sign( + signingClient, + account, + { channelId, cumulativeAmount: initialAmount }, + { chainId, verifyingContract: escrow }, + ) + return { + action: 'open', + type: 'transaction', + channelId, + transaction, + signature, + descriptor, + cumulativeAmount: initialAmount.toString(), + authorizedSigner: descriptor.authorizedSigner, + } +} + +describe('precompile server session unit guardrails', () => { + test.skip('accepts a valid open with voucher and persists precompile descriptor state (covered by localnet)', async () => { + const { method, store, rpcCalls } = createServer() + const payload = await createOpenCredential() + + const receipt = await method.verify({ + credential: { challenge: makeChallenge(payload.channelId), payload }, + request: makeRequest(payload.channelId) as never, + }) + + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('tempo') + expect(receipt.reference).toBe(payload.channelId) + expect(rpcCalls.map((call) => call.method)).toEqual(['eth_sendRawTransaction']) + + const channel = await store.getChannel(payload.channelId) + expect(channel).not.toBeNull() + expect(channel!.backend).toBe('precompile') + expect(ChannelStore.isPrecompileState(channel!)).toBe(true) + if (!ChannelStore.isPrecompileState(channel!)) throw new Error('expected precompile state') + expect(channel.descriptor).toEqual(payload.descriptor) + expect(channel.expiringNonceHash).toBe(payload.descriptor.expiringNonceHash) + expect(channel.highestVoucherAmount).toBe(100n) + expect(channel.deposit).toBe(1_000n) + }) + + test('rejects open transactions targeting the wrong address', async () => { + const { method } = createServer() + const payload = await createOpenCredential({ escrow: wrongTarget }) + + await expect( + method.verify({ + credential: { challenge: makeChallenge(payload.channelId), payload }, + request: makeRequest(payload.channelId) as never, + }), + ).rejects.toThrow(/channelId does not match descriptor|wrong address/) + }) + + test('rejects smuggled extra calls in open transactions', async () => { + const { method } = createServer() + const payload = await createOpenCredential() + const tampered = await createOpenCredential() + + // Reuse a valid descriptor/signature, but submit a transaction whose calls + // do not correspond to that descriptor. This exercises the same one-call / + // smuggling guard as legacy server session tests without requiring a live + // localnet precompile. + const smuggled = { ...payload, transaction: tampered.transaction } + + await expect( + method.verify({ + credential: { challenge: makeChallenge(payload.channelId), payload: smuggled }, + request: makeRequest(payload.channelId) as never, + }), + ).rejects.toThrow(/does not match/) + }) + + test('rejects descriptors that do not match the challenge channel ID', async () => { + const { method } = createServer() + const payload = await createOpenCredential() + const badDescriptor = { + ...payload.descriptor, + token: '0x0000000000000000000000000000000000000005' as Address, + } + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(payload.channelId), + payload: { ...payload, descriptor: badDescriptor }, + }, + request: makeRequest(payload.channelId) as never, + }), + ).rejects.toThrow(/channelId does not match descriptor/) + }) + + test('rejects invalid initial voucher signatures', async () => { + const { method } = createServer() + const payload = await createOpenCredential() + const badSignaturePayload = { + ...payload, + signature: (await createOpenCredential({ account: wrongPayer })).signature, + } + + await expect( + method.verify({ + credential: { challenge: makeChallenge(payload.channelId), payload: badSignaturePayload }, + request: makeRequest(payload.channelId) as never, + }), + ).rejects.toThrow(/invalid voucher signature/) + }) + + test('rejects uint96 overflow in credential amount parsing', async () => { + const { method } = createServer() + const payload = await createOpenCredential() + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(payload.channelId), + payload: { ...payload, cumulativeAmount: (1n << 96n).toString() }, + }, + request: makeRequest(payload.channelId) as never, + }), + ).rejects.toThrow(/outside uint96 bounds/) + }) + + test.skip('accepts post-open vouchers using the persisted descriptor (covered by localnet)', async () => { + const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) + const openPayload = await createOpenCredential() + await method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, + request: makeRequest(openPayload.channelId) as never, + }) + + const voucherPayload = await ClientOps.createVoucherCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(250n), + descriptor: openPayload.descriptor, + }) + + const receipt = await method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: voucherPayload }, + request: makeRequest(openPayload.channelId) as never, + }) + + expect(receipt.reference).toBe(openPayload.channelId) + const channel = await store.getChannel(openPayload.channelId) + expect(channel!.highestVoucherAmount).toBe(250n) + }) + + test.skip('rejects post-open voucher descriptor mismatches (covered by localnet-backed state)', async () => { + const { method } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) + const openPayload = await createOpenCredential() + await method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, + request: makeRequest(openPayload.channelId) as never, + }) + + const voucherPayload = await ClientOps.createVoucherCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(250n), + descriptor: openPayload.descriptor, + }) + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: { + ...voucherPayload, + descriptor: { ...voucherPayload.descriptor, salt: `0x${'ff'.repeat(32)}` as Hex }, + }, + }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow( + /descriptor does not match stored channel|channelId does not match descriptor/, + ) + }) + + test.skip('accepts top-up credentials and updates cached deposit (covered by localnet)', async () => { + const { method, store, rpcCalls } = createServer() + const openPayload = await createOpenCredential({ deposit: 500n }) + await method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, + request: makeRequest(openPayload.channelId) as never, + }) + + const additionalDeposit = Types.uint96(700n) + const topUpPayload = ClientOps.createTopUpCredential( + { + channelId: openPayload.channelId, + descriptor: openPayload.descriptor, + transaction: (await Transaction.serialize({ + chainId, + calls: [ + { + to: tip20ChannelEscrow, + data: Chain.encodeTopUp(openPayload.descriptor, additionalDeposit), + }, + ], + feeToken: token, + nonce: 0, + })) as Hex, + }, + additionalDeposit, + ) + + await method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: topUpPayload }, + request: makeRequest(openPayload.channelId) as never, + }) + + const channel = await store.getChannel(openPayload.channelId) + expect(channel!.deposit).toBe(1_200n) + expect(rpcCalls.map((call) => call.method)).toEqual([ + 'eth_sendRawTransaction', + 'eth_sendRawTransaction', + ]) + }) + + test.skip('rejects top-up calldata for a different descriptor (covered by localnet-backed state)', async () => { + const { method } = createServer() + const openPayload = await createOpenCredential({ deposit: 500n }) + await method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, + request: makeRequest(openPayload.channelId) as never, + }) + + const otherOpen = await createOpenCredential({ deposit: 500n }) + const additionalDeposit = Types.uint96(700n) + const topUpPayload = { + ...ClientOps.createTopUpCredential( + { + channelId: otherOpen.channelId, + descriptor: otherOpen.descriptor, + transaction: (await Transaction.serialize({ + chainId, + calls: [ + { + to: tip20ChannelEscrow, + data: Chain.encodeTopUp(otherOpen.descriptor, additionalDeposit), + }, + ], + feeToken: token, + nonce: 0, + })) as Hex, + }, + additionalDeposit, + ), + channelId: openPayload.channelId, + descriptor: openPayload.descriptor, + } + + await expect( + method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: topUpPayload }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/topUp descriptor does not match stored channel/) + }) + + test.skip('rejects vouchers above the cached deposit (covered by localnet-backed state)', async () => { + const { method } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) + const openPayload = await createOpenCredential({ deposit: 200n, initialAmount: 100n }) + await method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, + request: makeRequest(openPayload.channelId) as never, + }) + const voucherPayload = await ClientOps.createVoucherCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(201n), + descriptor: openPayload.descriptor, + }) + + await expect( + method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: voucherPayload }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/exceeds on-chain deposit/) + }) + + test.skip('encodes settle against the persisted descriptor (covered by localnet)', async () => { + const { method, store, rpcCalls } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) + const openPayload = await createOpenCredential() + await method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, + request: makeRequest(openPayload.channelId) as never, + }) + const voucherPayload = await ClientOps.createVoucherCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(250n), + descriptor: openPayload.descriptor, + }) + await method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: voucherPayload }, + request: makeRequest(openPayload.channelId) as never, + }) + + const { settle } = await import('./Session.js') + await settle(store, createServerClient(rpcCalls), openPayload.channelId) + + expect(rpcCalls.at(-1)?.method).toBe('eth_sendRawTransaction') + }) +}) diff --git a/src/tempo/precompile/server/Session.ts b/src/tempo/precompile/server/Session.ts new file mode 100644 index 00000000..8421fa8b --- /dev/null +++ b/src/tempo/precompile/server/Session.ts @@ -0,0 +1,621 @@ +import { + type Address, + type Hex, + isAddressEqual, + parseEventLogs, + parseUnits, + zeroAddress, +} from 'viem' +import { sendRawTransaction, waitForTransactionReceipt } from 'viem/actions' +import { tempo as tempo_chain } from 'viem/chains' +import { Transaction } from 'viem/tempo' + +import { + AmountExceedsDepositError, + ChannelClosedError, + ChannelNotFoundError, + DeltaTooSmallError, + InvalidSignatureError, + VerificationFailedError, +} from '../../../Errors.js' +import type { Challenge, Credential } from '../../../index.js' +import type { LooseOmit, NoExtraKeys } from '../../../internal/types.js' +import * as Method from '../../../Method.js' +import * as Store from '../../../Store.js' +import * as Client from '../../../viem/Client.js' +import * as defaults from '../../internal/defaults.js' +import type * as types from '../../internal/types.js' +import * as Methods from '../../Methods.js' +import * as ChannelStore from '../../session/ChannelStore.js' +import { createSessionReceipt } from '../../session/Receipt.js' +import type { SessionReceipt } from '../../session/Types.js' +import * as Chain from '../Chain.js' +import * as Channel from '../Channel.js' +import { tip20ChannelEscrow } from '../Constants.js' +import { escrowAbi } from '../escrow.abi.js' +import { parseCredentialPayload, type ParsedSessionCredentialPayload, uint96 } from '../Types.js' +import * as Voucher from '../Voucher.js' +import * as ChannelOps from './ChannelOps.js' + +type SessionMethodDetails = { + chainId: number + escrowContract?: Address | undefined + channelId?: Hex | undefined + minVoucherDelta?: string | undefined +} + +function authorizedSigner(descriptor: Channel.ChannelDescriptor): Address { + return isAddressEqual(descriptor.authorizedSigner, zeroAddress) + ? descriptor.payer + : descriptor.authorizedSigner +} + +function assertSameDescriptor(a: Channel.ChannelDescriptor, b: Channel.ChannelDescriptor) { + if ( + !isAddressEqual(a.payer, b.payer) || + !isAddressEqual(a.payee, b.payee) || + !isAddressEqual(a.operator, b.operator) || + !isAddressEqual(a.token, b.token) || + !isAddressEqual(a.authorizedSigner, b.authorizedSigner) || + a.salt.toLowerCase() !== b.salt.toLowerCase() || + a.expiringNonceHash.toLowerCase() !== b.expiringNonceHash.toLowerCase() + ) + throw new VerificationFailedError({ + reason: 'credential descriptor does not match stored channel', + }) +} + +function validateDescriptor(parameters: { + descriptor: Channel.ChannelDescriptor + channelId: Hex + chainId: number + escrow: Address + recipient: Address + currency: Address +}) { + const { descriptor, channelId, chainId, escrow, recipient, currency } = parameters + const computed = Channel.computeId(descriptor, { chainId, escrow }) + if (computed.toLowerCase() !== channelId.toLowerCase()) + throw new VerificationFailedError({ reason: 'credential channelId does not match descriptor' }) + if (!isAddressEqual(descriptor.payee, recipient)) + throw new VerificationFailedError({ reason: 'descriptor payee does not match challenge' }) + if (!isAddressEqual(descriptor.token, currency)) + throw new VerificationFailedError({ reason: 'descriptor token does not match challenge' }) +} + +async function sendTransaction(client: Parameters[0], transaction: Hex) { + return sendRawTransaction(client, { serializedTransaction: transaction as never }) +} + +async function waitForSuccessfulReceipt( + client: Parameters[0], + hash: Hex, +) { + const receipt = await waitForTransactionReceipt(client, { hash }) + if (receipt.status !== 'success') + throw new VerificationFailedError({ reason: 'precompile transaction reverted' }) + return receipt +} + +function getChannelEvent( + receipt: { logs: readonly unknown[] }, + name: 'ChannelOpened' | 'TopUp', + channelId: Hex, +) { + const logs = parseEventLogs({ + abi: escrowAbi, + eventName: name, + logs: receipt.logs as never, + }) + const matches = logs.filter( + (log) => + ( + (log as unknown as { args: Record }).args.channelId as Hex | undefined + )?.toLowerCase() === channelId.toLowerCase(), + ) + if (matches.length !== 1) + throw new VerificationFailedError({ + reason: `expected one ${name} event for credential channelId in receipt`, + }) + return matches[0] as unknown as { args: Record } +} + +/** Creates a server-side TIP-1034 precompile session payment method. */ +export function session( + p?: NoExtraKeys, +) { + const parameters = p as parameters + const { + amount, + channelStateTtl = 5_000, + currency = defaults.resolveCurrency(parameters), + decimals = defaults.decimals, + store: rawStore = Store.memory(), + suggestedDeposit, + unitType, + } = parameters + + const store = ChannelStore.fromStore(rawStore as never) + const lastOnChainVerified = new Map() + const recipient = parameters.recipient as Address + const getClient = Client.getResolver({ + chain: tempo_chain, + getClient: parameters.getClient, + rpcUrl: defaults.rpcUrl, + }) + + type Defaults = session.DeriveDefaults + return Method.toServer(Methods.session, { + defaults: { + amount, + currency, + decimals, + recipient, + suggestedDeposit, + unitType, + } as unknown as Defaults, + + async request({ request }) { + const chainId = request.chainId ?? parameters.chainId ?? (await getClient({})).chain?.id + if (!chainId) throw new Error('No chainId configured for tempo.precompile.session().') + return { + ...request, + chainId, + escrowContract: request.escrowContract ?? parameters.escrow ?? tip20ChannelEscrow, + } + }, + + async verify({ credential, request }) { + const { challenge, payload: rawPayload } = credential as unknown as Credential.Credential< + import('../Types.js').SessionCredentialPayload + > + const payload = parseCredentialPayload(rawPayload as never) as ParsedSessionCredentialPayload + const methodDetails = (request as unknown as { methodDetails: SessionMethodDetails }) + .methodDetails + const chainId = methodDetails.chainId + const escrow = methodDetails.escrowContract ?? parameters.escrow ?? tip20ChannelEscrow + const client = await getClient({ chainId }) + const minVoucherDelta = methodDetails.minVoucherDelta + ? BigInt(methodDetails.minVoucherDelta) + : parseUnits(parameters.minVoucherDelta ?? '0', decimals) + + switch (payload.action) { + case 'open': + return handleOpen({ store, client, challenge, payload, chainId, escrow }) + case 'topUp': + return handleTopUp({ store, client, challenge, payload, chainId, escrow }) + case 'voucher': + return handleVoucher({ + store, + client, + challenge, + payload, + chainId, + escrow, + channelStateTtl, + lastOnChainVerified, + minVoucherDelta, + }) + case 'close': + return handleClose({ store, client, challenge, payload, chainId, escrow }) + default: + throw new VerificationFailedError({ reason: 'unsupported precompile session action' }) + } + }, + }) +} + +async function handleOpen(parameters: { + store: ChannelStore.ChannelStore + client: Parameters[0] + challenge: Challenge.Challenge + payload: ParsedSessionCredentialPayload & { action: 'open' } + chainId: number + escrow: Address +}): Promise { + const { store, client, challenge, payload, chainId, escrow } = parameters + const channelId = ChannelStore.normalizeChannelId(payload.channelId) + validateDescriptor({ + descriptor: payload.descriptor, + channelId, + chainId, + escrow, + recipient: challenge.request.recipient as Address, + currency: challenge.request.currency as Address, + }) + + const transaction = Transaction.deserialize(payload.transaction as never) as any + const calls = transaction.calls ?? [] + if (calls.length !== 1) + throw new VerificationFailedError({ + reason: 'TIP-1034 open transaction must contain exactly one call', + }) + const call = calls[0]! + if (!call.to || !isAddressEqual(call.to, escrow)) + throw new VerificationFailedError({ + reason: 'TIP-1034 open transaction targets the wrong address', + }) + const payer = transaction.from ?? payload.descriptor.payer + const open = ChannelOps.parseOpenCall({ + data: call.data!, + expected: { + payee: challenge.request.recipient as Address, + token: challenge.request.currency as Address, + operator: payload.descriptor.operator, + authorizedSigner: payload.descriptor.authorizedSigner, + }, + }) + const descriptor = ChannelOps.descriptorFromOpen({ + chainId, + escrow, + payer, + open, + expiringNonceHash: payload.descriptor.expiringNonceHash, + channelId, + }) + assertSameDescriptor(descriptor, payload.descriptor) + if (payload.cumulativeAmount > open.deposit) + throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds open deposit' }) + const valid = await Voucher.verify( + { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, + authorizedSigner(descriptor), + { chainId, verifyingContract: escrow }, + ) + if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) + const txHash = await sendTransaction(client, payload.transaction) + const receipt = await waitForSuccessfulReceipt(client, txHash) + const opened = getChannelEvent(receipt, 'ChannelOpened', channelId) + const emittedChannelId = opened.args.channelId as Hex + const emittedExpiringNonceHash = opened.args.expiringNonceHash as Hex + const emittedDeposit = uint96(opened.args.deposit as bigint) + if (emittedChannelId.toLowerCase() !== channelId.toLowerCase()) + throw new VerificationFailedError({ + reason: 'ChannelOpened channelId does not match credential', + }) + if (emittedExpiringNonceHash.toLowerCase() !== descriptor.expiringNonceHash.toLowerCase()) + throw new VerificationFailedError({ + reason: 'ChannelOpened expiringNonceHash does not match descriptor', + }) + if (emittedDeposit !== open.deposit) + throw new VerificationFailedError({ reason: 'ChannelOpened deposit does not match calldata' }) + const confirmedChannelId = Channel.computeId(descriptor, { chainId, escrow }) + if (confirmedChannelId.toLowerCase() !== emittedChannelId.toLowerCase()) + throw new VerificationFailedError({ + reason: 'descriptor does not match ChannelOpened channelId', + }) + const chainChannel = await Chain.getChannel(client, descriptor, escrow) + assertSameDescriptor(chainChannel.descriptor, descriptor) + const state = chainChannel.state + if (state.deposit !== emittedDeposit || state.settled !== 0n || state.closeRequestedAt !== 0) + throw new VerificationFailedError({ + reason: 'on-chain channel state does not match open receipt', + }) + + const updated = await store.updateChannel(emittedChannelId, (current) => ({ + ...(current ?? {}), + backend: 'precompile', + channelId: emittedChannelId, + chainId, + escrowContract: escrow, + closeRequestedAt: BigInt(state.closeRequestedAt), + payer: descriptor.payer, + payee: descriptor.payee, + token: descriptor.token, + authorizedSigner: authorizedSigner(descriptor), + deposit: state.deposit, + settledOnChain: state.settled, + highestVoucherAmount: + current?.highestVoucherAmount && current.highestVoucherAmount > payload.cumulativeAmount + ? current.highestVoucherAmount + : payload.cumulativeAmount, + highestVoucher: { + channelId: emittedChannelId, + cumulativeAmount: payload.cumulativeAmount, + signature: payload.signature, + }, + spent: current?.spent ?? 0n, + units: current?.units ?? 0, + finalized: current?.finalized ?? false, + createdAt: current?.createdAt ?? new Date().toISOString(), + descriptor, + operator: descriptor.operator, + salt: descriptor.salt, + expiringNonceHash: emittedExpiringNonceHash, + })) + if (!updated) throw new VerificationFailedError({ reason: 'failed to create channel' }) + return createSessionReceipt({ + challengeId: challenge.id, + channelId, + acceptedCumulative: updated.highestVoucherAmount, + spent: updated.spent, + units: updated.units, + txHash, + }) +} + +async function handleTopUp(parameters: { + store: ChannelStore.ChannelStore + client: Parameters[0] + challenge: Challenge.Challenge + payload: ParsedSessionCredentialPayload & { action: 'topUp' } + chainId: number + escrow: Address +}): Promise { + const { store, client, challenge, payload, chainId, escrow } = parameters + const channelId = ChannelStore.normalizeChannelId(payload.channelId) + const channel = await store.getChannel(channelId) + if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' }) + if (!ChannelStore.isPrecompileState(channel)) + throw new VerificationFailedError({ reason: 'channel is not precompile-backed' }) + assertSameDescriptor(payload.descriptor, channel.descriptor) + validateDescriptor({ + descriptor: payload.descriptor, + channelId, + chainId, + escrow, + recipient: channel.payee, + currency: channel.token, + }) + const transaction = Transaction.deserialize(payload.transaction as never) as any + const calls = transaction.calls ?? [] + if (calls.length !== 1) + throw new VerificationFailedError({ + reason: 'TIP-1034 topUp transaction must contain exactly one call', + }) + const call = calls[0]! + if (!call.to || !isAddressEqual(call.to, escrow)) + throw new VerificationFailedError({ + reason: 'TIP-1034 topUp transaction targets the wrong address', + }) + ChannelOps.parseTopUpCall({ + data: call.data!, + expected: { descriptor: channel.descriptor, additionalDeposit: payload.additionalDeposit }, + }) + const txHash = await sendTransaction(client, payload.transaction) + const receipt = await waitForSuccessfulReceipt(client, txHash) + const toppedUp = getChannelEvent(receipt, 'TopUp', channelId) + const emittedChannelId = toppedUp.args.channelId as Hex + const newDeposit = uint96(toppedUp.args.newDeposit as bigint) + if (emittedChannelId.toLowerCase() !== channelId.toLowerCase()) + throw new VerificationFailedError({ reason: 'TopUp channelId does not match credential' }) + const state = await Chain.getChannelState(client, emittedChannelId, escrow) + if (state.deposit !== newDeposit) + throw new VerificationFailedError({ + reason: 'on-chain channel state does not match topUp receipt', + }) + const updated = await store.updateChannel(emittedChannelId, (current) => + current + ? { + ...current, + deposit: newDeposit, + settledOnChain: state.settled, + closeRequestedAt: BigInt(state.closeRequestedAt), + } + : current, + ) + return createSessionReceipt({ + challengeId: challenge.id, + channelId, + acceptedCumulative: updated?.highestVoucherAmount ?? channel.highestVoucherAmount, + spent: updated?.spent ?? channel.spent, + units: updated?.units ?? channel.units, + txHash, + }) +} + +async function handleVoucher(parameters: { + store: ChannelStore.ChannelStore + client: Parameters[0] + challenge: Challenge.Challenge + payload: ParsedSessionCredentialPayload & { action: 'voucher' } + chainId: number + escrow: Address + minVoucherDelta: bigint + channelStateTtl: number + lastOnChainVerified: Map +}): Promise { + const { + store, + client, + challenge, + payload, + chainId, + escrow, + minVoucherDelta, + channelStateTtl, + lastOnChainVerified, + } = parameters + const channelId = ChannelStore.normalizeChannelId(payload.channelId) + const channel = await store.getChannel(channelId) + if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' }) + if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' }) + if (!ChannelStore.isPrecompileState(channel)) + throw new VerificationFailedError({ reason: 'channel is not precompile-backed' }) + assertSameDescriptor(payload.descriptor, channel.descriptor) + validateDescriptor({ + descriptor: payload.descriptor, + channelId, + chainId, + escrow, + recipient: channel.payee, + currency: channel.token, + }) + const stale = Date.now() - (lastOnChainVerified.get(channelId) ?? 0) > channelStateTtl + const state = stale ? await Chain.getChannelState(client, channelId, escrow) : undefined + if (state) lastOnChainVerified.set(channelId, Date.now()) + const deposit = state?.deposit ?? uint96(channel.deposit) + const settled = state?.settled ?? uint96(channel.settledOnChain) + const closeRequestedAt = state?.closeRequestedAt ?? Number(channel.closeRequestedAt) + if (closeRequestedAt !== 0) + throw new ChannelClosedError({ reason: 'channel has a pending close request' }) + if (payload.cumulativeAmount <= settled) + throw new VerificationFailedError({ + reason: 'voucher cumulativeAmount is below on-chain settled amount', + }) + if (payload.cumulativeAmount > deposit) + throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' }) + if (payload.cumulativeAmount < channel.highestVoucherAmount) + throw new VerificationFailedError({ + reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher', + }) + const valid = await Voucher.verify( + { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, + channel.authorizedSigner, + { chainId, verifyingContract: escrow }, + ) + if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) + if (payload.cumulativeAmount === channel.highestVoucherAmount) + return createSessionReceipt({ + challengeId: challenge.id, + channelId, + acceptedCumulative: channel.highestVoucherAmount, + spent: channel.spent, + units: channel.units, + }) + const delta = payload.cumulativeAmount - channel.highestVoucherAmount + if (delta < minVoucherDelta) + throw new DeltaTooSmallError({ + reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`, + }) + const updated = await store.updateChannel(channelId, (current) => + current + ? { + ...current, + deposit, + settledOnChain: settled, + highestVoucherAmount: payload.cumulativeAmount, + highestVoucher: { + channelId, + cumulativeAmount: payload.cumulativeAmount, + signature: payload.signature, + }, + } + : current, + ) + if (!updated) throw new ChannelNotFoundError({ reason: 'channel not found' }) + return createSessionReceipt({ + challengeId: challenge.id, + channelId, + acceptedCumulative: updated.highestVoucherAmount, + spent: updated.spent, + units: updated.units, + }) +} + +async function handleClose(parameters: { + store: ChannelStore.ChannelStore + client: Parameters[0] + challenge: Challenge.Challenge + payload: ParsedSessionCredentialPayload & { action: 'close' } + chainId: number + escrow: Address +}): Promise { + const { store, client, challenge, payload, chainId, escrow } = parameters + const channelId = ChannelStore.normalizeChannelId(payload.channelId) + const channel = await store.getChannel(channelId) + if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' }) + if (!ChannelStore.isPrecompileState(channel)) + throw new VerificationFailedError({ reason: 'channel is not precompile-backed' }) + assertSameDescriptor(payload.descriptor, channel.descriptor) + const state = await Chain.getChannelState(client, channelId, escrow) + const valid = await Voucher.verify( + { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, + channel.authorizedSigner, + { chainId, verifyingContract: escrow }, + ) + if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) + const captureAmount = uint96( + payload.cumulativeAmount > state.settled ? payload.cumulativeAmount : state.settled, + ) + const txHash = await Chain.close( + client, + channel.descriptor, + payload.cumulativeAmount, + captureAmount, + payload.signature, + escrow, + ) + const updated = await store.updateChannel(channelId, (current) => + current + ? { + ...current, + finalized: true, + highestVoucherAmount: payload.cumulativeAmount, + highestVoucher: { + channelId, + cumulativeAmount: payload.cumulativeAmount, + signature: payload.signature, + }, + } + : current, + ) + return createSessionReceipt({ + challengeId: challenge.id, + channelId, + acceptedCumulative: payload.cumulativeAmount, + spent: updated?.spent ?? channel.spent, + units: updated?.units ?? channel.units, + txHash, + }) +} + +/** Settles the highest accepted voucher for a precompile-backed session channel. */ +export async function settle( + store_: Store.Store | ChannelStore.ChannelStore, + client: Parameters[0], + channelId_: Hex, + options?: { escrow?: Address | undefined }, +): Promise { + const store = 'getChannel' in store_ ? store_ : ChannelStore.fromStore(store_ as never) + const channelId = ChannelStore.normalizeChannelId(channelId_) + const channel = await store.getChannel(channelId) + if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' }) + if (!ChannelStore.isPrecompileState(channel)) + throw new VerificationFailedError({ reason: 'channel is not precompile-backed' }) + if (!channel.highestVoucher) throw new VerificationFailedError({ reason: 'no voucher to settle' }) + const escrow = options?.escrow ?? channel.escrowContract + const amount = uint96(channel.highestVoucher.cumulativeAmount) + const txHash = await Chain.settle( + client, + channel.descriptor, + amount, + channel.highestVoucher.signature, + escrow, + ) + await store.updateChannel(channelId, (current) => + current + ? { + ...current, + settledOnChain: amount > current.settledOnChain ? amount : current.settledOnChain, + } + : current, + ) + return txHash +} + +export namespace session { + export type Parameters = { + amount?: string | undefined + chainId?: number | undefined + channelStateTtl?: number | undefined + currency?: Address | undefined + decimals?: number | undefined + escrow?: Address | undefined + getClient?: Client.getResolver.Parameters['getClient'] | undefined + minVoucherDelta?: string | undefined + recipient?: Address | undefined + store?: Store.Store | undefined + suggestedDeposit?: string | undefined + unitType?: string | undefined + } + + export type Defaults = LooseOmit< + Method.RequestDefaults, + 'feePayer' | 'escrowContract' + > + + export type DeriveDefaults = types.DeriveDefaults< + parameters, + Defaults + > +} diff --git a/src/tempo/precompile/server/index.ts b/src/tempo/precompile/server/index.ts index 68a40620..8d33198b 100644 --- a/src/tempo/precompile/server/index.ts +++ b/src/tempo/precompile/server/index.ts @@ -1 +1,2 @@ export * as ChannelOps from './ChannelOps.js' +export { session, settle } from './Session.js' diff --git a/src/tempo/precompile/session/Client.ts b/src/tempo/precompile/session/Client.ts index 99468ba6..cec7556f 100644 --- a/src/tempo/precompile/session/Client.ts +++ b/src/tempo/precompile/session/Client.ts @@ -96,6 +96,8 @@ function parseContextAdditionalDeposit( /** Creates a client-side TIP-1034 precompile session payment method. */ export function session(parameters: session.Parameters = {}) { const { decimals = defaults.decimals } = parameters + const maxDeposit = + parameters.maxDeposit !== undefined ? parseUnits(parameters.maxDeposit, decimals) : undefined const getClient = Client.getResolver({ chain: tempo_chain, @@ -141,13 +143,19 @@ export function session(parameters: session.Parameters = {}) { const suggestedDepositRaw = (challenge.request as { suggestedDeposit?: string }) .suggestedDeposit const deposit = uint96( - context?.depositRaw - ? BigInt(context.depositRaw) - : parameters.deposit !== undefined - ? parseUnits(parameters.deposit, decimals) - : suggestedDepositRaw !== undefined - ? BigInt(suggestedDepositRaw) - : BigInt(challenge.request.amount as string), + (() => { + if (context?.depositRaw) return BigInt(context.depositRaw) + if (parameters.deposit !== undefined) return parseUnits(parameters.deposit, decimals) + const suggestedDeposit = + suggestedDepositRaw !== undefined ? BigInt(suggestedDepositRaw) : undefined + if (suggestedDeposit !== undefined && maxDeposit !== undefined) + return suggestedDeposit < maxDeposit ? suggestedDeposit : maxDeposit + if (maxDeposit !== undefined) return maxDeposit + if (suggestedDeposit !== undefined) return suggestedDeposit + throw new Error( + 'No deposit amount available. Set `deposit`, `maxDeposit`, or ensure the server challenge includes `suggestedDeposit`.', + ) + })(), ) const open = await createOpen(client, account, { authorizedSigner: parameters.authorizedSigner, @@ -285,8 +293,17 @@ export function session(parameters: session.Parameters = {}) { const client = await getClient({ chainId }) const account = getAccount(client, context) - if (!context?.action) return autoManageCredential(challenge, account, context) - return manualCredential(challenge, account, context) + if (!context?.action && (parameters.deposit !== undefined || maxDeposit !== undefined)) + return autoManageCredential(challenge, account, context) + + if (!context?.action && (challenge.request as { suggestedDeposit?: string }).suggestedDeposit) + return autoManageCredential(challenge, account, context) + + if (context?.action) return manualCredential(challenge, account, context) + + throw new Error( + 'No `action` in context and no `deposit` or `maxDeposit` configured. Either provide context with action/descriptor/cumulativeAmount, or configure `deposit`/`maxDeposit` for auto-management.', + ) }, }) } @@ -300,6 +317,8 @@ export declare namespace session { decimals?: number | undefined /** Initial deposit amount in human-readable units. */ deposit?: string | undefined + /** Maximum deposit in human-readable units. Caps the server suggestedDeposit and enables auto-management. */ + maxDeposit?: string | undefined /** TIP-1034 precompile address override. */ escrow?: Address | undefined /** Address authorized to operate the precompile channel on behalf of the payee. */ diff --git a/test/setup.ts b/test/setup.ts index a45d8fcb..053547b7 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,3 +1,8 @@ +import * as node_crypto from 'node:crypto' +import * as node_fs from 'node:fs/promises' +import * as node_os from 'node:os' +import * as node_path from 'node:path' + import { createClient, http as viem_http, parseUnits } from 'viem' import { sendTransactionSync } from 'viem/actions' import { Actions, Addresses } from 'viem/tempo' @@ -25,6 +30,44 @@ function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } +const localnetSetupKey = node_crypto.createHash('sha256').update(rpcUrl).digest('hex').slice(0, 16) +const localnetSetupDir = node_path.join(node_os.tmpdir(), `mppx-localnet-setup-${localnetSetupKey}`) +const localnetSetupDone = node_path.join(localnetSetupDir, 'done') +const localnetSetupLock = node_path.join(localnetSetupDir, 'lock') + +async function exists(path: string) { + try { + await node_fs.access(path) + return true + } catch { + return false + } +} + +async function runLocalnetSetupLocked(fn: () => Promise) { + await node_fs.mkdir(localnetSetupDir, { recursive: true }) + if (await exists(localnetSetupDone)) return + + for (;;) { + try { + await node_fs.mkdir(localnetSetupLock) + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + if (await exists(localnetSetupDone)) return + await sleep(250) + } + } + + try { + if (await exists(localnetSetupDone)) return + await fn() + await node_fs.writeFile(localnetSetupDone, new Date().toISOString()) + } finally { + await node_fs.rm(localnetSetupLock, { recursive: true, force: true }) + } +} + type LocalnetSetupState = { done: boolean promise: Promise | undefined @@ -66,29 +109,31 @@ beforeAll(async () => { return } - localnetSetupState.promise = (async () => { + localnetSetupState.promise = runLocalnetSetupLocked(async () => { // Send noop tx to trigger block. await warmupLocalnet() - // Mint liquidity for fee tokens. - await Promise.all( - [1n, 2n, 3n].map((id) => - Actions.amm.mintSync(client, { - account: accounts[0], - feeToken: Addresses.pathUsd, - nonceKey: 'expiring', - userTokenAddress: id, - validatorTokenAddress: Addresses.pathUsd, - validatorTokenAmount: parseUnits('1000', 6), - to: accounts[0].address, - }), - ), - ) + // Mint liquidity for fee tokens. Keep setup transactions sequential so + // externally managed localnet/devnet/testnet/mainnet RPCs cannot race nonce + // assignment for the shared funding account. + for (const id of [1n, 2n, 3n]) { + await Actions.amm.mintSync(client, { + account: accounts[0], + feeToken: Addresses.pathUsd, + nonceKey: 'expiring', + userTokenAddress: id, + validatorTokenAddress: Addresses.pathUsd, + validatorTokenAmount: parseUnits('1000', 6), + to: accounts[0].address, + }) + } await fundAccount({ address: accounts[1].address, token: asset }) await fundAccount({ address: accounts[2].address, token: asset }) + await fundAccount({ address: accounts[1].address, token: Addresses.pathUsd }) + await fundAccount({ address: accounts[2].address, token: Addresses.pathUsd }) localnetSetupState.done = true - })() + }) try { await localnetSetupState.promise From dab9d370121be79d0c0dc05c42f61aa7ec66d68f Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 13 May 2026 21:05:52 +0200 Subject: [PATCH 05/26] Harden precompile client and receipt flows --- .../server/Session.localnet.test.ts | 89 +++++++++ src/tempo/precompile/server/Session.ts | 23 ++- src/tempo/precompile/session/Client.test.ts | 178 +++++++++++++++++- src/tempo/precompile/session/Client.ts | 34 +++- 4 files changed, 313 insertions(+), 11 deletions(-) diff --git a/src/tempo/precompile/server/Session.localnet.test.ts b/src/tempo/precompile/server/Session.localnet.test.ts index 12ace9c9..f635622f 100644 --- a/src/tempo/precompile/server/Session.localnet.test.ts +++ b/src/tempo/precompile/server/Session.localnet.test.ts @@ -10,6 +10,7 @@ import * as ChannelStore from '../../session/ChannelStore.js' import * as Chain from '../Chain.js' import * as Channel from '../Channel.js' import { + createCloseCredential, createOpen, createOpenCredential, createTopUp, @@ -292,8 +293,96 @@ describe.runIf(isLocalnet)('precompile server session localnet', () => { const txHash = await settle(store, client, channelId) const settleReceipt = await waitForTransactionReceipt(client, { hash: txHash }) expect(settleReceipt.status).toBe('success') + const settled = getSingleEvent(settleReceipt, 'Settled') + expect(settled.args.channelId).toBe(channelId) + expect(settled.args.newSettled).toBe(300n) const state = await Chain.getChannelState(client, channelId, tip20ChannelEscrow) expect(state.settled).toBe(300n) + const settledStore = await store.getChannel(channelId) + expect(settledStore?.settledOnChain).toBe(300n) + }) + + test('closes a real precompile channel only after a successful close receipt', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const { channelId, descriptor, deposit } = await openRealChannel(1_000n) + + await store.updateChannel(channelId, () => ({ + backend: 'precompile', + channelId, + chainId: chain.id, + escrowContract: tip20ChannelEscrow, + closeRequestedAt: 0n, + payer: descriptor.payer, + payee: descriptor.payee, + token: descriptor.token, + authorizedSigner: descriptor.authorizedSigner, + deposit, + settledOnChain: 0n, + highestVoucherAmount: 300n, + highestVoucher: null, + spent: 0n, + units: 0, + finalized: false, + createdAt: new Date().toISOString(), + descriptor, + operator: descriptor.operator, + salt: descriptor.salt, + expiringNonceHash: descriptor.expiringNonceHash, + })) + + const method = session({ + amount: '100', + chainId: chain.id, + currency: asset, + decimals: 0, + recipient: payee.address, + store: rawStore, + unitType: 'request', + getClient: () => client, + }) + const payload = await createCloseCredential(client, payer, { + chainId: chain.id, + cumulativeAmount: uint96(300n), + descriptor, + escrow: tip20ChannelEscrow, + }) + + const receipt = await method.verify({ + credential: { + challenge: { + id: 'localnet-close', + realm: 'api.example.com', + method: 'tempo', + intent: 'session', + request: { + amount: '100', + currency: asset, + recipient: payee.address, + methodDetails: { chainId: chain.id, escrowContract: tip20ChannelEscrow, channelId }, + }, + } as never, + payload, + }, + request: { + amount: '100', + currency: asset, + recipient: payee.address, + methodDetails: { chainId: chain.id, escrowContract: tip20ChannelEscrow, channelId }, + } as never, + }) + + if (!('txHash' in receipt)) throw new Error('expected close txHash') + const closeReceipt = await waitForTransactionReceipt(client, { + hash: receipt.txHash as Hex.Hex, + }) + expect(closeReceipt.status).toBe('success') + const closed = getSingleEvent(closeReceipt, 'ChannelClosed') + expect(closed.args.channelId).toBe(channelId) + expect(closed.args.settledToPayee).toBe(300n) + const stored = await store.getChannel(channelId) + expect(stored?.finalized).toBe(true) + expect(stored?.settledOnChain).toBe(300n) }) }) diff --git a/src/tempo/precompile/server/Session.ts b/src/tempo/precompile/server/Session.ts index 8421fa8b..a465ad7a 100644 --- a/src/tempo/precompile/server/Session.ts +++ b/src/tempo/precompile/server/Session.ts @@ -99,7 +99,7 @@ async function waitForSuccessfulReceipt( function getChannelEvent( receipt: { logs: readonly unknown[] }, - name: 'ChannelOpened' | 'TopUp', + name: 'ChannelOpened' | 'TopUp' | 'Settled' | 'ChannelClosed', channelId: Hex, ) { const logs = parseEventLogs({ @@ -535,11 +535,20 @@ async function handleClose(parameters: { payload.signature, escrow, ) + const receipt = await waitForSuccessfulReceipt(client, txHash) + const closed = getChannelEvent(receipt, 'ChannelClosed', channelId) + const settledToPayee = uint96(closed.args.settledToPayee as bigint) + const refundedToPayer = uint96(closed.args.refundedToPayer as bigint) + if (settledToPayee > captureAmount || settledToPayee + refundedToPayer > state.deposit) + throw new VerificationFailedError({ reason: 'ChannelClosed amounts do not match state' }) const updated = await store.updateChannel(channelId, (current) => current ? { ...current, finalized: true, + deposit: state.deposit, + settledOnChain: + settledToPayee > current.settledOnChain ? settledToPayee : current.settledOnChain, highestVoucherAmount: payload.cumulativeAmount, highestVoucher: { channelId, @@ -582,11 +591,21 @@ export async function settle( channel.highestVoucher.signature, escrow, ) + const receipt = await waitForSuccessfulReceipt(client, txHash) + const settled = getChannelEvent(receipt, 'Settled', channelId) + const newSettled = uint96(settled.args.newSettled as bigint) + if (newSettled < amount) + throw new VerificationFailedError({ reason: 'Settled event is below voucher amount' }) + const state = await Chain.getChannelState(client, channelId, escrow) + if (state.settled !== newSettled) + throw new VerificationFailedError({ + reason: 'on-chain channel state does not match settle receipt', + }) await store.updateChannel(channelId, (current) => current ? { ...current, - settledOnChain: amount > current.settledOnChain ? amount : current.settledOnChain, + settledOnChain: newSettled > current.settledOnChain ? newSettled : current.settledOnChain, } : current, ) diff --git a/src/tempo/precompile/session/Client.test.ts b/src/tempo/precompile/session/Client.test.ts index 281e4127..aaa7d05e 100644 --- a/src/tempo/precompile/session/Client.test.ts +++ b/src/tempo/precompile/session/Client.test.ts @@ -1,11 +1,13 @@ -import { type Address, createClient, custom } from 'viem' +import { type Address, createClient, custom, decodeFunctionData } from 'viem' import { privateKeyToAccount } from 'viem/accounts' +import { Transaction } from 'viem/tempo' import { describe, expect, test } from 'vp/test' import type { Challenge } from '../../../Challenge.js' import * as Credential from '../../../Credential.js' import * as Channel from '../Channel.js' import { tip20ChannelEscrow } from '../Constants.js' +import { escrowAbi } from '../escrow.abi.js' import * as Types from '../Types.js' import * as Voucher from '../Voucher.js' import { session } from './Client.js' @@ -13,15 +15,21 @@ import { session } from './Client.js' const account = privateKeyToAccount( '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', ) +const chainId = 42431 const client = createClient({ account, + chain: { id: chainId } as never, transport: custom({ - async request() { - throw new Error('unexpected rpc request') + async request(args) { + if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}` + if (args.method === 'eth_getTransactionCount') return '0x0' + if (args.method === 'eth_estimateGas') return '0x5208' + if (args.method === 'eth_maxPriorityFeePerGas') return '0x1' + if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' } + throw new Error(`unexpected rpc request: ${args.method}`) }, }), }) -const chainId = 42431 const descriptor = { payer: account.address, @@ -33,7 +41,7 @@ const descriptor = { expiringNonceHash: `0x${'22'.repeat(32)}` as `0x${string}`, } satisfies Channel.ChannelDescriptor -function makeChallenge(): Challenge { +function makeChallenge(overrides: Record = {}): Challenge { return { id: 'test-id', realm: 'test.com', @@ -43,12 +51,170 @@ function makeChallenge(): Challenge { amount: '100', currency: descriptor.token, recipient: descriptor.payee, - methodDetails: { chainId }, + methodDetails: { chainId, escrowContract: tip20ChannelEscrow }, + ...overrides, }, } } +function deserialize(credential: string) { + return Credential.deserialize(credential).payload as Types.SessionCredentialPayload +} + +function openDeposit(payload: Types.SessionCredentialPayload): bigint { + if (payload.action !== 'open') throw new Error('expected open payload') + const transaction = Transaction.deserialize(payload.transaction) + if (!('calls' in transaction)) throw new Error('expected tempo calls') + const calls = transaction.calls as readonly { to?: Address; data?: `0x${string}` }[] + const call = calls[0]! + const decoded = decodeFunctionData({ abi: escrowAbi, data: call.data! }) + if (decoded.functionName !== 'open') throw new Error('expected open call') + return decoded.args[3] +} + describe('precompile client session', () => { + test('throws without action, descriptor, deposit, maxDeposit, or suggestedDeposit', async () => { + const method = session({ account, getClient: () => client }) + + await expect( + method.createCredential({ challenge: makeChallenge() as never, context: {} }), + ).rejects.toThrow('No `action` in context and no `deposit` or `maxDeposit` configured') + }) + + test('uses context depositRaw before configured deposit', async () => { + const method = session({ account, deposit: '99', getClient: () => client }) + const payload = deserialize( + await method.createCredential({ + challenge: makeChallenge() as never, + context: { depositRaw: '500' }, + }), + ) + + expect(payload.action).toBe('open') + expect(openDeposit(payload)).toBe(500n) + }) + + test('caps suggestedDeposit by maxDeposit', async () => { + const method = session({ account, decimals: 0, maxDeposit: '500', getClient: () => client }) + const payload = deserialize( + await method.createCredential({ + challenge: makeChallenge({ suggestedDeposit: '1000' }) as never, + context: {}, + }), + ) + + expect(payload.action).toBe('open') + expect(openDeposit(payload)).toBe(500n) + }) + + test('uses suggestedDeposit when below maxDeposit', async () => { + const method = session({ account, decimals: 0, maxDeposit: '1000', getClient: () => client }) + const payload = deserialize( + await method.createCredential({ + challenge: makeChallenge({ suggestedDeposit: '700' }) as never, + context: {}, + }), + ) + + expect(payload.action).toBe('open') + expect(openDeposit(payload)).toBe(700n) + }) + + test('uses maxDeposit without suggestedDeposit', async () => { + const method = session({ account, decimals: 0, maxDeposit: '1000', getClient: () => client }) + const payload = deserialize( + await method.createCredential({ challenge: makeChallenge() as never, context: {} }), + ) + + expect(payload.action).toBe('open') + expect(openDeposit(payload)).toBe(1000n) + }) + + test('prefers local escrow override over challenge escrowContract', async () => { + const escrow = '0x0000000000000000000000000000000000000004' as Address + const challengeEscrow = '0x0000000000000000000000000000000000000005' as Address + const method = session({ account, decimals: 0, deposit: '10', escrow, getClient: () => client }) + const payload = deserialize( + await method.createCredential({ + challenge: makeChallenge({ + methodDetails: { chainId, escrowContract: challengeEscrow }, + }) as never, + context: {}, + }), + ) + + if (payload.action !== 'open') throw new Error('expected open payload') + const transaction = Transaction.deserialize(payload.transaction) + if (!('calls' in transaction)) throw new Error('expected tempo calls') + const calls = transaction.calls as readonly { to?: Address; data?: `0x${string}` }[] + expect(calls[0]!.to?.toLowerCase()).toBe(escrow.toLowerCase()) + }) + + test('tracks cumulative amount and calls onChannelUpdate in auto mode', async () => { + const updates: bigint[] = [] + const method = session({ + account, + decimals: 0, + deposit: '1000', + getClient: () => client, + onChannelUpdate: (entry) => updates.push(entry.cumulativeAmount), + }) + const first = deserialize( + await method.createCredential({ challenge: makeChallenge() as never, context: {} }), + ) + const second = deserialize( + await method.createCredential({ challenge: makeChallenge() as never, context: {} }), + ) + + expect(first.action).toBe('open') + expect(second.action).toBe('voucher') + if (second.action !== 'voucher') throw new Error('expected voucher') + expect(second.channelId).toBe(first.channelId) + expect(second.cumulativeAmount).toBe('200') + expect(updates).toEqual([100n, 200n]) + }) + + test('recovers and reuses a descriptor supplied in context', async () => { + const updates: bigint[] = [] + const method = session({ + account, + decimals: 0, + deposit: '1000', + getClient: () => client, + onChannelUpdate: (entry) => updates.push(entry.cumulativeAmount), + }) + const channelId = Channel.computeId(descriptor, { chainId, escrow: tip20ChannelEscrow }) + const recovered = deserialize( + await method.createCredential({ + challenge: makeChallenge() as never, + context: { channelId, descriptor }, + }), + ) + const next = deserialize( + await method.createCredential({ challenge: makeChallenge() as never, context: {} }), + ) + + expect(recovered.action).toBe('voucher') + if (recovered.action !== 'voucher' || next.action !== 'voucher') + throw new Error('expected voucher') + expect(recovered.channelId).toBe(channelId) + expect(recovered.cumulativeAmount).toBe('100') + expect(next.channelId).toBe(channelId) + expect(next.cumulativeAmount).toBe('200') + expect(updates).toEqual([100n, 200n]) + }) + + test('rejects channel recovery without descriptor', async () => { + const method = session({ account, decimals: 0, deposit: '1000', getClient: () => client }) + + await expect( + method.createCredential({ + challenge: makeChallenge() as never, + context: { channelId: `0x${'33'.repeat(32)}` }, + }), + ).rejects.toThrow('descriptor required to reuse precompile channel') + }) + test('creates manual voucher credentials with descriptor payloads', async () => { const method = session({ account, getClient: () => client }) const credential = await method.createCredential({ diff --git a/src/tempo/precompile/session/Client.ts b/src/tempo/precompile/session/Client.ts index cec7556f..ad232143 100644 --- a/src/tempo/precompile/session/Client.ts +++ b/src/tempo/precompile/session/Client.ts @@ -67,8 +67,12 @@ function resolveEscrow( challenge: { request: { methodDetails?: unknown } }, escrowOverride?: Address | undefined, ): Address { - const challengeEscrow = (challenge.request.methodDetails as { escrow?: string } | undefined) - ?.escrow as Address | undefined + const methodDetails = challenge.request.methodDetails as + | { escrow?: string | undefined; escrowContract?: string | undefined } + | undefined + const challengeEscrow = (methodDetails?.escrowContract ?? methodDetails?.escrow) as + | Address + | undefined return escrowOverride ?? challengeEscrow ?? tip20ChannelEscrow } @@ -129,7 +133,31 @@ export function session(parameters: session.Parameters = {}) { const existing = channels.get(key) let payload: SessionCredentialPayload - if (existing?.opened) { + if (!existing && context?.channelId && !context.descriptor) + throw new Error('descriptor required to reuse precompile channel') + if (!existing && context?.descriptor) { + const channelId = Channel.computeId(context.descriptor, { chainId, escrow }) + if (context.channelId && context.channelId.toLowerCase() !== channelId.toLowerCase()) + throw new Error('context channelId does not match descriptor') + const cumulativeAmount = parseContextAmount(context, decimals) ?? amount + payload = await createVoucherCredential(client, account, { + chainId, + cumulativeAmount, + descriptor: context.descriptor, + escrow, + }) + const entry: ChannelEntry = { + channelId, + cumulativeAmount, + descriptor: context.descriptor, + escrow, + chainId, + opened: true, + } + channels.set(key, entry) + channelIdToKey.set(channelId, key) + notifyUpdate(entry) + } else if (existing?.opened) { const cumulativeAmount = uint96(existing.cumulativeAmount + amount) payload = await createVoucherCredential(client, account, { chainId, From 0b23e3f41d570707563db374cd949912255c0dd3 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 13 May 2026 21:23:30 +0200 Subject: [PATCH 06/26] Tighten TIP-1034 precompile types --- src/tempo/Methods.ts | 9 ++-- src/tempo/precompile/Channel.ts | 22 +++------- src/tempo/precompile/Types.ts | 3 ++ src/tempo/precompile/server/Session.ts | 61 ++++++++++++++++---------- src/tempo/precompile/session/Client.ts | 10 +++-- 5 files changed, 57 insertions(+), 48 deletions(-) diff --git a/src/tempo/Methods.ts b/src/tempo/Methods.ts index 05a85be2..18d4d61d 100644 --- a/src/tempo/Methods.ts +++ b/src/tempo/Methods.ts @@ -3,6 +3,7 @@ import { parseUnits } from 'viem' import * as Method from '../Method.js' import * as z from '../zod.js' +import type * as PrecompileChannel from './precompile/Channel.js' import type { SubscriptionPeriodUnit } from './subscription/Types.js' export const chargeModes = ['push', 'pull'] as const @@ -188,7 +189,7 @@ export const session = Method.from({ authorizedSigner: z.optional(z.string()), channelId: z.hash(), cumulativeAmount: z.amount(), - descriptor: z.optional(z.custom>()), + descriptor: z.optional(z.custom()), signature: z.signature(), transaction: z.signature(), type: z.literal('transaction'), @@ -197,7 +198,7 @@ export const session = Method.from({ action: z.literal('topUp'), additionalDeposit: z.amount(), channelId: z.hash(), - descriptor: z.optional(z.custom>()), + descriptor: z.optional(z.custom()), transaction: z.signature(), type: z.literal('transaction'), }), @@ -205,14 +206,14 @@ export const session = Method.from({ action: z.literal('voucher'), channelId: z.hash(), cumulativeAmount: z.amount(), - descriptor: z.optional(z.custom>()), + descriptor: z.optional(z.custom()), signature: z.signature(), }), z.object({ action: z.literal('close'), channelId: z.hash(), cumulativeAmount: z.amount(), - descriptor: z.optional(z.custom>()), + descriptor: z.optional(z.custom()), signature: z.signature(), }), ]), diff --git a/src/tempo/precompile/Channel.ts b/src/tempo/precompile/Channel.ts index ffa594da..82b79ab5 100644 --- a/src/tempo/precompile/Channel.ts +++ b/src/tempo/precompile/Channel.ts @@ -69,21 +69,9 @@ export function computeExpiringNonceHash( transaction: ExpiringNonceTransaction, parameters: { sender: Address }, ): Hex.Hex { - const transactionModule = Transaction as unknown as Record - const getChannelOpenContextHash = - transactionModule['getChannelOpenContextHash'] ?? - transactionModule['getExpiringNonceHash'] ?? - transactionModule['getSenderScopedHash'] - - if (!getChannelOpenContextHash) - throw new Error( - 'viem/tempo Transaction.getChannelOpenContextHash is required to compute TIP-1034 expiringNonceHash.', - ) - - return ( - getChannelOpenContextHash as ( - transaction: ExpiringNonceTransaction, - options: { sender: Address }, - ) => Hex.Hex - )(transaction, parameters) + const getChannelOpenContextHash = Transaction.getChannelOpenContextHash as ( + transaction: ExpiringNonceTransaction, + options: { sender: Address }, + ) => Hex.Hex + return getChannelOpenContextHash(transaction, parameters) } diff --git a/src/tempo/precompile/Types.ts b/src/tempo/precompile/Types.ts index 204e952e..3da45dff 100644 --- a/src/tempo/precompile/Types.ts +++ b/src/tempo/precompile/Types.ts @@ -104,6 +104,9 @@ export function parseCredentialPayload( export function parseCredentialPayload( payload: CloseCredentialPayload, ): ParsedCloseCredentialPayload +export function parseCredentialPayload( + payload: SessionCredentialPayload, +): ParsedSessionCredentialPayload /** Parses and brands decimal string amounts from a precompile session credential payload. */ export function parseCredentialPayload( payload: SessionCredentialPayload, diff --git a/src/tempo/precompile/server/Session.ts b/src/tempo/precompile/server/Session.ts index a465ad7a..959350f3 100644 --- a/src/tempo/precompile/server/Session.ts +++ b/src/tempo/precompile/server/Session.ts @@ -18,7 +18,7 @@ import { InvalidSignatureError, VerificationFailedError, } from '../../../Errors.js' -import type { Challenge, Credential } from '../../../index.js' +import type { Challenge } from '../../../index.js' import type { LooseOmit, NoExtraKeys } from '../../../internal/types.js' import * as Method from '../../../Method.js' import * as Store from '../../../Store.js' @@ -33,7 +33,12 @@ import * as Chain from '../Chain.js' import * as Channel from '../Channel.js' import { tip20ChannelEscrow } from '../Constants.js' import { escrowAbi } from '../escrow.abi.js' -import { parseCredentialPayload, type ParsedSessionCredentialPayload, uint96 } from '../Types.js' +import { + parseCredentialPayload, + type ParsedSessionCredentialPayload, + type SessionCredentialPayload, + uint96, +} from '../Types.js' import * as Voucher from '../Voucher.js' import * as ChannelOps from './ChannelOps.js' @@ -84,7 +89,7 @@ function validateDescriptor(parameters: { } async function sendTransaction(client: Parameters[0], transaction: Hex) { - return sendRawTransaction(client, { serializedTransaction: transaction as never }) + return sendRawTransaction(client, { serializedTransaction: transaction }) } async function waitForSuccessfulReceipt( @@ -97,27 +102,34 @@ async function waitForSuccessfulReceipt( return receipt } +type ChannelReceiptEvent = { + args: { + channelId: Hex + expiringNonceHash?: Hex | undefined + deposit?: bigint | undefined + newDeposit?: bigint | undefined + newSettled?: bigint | undefined + settledToPayee?: bigint | undefined + refundedToPayer?: bigint | undefined + } +} + function getChannelEvent( - receipt: { logs: readonly unknown[] }, + receipt: { logs: Parameters[0]['logs'] }, name: 'ChannelOpened' | 'TopUp' | 'Settled' | 'ChannelClosed', channelId: Hex, -) { +): ChannelReceiptEvent { const logs = parseEventLogs({ abi: escrowAbi, eventName: name, - logs: receipt.logs as never, - }) - const matches = logs.filter( - (log) => - ( - (log as unknown as { args: Record }).args.channelId as Hex | undefined - )?.toLowerCase() === channelId.toLowerCase(), - ) + logs: receipt.logs, + }) as ChannelReceiptEvent[] + const matches = logs.filter((log) => log.args.channelId.toLowerCase() === channelId.toLowerCase()) if (matches.length !== 1) throw new VerificationFailedError({ reason: `expected one ${name} event for credential channelId in receipt`, }) - return matches[0] as unknown as { args: Record } + return matches[0]! } /** Creates a server-side TIP-1034 precompile session payment method. */ @@ -166,12 +178,11 @@ export function session( }, async verify({ credential, request }) { - const { challenge, payload: rawPayload } = credential as unknown as Credential.Credential< - import('../Types.js').SessionCredentialPayload - > - const payload = parseCredentialPayload(rawPayload as never) as ParsedSessionCredentialPayload - const methodDetails = (request as unknown as { methodDetails: SessionMethodDetails }) + const { challenge, payload: rawPayload } = credential + const payload = parseCredentialPayload(rawPayload as SessionCredentialPayload) + const methodDetails = (request as typeof request & { methodDetails?: SessionMethodDetails }) .methodDetails + if (!methodDetails) throw new VerificationFailedError({ reason: 'missing methodDetails' }) const chainId = methodDetails.chainId const escrow = methodDetails.escrowContract ?? parameters.escrow ?? tip20ChannelEscrow const client = await getClient({ chainId }) @@ -224,8 +235,10 @@ async function handleOpen(parameters: { currency: challenge.request.currency as Address, }) - const transaction = Transaction.deserialize(payload.transaction as never) as any - const calls = transaction.calls ?? [] + const transaction = Transaction.deserialize( + payload.transaction as Transaction.TransactionSerializedTempo, + ) + const calls = transaction.calls if (calls.length !== 1) throw new VerificationFailedError({ reason: 'TIP-1034 open transaction must contain exactly one call', @@ -356,8 +369,10 @@ async function handleTopUp(parameters: { recipient: channel.payee, currency: channel.token, }) - const transaction = Transaction.deserialize(payload.transaction as never) as any - const calls = transaction.calls ?? [] + const transaction = Transaction.deserialize( + payload.transaction as Transaction.TransactionSerializedTempo, + ) + const calls = transaction.calls if (calls.length !== 1) throw new VerificationFailedError({ reason: 'TIP-1034 topUp transaction must contain exactly one call', diff --git a/src/tempo/precompile/session/Client.ts b/src/tempo/precompile/session/Client.ts index ad232143..ea9a0a9a 100644 --- a/src/tempo/precompile/session/Client.ts +++ b/src/tempo/precompile/session/Client.ts @@ -64,12 +64,14 @@ function channelKey(payee: Address, token: Address, escrow: Address): string { } function resolveEscrow( - challenge: { request: { methodDetails?: unknown } }, + challenge: { + request: { + methodDetails?: { escrow?: string | undefined; escrowContract?: string | undefined } + } + }, escrowOverride?: Address | undefined, ): Address { - const methodDetails = challenge.request.methodDetails as - | { escrow?: string | undefined; escrowContract?: string | undefined } - | undefined + const methodDetails = challenge.request.methodDetails const challengeEscrow = (methodDetails?.escrowContract ?? methodDetails?.escrow) as | Address | undefined From a9b1e371db60514ea8961e72ee2a44e357792d64 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 13 May 2026 22:28:46 +0200 Subject: [PATCH 07/26] Add precompile session manager --- .changeset/precompile-session-manager.md | 5 + src/tempo/precompile/Chain.ts | 4 + src/tempo/precompile/index.ts | 1 + src/tempo/precompile/server/Session.test.ts | 111 ++- src/tempo/precompile/server/Session.ts | 65 +- .../precompile/session/SessionManager.test.ts | 248 +++++ .../precompile/session/SessionManager.ts | 856 ++++++++++++++++++ src/tempo/precompile/session/index.ts | 1 + 8 files changed, 1270 insertions(+), 21 deletions(-) create mode 100644 .changeset/precompile-session-manager.md create mode 100644 src/tempo/precompile/session/SessionManager.test.ts create mode 100644 src/tempo/precompile/session/SessionManager.ts diff --git a/.changeset/precompile-session-manager.md b/.changeset/precompile-session-manager.md new file mode 100644 index 00000000..9cb2ad98 --- /dev/null +++ b/.changeset/precompile-session-manager.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added TIP-1034 precompile session manager exports and hardened precompile settle/close account validation. diff --git a/src/tempo/precompile/Chain.ts b/src/tempo/precompile/Chain.ts index 4c2cbfab..4005d849 100644 --- a/src/tempo/precompile/Chain.ts +++ b/src/tempo/precompile/Chain.ts @@ -175,8 +175,10 @@ export async function settle( cumulativeAmount: Uint96, signature: Hex, escrow: Address = tip20ChannelEscrow, + options?: { account?: Parameters[1]['account'] | undefined }, ): Promise { return sendTransaction(client, { + ...(options?.account ? { account: options.account } : {}), to: escrow, data: encodeSettle(descriptor, cumulativeAmount, signature), } as never) @@ -227,8 +229,10 @@ export async function close( captureAmount: Uint96, signature: Hex, escrow: Address = tip20ChannelEscrow, + options?: { account?: Parameters[1]['account'] | undefined }, ): Promise { return sendTransaction(client, { + ...(options?.account ? { account: options.account } : {}), to: escrow, data: encodeClose(descriptor, cumulativeAmount, captureAmount, signature), } as never) diff --git a/src/tempo/precompile/index.ts b/src/tempo/precompile/index.ts index 356d90c6..df1f1ec5 100644 --- a/src/tempo/precompile/index.ts +++ b/src/tempo/precompile/index.ts @@ -1,5 +1,6 @@ export * as Client from './client/index.js' export { session } from './session/Client.js' +export { sessionManager } from './session/SessionManager.js' export * as Session from './session/index.js' export * as Chain from './Chain.js' export * as Channel from './Channel.js' diff --git a/src/tempo/precompile/server/Session.test.ts b/src/tempo/precompile/server/Session.test.ts index 97693310..e7feba92 100644 --- a/src/tempo/precompile/server/Session.test.ts +++ b/src/tempo/precompile/server/Session.test.ts @@ -44,9 +44,9 @@ function createSigningClient(account = payer) { }) } -function createServerClient(calls: RpcCall[] = []) { +function createServerClient(calls: RpcCall[] = [], account: typeof payer | null = payer) { return createClient({ - account: payer, + ...(account ? { account } : {}), chain: { id: chainId } as never, transport: custom({ async request(args) { @@ -163,6 +163,41 @@ async function createOpenCredential( } } +async function persistPrecompileChannel( + store: ChannelStore.ChannelStore, + payload: OpenCredentialPayload, + overrides: Partial = {}, +) { + await store.updateChannel(payload.channelId, () => ({ + backend: 'precompile', + channelId: payload.channelId, + chainId, + escrowContract: tip20ChannelEscrow, + closeRequestedAt: 0n, + payer: payload.descriptor.payer, + payee, + token, + authorizedSigner: payload.descriptor.authorizedSigner, + deposit: 1_000n, + settledOnChain: 0n, + highestVoucherAmount: BigInt(payload.cumulativeAmount), + highestVoucher: { + channelId: payload.channelId, + cumulativeAmount: BigInt(payload.cumulativeAmount), + signature: payload.signature, + }, + spent: 0n, + units: 0, + finalized: false, + createdAt: new Date(0).toISOString(), + descriptor: payload.descriptor, + operator: payload.descriptor.operator, + salt: payload.descriptor.salt, + expiringNonceHash: payload.descriptor.expiringNonceHash, + ...overrides, + })) +} + describe('precompile server session unit guardrails', () => { test.skip('accepts a valid open with voucher and persists precompile descriptor state (covered by localnet)', async () => { const { method, store, rpcCalls } = createServer() @@ -427,26 +462,64 @@ describe('precompile server session unit guardrails', () => { ).rejects.toThrow(/exceeds on-chain deposit/) }) - test.skip('encodes settle against the persisted descriptor (covered by localnet)', async () => { - const { method, store, rpcCalls } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) + test('rejects settle when no account is available', async () => { + const { store } = createServer() const openPayload = await createOpenCredential() - await method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, - request: makeRequest(openPayload.channelId) as never, - }) - const voucherPayload = await ClientOps.createVoucherCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(250n), - descriptor: openPayload.descriptor, - }) - await method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload: voucherPayload }, - request: makeRequest(openPayload.channelId) as never, - }) + await persistPrecompileChannel(store, openPayload) const { settle } = await import('./Session.js') - await settle(store, createServerClient(rpcCalls), openPayload.channelId) + await expect( + settle(store, createServerClient([], null), openPayload.channelId), + ).rejects.toThrow(/no account available/) + }) - expect(rpcCalls.at(-1)?.method).toBe('eth_sendRawTransaction') + test('rejects settle when sender is not the channel payee', async () => { + const { store } = createServer() + const openPayload = await createOpenCredential() + await persistPrecompileChannel(store, openPayload) + + const { settle } = await import('./Session.js') + await expect( + settle(store, createServerClient([], wrongPayer), openPayload.channelId), + ).rejects.toThrow(/tx sender .* is not the channel payee/) + }) + + test('rejects unsupported precompile settle fee payer options', async () => { + const { store } = createServer() + const openPayload = await createOpenCredential() + await persistPrecompileChannel(store, openPayload) + + const { settle } = await import('./Session.js') + await expect( + settle(store, createServerClient([], payer), openPayload.channelId, { + feePayer: wrongPayer, + } as never), + ).rejects.toThrow(/does not support feePayer or feeToken/) + }) + + test('accepts settle account override matching the channel payee', async () => { + const { store } = createServer() + const openPayload = await createOpenCredential() + await persistPrecompileChannel(store, openPayload, { payee: wrongPayer.address }) + const calls: RpcCall[] = [] + const client = createClient({ + account: payer, + chain: { id: chainId } as never, + transport: custom({ + async request(args) { + calls.push(args) + if (args.method === 'eth_sendTransaction') throw new Error('sent settle transaction') + if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}` + throw new Error(`unexpected rpc request: ${args.method}`) + }, + }), + }) + + const { settle } = await import('./Session.js') + await expect( + settle(store, client, openPayload.channelId, { + account: wrongPayer, + }), + ).rejects.toThrow(/eth_getTransactionCount/) }) }) diff --git a/src/tempo/precompile/server/Session.ts b/src/tempo/precompile/server/Session.ts index 959350f3..91a3ff94 100644 --- a/src/tempo/precompile/server/Session.ts +++ b/src/tempo/precompile/server/Session.ts @@ -5,6 +5,7 @@ import { parseEventLogs, parseUnits, zeroAddress, + type Account as viem_Account, } from 'viem' import { sendRawTransaction, waitForTransactionReceipt } from 'viem/actions' import { tempo as tempo_chain } from 'viem/chains' @@ -17,6 +18,7 @@ import { DeltaTooSmallError, InvalidSignatureError, VerificationFailedError, + BadRequestError, } from '../../../Errors.js' import type { Challenge } from '../../../index.js' import type { LooseOmit, NoExtraKeys } from '../../../internal/types.js' @@ -114,6 +116,29 @@ type ChannelReceiptEvent = { } } +function assertSettlementSender(parameters: { + operation: 'close' | 'settle' + channelId: Hex + payee: Address + sender: Address | undefined +}) { + const { operation, channelId, payee, sender } = parameters + if (!sender) + throw new Error( + `Cannot ${operation} precompile channel ${channelId}: no account available. Pass an account override, or provide a getClient() that returns an account-bearing client.`, + ) + if (sender.toLowerCase() === payee.toLowerCase()) return + throw new BadRequestError({ + reason: + `Cannot ${operation} precompile channel ${channelId}: tx sender ${sender} is not the channel payee ${payee}. ` + + 'If using an access key, pass a Tempo access-key account whose address is the payee wallet, not the raw delegated key address.', + }) +} + +function getClientAccount(client: { account?: viem_Account | undefined }) { + return client.account +} + function getChannelEvent( receipt: { logs: Parameters[0]['logs'] }, name: 'ChannelOpened' | 'TopUp' | 'Settled' | 'ChannelClosed', @@ -208,7 +233,15 @@ export function session( minVoucherDelta, }) case 'close': - return handleClose({ store, client, challenge, payload, chainId, escrow }) + return handleClose({ + store, + client, + challenge, + payload, + chainId, + escrow, + account: parameters.account, + }) default: throw new VerificationFailedError({ reason: 'unsupported precompile session action' }) } @@ -524,6 +557,7 @@ async function handleClose(parameters: { payload: ParsedSessionCredentialPayload & { action: 'close' } chainId: number escrow: Address + account?: viem_Account | undefined }): Promise { const { store, client, challenge, payload, chainId, escrow } = parameters const channelId = ChannelStore.normalizeChannelId(payload.channelId) @@ -542,6 +576,13 @@ async function handleClose(parameters: { const captureAmount = uint96( payload.cumulativeAmount > state.settled ? payload.cumulativeAmount : state.settled, ) + const account = parameters.account ?? getClientAccount(client) + assertSettlementSender({ + operation: 'close', + channelId, + payee: channel.payee, + sender: account?.address, + }) const txHash = await Chain.close( client, channel.descriptor, @@ -549,6 +590,7 @@ async function handleClose(parameters: { captureAmount, payload.signature, escrow, + account ? { account } : undefined, ) const receipt = await waitForSuccessfulReceipt(client, txHash) const closed = getChannelEvent(receipt, 'ChannelClosed', channelId) @@ -588,7 +630,12 @@ export async function settle( store_: Store.Store | ChannelStore.ChannelStore, client: Parameters[0], channelId_: Hex, - options?: { escrow?: Address | undefined }, + options?: { + account?: viem_Account | undefined + escrow?: Address | undefined + feePayer?: never + feeToken?: never + }, ): Promise { const store = 'getChannel' in store_ ? store_ : ChannelStore.fromStore(store_ as never) const channelId = ChannelStore.normalizeChannelId(channelId_) @@ -597,7 +644,18 @@ export async function settle( if (!ChannelStore.isPrecompileState(channel)) throw new VerificationFailedError({ reason: 'channel is not precompile-backed' }) if (!channel.highestVoucher) throw new VerificationFailedError({ reason: 'no voucher to settle' }) + if ('feePayer' in (options ?? {}) || 'feeToken' in (options ?? {})) + throw new BadRequestError({ + reason: 'tempo.precompile.settle() does not support feePayer or feeToken options yet', + }) const escrow = options?.escrow ?? channel.escrowContract + const account = options?.account ?? getClientAccount(client) + assertSettlementSender({ + operation: 'settle', + channelId, + payee: channel.payee, + sender: account?.address, + }) const amount = uint96(channel.highestVoucher.cumulativeAmount) const txHash = await Chain.settle( client, @@ -605,6 +663,7 @@ export async function settle( amount, channel.highestVoucher.signature, escrow, + account ? { account } : undefined, ) const receipt = await waitForSuccessfulReceipt(client, txHash) const settled = getChannelEvent(receipt, 'Settled', channelId) @@ -641,6 +700,8 @@ export namespace session { store?: Store.Store | undefined suggestedDeposit?: string | undefined unitType?: string | undefined + /** Account used for server-driven close transactions. Defaults to the client account. */ + account?: viem_Account | undefined } export type Defaults = LooseOmit< diff --git a/src/tempo/precompile/session/SessionManager.test.ts b/src/tempo/precompile/session/SessionManager.test.ts new file mode 100644 index 00000000..5622cbf4 --- /dev/null +++ b/src/tempo/precompile/session/SessionManager.test.ts @@ -0,0 +1,248 @@ +import type { Hex } from 'viem' +import { describe, expect, test, vi } from 'vp/test' + +import * as Challenge from '../../../Challenge.js' +import { formatNeedVoucherEvent, parseEvent } from '../../session/Sse.js' +import type { NeedVoucherEvent, SessionReceipt } from '../../session/Types.js' +import { sessionManager } from './SessionManager.js' + +const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex +const challengeId = 'test-challenge-1' +const realm = 'test.example.com' + +function makeChallenge(overrides: Record = {}): Challenge.Challenge { + return Challenge.from({ + id: challengeId, + realm, + method: 'tempo', + intent: 'session', + request: { + amount: '1000000', + currency: '0x20c0000000000000000000000000000000000001', + recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00', + decimals: 6, + methodDetails: { + escrowContract: '0x9d136eEa063eDE5418A6BC7bEafF009bBb6CFa70', + chainId: 4217, + }, + ...overrides, + }, + }) +} + +function make402Response(challenge?: Challenge.Challenge): Response { + const c = challenge ?? makeChallenge() + return new Response(null, { + status: 402, + headers: { 'WWW-Authenticate': Challenge.serialize(c) }, + }) +} + +function makeOkResponse(body?: string): Response { + return new Response(body ?? 'ok', { status: 200 }) +} + +function makeSseResponse(events: string[]): Response { + const body = events.join('') + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) +} + +describe('Session', () => { + describe('parseEvent round-trip via SSE', () => { + test('parses message events from SSE stream', () => { + const raw = 'event: message\ndata: hello world\n\n' + const event = parseEvent(raw) + expect(event).toEqual({ type: 'message', data: 'hello world' }) + }) + + test('parses payment-need-voucher events', () => { + const params: NeedVoucherEvent = { + channelId, + requiredCumulative: '6000000', + acceptedCumulative: '5000000', + deposit: '10000000', + } + const raw = formatNeedVoucherEvent(params) + const event = parseEvent(raw) + expect(event).toEqual({ type: 'payment-need-voucher', data: params }) + }) + }) + + describe('session creation', () => { + test('creates session with initial state', () => { + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + maxDeposit: '10', + }) + + expect(s.channelId).toBeUndefined() + expect(s.cumulative).toBe(0n) + expect(s.opened).toBe(false) + }) + }) + + describe('.fetch()', () => { + test('passes through non-402 responses', async () => { + const mockFetch = vi.fn().mockResolvedValue(makeOkResponse('hello')) + + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch as typeof globalThis.fetch, + }) + + const res = await s.fetch('https://api.example.com/data') + expect(res.status).toBe(200) + expect(await res.text()).toBe('hello') + expect(mockFetch).toHaveBeenCalledOnce() + }) + + test('throws on 402 without maxDeposit or open channel', async () => { + const mockFetch = vi.fn().mockResolvedValue(make402Response()) + + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch as typeof globalThis.fetch, + }) + + await expect(s.fetch('https://api.example.com/data')).rejects.toThrow( + 'no `deposit` or `maxDeposit` configured', + ) + }) + }) + + describe('.open()', () => { + test('throws when no challenge is available', async () => { + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + maxDeposit: '10', + }) + + await expect(s.open()).rejects.toThrow('No challenge available') + }) + }) + + describe('.sse() event parsing', () => { + test('yields only message data from SSE stream', async () => { + const events = [ + 'event: message\ndata: chunk1\n\n', + 'event: message\ndata: chunk2\n\n', + `event: payment-receipt\ndata: ${JSON.stringify({ + method: 'tempo', + intent: 'session', + status: 'success', + timestamp: '2025-01-01T00:00:00.000Z', + reference: channelId, + challengeId, + channelId, + acceptedCumulative: '2000000', + spent: '2000000', + units: 2, + } satisfies SessionReceipt)}\n\n`, + ] + + let callCount = 0 + const mockFetch = vi.fn().mockImplementation(() => { + callCount++ + if (callCount === 1) return Promise.resolve(makeSseResponse(events)) + return Promise.resolve(makeOkResponse()) + }) + + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch as typeof globalThis.fetch, + }) + + // Manually set channel state to skip auto-open flow + ;(s as any).__test_setChannel?.() + + const receiptCb = vi.fn() + const iterable = await s.sse('https://api.example.com/stream', { + onReceipt: receiptCb, + }) + + const messages: string[] = [] + for await (const msg of iterable) { + messages.push(msg) + } + + expect(messages).toEqual(['chunk1', 'chunk2']) + expect(receiptCb).toHaveBeenCalledOnce() + expect(receiptCb.mock.calls[0]![0].units).toBe(2) + }) + }) + + describe('error handling', () => { + test('.sse() silently skips payment-need-voucher when no channel open', async () => { + const needVoucher: NeedVoucherEvent = { + channelId, + requiredCumulative: '2000000', + acceptedCumulative: '1000000', + deposit: '10000000', + } + + const events = [ + 'event: message\ndata: chunk1\n\n', + formatNeedVoucherEvent(needVoucher), + 'event: message\ndata: chunk2\n\n', + ] + + const mockFetch = vi.fn().mockResolvedValue(makeSseResponse(events)) + + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch as typeof globalThis.fetch, + }) + + const iterable = await s.sse('https://api.example.com/stream') + + const messages: string[] = [] + for await (const msg of iterable) { + messages.push(msg) + } + + expect(messages).toEqual(['chunk1', 'chunk2']) + expect(mockFetch).toHaveBeenCalledOnce() + }) + }) + + describe('.sse() headers normalization', () => { + test('preserves Headers instance properties when passed as headers', async () => { + const mockFetch = vi.fn().mockResolvedValue(makeSseResponse(['event: message\ndata: ok\n\n'])) + + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch as typeof globalThis.fetch, + }) + + const iterable = await s.sse('https://api.example.com/stream', { + headers: new Headers({ 'Content-Type': 'application/json', 'X-Custom': 'value' }), + }) + + for await (const _ of iterable) { + // drain + } + + const calledHeaders = new Headers((mockFetch.mock.calls[0]![1] as RequestInit).headers) + expect(calledHeaders.get('content-type')).toBe('application/json') + expect(calledHeaders.get('x-custom')).toBe('value') + expect(calledHeaders.get('accept')).toBe('text/event-stream') + }) + }) + + describe('.close()', () => { + test('is no-op when not opened', async () => { + const mockFetch = vi.fn() + + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch as typeof globalThis.fetch, + }) + + await s.close() + expect(mockFetch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/tempo/precompile/session/SessionManager.ts b/src/tempo/precompile/session/SessionManager.ts new file mode 100644 index 00000000..6e38e15d --- /dev/null +++ b/src/tempo/precompile/session/SessionManager.ts @@ -0,0 +1,856 @@ +import type { Hex } from 'ox' +import { parseUnits, type Address } from 'viem' + +import * as Challenge from '../../../Challenge.js' +import * as Fetch from '../../../client/internal/Fetch.js' +import * as PaymentCredential from '../../../Credential.js' +import type * as Account from '../../../viem/Account.js' +import type * as Client from '../../../viem/Client.js' +import { deserializeSessionReceipt } from '../../session/Receipt.js' +import { parseEvent } from '../../session/Sse.js' +import type { SessionCredentialPayload, SessionReceipt } from '../../session/Types.js' +import * as Ws from '../../session/Ws.js' +import { uint96 } from '../Types.js' +import type { ChannelEntry } from './Client.js' +import { session as sessionPlugin } from './Client.js' + +type WebSocketConstructor = { + new (url: string | URL, protocols?: string | string[]): WebSocket +} + +type ReceiptWaiter = { + predicate: (receipt: SessionReceipt) => boolean + reject(error: Error): void + resolve(receipt: SessionReceipt): void +} + +type CloseReadyWaiter = { + reject(error: Error): void + resolve(receipt: SessionReceipt): void +} + +const WebSocketReadyState = { + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3, +} as const + +// Browser-style WebSocket clients may only initiate close with 1000 or 3000-4999. +// Keep protocol/policy close codes on the server side and use an app-defined code here. +const ClientWebSocketProtocolErrorCloseCode = 3008 + +export type SessionManager = { + readonly channelId: Hex.Hex | undefined + readonly cumulative: bigint + readonly opened: boolean + + open(options?: { deposit?: bigint }): Promise + fetch(input: RequestInfo | URL, init?: RequestInit): Promise + sse( + input: RequestInfo | URL, + init?: RequestInit & { + onReceipt?: ((receipt: SessionReceipt) => void) | undefined + signal?: AbortSignal | undefined + }, + ): Promise> + ws( + input: string | URL, + init?: { + onReceipt?: ((receipt: SessionReceipt) => void) | undefined + protocols?: string | string[] | undefined + signal?: AbortSignal | undefined + }, + ): Promise + close(): Promise +} + +export type PaymentResponse = Response & { + receipt: SessionReceipt | null + challenge: Challenge.Challenge | null + channelId: Hex.Hex | null + cumulative: bigint +} + +/** + * Creates a session manager that handles the full client payment lifecycle: + * channel open, incremental vouchers, SSE streaming, and channel close. + * + * Internally delegates to the `session()` method for all + * channel state management and credential creation, and to `Fetch.from` + * for the 402 challenge/retry flow. + * + * ## Session resumption + * + * All channel state is held **in memory**. If the client process restarts, + * the session is lost and a new on-chain channel will be opened on the next + * request — the previous channel's deposit is orphaned until manually closed. + * + * Precompile channel identity is descriptor-based. Recovery requires a persisted + * channel descriptor; a channel ID alone is not sufficient to resume a TIP-1034 + * channel. + */ +export function sessionManager(parameters: sessionManager.Parameters): SessionManager { + const fetchFn = parameters.fetch ?? globalThis.fetch + const WebSocketImpl = + parameters.webSocket ?? + (globalThis as typeof globalThis & { WebSocket?: WebSocketConstructor }).WebSocket + const maxVoucherCumulative = + parameters.maxDeposit !== undefined + ? parseUnits(parameters.maxDeposit, parameters.decimals ?? 6) + : null + + let channel: ChannelEntry | null = null + let lastChallenge: Challenge.Challenge | null = null + let lastUrl: RequestInfo | URL | null = null + let spent = 0n + let activeSocketChallenge: Challenge.Challenge | null = null + let activeSocketChannelId: Hex.Hex | null = null + let activeSocket: WebSocket | null = null + let closeReadyReceipt: SessionReceipt | null = null + let closeReadyWaiter: CloseReadyWaiter | null = null + let expectedSocketCloseAmount: string | null = null + let receiptWaiter: ReceiptWaiter | null = null + let wsDeliveredChunks = 0n + let wsTickCost = 0n + + const method = sessionPlugin({ + account: parameters.account, + authorizedSigner: parameters.authorizedSigner, + getClient: parameters.client ? () => parameters.client! : parameters.getClient, + escrow: parameters.escrow, + decimals: parameters.decimals, + maxDeposit: parameters.maxDeposit, + onChannelUpdate(entry) { + if (entry.channelId !== channel?.channelId) spent = 0n + channel = entry + }, + }) + + const wrappedFetch = Fetch.from({ + fetch: fetchFn, + methods: [method], + onChallenge: async (challenge, _helpers) => { + lastChallenge = challenge + return undefined + }, + }) + + function updateSpentFromReceipt(receipt: SessionReceipt | null | undefined) { + if (!receipt || receipt.channelId !== channel?.channelId) return + assertReceiptWithinLocalState(receipt) + const next = BigInt(receipt.spent) + spent = spent > next ? spent : next + } + + function assertReceiptWithinLocalState(receipt: SessionReceipt) { + if (!channel || receipt.channelId !== channel.channelId) return + const acceptedCumulative = BigInt(receipt.acceptedCumulative) + const receiptSpent = BigInt(receipt.spent) + if (receiptSpent > acceptedCumulative) { + throw new Error('receipt spent exceeds accepted cumulative voucher amount') + } + if (acceptedCumulative > channel.cumulativeAmount) { + throw new Error('receipt accepted cumulative exceeds local voucher state') + } + if (receiptSpent > channel.cumulativeAmount) { + throw new Error('receipt spent exceeds local voucher state') + } + assertVoucherWithinLocalLimit(acceptedCumulative) + assertVoucherWithinLocalLimit(receiptSpent) + } + + function waitForReceipt(predicate: (receipt: SessionReceipt) => boolean = () => true) { + if (receiptWaiter) throw new Error('receipt wait already in progress') + return new Promise((resolve, reject) => { + receiptWaiter = { predicate, resolve, reject } + }) + } + + function waitForCloseReady() { + if (closeReadyReceipt) return Promise.resolve(closeReadyReceipt) + if (closeReadyWaiter) throw new Error('close-ready wait already in progress') + return new Promise((resolve, reject) => { + closeReadyWaiter = { resolve, reject } + }) + } + + function settleReceipt(receipt: SessionReceipt) { + if (!receiptWaiter) return + if (!receiptWaiter.predicate(receipt)) return + const waiter = receiptWaiter + receiptWaiter = null + waiter.resolve(receipt) + } + + function settleCloseReady(receipt: SessionReceipt) { + closeReadyReceipt = receipt + if (!closeReadyWaiter) return + const waiter = closeReadyWaiter + closeReadyWaiter = null + waiter.resolve(receipt) + } + + function rejectReceipt(error: Error) { + if (!receiptWaiter) return + const waiter = receiptWaiter + receiptWaiter = null + waiter.reject(error) + } + + function rejectCloseReady(error: Error) { + if (!closeReadyWaiter) return + const waiter = closeReadyWaiter + closeReadyWaiter = null + waiter.reject(error) + } + + function getFallbackCloseAmount(challenge: Challenge.Challenge, channelId: Hex.Hex): string { + if ( + closeReadyReceipt && + closeReadyReceipt.challengeId === challenge.id && + closeReadyReceipt.channelId === channelId + ) { + return closeReadyReceipt.spent + } + + const cumulative = channel?.channelId === channelId ? channel.cumulativeAmount : 0n + + // For WS sessions, use delivered chunk count × tick cost as a tight spend + // estimate. Without this, a socket death before close-ready would cause + // the client to sign for the full cumulative voucher authorization — + // potentially orders of magnitude more than what was actually consumed. + // The estimate may undercount by at most 1 chunk (if the server committed + // a charge but the socket died before delivering the message). + if (wsTickCost > 0n) { + const deliveryEstimate = wsDeliveredChunks * wsTickCost + const bestSpent = spent > deliveryEstimate ? spent : deliveryEstimate + return (bestSpent > cumulative ? cumulative : bestSpent).toString() + } + + // SSE/HTTP: spent is kept in sync by inline receipts, use it directly. + return spent.toString() + } + + function assertVoucherWithinLocalLimit(cumulativeAmount: bigint) { + if (maxVoucherCumulative === null) return + if (cumulativeAmount <= maxVoucherCumulative) return + throw new Error( + `requested voucher amount ${cumulativeAmount} exceeds local maxDeposit ${maxVoucherCumulative}`, + ) + } + + function toPaymentResponse(response: Response): PaymentResponse { + const receiptHeader = response.headers.get('Payment-Receipt') + const receipt = receiptHeader ? deserializeSessionReceipt(receiptHeader) : null + updateSpentFromReceipt(receipt) + return Object.assign(response, { + receipt, + challenge: lastChallenge, + channelId: channel?.channelId ?? null, + cumulative: channel?.cumulativeAmount ?? 0n, + }) + } + + async function doFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + lastUrl = input + const response = await wrappedFetch(input, init) + return toPaymentResponse(response) + } + + function createManagedSocket(socket: WebSocket) { + type EventType = 'close' | 'error' | 'message' | 'open' + type MessageEvent = { data: string; type: 'message' } + type Listener = { + once: boolean + value: ((event: any) => void) | { handleEvent(event: any): void } + } + const listeners = new Map>() + let emittedClose = false + let messageBuffer: MessageEvent[] | null = [] + let readyState = socket.readyState + + const add = ( + type: EventType, + listener: ((event: any) => void) | { handleEvent(event: any): void }, + options?: boolean | AddEventListenerOptions, + ) => { + let set = listeners.get(type) + if (!set) { + set = new Set() + listeners.set(type, set) + } + set.add({ + once: typeof options === 'object' ? options.once === true : false, + value: listener, + }) + if (type === 'message' && messageBuffer) { + const buffered = messageBuffer + messageBuffer = null + for (const event of buffered) emit('message', event) + } + } + + const remove = ( + type: EventType, + listener: ((event: any) => void) | { handleEvent(event: any): void }, + ) => { + const set = listeners.get(type) + if (!set) return + for (const entry of set) { + if (entry.value === listener) set.delete(entry) + } + } + + const emit = (type: EventType, event: any) => { + if (type === 'close') { + if (emittedClose) return + emittedClose = true + readyState = WebSocketReadyState.CLOSED + messageBuffer = null + } + if (type === 'open') readyState = WebSocketReadyState.OPEN + + if (type === 'message' && messageBuffer) { + messageBuffer.push(event) + return + } + + const property = `on${type}` as const + const handler = (managed as Record)[property] + if (typeof handler === 'function') handler(event) + + const set = listeners.get(type) + if (!set) return + for (const entry of Array.from(set)) { + if (typeof entry.value === 'function') entry.value(event) + else entry.value.handleEvent(event) + if (entry.once) set.delete(entry) + } + } + + const managed = { + addEventListener: add, + close(code?: number, reason?: string) { + socket.close(code, reason) + }, + get bufferedAmount() { + return socket.bufferedAmount + }, + get extensions() { + return socket.extensions + }, + on(type: EventType, listener: (...args: any[]) => void) { + add(type, listener) + }, + onclose: null as ((event: any) => void) | null, + onerror: null as ((event: any) => void) | null, + _onmessage: null as ((event: any) => void) | null, + get onmessage() { + return managed._onmessage + }, + set onmessage(fn: ((event: any) => void) | null) { + managed._onmessage = fn + if (fn && messageBuffer) { + const buffered = messageBuffer + messageBuffer = null + for (const event of buffered) emit('message', event) + } + }, + onopen: null as ((event: any) => void) | null, + off(type: EventType, listener: (...args: any[]) => void) { + remove(type, listener) + }, + get protocol() { + return socket.protocol + }, + get readyState() { + return readyState + }, + removeEventListener: remove, + send(data: string) { + socket.send(data) + }, + get url() { + return socket.url + }, + } + + return { + emit, + socket: managed as unknown as WebSocket, + } + } + + const self: SessionManager = { + get channelId() { + return channel?.channelId + }, + get cumulative() { + return channel?.cumulativeAmount ?? 0n + }, + get opened() { + return channel?.opened ?? false + }, + + async open(options) { + if (channel?.opened) return + + if (!lastChallenge) { + throw new Error( + 'No challenge available. Make a request first to receive a 402 challenge, or pass a challenge via .fetch()/.sse().', + ) + } + + const deposit = options?.deposit + const credential = await method.createCredential({ + challenge: lastChallenge as never, + context: { + ...(deposit !== undefined && { depositRaw: deposit.toString() }), + }, + }) + + if (!lastUrl) throw new Error('No URL available — call fetch() or sse() before open().') + const response = await fetchFn(lastUrl, { + method: 'POST', + headers: { Authorization: credential }, + }) + if (!response.ok) { + const body = await response.text().catch(() => '') + const wwwAuth = response.headers.get('WWW-Authenticate') ?? '' + throw new Error( + `Open request failed with status ${response.status}${body ? `: ${body}` : ''}${wwwAuth ? ` [WWW-Authenticate: ${wwwAuth}]` : ''}`, + ) + } + }, + + fetch: doFetch, + + async sse(input, init) { + const { onReceipt, signal, ...fetchInit } = init ?? {} + + const sseInit = { + ...fetchInit, + headers: { + ...Fetch.normalizeHeaders(fetchInit.headers), + Accept: 'text/event-stream', + }, + ...(signal ? { signal } : {}), + } + + const response = await doFetch(input, sseInit) + + // Snapshot the challenge at SSE open time so concurrent + // calls don't overwrite it. + const sseChallenge = lastChallenge + + if (!response.body) throw new Error('Response has no body.') + + const reader = response.body.getReader() + const decoder = new TextDecoder() + + async function* iterate(): AsyncGenerator { + let buffer = '' + + try { + while (true) { + if (signal?.aborted) break + + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + + const parts = buffer.split('\n\n') + buffer = parts.pop()! + + for (const part of parts) { + if (!part.trim()) continue + + const event = parseEvent(part) + if (!event) continue + + switch (event.type) { + case 'message': + yield event.data + break + + case 'payment-need-voucher': { + if (!channel || !sseChallenge) break + const required = BigInt(event.data.requiredCumulative) + assertVoucherWithinLocalLimit(required) + channel.cumulativeAmount = + channel.cumulativeAmount > required + ? channel.cumulativeAmount + : uint96(required) + + const credential = await method.createCredential({ + challenge: sseChallenge as never, + context: { + action: 'voucher', + channelId: channel.channelId, + descriptor: channel.descriptor, + cumulativeAmountRaw: channel.cumulativeAmount.toString(), + }, + }) + const voucherResponse = await fetchFn(input, { + method: 'POST', + headers: { Authorization: credential }, + }) + if (!voucherResponse.ok) { + throw new Error(`Voucher POST failed with status ${voucherResponse.status}`) + } + break + } + + case 'payment-receipt': + updateSpentFromReceipt(event.data) + onReceipt?.(event.data) + break + } + } + } + } finally { + reader.releaseLock() + } + } + + return iterate() + }, + + async ws(input, init) { + if (!WebSocketImpl) { + throw new Error( + 'No WebSocket implementation available. Pass `webSocket` to sessionManager() in this runtime.', + ) + } + + const { onReceipt, protocols, signal } = init ?? {} + const wsUrl = new URL(input.toString()) + const httpUrl = new URL(wsUrl.toString()) + if (httpUrl.protocol === 'ws:') httpUrl.protocol = 'http:' + if (httpUrl.protocol === 'wss:') httpUrl.protocol = 'https:' + + lastUrl = httpUrl.toString() + const probe = await fetchFn(httpUrl, signal ? { signal } : undefined) + if (probe.status !== 402) { + throw new Error( + `Expected a 402 payment challenge from ${httpUrl}, received ${probe.status} instead.`, + ) + } + + const challenge = Challenge.fromResponseList(probe).find( + (item) => item.method === method.name && item.intent === method.intent, + ) + if (!challenge) { + throw new Error( + 'No payment challenge received from HTTP endpoint for this WebSocket URL. The server may not require payment or did not advertise a challenge.', + ) + } + lastChallenge = challenge + + const credential = await method.createCredential({ + challenge: challenge as never, + context: {}, + }) + + closeReadyReceipt = null + activeSocketChallenge = challenge + wsDeliveredChunks = 0n + wsTickCost = BigInt(challenge.request.amount as string) + const openCredential = PaymentCredential.deserialize(credential) + activeSocketChannelId = openCredential.payload.channelId + const rawSocket = new WebSocketImpl(wsUrl, protocols) + activeSocket = rawSocket + const managedSocket = createManagedSocket(rawSocket) + + const failSocketFlow = (message: string) => { + rejectReceipt(new Error(message)) + rejectCloseReady(new Error(message)) + if ( + rawSocket.readyState === WebSocketReadyState.CONNECTING || + rawSocket.readyState === WebSocketReadyState.OPEN + ) { + rawSocket.close(ClientWebSocketProtocolErrorCloseCode, message) + } + } + + const isExpectedReceipt = (receipt: SessionReceipt) => + receipt.challengeId === challenge.id && receipt.channelId === activeSocketChannelId + + const socketOpened = new Promise((resolve, reject) => { + const onOpen = () => { + rawSocket.removeEventListener('error', onError) + managedSocket.emit('open', { type: 'open' }) + resolve() + } + const onError = () => { + rawSocket.removeEventListener('open', onOpen) + reject(new Error(`WebSocket connection to ${wsUrl} failed to open.`)) + } + rawSocket.addEventListener('open', onOpen, { once: true }) + rawSocket.addEventListener('error', onError, { once: true }) + }) + + rawSocket.addEventListener('close', (event) => { + if (activeSocket === rawSocket) activeSocket = null + if (activeSocketChallenge === challenge) activeSocketChallenge = null + if (activeSocketChannelId === openCredential.payload.channelId) activeSocketChannelId = null + expectedSocketCloseAmount = null + rejectReceipt(new Error('WebSocket closed before the payment flow completed.')) + rejectCloseReady(new Error('WebSocket closed before the payment flow completed.')) + managedSocket.emit('close', { + code: (event as CloseEvent).code ?? 1000, + reason: (event as CloseEvent).reason ?? '', + type: 'close', + wasClean: true, + }) + }) + + rawSocket.addEventListener('error', () => { + managedSocket.emit('error', { type: 'error' }) + }) + + rawSocket.addEventListener('message', async (event) => { + const raw = typeof event.data === 'string' ? event.data : undefined + if (!raw) return + + const message = Ws.parseMessage(raw) + if (!message) { + managedSocket.emit('message', { data: raw, type: 'message' }) + return + } + + switch (message.mpp) { + case 'authorization': + break + case 'message': + wsDeliveredChunks += 1n + managedSocket.emit('message', { data: message.data, type: 'message' }) + break + case 'payment-close-ready': + if (!isExpectedReceipt(message.data)) { + failSocketFlow('received mismatched payment-close-ready frame') + break + } + if (BigInt(message.data.spent) > (channel?.cumulativeAmount ?? 0n)) { + failSocketFlow('received payment-close-ready beyond local voucher state') + break + } + updateSpentFromReceipt(message.data) + onReceipt?.(message.data) + settleCloseReady(message.data) + managedSocket.emit('close', { code: 1000, reason: 'stream complete', type: 'close' }) + break + case 'payment-error': + rejectReceipt(new Error(message.message)) + rejectCloseReady(new Error(message.message)) + break + case 'payment-need-voucher': { + if (message.data.channelId !== activeSocketChannelId) { + failSocketFlow('received mismatched payment-need-voucher frame') + break + } + const required = BigInt(message.data.requiredCumulative) + try { + assertVoucherWithinLocalLimit(required) + } catch (error) { + failSocketFlow( + error instanceof Error + ? error.message + : 'requested voucher amount exceeds local maxDeposit', + ) + break + } + const nextCumulative = + (channel?.cumulativeAmount ?? 0n) > required + ? (channel?.cumulativeAmount ?? 0n) + : required + if (channel?.channelId === activeSocketChannelId) + channel.cumulativeAmount = uint96(nextCumulative) + + const voucher = await method.createCredential({ + challenge: challenge as never, + context: { + action: 'voucher', + channelId: activeSocketChannelId, + descriptor: channel?.descriptor, + cumulativeAmountRaw: nextCumulative.toString(), + }, + }) + rawSocket.send(Ws.formatAuthorizationMessage(voucher)) + break + } + case 'payment-receipt': + if (!isExpectedReceipt(message.data)) { + failSocketFlow('received mismatched payment-receipt frame') + break + } + if ( + expectedSocketCloseAmount !== null && + Boolean(message.data.txHash) && + (message.data.acceptedCumulative !== expectedSocketCloseAmount || + message.data.spent !== expectedSocketCloseAmount) + ) { + failSocketFlow('received mismatched payment-close receipt frame') + break + } + updateSpentFromReceipt(message.data) + onReceipt?.(message.data) + settleReceipt(message.data) + break + } + }) + + if (signal) { + signal.addEventListener( + 'abort', + () => { + rejectReceipt(new Error('WebSocket payment flow aborted.')) + rejectCloseReady(new Error('WebSocket payment flow aborted.')) + rawSocket.close() + }, + { once: true }, + ) + } + + await socketOpened + rawSocket.send(Ws.formatAuthorizationMessage(credential)) + await waitForReceipt() + return managedSocket.socket + }, + + async close() { + const closeChallenge = activeSocketChallenge ?? lastChallenge + const closeChannelId = activeSocketChannelId ?? channel?.channelId + + if (!channel?.opened) return undefined + + if (!closeChallenge) { + throw new Error( + 'Cannot close session: no challenge available. This usually means close() was called on a SessionManager instance that was recreated after the session was opened. Use the same SessionManager instance that opened the session, or make a request first to receive a fresh 402 challenge.', + ) + } + if (!closeChannelId) { + throw new Error( + 'Cannot close session: no channel ID available. The session may not have been fully opened.', + ) + } + + if (activeSocket?.readyState === WebSocketReadyState.OPEN) { + const ready = + closeReadyReceipt ?? + (await (async () => { + activeSocket.send(Ws.formatCloseRequestMessage()) + return waitForCloseReady() + })()) + const readySpent = BigInt(ready.spent) + if (readySpent > (channel.cumulativeAmount > spent ? channel.cumulativeAmount : spent)) { + throw new Error('close-ready spent exceeds local voucher state') + } + + const credential = await method.createCredential({ + challenge: closeChallenge as never, + context: { + action: 'close', + channelId: closeChannelId, + descriptor: channel.descriptor, + cumulativeAmountRaw: readySpent.toString(), + }, + }) + + const expectedCloseAmount = readySpent.toString() + expectedSocketCloseAmount = expectedCloseAmount + try { + const pendingReceipt = waitForReceipt( + (receipt) => + Boolean(receipt.txHash) && + receipt.challengeId === closeChallenge.id && + receipt.channelId === closeChannelId && + receipt.acceptedCumulative === expectedCloseAmount && + receipt.spent === expectedCloseAmount, + ) + activeSocket.send(Ws.formatAuthorizationMessage(credential)) + const receipt = await pendingReceipt + activeSocket.close() + closeReadyReceipt = null + return receipt + } finally { + expectedSocketCloseAmount = null + } + } + + const credential = await method.createCredential({ + challenge: closeChallenge as never, + context: { + action: 'close', + channelId: closeChannelId, + descriptor: channel.descriptor, + cumulativeAmountRaw: (() => { + const closeAmount = BigInt(getFallbackCloseAmount(closeChallenge, closeChannelId)) + if (closeAmount > channel.cumulativeAmount) { + throw new Error('fallback close amount exceeds local voucher state') + } + assertVoucherWithinLocalLimit(closeAmount) + return closeAmount.toString() + })(), + }, + }) + + if (!lastUrl) { + throw new Error( + 'Cannot close session: no URL available. This usually means close() was called on a SessionManager instance that was recreated after the session was opened. Use the same SessionManager instance that opened the session, or call fetch()/sse() before close().', + ) + } + + const response = await fetchFn(lastUrl, { + method: 'POST', + headers: { Authorization: credential }, + }) + if (!response.ok) { + const body = await response.text().catch(() => '') + const detail = (() => { + if (!body) return '' + if (!response.headers.get('Content-Type')?.includes('application/problem+json')) { + return body + } + try { + const problem = JSON.parse(body) as { detail?: string } + return problem.detail ?? body + } catch { + return body + } + })() + const wwwAuth = response.headers.get('WWW-Authenticate') ?? '' + throw new Error( + `Close request failed with status ${response.status}${detail ? `: ${detail}` : ''}${wwwAuth ? ` [WWW-Authenticate: ${wwwAuth}]` : ''}`, + ) + } + const receiptHeader = response.headers.get('Payment-Receipt') + const receipt = receiptHeader ? deserializeSessionReceipt(receiptHeader) : undefined + + return receipt + }, + } + + return self +} + +export declare namespace sessionManager { + type Parameters = Account.getResolver.Parameters & + Client.getResolver.Parameters & { + /** Address authorized to sign vouchers. Defaults to the account address. */ + authorizedSigner?: Address | undefined + /** Viem client instance. Shorthand for `getClient: () => client`. */ + client?: import('viem').Client | undefined + /** Token decimals used to convert `maxDeposit` to raw units. Defaults to `6`. */ + decimals?: number | undefined + /** TIP-1034 precompile address override. */ + escrow?: Address | undefined + fetch?: typeof globalThis.fetch | undefined + /** Maximum deposit in human-readable units (e.g. `'10'` for 10 tokens). Converted to raw units via `decimals`. */ + maxDeposit?: string | undefined + /** Optional websocket constructor for runtimes without a global WebSocket. */ + webSocket?: WebSocketConstructor | undefined + } +} diff --git a/src/tempo/precompile/session/index.ts b/src/tempo/precompile/session/index.ts index 75912963..8af15a43 100644 --- a/src/tempo/precompile/session/index.ts +++ b/src/tempo/precompile/session/index.ts @@ -1 +1,2 @@ export { session } from './Client.js' +export { sessionManager } from './SessionManager.js' From 4330de9d35d22113f4f54f5e42b7e011d819282a Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 13 May 2026 22:33:57 +0200 Subject: [PATCH 08/26] Run precompile integration tests on devnet --- ....localnet.test.ts => Chain.integration.test.ts} | 4 ++-- ...ocalnet.test.ts => Session.integration.test.ts} | 14 +++++++------- src/tempo/precompile/server/Session.test.ts | 14 +++++++------- test/tempo/viem.ts | 3 ++- 4 files changed, 18 insertions(+), 17 deletions(-) rename src/tempo/precompile/{Chain.localnet.test.ts => Chain.integration.test.ts} (96%) rename src/tempo/precompile/server/{Session.localnet.test.ts => Session.integration.test.ts} (97%) diff --git a/src/tempo/precompile/Chain.localnet.test.ts b/src/tempo/precompile/Chain.integration.test.ts similarity index 96% rename from src/tempo/precompile/Chain.localnet.test.ts rename to src/tempo/precompile/Chain.integration.test.ts index 8e0030fd..21a516da 100644 --- a/src/tempo/precompile/Chain.localnet.test.ts +++ b/src/tempo/precompile/Chain.integration.test.ts @@ -12,7 +12,7 @@ import { escrowAbi } from './escrow.abi.js' import { uint96 } from './Types.js' import * as Voucher from './Voucher.js' -const isLocalnet = nodeEnv === 'localnet' +const isPrecompileTestnet = nodeEnv === 'localnet' || nodeEnv === 'devnet' const payer = accounts[2] const payee = accounts[0] @@ -69,7 +69,7 @@ async function openChannel(parameters: { deposit?: bigint | undefined } = {}) { return { channelId, descriptor, deposit } } -describe.runIf(isLocalnet)('TIP-1034 precompile localnet chain operations', () => { +describe.runIf(isPrecompileTestnet)('TIP-1034 precompile chain operations', () => { test('opens a channel, parses ChannelOpened, and reads channel state', async () => { const { channelId, descriptor, deposit } = await openChannel() diff --git a/src/tempo/precompile/server/Session.localnet.test.ts b/src/tempo/precompile/server/Session.integration.test.ts similarity index 97% rename from src/tempo/precompile/server/Session.localnet.test.ts rename to src/tempo/precompile/server/Session.integration.test.ts index f635622f..05fcd18f 100644 --- a/src/tempo/precompile/server/Session.localnet.test.ts +++ b/src/tempo/precompile/server/Session.integration.test.ts @@ -22,7 +22,7 @@ import { escrowAbi } from '../escrow.abi.js' import { uint96 } from '../Types.js' import { session, settle } from './Session.js' -const isLocalnet = nodeEnv === 'localnet' +const isPrecompileTestnet = nodeEnv === 'localnet' || nodeEnv === 'devnet' const payer = accounts[2] const payee = accounts[0] @@ -78,7 +78,7 @@ async function openRealChannel(deposit = 1_000n) { return { channelId, descriptor, deposit: uint96(deposit) } } -describe.runIf(isLocalnet)('precompile server session localnet', () => { +describe.runIf(isPrecompileTestnet)('precompile server session chain integration', () => { test('broadcasts and verifies a real precompile open credential', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) @@ -104,7 +104,7 @@ describe.runIf(isLocalnet)('precompile server session localnet', () => { const receipt = await method.verify({ credential: { challenge: { - id: 'localnet-open-challenge', + id: 'chain-open-challenge', realm: 'api.example.com', method: 'tempo', intent: 'session', @@ -170,7 +170,7 @@ describe.runIf(isLocalnet)('precompile server session localnet', () => { await method.verify({ credential: { challenge: { - id: 'localnet-topup-open', + id: 'chain-topup-open', request: { currency: asset, recipient: payee.address }, } as never, payload: openPayload, @@ -193,7 +193,7 @@ describe.runIf(isLocalnet)('precompile server session localnet', () => { const receipt = await method.verify({ credential: { challenge: { - id: 'localnet-topup', + id: 'chain-topup', request: { currency: asset, recipient: payee.address }, } as never, payload: topUpPayload, @@ -268,7 +268,7 @@ describe.runIf(isLocalnet)('precompile server session localnet', () => { const receipt = await method.verify({ credential: { challenge: { - id: 'localnet-challenge', + id: 'chain-challenge', realm: 'api.example.com', method: 'tempo', intent: 'session', @@ -352,7 +352,7 @@ describe.runIf(isLocalnet)('precompile server session localnet', () => { const receipt = await method.verify({ credential: { challenge: { - id: 'localnet-close', + id: 'chain-close', realm: 'api.example.com', method: 'tempo', intent: 'session', diff --git a/src/tempo/precompile/server/Session.test.ts b/src/tempo/precompile/server/Session.test.ts index e7feba92..52fda7c7 100644 --- a/src/tempo/precompile/server/Session.test.ts +++ b/src/tempo/precompile/server/Session.test.ts @@ -199,7 +199,7 @@ async function persistPrecompileChannel( } describe('precompile server session unit guardrails', () => { - test.skip('accepts a valid open with voucher and persists precompile descriptor state (covered by localnet)', async () => { + test.skip('accepts a valid open with voucher and persists precompile descriptor state (covered by chain integration)', async () => { const { method, store, rpcCalls } = createServer() const payload = await createOpenCredential() @@ -244,7 +244,7 @@ describe('precompile server session unit guardrails', () => { // Reuse a valid descriptor/signature, but submit a transaction whose calls // do not correspond to that descriptor. This exercises the same one-call / // smuggling guard as legacy server session tests without requiring a live - // localnet precompile. + // chain-backed precompile. const smuggled = { ...payload, transaction: tampered.transaction } await expect( @@ -305,7 +305,7 @@ describe('precompile server session unit guardrails', () => { ).rejects.toThrow(/outside uint96 bounds/) }) - test.skip('accepts post-open vouchers using the persisted descriptor (covered by localnet)', async () => { + test.skip('accepts post-open vouchers using the persisted descriptor (covered by chain integration)', async () => { const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) const openPayload = await createOpenCredential() await method.verify({ @@ -329,7 +329,7 @@ describe('precompile server session unit guardrails', () => { expect(channel!.highestVoucherAmount).toBe(250n) }) - test.skip('rejects post-open voucher descriptor mismatches (covered by localnet-backed state)', async () => { + test.skip('rejects post-open voucher descriptor mismatches (covered by chain-backed state)', async () => { const { method } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) const openPayload = await createOpenCredential() await method.verify({ @@ -359,7 +359,7 @@ describe('precompile server session unit guardrails', () => { ) }) - test.skip('accepts top-up credentials and updates cached deposit (covered by localnet)', async () => { + test.skip('accepts top-up credentials and updates cached deposit (covered by chain integration)', async () => { const { method, store, rpcCalls } = createServer() const openPayload = await createOpenCredential({ deposit: 500n }) await method.verify({ @@ -400,7 +400,7 @@ describe('precompile server session unit guardrails', () => { ]) }) - test.skip('rejects top-up calldata for a different descriptor (covered by localnet-backed state)', async () => { + test.skip('rejects top-up calldata for a different descriptor (covered by chain-backed state)', async () => { const { method } = createServer() const openPayload = await createOpenCredential({ deposit: 500n }) await method.verify({ @@ -441,7 +441,7 @@ describe('precompile server session unit guardrails', () => { ).rejects.toThrow(/topUp descriptor does not match stored channel/) }) - test.skip('rejects vouchers above the cached deposit (covered by localnet-backed state)', async () => { + test.skip('rejects vouchers above the cached deposit (covered by chain-backed state)', async () => { const { method } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) const openPayload = await createOpenCredential({ deposit: 200n, initialAmount: 100n }) await method.verify({ diff --git a/test/tempo/viem.ts b/test/tempo/viem.ts index 116d6e32..075517a7 100644 --- a/test/tempo/viem.ts +++ b/test/tempo/viem.ts @@ -18,7 +18,8 @@ const localnetTransportOptions = : undefined const accountsMnemonic = (() => { - if (nodeEnv === 'localnet') return 'test test test test test test test test test test test junk' + if (nodeEnv === 'localnet' || nodeEnv === 'devnet') + return 'test test test test test test test test test test test junk' return generateMnemonic(english) })() From 0479d72573768d31877254cc62bb42e36ae92504 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 13 May 2026 23:34:21 +0200 Subject: [PATCH 09/26] Add precompile fee payer close parity --- .changeset/precompile-fee-payer.md | 5 + src/tempo/precompile/Chain.ts | 126 +++- .../server/Session.integration.test.ts | 121 ++++ src/tempo/precompile/server/Session.test.ts | 635 +++++++++++++----- src/tempo/precompile/server/Session.ts | 144 +++- 5 files changed, 821 insertions(+), 210 deletions(-) create mode 100644 .changeset/precompile-fee-payer.md diff --git a/.changeset/precompile-fee-payer.md b/.changeset/precompile-fee-payer.md new file mode 100644 index 00000000..40a7d591 --- /dev/null +++ b/.changeset/precompile-fee-payer.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added fee-payer and fee-token support for server-driven Tempo precompile settle and close transactions. diff --git a/src/tempo/precompile/Chain.ts b/src/tempo/precompile/Chain.ts index 4005d849..9c2adedd 100644 --- a/src/tempo/precompile/Chain.ts +++ b/src/tempo/precompile/Chain.ts @@ -1,7 +1,17 @@ -import type { Address, Client, Hex } from 'viem' +import type { Account, Address, Client, Hex } from 'viem' import { encodeFunctionData } from 'viem' -import { readContract, sendTransaction } from 'viem/actions' +import { + prepareTransactionRequest, + readContract, + sendRawTransactionSync, + sendTransaction, + signTransaction, +} from 'viem/actions' +import { Transaction } from 'viem/tempo' +import { BadRequestError, VerificationFailedError } from '../../Errors.js' +import type * as FeePayer from '../internal/fee-payer.js' +import { resolveFeeToken } from '../internal/fee-token.js' import type { ChannelDescriptor } from './Channel.js' import { tip20ChannelEscrow } from './Constants.js' import { escrowAbi } from './escrow.abi.js' @@ -168,20 +178,102 @@ export async function getChannelStatesBatch( return states.map(stateFromTuple) } -/** Broadcasts a descriptor-based TIP-1034 settle transaction with the client's account. */ +type SendOptions = { + account?: Account | undefined + candidateFeeTokens?: readonly Address[] | undefined + feePayer?: Account | undefined + feePayerPolicy?: Partial | undefined + feeToken?: Address | undefined +} + +function assertFeePayerPolicy( + prepared: { + gas?: bigint | undefined + maxFeePerGas?: bigint | undefined + maxPriorityFeePerGas?: bigint | undefined + }, + policy: Partial | undefined, +) { + if (!policy) return + if (policy.maxGas !== undefined && (prepared.gas ?? 0n) > policy.maxGas) + throw new BadRequestError({ reason: 'fee-payer policy maxGas exceeded' }) + if (policy.maxFeePerGas !== undefined && (prepared.maxFeePerGas ?? 0n) > policy.maxFeePerGas) + throw new BadRequestError({ reason: 'fee-payer policy maxFeePerGas exceeded' }) + if ( + policy.maxPriorityFeePerGas !== undefined && + (prepared.maxPriorityFeePerGas ?? 0n) > policy.maxPriorityFeePerGas + ) + throw new BadRequestError({ reason: 'fee-payer policy maxPriorityFeePerGas exceeded' }) + if ( + policy.maxTotalFee !== undefined && + (prepared.gas ?? 0n) * (prepared.maxFeePerGas ?? 0n) > policy.maxTotalFee + ) + throw new BadRequestError({ reason: 'fee-payer policy maxTotalFee exceeded' }) +} + +async function sendPrecompileTransaction( + client: Client, + to: Address, + data: Hex, + label: string, + options?: SendOptions, +): Promise { + if (options?.feePayer) { + const account = options.account ?? client.account + if (!account) throw new Error(`Cannot ${label} precompile channel: no account available.`) + const feeToken = + options.feeToken ?? + (await resolveFeeToken({ + account: options.feePayer.address, + candidateTokens: options.candidateFeeTokens, + client, + })) + const prepared = await prepareTransactionRequest(client, { + account, + calls: [{ to, data }], + feePayer: true, + ...(feeToken ? { feeToken } : {}), + } as never) + assertFeePayerPolicy(prepared, options.feePayerPolicy) + const serialized = (await signTransaction(client, { + ...prepared, + account, + feePayer: options.feePayer, + } as never)) as Hex + const receipt = await sendRawTransactionSync(client, { + serializedTransaction: serialized as Transaction.TransactionSerializedTempo, + }) + if (receipt.status !== 'success') + throw new VerificationFailedError({ + reason: `${label} precompile transaction reverted: ${receipt.transactionHash}`, + }) + return receipt.transactionHash + } + + return sendTransaction(client, { + ...(options?.account ? { account: options.account } : {}), + to, + data, + ...(options?.feeToken ? { feeToken: options.feeToken } : {}), + } as never) +} + +/** Broadcasts a descriptor-based TIP-1034 settle transaction with optional fee sponsorship. */ export async function settle( client: Client, descriptor: ChannelDescriptor, cumulativeAmount: Uint96, signature: Hex, escrow: Address = tip20ChannelEscrow, - options?: { account?: Parameters[1]['account'] | undefined }, + options?: SendOptions, ): Promise { - return sendTransaction(client, { - ...(options?.account ? { account: options.account } : {}), - to: escrow, - data: encodeSettle(descriptor, cumulativeAmount, signature), - } as never) + return sendPrecompileTransaction( + client, + escrow, + encodeSettle(descriptor, cumulativeAmount, signature), + 'settle', + options, + ) } /** Broadcasts a descriptor-based TIP-1034 top-up transaction with the client's account. */ @@ -221,7 +313,7 @@ export async function withdraw( } as never) } -/** Broadcasts a descriptor-based TIP-1034 close transaction with the client's account. */ +/** Broadcasts a descriptor-based TIP-1034 close transaction with optional fee sponsorship. */ export async function close( client: Client, descriptor: ChannelDescriptor, @@ -229,11 +321,13 @@ export async function close( captureAmount: Uint96, signature: Hex, escrow: Address = tip20ChannelEscrow, - options?: { account?: Parameters[1]['account'] | undefined }, + options?: SendOptions, ): Promise { - return sendTransaction(client, { - ...(options?.account ? { account: options.account } : {}), - to: escrow, - data: encodeClose(descriptor, cumulativeAmount, captureAmount, signature), - } as never) + return sendPrecompileTransaction( + client, + escrow, + encodeClose(descriptor, cumulativeAmount, captureAmount, signature), + 'close', + options, + ) } diff --git a/src/tempo/precompile/server/Session.integration.test.ts b/src/tempo/precompile/server/Session.integration.test.ts index 05fcd18f..4ba84deb 100644 --- a/src/tempo/precompile/server/Session.integration.test.ts +++ b/src/tempo/precompile/server/Session.integration.test.ts @@ -25,6 +25,7 @@ import { session, settle } from './Session.js' const isPrecompileTestnet = nodeEnv === 'localnet' || nodeEnv === 'devnet' const payer = accounts[2] const payee = accounts[0] +const feePayer = accounts[1] async function sendPrecompileCall(data: Hex.Hex, account = payer) { const hash = await sendTransaction(client, { @@ -303,6 +304,126 @@ describe.runIf(isPrecompileTestnet)('precompile server session chain integration expect(settledStore?.settledOnChain).toBe(300n) }) + test('settles a real precompile channel with fee-payer sponsorship', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const { channelId, descriptor, deposit } = await openRealChannel(1_000n) + + const voucher = await createVoucherCredential(client, payer, { + chainId: chain.id, + cumulativeAmount: uint96(250n), + descriptor, + escrow: tip20ChannelEscrow, + }) + await store.updateChannel(channelId, () => ({ + backend: 'precompile', + channelId, + chainId: chain.id, + escrowContract: tip20ChannelEscrow, + closeRequestedAt: 0n, + payer: descriptor.payer, + payee: descriptor.payee, + token: descriptor.token, + authorizedSigner: descriptor.authorizedSigner, + deposit, + settledOnChain: 0n, + highestVoucherAmount: 250n, + highestVoucher: { + channelId, + cumulativeAmount: 250n, + signature: voucher.signature, + }, + spent: 0n, + units: 0, + finalized: false, + createdAt: new Date().toISOString(), + descriptor, + operator: descriptor.operator, + salt: descriptor.salt, + expiringNonceHash: descriptor.expiringNonceHash, + })) + + const txHash = await settle(store, client, channelId, { + account: payee, + feePayer, + feeToken: asset, + }) + const receipt = await waitForTransactionReceipt(client, { hash: txHash }) + expect(receipt.status).toBe('success') + const settled = getSingleEvent(receipt, 'Settled') + expect(settled.args.channelId).toBe(channelId) + expect(settled.args.newSettled).toBe(250n) + }) + + test('closes a real precompile channel with fee-payer sponsorship', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const { channelId, descriptor, deposit } = await openRealChannel(1_000n) + await store.updateChannel(channelId, () => ({ + backend: 'precompile', + channelId, + chainId: chain.id, + escrowContract: tip20ChannelEscrow, + closeRequestedAt: 0n, + payer: descriptor.payer, + payee: descriptor.payee, + token: descriptor.token, + authorizedSigner: descriptor.authorizedSigner, + deposit, + settledOnChain: 0n, + highestVoucherAmount: 300n, + highestVoucher: null, + spent: 0n, + units: 0, + finalized: false, + createdAt: new Date().toISOString(), + descriptor, + operator: descriptor.operator, + salt: descriptor.salt, + expiringNonceHash: descriptor.expiringNonceHash, + })) + const method = session({ + account: payee, + amount: '100', + chainId: chain.id, + currency: asset, + decimals: 0, + feePayer, + feeToken: asset, + recipient: payee.address, + store: rawStore, + unitType: 'request', + getClient: () => client, + }) + const payload = await createCloseCredential(client, payer, { + chainId: chain.id, + cumulativeAmount: uint96(300n), + descriptor, + escrow: tip20ChannelEscrow, + }) + + const receipt = await method.verify({ + credential: { + challenge: { + id: 'chain-sponsored-close', + request: { currency: asset, recipient: payee.address }, + } as never, + payload, + }, + request: { + methodDetails: { chainId: chain.id, escrowContract: tip20ChannelEscrow, channelId }, + } as never, + }) + if (!('txHash' in receipt)) throw new Error('expected sponsored close txHash') + const closeReceipt = await waitForTransactionReceipt(client, { + hash: receipt.txHash as Hex.Hex, + }) + expect(closeReceipt.status).toBe('success') + const closed = getSingleEvent(closeReceipt, 'ChannelClosed') + expect(closed.args.channelId).toBe(channelId) + expect(closed.args.settledToPayee).toBe(300n) + }) + test('closes a real precompile channel only after a successful close receipt', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) diff --git a/src/tempo/precompile/server/Session.test.ts b/src/tempo/precompile/server/Session.test.ts index 52fda7c7..c62f6452 100644 --- a/src/tempo/precompile/server/Session.test.ts +++ b/src/tempo/precompile/server/Session.test.ts @@ -1,4 +1,11 @@ -import { type Address, createClient, custom, type Hex, zeroAddress } from 'viem' +import { + type Address, + createClient, + custom, + encodeFunctionResult, + type Hex, + zeroAddress, +} from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { Transaction } from 'viem/tempo' import { describe, expect, test } from 'vp/test' @@ -9,6 +16,7 @@ import * as Chain from '../Chain.js' import * as Channel from '../Channel.js' import * as ClientOps from '../client/ChannelOps.js' import { tip20ChannelEscrow } from '../Constants.js' +import { escrowAbi } from '../escrow.abi.js' import type { OpenCredentialPayload } from '../Types.js' import * as Types from '../Types.js' import * as Voucher from '../Voucher.js' @@ -44,7 +52,11 @@ function createSigningClient(account = payer) { }) } -function createServerClient(calls: RpcCall[] = [], account: typeof payer | null = payer) { +function createServerClient( + calls: RpcCall[] = [], + account: typeof payer | null = payer, + _eventChannelId: Hex = `0x${'00'.repeat(32)}` as Hex, +) { return createClient({ ...(account ? { account } : {}), chain: { id: chainId } as never, @@ -58,6 +70,42 @@ function createServerClient(calls: RpcCall[] = [], account: typeof payer | null if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' } if (args.method === 'eth_sendRawTransaction') return `0x${'aa'.repeat(32)}` if (args.method === 'eth_sendTransaction') return `0x${'bb'.repeat(32)}` + if (args.method === 'eth_call') + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'getChannelState', + result: { settled: 100n, deposit: 1_000n, closeRequestedAt: 0 }, + }) + throw new Error(`unexpected rpc request: ${args.method}`) + }, + }), + }) +} + +function createStateClient( + account: typeof payer | null = payer, + state: { settled: bigint; deposit: bigint; closeRequestedAt: number } = { + settled: 0n, + deposit: 1_000n, + closeRequestedAt: 0, + }, +) { + return createClient({ + ...(account ? { account } : {}), + chain: { id: chainId } as never, + transport: custom({ + async request(args) { + if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}` + if (args.method === 'eth_call') + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'getChannelState', + result: state, + }) + if (args.method === 'eth_getTransactionCount') return '0x0' + if (args.method === 'eth_estimateGas') return '0x5208' + if (args.method === 'eth_maxPriorityFeePerGas') return '0x1' + if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' } throw new Error(`unexpected rpc request: ${args.method}`) }, }), @@ -199,29 +247,81 @@ async function persistPrecompileChannel( } describe('precompile server session unit guardrails', () => { - test.skip('accepts a valid open with voucher and persists precompile descriptor state (covered by chain integration)', async () => { - const { method, store, rpcCalls } = createServer() - const payload = await createOpenCredential() + test('request normalizes fee-payer to boolean for challenge issuance and account for verification', async () => { + const { method } = createServer({ feePayer: wrongPayer }) + + const challengeRequest = await method.request!({ + credential: null, + request: { + amount: '1', + currency: token, + decimals: 0, + recipient: payee, + unitType: 'request', + }, + } as never) + expect(challengeRequest.feePayer).toBe(true) + + const verificationRequest = await method.request!({ + credential: { challenge: {}, payload: {} } as never, + request: { + amount: '1', + currency: token, + decimals: 0, + feePayer: payer, + recipient: payee, + unitType: 'request', + }, + } as never) + expect(verificationRequest.feePayer).toBe(payer) + }) - const receipt = await method.verify({ - credential: { challenge: makeChallenge(payload.channelId), payload }, - request: makeRequest(payload.channelId) as never, - }) + test('request allows callers to explicitly disable precompile fee-payer', async () => { + const { method } = createServer({ feePayer: wrongPayer }) + + const challengeRequest = await method.request!({ + credential: null, + request: { + amount: '1', + currency: token, + decimals: 0, + feePayer: false, + recipient: payee, + unitType: 'request', + }, + } as never) + expect(challengeRequest.feePayer).toBeUndefined() + + const verificationRequest = await method.request!({ + credential: { challenge: {}, payload: {} } as never, + request: { + amount: '1', + currency: token, + decimals: 0, + feePayer: false, + recipient: payee, + unitType: 'request', + }, + } as never) + expect(verificationRequest.feePayer).toBe(false) + }) + + test('request throws when resolved precompile client chain mismatches requested chain', async () => { + const { method } = createServer({ chainId: 1 }) - expect(receipt.status).toBe('success') - expect(receipt.method).toBe('tempo') - expect(receipt.reference).toBe(payload.channelId) - expect(rpcCalls.map((call) => call.method)).toEqual(['eth_sendRawTransaction']) - - const channel = await store.getChannel(payload.channelId) - expect(channel).not.toBeNull() - expect(channel!.backend).toBe('precompile') - expect(ChannelStore.isPrecompileState(channel!)).toBe(true) - if (!ChannelStore.isPrecompileState(channel!)) throw new Error('expected precompile state') - expect(channel.descriptor).toEqual(payload.descriptor) - expect(channel.expiringNonceHash).toBe(payload.descriptor.expiringNonceHash) - expect(channel.highestVoucherAmount).toBe(100n) - expect(channel.deposit).toBe(1_000n) + await expect( + method.request!({ + credential: null, + request: { + amount: '1', + chainId: 1, + currency: token, + decimals: 0, + recipient: payee, + unitType: 'request', + }, + } as never), + ).rejects.toThrow('Client not configured with chainId 1.') }) test('rejects open transactions targeting the wrong address', async () => { @@ -305,221 +405,416 @@ describe('precompile server session unit guardrails', () => { ).rejects.toThrow(/outside uint96 bounds/) }) - test.skip('accepts post-open vouchers using the persisted descriptor (covered by chain integration)', async () => { - const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) + test('rejects settle when no account is available', async () => { + const { store } = createServer() const openPayload = await createOpenCredential() - await method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, - request: makeRequest(openPayload.channelId) as never, - }) + await persistPrecompileChannel(store, openPayload) - const voucherPayload = await ClientOps.createVoucherCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(250n), - descriptor: openPayload.descriptor, - }) + const { settle } = await import('./Session.js') + await expect( + settle(store, createServerClient([], null), openPayload.channelId), + ).rejects.toThrow(/no account available/) + }) + + test('rejects settle when sender is not the channel payee', async () => { + const { store } = createServer() + const openPayload = await createOpenCredential() + await persistPrecompileChannel(store, openPayload) + + const { settle } = await import('./Session.js') + await expect( + settle(store, createServerClient([], wrongPayer), openPayload.channelId), + ).rejects.toThrow(/tx sender .* is not the channel payee/) + }) + + test('precompile settle fee payer options still enforce payee sender policy', async () => { + const { store } = createServer() + const openPayload = await createOpenCredential() + await persistPrecompileChannel(store, openPayload) + + const { settle } = await import('./Session.js') + await expect( + settle(store, createServerClient([], payer), openPayload.channelId, { + feePayer: wrongPayer, + }), + ).rejects.toThrow(/tx sender .* is not the channel payee/) + }) - const receipt = await method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload: voucherPayload }, - request: makeRequest(openPayload.channelId) as never, + test('accepts precompile settle fee token options', async () => { + const { store } = createServer() + const openPayload = await createOpenCredential() + await persistPrecompileChannel(store, openPayload, { payee: payer.address }) + const client = createClient({ + account: payer, + chain: { id: chainId } as never, + transport: custom({ + async request(args) { + if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}` + if (args.method === 'eth_sendTransaction') throw new Error('sent fee-token settle') + throw new Error(`unexpected rpc request: ${args.method}`) + }, + }), }) - expect(receipt.reference).toBe(openPayload.channelId) - const channel = await store.getChannel(openPayload.channelId) - expect(channel!.highestVoucherAmount).toBe(250n) + const { settle } = await import('./Session.js') + await expect( + settle(store, client, openPayload.channelId, { + feeToken: token, + }), + ).rejects.toThrow(/eth_getTransactionCount/) }) - test.skip('rejects post-open voucher descriptor mismatches (covered by chain-backed state)', async () => { - const { method } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) + test('accepts settle account override matching the channel payee', async () => { + const { store } = createServer() const openPayload = await createOpenCredential() - await method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, - request: makeRequest(openPayload.channelId) as never, + await persistPrecompileChannel(store, openPayload, { payee: wrongPayer.address }) + const client = createClient({ + account: payer, + chain: { id: chainId } as never, + transport: custom({ + async request(args) { + if (args.method === 'eth_sendTransaction') throw new Error('sent settle transaction') + if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}` + throw new Error(`unexpected rpc request: ${args.method}`) + }, + }), }) - const voucherPayload = await ClientOps.createVoucherCredential(createSigningClient(), payer, { + const { settle } = await import('./Session.js') + await expect( + settle(store, client, openPayload.channelId, { + account: wrongPayer, + }), + ).rejects.toThrow(/eth_getTransactionCount/) + }) + + test('rejects precompile settle fee-payer policy violations', async () => { + const { store } = createServer() + const openPayload = await createOpenCredential() + await persistPrecompileChannel(store, openPayload, { payee: payer.address }) + + const { settle } = await import('./Session.js') + await expect( + settle(store, createServerClient([], payer), openPayload.channelId, { + feePayer: wrongPayer, + feePayerPolicy: { maxGas: 1n }, + feeToken: token, + }), + ).rejects.toThrow(/fee-payer policy maxGas exceeded/) + }) + + test('rejects close voucher below local spent', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenCredential() + await persistPrecompileChannel(store, openPayload, { payee: payer.address, spent: 150n }) + const method = session({ + account: payer, + amount: '1', chainId, - cumulativeAmount: Types.uint96(250n), + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => createStateClient(payer), + }) + const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(100n), descriptor: openPayload.descriptor, }) await expect( method.verify({ - credential: { - challenge: makeChallenge(openPayload.channelId), - payload: { - ...voucherPayload, - descriptor: { ...voucherPayload.descriptor, salt: `0x${'ff'.repeat(32)}` as Hex }, - }, - }, + credential: { challenge: makeChallenge(openPayload.channelId), payload }, request: makeRequest(openPayload.channelId) as never, }), - ).rejects.toThrow( - /descriptor does not match stored channel|channelId does not match descriptor/, - ) + ).rejects.toThrow(/close voucher amount must be >= 150 \(spent\)/) }) - test.skip('accepts top-up credentials and updates cached deposit (covered by chain integration)', async () => { - const { method, store, rpcCalls } = createServer() - const openPayload = await createOpenCredential({ deposit: 500n }) - await method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, - request: makeRequest(openPayload.channelId) as never, + test('rejects close voucher at or below on-chain settled except untouched zero-close', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenCredential() + await persistPrecompileChannel(store, openPayload, { payee: payer.address }) + const method = session({ + account: payer, + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => + createStateClient(payer, { settled: 100n, deposit: 1_000n, closeRequestedAt: 0 }), + }) + const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(100n), + descriptor: openPayload.descriptor, }) - const additionalDeposit = Types.uint96(700n) - const topUpPayload = ClientOps.createTopUpCredential( - { - channelId: openPayload.channelId, - descriptor: openPayload.descriptor, - transaction: (await Transaction.serialize({ - chainId, - calls: [ - { - to: tip20ChannelEscrow, - data: Chain.encodeTopUp(openPayload.descriptor, additionalDeposit), - }, - ], - feeToken: token, - nonce: 0, - })) as Hex, - }, - additionalDeposit, - ) + await expect( + method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/close voucher amount must be > 100 \(on-chain settled\)/) + }) - await method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload: topUpPayload }, - request: makeRequest(openPayload.channelId) as never, + test('rejects close voucher exceeding on-chain precompile deposit', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenCredential() + await persistPrecompileChannel(store, openPayload, { payee: payer.address }) + const method = session({ + account: payer, + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => createStateClient(payer, { settled: 0n, deposit: 99n, closeRequestedAt: 0 }), + }) + const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(100n), + descriptor: openPayload.descriptor, }) - const channel = await store.getChannel(openPayload.channelId) - expect(channel!.deposit).toBe(1_200n) - expect(rpcCalls.map((call) => call.method)).toEqual([ - 'eth_sendRawTransaction', - 'eth_sendRawTransaction', - ]) + await expect( + method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/close voucher amount exceeds on-chain deposit/) }) - test.skip('rejects top-up calldata for a different descriptor (covered by chain-backed state)', async () => { - const { method } = createServer() - const openPayload = await createOpenCredential({ deposit: 500n }) - await method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, - request: makeRequest(openPayload.channelId) as never, + test('rejects close for locally finalized and pending precompile channels', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenCredential() + const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(100n), + descriptor: openPayload.descriptor, + }) + const method = session({ + account: payer, + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => createStateClient(payer), }) - const otherOpen = await createOpenCredential({ deposit: 500n }) - const additionalDeposit = Types.uint96(700n) - const topUpPayload = { - ...ClientOps.createTopUpCredential( - { - channelId: otherOpen.channelId, - descriptor: otherOpen.descriptor, - transaction: (await Transaction.serialize({ - chainId, - calls: [ - { - to: tip20ChannelEscrow, - data: Chain.encodeTopUp(otherOpen.descriptor, additionalDeposit), - }, - ], - feeToken: token, - nonce: 0, - })) as Hex, - }, - additionalDeposit, - ), - channelId: openPayload.channelId, - descriptor: openPayload.descriptor, - } + await persistPrecompileChannel(store, openPayload, { finalized: true, payee: payer.address }) + await expect( + method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/channel is already finalized/) + await persistPrecompileChannel(store, openPayload, { + closeRequestedAt: 1n, + payee: payer.address, + }) await expect( method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload: topUpPayload }, + credential: { challenge: makeChallenge(openPayload.channelId), payload }, request: makeRequest(openPayload.channelId) as never, }), - ).rejects.toThrow(/topUp descriptor does not match stored channel/) + ).rejects.toThrow(/channel has a pending close request/) }) - test.skip('rejects vouchers above the cached deposit (covered by chain-backed state)', async () => { - const { method } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) - const openPayload = await createOpenCredential({ deposit: 200n, initialAmount: 100n }) - await method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, - request: makeRequest(openPayload.channelId) as never, + test('marks pending precompile close before broadcast and restores it when broadcast fails', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenCredential() + await persistPrecompileChannel(store, openPayload, { payee: payer.address }) + let observedPending = false + const method = session({ + account: payer, + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => + createClient({ + account: payer, + chain: { id: chainId } as never, + transport: custom({ + async request(args) { + if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}` + if (args.method === 'eth_call') + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'getChannelState', + result: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 }, + }) + if (args.method === 'eth_getTransactionCount') { + observedPending = + (await store.getChannel(openPayload.channelId))!.closeRequestedAt !== 0n + throw new Error('broadcast failed') + } + if (args.method === 'eth_estimateGas') return '0x5208' + if (args.method === 'eth_maxPriorityFeePerGas') return '0x1' + if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' } + throw new Error(`unexpected rpc request: ${args.method}`) + }, + }), + }), }) - const voucherPayload = await ClientOps.createVoucherCredential(createSigningClient(), payer, { + const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { chainId, - cumulativeAmount: Types.uint96(201n), + cumulativeAmount: Types.uint96(100n), descriptor: openPayload.descriptor, }) await expect( method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload: voucherPayload }, + credential: { challenge: makeChallenge(openPayload.channelId), payload }, request: makeRequest(openPayload.channelId) as never, }), - ).rejects.toThrow(/exceeds on-chain deposit/) + ).rejects.toThrow(/broadcast failed/) + expect(observedPending).toBe(true) + expect((await store.getChannel(openPayload.channelId))!.closeRequestedAt).toBe(0n) }) - test('rejects settle when no account is available', async () => { - const { store } = createServer() + test('rejects server-driven close when no account is available', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenCredential() await persistPrecompileChannel(store, openPayload) + const method = session({ + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => createStateClient(null), + }) + const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(100n), + descriptor: openPayload.descriptor, + }) - const { settle } = await import('./Session.js') await expect( - settle(store, createServerClient([], null), openPayload.channelId), + method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload }, + request: makeRequest(openPayload.channelId) as never, + }), ).rejects.toThrow(/no account available/) }) - test('rejects settle when sender is not the channel payee', async () => { - const { store } = createServer() + test('accepts server-driven close account override matching the channel payee', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenCredential() - await persistPrecompileChannel(store, openPayload) + await persistPrecompileChannel(store, openPayload, { payee: wrongPayer.address }) + const method = session({ + account: wrongPayer, + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => createStateClient(payer), + }) + const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(100n), + descriptor: openPayload.descriptor, + }) - const { settle } = await import('./Session.js') await expect( - settle(store, createServerClient([], wrongPayer), openPayload.channelId), - ).rejects.toThrow(/tx sender .* is not the channel payee/) + method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/eth_sendRawTransaction/) }) - test('rejects unsupported precompile settle fee payer options', async () => { - const { store } = createServer() + test('uses request-specified fee payer account for server-driven precompile close', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenCredential() - await persistPrecompileChannel(store, openPayload) + await persistPrecompileChannel(store, openPayload, { payee: wrongPayer.address }) + const method = session({ + account: wrongPayer, + amount: '1', + chainId, + currency: token, + decimals: 0, + feeToken: token, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => createStateClient(payer), + }) + const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(100n), + descriptor: openPayload.descriptor, + }) - const { settle } = await import('./Session.js') await expect( - settle(store, createServerClient([], payer), openPayload.channelId, { - feePayer: wrongPayer, - } as never), - ).rejects.toThrow(/does not support feePayer or feeToken/) + method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload }, + request: { + ...makeRequest(openPayload.channelId), + feePayer: payer, + methodDetails: { + ...makeRequest(openPayload.channelId).methodDetails, + feePayer: true, + }, + } as never, + }), + ).rejects.toThrow(/eth_sendRawTransaction/) }) - test('accepts settle account override matching the channel payee', async () => { - const { store } = createServer() + test('rejects server-driven close when sender is not the channel payee', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenCredential() - await persistPrecompileChannel(store, openPayload, { payee: wrongPayer.address }) - const calls: RpcCall[] = [] - const client = createClient({ - account: payer, - chain: { id: chainId } as never, - transport: custom({ - async request(args) { - calls.push(args) - if (args.method === 'eth_sendTransaction') throw new Error('sent settle transaction') - if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}` - throw new Error(`unexpected rpc request: ${args.method}`) - }, - }), + await persistPrecompileChannel(store, openPayload) + const method = session({ + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => createStateClient(wrongPayer), + }) + const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(100n), + descriptor: openPayload.descriptor, }) - const { settle } = await import('./Session.js') await expect( - settle(store, client, openPayload.channelId, { - account: wrongPayer, + method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload }, + request: makeRequest(openPayload.channelId) as never, }), - ).rejects.toThrow(/eth_getTransactionCount/) + ).rejects.toThrow(/tx sender .* is not the channel payee/) }) }) diff --git a/src/tempo/precompile/server/Session.ts b/src/tempo/precompile/server/Session.ts index 91a3ff94..030a807c 100644 --- a/src/tempo/precompile/server/Session.ts +++ b/src/tempo/precompile/server/Session.ts @@ -26,6 +26,7 @@ import * as Method from '../../../Method.js' import * as Store from '../../../Store.js' import * as Client from '../../../viem/Client.js' import * as defaults from '../../internal/defaults.js' +import type * as FeePayer from '../../internal/fee-payer.js' import type * as types from '../../internal/types.js' import * as Methods from '../../Methods.js' import * as ChannelStore from '../../session/ChannelStore.js' @@ -48,6 +49,7 @@ type SessionMethodDetails = { chainId: number escrowContract?: Address | undefined channelId?: Hex | undefined + feePayer?: boolean | undefined minVoucherDelta?: string | undefined } @@ -192,13 +194,25 @@ export function session( unitType, } as unknown as Defaults, - async request({ request }) { + async request({ credential, request }) { const chainId = request.chainId ?? parameters.chainId ?? (await getClient({})).chain?.id if (!chainId) throw new Error('No chainId configured for tempo.precompile.session().') + const client = await getClient({ chainId }) + if (client.chain?.id !== chainId) + throw new Error(`Client not configured with chainId ${chainId}.`) + const resolvedFeePayer = (() => { + if (request.feePayer === false) return credential ? false : undefined + const account = + typeof request.feePayer === 'object' ? request.feePayer : parameters.feePayer + if (credential) return account ?? undefined + if (account) return true + return undefined + })() return { ...request, chainId, escrowContract: request.escrowContract ?? parameters.escrow ?? tip20ChannelEscrow, + feePayer: resolvedFeePayer, } }, @@ -211,6 +225,17 @@ export function session( const chainId = methodDetails.chainId const escrow = methodDetails.escrowContract ?? parameters.escrow ?? tip20ChannelEscrow const client = await getClient({ chainId }) + const requestAllowsFeePayer = + request.feePayer !== false && + (request.feePayer === undefined || + request.feePayer === true || + typeof request.feePayer === 'object') + const resolvedFeePayer = + methodDetails.feePayer === true && requestAllowsFeePayer + ? typeof request.feePayer === 'object' + ? request.feePayer + : parameters.feePayer + : undefined const minVoucherDelta = methodDetails.minVoucherDelta ? BigInt(methodDetails.minVoucherDelta) : parseUnits(parameters.minVoucherDelta ?? '0', decimals) @@ -241,6 +266,9 @@ export function session( chainId, escrow, account: parameters.account, + feePayer: resolvedFeePayer, + feePayerPolicy: parameters.feePayerPolicy, + feeToken: parameters.feeToken, }) default: throw new VerificationFailedError({ reason: 'unsupported precompile session action' }) @@ -558,6 +586,9 @@ async function handleClose(parameters: { chainId: number escrow: Address account?: viem_Account | undefined + feePayer?: viem_Account | undefined + feePayerPolicy?: Partial | undefined + feeToken?: Address | undefined }): Promise { const { store, client, challenge, payload, chainId, escrow } = parameters const channelId = ChannelStore.normalizeChannelId(payload.channelId) @@ -565,8 +596,25 @@ async function handleClose(parameters: { if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' }) if (!ChannelStore.isPrecompileState(channel)) throw new VerificationFailedError({ reason: 'channel is not precompile-backed' }) + if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is already finalized' }) assertSameDescriptor(payload.descriptor, channel.descriptor) const state = await Chain.getChannelState(client, channelId, escrow) + if (state.closeRequestedAt !== 0) + throw new ChannelClosedError({ reason: 'channel has a pending close request' }) + if (state.deposit === 0n && (payload.cumulativeAmount !== 0n || channel.spent !== 0n)) + throw new ChannelClosedError({ reason: 'channel deposit is zero (settled)' }) + if (payload.cumulativeAmount < channel.spent) + throw new VerificationFailedError({ + reason: `close voucher amount must be >= ${channel.spent} (spent)`, + }) + const isUntouchedZeroClose = + payload.cumulativeAmount === 0n && channel.spent === 0n && state.settled === 0n + if (!isUntouchedZeroClose && payload.cumulativeAmount <= state.settled) + throw new VerificationFailedError({ + reason: `close voucher amount must be > ${state.settled} (on-chain settled)`, + }) + if (payload.cumulativeAmount > state.deposit) + throw new AmountExceedsDepositError({ reason: 'close voucher amount exceeds on-chain deposit' }) const valid = await Voucher.verify( { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, channel.authorizedSigner, @@ -576,23 +624,59 @@ async function handleClose(parameters: { const captureAmount = uint96( payload.cumulativeAmount > state.settled ? payload.cumulativeAmount : state.settled, ) - const account = parameters.account ?? getClientAccount(client) - assertSettlementSender({ - operation: 'close', - channelId, - payee: channel.payee, - sender: account?.address, + const pendingCloseStartedAt = BigInt(Math.floor(Date.now() / 1000) || 1) + const previousCloseRequestedAt = channel.closeRequestedAt + let pendingCloseMarked = false + await store.updateChannel(channelId, (current) => { + if (!current) return null + if (current.finalized) throw new ChannelClosedError({ reason: 'channel is already finalized' }) + if (current.closeRequestedAt !== 0n) + throw new ChannelClosedError({ reason: 'channel has a pending close request' }) + if (payload.cumulativeAmount < current.spent) + throw new VerificationFailedError({ + reason: `close voucher amount must be >= ${current.spent} (spent)`, + }) + pendingCloseMarked = true + return { ...current, closeRequestedAt: pendingCloseStartedAt } }) - const txHash = await Chain.close( - client, - channel.descriptor, - payload.cumulativeAmount, - captureAmount, - payload.signature, - escrow, - account ? { account } : undefined, - ) - const receipt = await waitForSuccessfulReceipt(client, txHash) + const account = parameters.account ?? getClientAccount(client) + let txHash: Hex | undefined + let receipt: Awaited> + try { + assertSettlementSender({ + operation: 'close', + channelId, + payee: channel.payee, + sender: account?.address, + }) + txHash = await Chain.close( + client, + channel.descriptor, + payload.cumulativeAmount, + captureAmount, + payload.signature, + escrow, + account + ? { + account, + ...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}), + ...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}), + ...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}), + candidateFeeTokens: [channel.token], + } + : undefined, + ) + receipt = await waitForSuccessfulReceipt(client, txHash) + } catch (error) { + if (pendingCloseMarked) { + await store.updateChannel(channelId, (current) => + current && current.closeRequestedAt === pendingCloseStartedAt + ? { ...current, closeRequestedAt: previousCloseRequestedAt } + : current, + ) + } + throw error + } const closed = getChannelEvent(receipt, 'ChannelClosed', channelId) const settledToPayee = uint96(closed.args.settledToPayee as bigint) const refundedToPayer = uint96(closed.args.refundedToPayer as bigint) @@ -632,9 +716,11 @@ export async function settle( channelId_: Hex, options?: { account?: viem_Account | undefined + candidateFeeTokens?: readonly Address[] | undefined escrow?: Address | undefined - feePayer?: never - feeToken?: never + feePayer?: viem_Account | undefined + feePayerPolicy?: Partial | undefined + feeToken?: Address | undefined }, ): Promise { const store = 'getChannel' in store_ ? store_ : ChannelStore.fromStore(store_ as never) @@ -644,10 +730,6 @@ export async function settle( if (!ChannelStore.isPrecompileState(channel)) throw new VerificationFailedError({ reason: 'channel is not precompile-backed' }) if (!channel.highestVoucher) throw new VerificationFailedError({ reason: 'no voucher to settle' }) - if ('feePayer' in (options ?? {}) || 'feeToken' in (options ?? {})) - throw new BadRequestError({ - reason: 'tempo.precompile.settle() does not support feePayer or feeToken options yet', - }) const escrow = options?.escrow ?? channel.escrowContract const account = options?.account ?? getClientAccount(client) assertSettlementSender({ @@ -663,7 +745,15 @@ export async function settle( amount, channel.highestVoucher.signature, escrow, - account ? { account } : undefined, + account + ? { + account, + ...(options?.feePayer ? { feePayer: options.feePayer } : {}), + ...(options?.feePayerPolicy ? { feePayerPolicy: options.feePayerPolicy } : {}), + ...(options?.feeToken ? { feeToken: options.feeToken } : {}), + candidateFeeTokens: options?.candidateFeeTokens ?? [channel.token], + } + : undefined, ) const receipt = await waitForSuccessfulReceipt(client, txHash) const settled = getChannelEvent(receipt, 'Settled', channelId) @@ -702,6 +792,12 @@ export namespace session { unitType?: string | undefined /** Account used for server-driven close transactions. Defaults to the client account. */ account?: viem_Account | undefined + /** Optional fee payer used to sponsor server-driven close transactions. */ + feePayer?: viem_Account | undefined + /** Optional fee-payer policy limits for server-driven close transactions. */ + feePayerPolicy?: Partial | undefined + /** Optional fee token used for server-driven close transactions. */ + feeToken?: Address | undefined } export type Defaults = LooseOmit< From 7f23a5cd30e1016164347a825eee88176ae26aaf Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 09:54:52 +0200 Subject: [PATCH 10/26] chore: hardening --- .changeset/precompile-session-parity.md | 5 + src/tempo/precompile/Chain.ts | 36 +- src/tempo/precompile/Voucher.test.ts | 24 ++ src/tempo/precompile/Voucher.ts | 33 +- src/tempo/precompile/client/ChannelOps.ts | 10 +- .../server/Session.integration.test.ts | 8 +- src/tempo/precompile/server/Session.test.ts | 189 ++++++++- src/tempo/precompile/server/Session.ts | 400 +++++++++++++++--- src/tempo/precompile/server/index.ts | 2 +- src/tempo/precompile/session/Client.test.ts | 54 ++- src/tempo/precompile/session/Client.ts | 33 +- .../precompile/session/SessionManager.test.ts | 75 +++- .../precompile/session/SessionManager.ts | 2 +- src/tempo/server/AtomicStore.test-d.ts | 20 + 14 files changed, 780 insertions(+), 111 deletions(-) create mode 100644 .changeset/precompile-session-parity.md diff --git a/.changeset/precompile-session-parity.md b/.changeset/precompile-session-parity.md new file mode 100644 index 00000000..e89ab0ef --- /dev/null +++ b/.changeset/precompile-session-parity.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Fixed precompile session store atomicity, voucher-signing compatibility documentation, and finalized channel bookkeeping parity. diff --git a/src/tempo/precompile/Chain.ts b/src/tempo/precompile/Chain.ts index 9c2adedd..ddf1f938 100644 --- a/src/tempo/precompile/Chain.ts +++ b/src/tempo/precompile/Chain.ts @@ -276,41 +276,47 @@ export async function settle( ) } -/** Broadcasts a descriptor-based TIP-1034 top-up transaction with the client's account. */ +/** Broadcasts a descriptor-based TIP-1034 top-up transaction with optional fee sponsorship. */ export async function topUp( client: Client, descriptor: ChannelDescriptor, additionalDeposit: Uint96, escrow: Address = tip20ChannelEscrow, + options?: SendOptions, ): Promise { - return sendTransaction(client, { - to: escrow, - data: encodeTopUp(descriptor, additionalDeposit), - } as never) + return sendPrecompileTransaction( + client, + escrow, + encodeTopUp(descriptor, additionalDeposit), + 'topUp', + options, + ) } -/** Broadcasts a descriptor-based TIP-1034 request-close transaction with the client's account. */ +/** Broadcasts a descriptor-based TIP-1034 request-close transaction with optional fee sponsorship. */ export async function requestClose( client: Client, descriptor: ChannelDescriptor, escrow: Address = tip20ChannelEscrow, + options?: SendOptions, ): Promise { - return sendTransaction(client, { - to: escrow, - data: encodeRequestClose(descriptor), - } as never) + return sendPrecompileTransaction( + client, + escrow, + encodeRequestClose(descriptor), + 'requestClose', + options, + ) } -/** Broadcasts a descriptor-based TIP-1034 withdraw transaction with the client's account. */ +/** Broadcasts a descriptor-based TIP-1034 withdraw transaction with optional fee sponsorship. */ export async function withdraw( client: Client, descriptor: ChannelDescriptor, escrow: Address = tip20ChannelEscrow, + options?: SendOptions, ): Promise { - return sendTransaction(client, { - to: escrow, - data: encodeWithdraw(descriptor), - } as never) + return sendPrecompileTransaction(client, escrow, encodeWithdraw(descriptor), 'withdraw', options) } /** Broadcasts a descriptor-based TIP-1034 close transaction with optional fee sponsorship. */ diff --git a/src/tempo/precompile/Voucher.test.ts b/src/tempo/precompile/Voucher.test.ts index 06713c35..4075c02d 100644 --- a/src/tempo/precompile/Voucher.test.ts +++ b/src/tempo/precompile/Voucher.test.ts @@ -1,7 +1,9 @@ +import { P256, Secp256k1 } from 'ox' import { SignatureEnvelope } from 'ox/tempo' import { createClient, hashTypedData, http } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { signTypedData } from 'viem/actions' +import { Account as TempoAccount } from 'viem/tempo' import { describe, expect, test } from 'vp/test' import { uint96 } from './Types.js' @@ -216,6 +218,28 @@ describe('Precompile Voucher', () => { ).toBe(false) }) + test('sign rejects p256 keychain access-key voucher delegation explicitly', async () => { + const rootAccount = TempoAccount.fromSecp256k1(Secp256k1.randomPrivateKey()) + const accessKey = TempoAccount.fromP256(P256.randomPrivateKey(), { access: rootAccount }) + const accessKeyClient = createClient({ + account: accessKey, + transport: http('http://127.0.0.1'), + }) + + await expect( + sign( + accessKeyClient, + accessKey, + { channelId, cumulativeAmount }, + { + authorizedSigner: accessKey.accessKeyAddress, + chainId, + verifyingContract: escrowContract, + }, + ), + ).rejects.toThrow('TIP-1034 voucher signing only supports secp256k1 keychain access keys.') + }) + test('domain and type match TIP-1034', () => { expect(domain(chainId, escrowContract)).toEqual({ name: 'TIP20 Channel Escrow', diff --git a/src/tempo/precompile/Voucher.ts b/src/tempo/precompile/Voucher.ts index c77fc8e7..426707ec 100644 --- a/src/tempo/precompile/Voucher.ts +++ b/src/tempo/precompile/Voucher.ts @@ -50,7 +50,14 @@ export const types = { ], } as const -/** Signs a TIP-1034 voucher and unwraps keychain signatures for delegated secp256k1 signers. */ +/** + * Signs a TIP-1034 voucher. + * + * When `authorizedSigner` is a delegated access key, only secp256k1 keychain + * signatures can be unwrapped into the raw ECDSA signature accepted by the + * precompile. p256/WebAuthn keychain wrappers are rejected; pass an explicit + * secp256k1 authorized signer for voucher delegation. + */ export async function sign( client: Client, account: Account, @@ -70,17 +77,29 @@ export async function sign( }) if (parameters.authorizedSigner) { - try { - const envelope = SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) - if (envelope.type === 'keychain' && envelope.inner.type === 'secp256k1') - return Signature.toHex(envelope.inner.signature) - } catch {} + const envelope = (() => { + try { + return SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) + } catch { + return undefined + } + })() + if (envelope?.type === 'keychain' && envelope.inner.type === 'secp256k1') + return Signature.toHex(envelope.inner.signature) + if (envelope?.type === 'keychain') + throw new Error('TIP-1034 voucher signing only supports secp256k1 keychain access keys.') } return signature } -/** Verifies a direct TIP-1034 voucher signature and rejects keychain wrapper signatures. */ +/** + * Verifies a direct TIP-1034 voucher signature. + * + * Only raw secp256k1 signatures are accepted. Keychain wrapper signatures are + * rejected because the precompile verifies vouchers with ecrecover against the + * channel's authorized signer. + */ export function verify( voucher: SignedVoucher, expectedSigner: Address, diff --git a/src/tempo/precompile/client/ChannelOps.ts b/src/tempo/precompile/client/ChannelOps.ts index 8ac4ffb9..b9601e16 100644 --- a/src/tempo/precompile/client/ChannelOps.ts +++ b/src/tempo/precompile/client/ChannelOps.ts @@ -31,6 +31,10 @@ function voucherAuthorizedSigner(address: Address): Address | undefined { return address.toLowerCase() === zeroAddress ? undefined : address } +function defaultAuthorizedSigner(account: Account): Address { + return (account as unknown as { accessKeyAddress?: Address }).accessKeyAddress ?? account.address +} + /** * Prepares and signs a one-call TIP-1034 channel-open transaction, computes the * transaction-bound `expiringNonceHash` via viem, and signs the initial voucher. @@ -43,6 +47,7 @@ export async function createOpen( chainId: number deposit: Uint96 escrow?: Address | undefined + feePayer?: boolean | undefined initialAmount: Uint96 operator?: Address | undefined payee: Address @@ -50,7 +55,7 @@ export async function createOpen( }, ): Promise { const escrow = parameters.escrow ?? tip20ChannelEscrow - const authorizedSigner = parameters.authorizedSigner ?? account.address + const authorizedSigner = parameters.authorizedSigner ?? defaultAuthorizedSigner(account) const operator = parameters.operator ?? '0x0000000000000000000000000000000000000000' const salt = Hex.random(32) @@ -65,6 +70,7 @@ export async function createOpen( const prepared = await prepareTransactionRequest(client, { account, calls: [{ to: escrow, data: openData }], + ...(parameters.feePayer ? { feePayer: true } : {}), feeToken: parameters.token, } as never) @@ -159,6 +165,7 @@ export async function createTopUp( chainId: number descriptor: Channel.ChannelDescriptor escrow?: Address | undefined + feePayer?: boolean | undefined }, ): Promise { const escrow = parameters.escrow ?? tip20ChannelEscrow @@ -174,6 +181,7 @@ export async function createTopUp( data: Chain.encodeTopUp(parameters.descriptor, parameters.additionalDeposit), }, ], + ...(parameters.feePayer ? { feePayer: true } : {}), feeToken: parameters.descriptor.token, } as never) const transaction = (await signTransaction(client, prepared as never)) as Hex.Hex diff --git a/src/tempo/precompile/server/Session.integration.test.ts b/src/tempo/precompile/server/Session.integration.test.ts index 4ba84deb..4c864b9a 100644 --- a/src/tempo/precompile/server/Session.integration.test.ts +++ b/src/tempo/precompile/server/Session.integration.test.ts @@ -373,8 +373,8 @@ describe.runIf(isPrecompileTestnet)('precompile server session chain integration settledOnChain: 0n, highestVoucherAmount: 300n, highestVoucher: null, - spent: 0n, - units: 0, + spent: 300n, + units: 3, finalized: false, createdAt: new Date().toISOString(), descriptor, @@ -443,8 +443,8 @@ describe.runIf(isPrecompileTestnet)('precompile server session chain integration settledOnChain: 0n, highestVoucherAmount: 300n, highestVoucher: null, - spent: 0n, - units: 0, + spent: 300n, + units: 3, finalized: false, createdAt: new Date().toISOString(), descriptor, diff --git a/src/tempo/precompile/server/Session.test.ts b/src/tempo/precompile/server/Session.test.ts index c62f6452..67ed51dc 100644 --- a/src/tempo/precompile/server/Session.test.ts +++ b/src/tempo/precompile/server/Session.test.ts @@ -12,6 +12,7 @@ import { describe, expect, test } from 'vp/test' import * as Store from '../../../Store.js' import * as ChannelStore from '../../session/ChannelStore.js' +import type { SessionReceipt } from '../../session/Types.js' import * as Chain from '../Chain.js' import * as Channel from '../Channel.js' import * as ClientOps from '../client/ChannelOps.js' @@ -161,6 +162,7 @@ async function createOpenCredential( initialAmount?: bigint | undefined escrow?: Address | undefined account?: typeof payer | undefined + operator?: Address | undefined } = {}, ): Promise { const account = parameters.account ?? payer @@ -168,23 +170,15 @@ async function createOpenCredential( const initialAmount = Types.uint96(parameters.initialAmount ?? 100n) const deposit = Types.uint96(parameters.deposit ?? 1_000n) const salt = `0x${(++saltCounter).toString(16).padStart(64, '0')}` as Hex - const descriptor = { - payer: account.address, - payee, - operator: zeroAddress, - token, - salt, - authorizedSigner: account.address, - expiringNonceHash: `0x${saltCounter.toString(16).padStart(64, '0')}` as Hex, - } satisfies Channel.ChannelDescriptor - const channelId = Channel.computeId(descriptor, { chainId, escrow }) + const operator = parameters.operator ?? zeroAddress + const authorizedSigner = account.address const data = Chain.encodeOpen({ payee, - operator: descriptor.operator, + operator, token, deposit, salt, - authorizedSigner: descriptor.authorizedSigner, + authorizedSigner, }) const signingClient = createSigningClient(account) const transaction = (await Transaction.serialize({ @@ -193,6 +187,22 @@ async function createOpenCredential( feeToken: token, nonce: 0, })) as Hex + const expiringNonceHash = Channel.computeExpiringNonceHash( + Transaction.deserialize( + transaction as Transaction.TransactionSerializedTempo, + ) as Channel.ExpiringNonceTransaction, + { sender: account.address }, + ) + const descriptor = { + payer: account.address, + payee, + operator, + token, + salt, + authorizedSigner, + expiringNonceHash, + } satisfies Channel.ChannelDescriptor + const channelId = Channel.computeId(descriptor, { chainId, escrow }) const signature = await Voucher.sign( signingClient, account, @@ -390,6 +400,22 @@ describe('precompile server session unit guardrails', () => { ).rejects.toThrow(/invalid voucher signature/) }) + test('rejects missing precompile descriptors with a verification error', async () => { + const { method } = createServer() + const payload = await createOpenCredential() + const { descriptor: _descriptor, ...payloadWithoutDescriptor } = payload + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(payload.channelId), + payload: payloadWithoutDescriptor, + }, + request: makeRequest(payload.channelId) as never, + }), + ).rejects.toThrow(/descriptor required for precompile session action/) + }) + test('rejects uint96 overflow in credential amount parsing', async () => { const { method } = createServer() const payload = await createOpenCredential() @@ -416,7 +442,7 @@ describe('precompile server session unit guardrails', () => { ).rejects.toThrow(/no account available/) }) - test('rejects settle when sender is not the channel payee', async () => { + test('rejects settle when sender is not the channel payee or operator', async () => { const { store } = createServer() const openPayload = await createOpenCredential() await persistPrecompileChannel(store, openPayload) @@ -427,6 +453,27 @@ describe('precompile server session unit guardrails', () => { ).rejects.toThrow(/tx sender .* is not the channel payee/) }) + test('accepts settle sender matching a nonzero precompile operator', async () => { + const { store } = createServer() + const openPayload = await createOpenCredential({ operator: wrongPayer.address }) + await persistPrecompileChannel(store, openPayload) + + const { settle } = await import('./Session.js') + const client = createClient({ + account: wrongPayer, + chain: { id: chainId } as never, + transport: custom({ + async request(args) { + if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}` + throw new Error(`unexpected rpc request: ${args.method}`) + }, + }), + }) + await expect(settle(store, client, openPayload.channelId)).rejects.toThrow( + /eth_getTransactionCount/, + ) + }) + test('precompile settle fee payer options still enforce payee sender policy', async () => { const { store } = createServer() const openPayload = await createOpenCredential() @@ -533,7 +580,7 @@ describe('precompile server session unit guardrails', () => { ).rejects.toThrow(/close voucher amount must be >= 150 \(spent\)/) }) - test('rejects close voucher at or below on-chain settled except untouched zero-close', async () => { + test('rejects close voucher below on-chain settled', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenCredential() @@ -552,7 +599,7 @@ describe('precompile server session unit guardrails', () => { }) const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { chainId, - cumulativeAmount: Types.uint96(100n), + cumulativeAmount: Types.uint96(99n), descriptor: openPayload.descriptor, }) @@ -561,14 +608,14 @@ describe('precompile server session unit guardrails', () => { credential: { challenge: makeChallenge(openPayload.channelId), payload }, request: makeRequest(openPayload.channelId) as never, }), - ).rejects.toThrow(/close voucher amount must be > 100 \(on-chain settled\)/) + ).rejects.toThrow(/close voucher amount must be >= 100 \(on-chain settled\)/) }) - test('rejects close voucher exceeding on-chain precompile deposit', async () => { + test('rejects close capture exceeding on-chain precompile deposit', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenCredential() - await persistPrecompileChannel(store, openPayload, { payee: payer.address }) + await persistPrecompileChannel(store, openPayload, { payee: payer.address, spent: 100n }) const method = session({ account: payer, amount: '1', @@ -591,7 +638,7 @@ describe('precompile server session unit guardrails', () => { credential: { challenge: makeChallenge(openPayload.channelId), payload }, request: makeRequest(openPayload.channelId) as never, }), - ).rejects.toThrow(/close voucher amount exceeds on-chain deposit/) + ).rejects.toThrow(/close capture amount exceeds on-chain deposit/) }) test('rejects close for locally finalized and pending precompile channels', async () => { @@ -635,6 +682,78 @@ describe('precompile server session unit guardrails', () => { ).rejects.toThrow(/channel has a pending close request/) }) + test('does not let a racing lower voucher regress highest accepted precompile voucher', async () => { + const openPayload = await createOpenCredential({ initialAmount: 100n }) + const lowerVoucher = await ClientOps.createVoucherCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(200n), + descriptor: openPayload.descriptor, + }) + const higherVoucher = await ClientOps.createVoucherCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(500n), + descriptor: openPayload.descriptor, + }) + + const seedStore = ChannelStore.fromStore(Store.memory() as never) + await persistPrecompileChannel(seedStore, openPayload, { + highestVoucherAmount: 100n, + highestVoucher: { + channelId: openPayload.channelId, + cumulativeAmount: 100n, + signature: openPayload.signature, + }, + }) + const stale = (await seedStore.getChannel(openPayload.channelId))! + let stored: ChannelStore.State = { + ...stale, + highestVoucherAmount: 500n, + highestVoucher: { + channelId: openPayload.channelId, + cumulativeAmount: 500n, + signature: higherVoucher.signature, + }, + } + const racingStore = { + async get(_key: string) { + return stale as never + }, + async put(_key: string, value: unknown) { + stored = value as ChannelStore.State + }, + async delete(_key: string) {}, + async update( + _key: string, + fn: (current: unknown | null) => Store.Change, + ): Promise { + const change = fn(stored) + if (change.op === 'set') stored = change.value as ChannelStore.State + return change.result + }, + } as Store.AtomicStore + const method = session({ + account: payer, + amount: '1', + chainId, + channelStateTtl: Number.MAX_SAFE_INTEGER, + currency: token, + decimals: 0, + recipient: payee, + store: racingStore, + unitType: 'request', + getClient: () => createStateClient(payer), + }) + + const receipt = await method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: lowerVoucher }, + request: makeRequest(openPayload.channelId) as never, + }) + + expect((receipt as SessionReceipt).acceptedCumulative).toBe('500') + expect(stored.highestVoucherAmount).toBe(500n) + expect(stored.highestVoucher?.signature).toBe(higherVoucher.signature) + }) + test('marks pending precompile close before broadcast and restores it when broadcast fails', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) @@ -789,7 +908,37 @@ describe('precompile server session unit guardrails', () => { ).rejects.toThrow(/eth_sendRawTransaction/) }) - test('rejects server-driven close when sender is not the channel payee', async () => { + test('accepts server-driven close sender matching a nonzero precompile operator', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenCredential({ operator: wrongPayer.address }) + await persistPrecompileChannel(store, openPayload) + const method = session({ + account: wrongPayer, + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => createStateClient(payer), + }) + const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { + chainId, + cumulativeAmount: Types.uint96(100n), + descriptor: openPayload.descriptor, + }) + + await expect( + method.verify({ + credential: { challenge: makeChallenge(openPayload.channelId), payload }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/eth_sendRawTransaction/) + }) + + test('rejects server-driven close when sender is not the channel payee or operator', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenCredential() diff --git a/src/tempo/precompile/server/Session.ts b/src/tempo/precompile/server/Session.ts index 030a807c..05bf39a5 100644 --- a/src/tempo/precompile/server/Session.ts +++ b/src/tempo/precompile/server/Session.ts @@ -7,7 +7,13 @@ import { zeroAddress, type Account as viem_Account, } from 'viem' -import { sendRawTransaction, waitForTransactionReceipt } from 'viem/actions' +import { + call, + sendRawTransaction, + sendRawTransactionSync, + signTransaction, + waitForTransactionReceipt, +} from 'viem/actions' import { tempo as tempo_chain } from 'viem/chains' import { Transaction } from 'viem/tempo' @@ -16,19 +22,24 @@ import { ChannelClosedError, ChannelNotFoundError, DeltaTooSmallError, + InsufficientBalanceError, InvalidSignatureError, VerificationFailedError, BadRequestError, } from '../../../Errors.js' -import type { Challenge } from '../../../index.js' +import type { Challenge, Credential } from '../../../index.js' import type { LooseOmit, NoExtraKeys } from '../../../internal/types.js' import * as Method from '../../../Method.js' import * as Store from '../../../Store.js' import * as Client from '../../../viem/Client.js' import * as defaults from '../../internal/defaults.js' -import type * as FeePayer from '../../internal/fee-payer.js' +import * as FeePayer from '../../internal/fee-payer.js' import type * as types from '../../internal/types.js' import * as Methods from '../../Methods.js' +import { + captureRequestBodyProbe, + isSessionContentRequest, +} from '../../server/internal/request-body.js' import * as ChannelStore from '../../session/ChannelStore.js' import { createSessionReceipt } from '../../session/Receipt.js' import type { SessionReceipt } from '../../session/Types.js' @@ -118,22 +129,41 @@ type ChannelReceiptEvent = { } } +function assertSenderSigned(transaction: ReturnType<(typeof Transaction)['deserialize']>): void { + if (!transaction.signature || !transaction.from) + throw new BadRequestError({ + reason: 'Transaction must be signed by the sender before fee payer co-signing', + }) +} + +function assertDescriptor(payload: { + descriptor?: Channel.ChannelDescriptor | undefined +}): asserts payload is { descriptor: Channel.ChannelDescriptor } { + if (!payload.descriptor) + throw new VerificationFailedError({ + reason: 'descriptor required for precompile session action', + }) +} + function assertSettlementSender(parameters: { operation: 'close' | 'settle' channelId: Hex + operator: Address payee: Address sender: Address | undefined }) { - const { operation, channelId, payee, sender } = parameters + const { operation, channelId, operator, payee, sender } = parameters if (!sender) throw new Error( `Cannot ${operation} precompile channel ${channelId}: no account available. Pass an account override, or provide a getClient() that returns an account-bearing client.`, ) - if (sender.toLowerCase() === payee.toLowerCase()) return + if (isAddressEqual(sender, payee)) return + if (!isAddressEqual(operator, zeroAddress) && isAddressEqual(sender, operator)) return throw new BadRequestError({ reason: - `Cannot ${operation} precompile channel ${channelId}: tx sender ${sender} is not the channel payee ${payee}. ` + - 'If using an access key, pass a Tempo access-key account whose address is the payee wallet, not the raw delegated key address.', + `Cannot ${operation} precompile channel ${channelId}: tx sender ${sender} is not the channel payee ${payee}` + + (isAddressEqual(operator, zeroAddress) ? '.' : ` or operator ${operator}.`) + + ' If using an access key, pass a Tempo access-key account whose address is the payee/operator wallet, not the raw delegated key address.', }) } @@ -159,6 +189,73 @@ function getChannelEvent( return matches[0]! } +/** Broadcasts a client-signed management transaction, adding a fee-payer co-signature when requested. */ +async function sendCredentialTransaction(parameters: { + challengeExpires?: string | undefined + chainId: number + client: Parameters[0] + details: Record + expectedFeeToken?: Address | undefined + feePayer?: viem_Account | undefined + feePayerPolicy?: Partial | undefined + label: 'open' | 'topUp' + serializedTransaction: Hex + transaction: ReturnType<(typeof Transaction)['deserialize']> +}) { + const { + challengeExpires, + chainId, + client, + details, + expectedFeeToken, + feePayer, + feePayerPolicy, + label, + serializedTransaction, + transaction, + } = parameters + + if (!feePayer) { + const txHash = await sendTransaction(client, serializedTransaction) + return waitForSuccessfulReceipt(client, txHash) + } + + if (!FeePayer.isTempoTransaction(serializedTransaction)) + throw new BadRequestError({ + reason: 'Only Tempo (0x76/0x78) transactions are supported', + }) + assertSenderSigned(transaction) + + await call(client, { + ...transaction, + account: transaction.from, + calls: transaction.calls ?? [], + feePayerSignature: undefined, + } as never) + + const sponsored = FeePayer.prepareSponsoredTransaction({ + account: feePayer, + challengeExpires, + chainId, + details, + expectedFeeToken, + policy: feePayerPolicy, + transaction: { + ...transaction, + ...(expectedFeeToken ? { feeToken: transaction.feeToken ?? expectedFeeToken } : {}), + }, + }) + const serialized = (await signTransaction(client, sponsored as never)) as Hex + const receipt = await sendRawTransactionSync(client, { + serializedTransaction: serialized as Transaction.TransactionSerializedTempo, + }) + if (receipt.status !== 'success') + throw new VerificationFailedError({ + reason: `${label} precompile transaction reverted: ${receipt.transactionHash}`, + }) + return receipt +} + /** Creates a server-side TIP-1034 precompile session payment method. */ export function session( p?: NoExtraKeys, @@ -216,7 +313,7 @@ export function session( } }, - async verify({ credential, request }) { + async verify({ credential, envelope, request }) { const { challenge, payload: rawPayload } = credential const payload = parseCredentialPayload(rawPayload as SessionCredentialPayload) const methodDetails = (request as typeof request & { methodDetails?: SessionMethodDetails }) @@ -240,13 +337,39 @@ export function session( ? BigInt(methodDetails.minVoucherDelta) : parseUnits(parameters.minVoucherDelta ?? '0', decimals) + let sessionReceipt: SessionReceipt + switch (payload.action) { - case 'open': - return handleOpen({ store, client, challenge, payload, chainId, escrow }) - case 'topUp': - return handleTopUp({ store, client, challenge, payload, chainId, escrow }) - case 'voucher': - return handleVoucher({ + case 'open': { + sessionReceipt = await handleOpen({ + store, + client, + challenge, + payload, + chainId, + escrow, + feePayer: resolvedFeePayer, + feePayerPolicy: parameters.feePayerPolicy, + }) + lastOnChainVerified.set(sessionReceipt.channelId as Hex, Date.now()) + break + } + case 'topUp': { + sessionReceipt = await handleTopUp({ + store, + client, + challenge, + payload, + chainId, + escrow, + feePayer: resolvedFeePayer, + feePayerPolicy: parameters.feePayerPolicy, + }) + lastOnChainVerified.set(sessionReceipt.channelId as Hex, Date.now()) + break + } + case 'voucher': { + sessionReceipt = await handleVoucher({ store, client, challenge, @@ -257,8 +380,10 @@ export function session( lastOnChainVerified, minVoucherDelta, }) - case 'close': - return handleClose({ + break + } + case 'close': { + sessionReceipt = await handleClose({ store, client, challenge, @@ -270,13 +395,69 @@ export function session( feePayerPolicy: parameters.feePayerPolicy, feeToken: parameters.feeToken, }) + break + } default: throw new VerificationFailedError({ reason: 'unsupported precompile session action' }) } + + if ( + envelope && + isSessionContentRequest(envelope.capturedRequest) && + (payload.action === 'open' || payload.action === 'voucher') + ) { + const charged = await charge( + store, + sessionReceipt.channelId as Hex, + BigInt((request as { amount?: string }).amount ?? challenge.request.amount), + ) + sessionReceipt = { + ...sessionReceipt, + spent: charged.spent.toString(), + units: charged.units, + } + } + + return sessionReceipt + }, + + respond({ credential, envelope, input }) { + const { payload } = credential as Credential.Credential + + if (payload.action === 'close') return new Response(null, { status: 204 }) + if (payload.action === 'topUp') return new Response(null, { status: 204 }) + + const request = envelope?.capturedRequest ?? captureRequestBodyProbe(input) + if (isSessionContentRequest(request)) return undefined + return new Response(null, { status: 204 }) }, }) } +/** Charges a fulfilled request against a precompile-backed session channel. */ +export async function charge( + store: ChannelStore.ChannelStore, + channelId: Hex, + amount: bigint, +): Promise { + let result: Awaited> + try { + result = await ChannelStore.deductFromChannel(store, channelId, amount) + } catch { + throw new ChannelClosedError({ reason: 'channel not found' }) + } + if (!result.ok) { + if (result.channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' }) + if (result.channel.closeRequestedAt !== 0n) + throw new ChannelClosedError({ reason: 'channel has a pending close request' }) + const available = result.channel.highestVoucherAmount - result.channel.spent + throw new InsufficientBalanceError({ + reason: `requested ${amount}, available ${available}`, + }) + } + return result.channel +} + async function handleOpen(parameters: { store: ChannelStore.ChannelStore client: Parameters[0] @@ -284,8 +465,18 @@ async function handleOpen(parameters: { payload: ParsedSessionCredentialPayload & { action: 'open' } chainId: number escrow: Address + feePayer?: viem_Account | undefined + feePayerPolicy?: Partial | undefined }): Promise { const { store, client, challenge, payload, chainId, escrow } = parameters + assertDescriptor(payload) + if ( + payload.authorizedSigner !== undefined && + !isAddressEqual(payload.authorizedSigner, payload.descriptor.authorizedSigner) + ) + throw new VerificationFailedError({ + reason: 'credential authorizedSigner does not match descriptor', + }) const channelId = ChannelStore.normalizeChannelId(payload.channelId) validateDescriptor({ descriptor: payload.descriptor, @@ -328,6 +519,14 @@ async function handleOpen(parameters: { channelId, }) assertSameDescriptor(descriptor, payload.descriptor) + const expiringNonceHash = Channel.computeExpiringNonceHash( + transaction as Channel.ExpiringNonceTransaction, + { sender: payer }, + ) + if (expiringNonceHash.toLowerCase() !== descriptor.expiringNonceHash.toLowerCase()) + throw new VerificationFailedError({ + reason: 'credential expiringNonceHash does not match transaction', + }) if (payload.cumulativeAmount > open.deposit) throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds open deposit' }) const valid = await Voucher.verify( @@ -336,8 +535,23 @@ async function handleOpen(parameters: { { chainId, verifyingContract: escrow }, ) if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) - const txHash = await sendTransaction(client, payload.transaction) - const receipt = await waitForSuccessfulReceipt(client, txHash) + const receipt = await sendCredentialTransaction({ + challengeExpires: challenge.expires, + chainId, + client, + details: { + channelId, + currency: challenge.request.currency as Address, + recipient: challenge.request.recipient as Address, + }, + expectedFeeToken: challenge.request.currency as Address, + feePayer: parameters.feePayer, + feePayerPolicy: parameters.feePayerPolicy, + label: 'open', + serializedTransaction: payload.transaction, + transaction, + }) + const txHash = receipt.transactionHash const opened = getChannelEvent(receipt, 'ChannelOpened', channelId) const emittedChannelId = opened.args.channelId as Hex const emittedExpiringNonceHash = opened.args.expiringNonceHash as Hex @@ -371,23 +585,30 @@ async function handleOpen(parameters: { channelId: emittedChannelId, chainId, escrowContract: escrow, - closeRequestedAt: BigInt(state.closeRequestedAt), + closeRequestedAt: + current && current.closeRequestedAt > BigInt(state.closeRequestedAt) + ? current.closeRequestedAt + : BigInt(state.closeRequestedAt), payer: descriptor.payer, payee: descriptor.payee, token: descriptor.token, authorizedSigner: authorizedSigner(descriptor), deposit: state.deposit, - settledOnChain: state.settled, + settledOnChain: + current && current.settledOnChain > state.settled ? current.settledOnChain : state.settled, highestVoucherAmount: current?.highestVoucherAmount && current.highestVoucherAmount > payload.cumulativeAmount ? current.highestVoucherAmount : payload.cumulativeAmount, - highestVoucher: { - channelId: emittedChannelId, - cumulativeAmount: payload.cumulativeAmount, - signature: payload.signature, - }, - spent: current?.spent ?? 0n, + highestVoucher: + current?.highestVoucherAmount && current.highestVoucherAmount > payload.cumulativeAmount + ? current.highestVoucher + : { + channelId: emittedChannelId, + cumulativeAmount: payload.cumulativeAmount, + signature: payload.signature, + }, + spent: current && current.spent > state.settled ? current.spent : state.settled, units: current?.units ?? 0, finalized: current?.finalized ?? false, createdAt: current?.createdAt ?? new Date().toISOString(), @@ -414,8 +635,11 @@ async function handleTopUp(parameters: { payload: ParsedSessionCredentialPayload & { action: 'topUp' } chainId: number escrow: Address + feePayer?: viem_Account | undefined + feePayerPolicy?: Partial | undefined }): Promise { const { store, client, challenge, payload, chainId, escrow } = parameters + assertDescriptor(payload) const channelId = ChannelStore.normalizeChannelId(payload.channelId) const channel = await store.getChannel(channelId) if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' }) @@ -447,8 +671,23 @@ async function handleTopUp(parameters: { data: call.data!, expected: { descriptor: channel.descriptor, additionalDeposit: payload.additionalDeposit }, }) - const txHash = await sendTransaction(client, payload.transaction) - const receipt = await waitForSuccessfulReceipt(client, txHash) + const receipt = await sendCredentialTransaction({ + challengeExpires: challenge.expires, + chainId, + client, + details: { + additionalDeposit: payload.additionalDeposit.toString(), + channelId, + currency: channel.token, + }, + expectedFeeToken: channel.token, + feePayer: parameters.feePayer, + feePayerPolicy: parameters.feePayerPolicy, + label: 'topUp', + serializedTransaction: payload.transaction, + transaction, + }) + const txHash = receipt.transactionHash const toppedUp = getChannelEvent(receipt, 'TopUp', channelId) const emittedChannelId = toppedUp.args.channelId as Hex const newDeposit = uint96(toppedUp.args.newDeposit as bigint) @@ -463,9 +702,13 @@ async function handleTopUp(parameters: { current ? { ...current, - deposit: newDeposit, - settledOnChain: state.settled, - closeRequestedAt: BigInt(state.closeRequestedAt), + deposit: newDeposit > current.deposit ? newDeposit : current.deposit, + settledOnChain: + state.settled > current.settledOnChain ? state.settled : current.settledOnChain, + closeRequestedAt: + BigInt(state.closeRequestedAt) > current.closeRequestedAt + ? BigInt(state.closeRequestedAt) + : current.closeRequestedAt, } : current, ) @@ -502,6 +745,7 @@ async function handleVoucher(parameters: { lastOnChainVerified, } = parameters const channelId = ChannelStore.normalizeChannelId(payload.channelId) + assertDescriptor(payload) const channel = await store.getChannel(channelId) if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' }) if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' }) @@ -522,8 +766,21 @@ async function handleVoucher(parameters: { const deposit = state?.deposit ?? uint96(channel.deposit) const settled = state?.settled ?? uint96(channel.settledOnChain) const closeRequestedAt = state?.closeRequestedAt ?? Number(channel.closeRequestedAt) - if (closeRequestedAt !== 0) + if (closeRequestedAt !== 0) { + await store.updateChannel(channelId, (current) => + current + ? { + ...current, + closeRequestedAt: + BigInt(closeRequestedAt) > current.closeRequestedAt + ? BigInt(closeRequestedAt) + : current.closeRequestedAt, + } + : current, + ) throw new ChannelClosedError({ reason: 'channel has a pending close request' }) + } + if (deposit === 0n) throw new ChannelClosedError({ reason: 'channel deposit is zero (settled)' }) if (payload.cumulativeAmount <= settled) throw new VerificationFailedError({ reason: 'voucher cumulativeAmount is below on-chain settled amount', @@ -554,11 +811,19 @@ async function handleVoucher(parameters: { reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`, }) const updated = await store.updateChannel(channelId, (current) => - current - ? { + (() => { + if (!current) throw new ChannelNotFoundError({ reason: 'channel not found' }) + if (current.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' }) + if (current.closeRequestedAt !== 0n) + throw new ChannelClosedError({ reason: 'channel has a pending close request' }) + + const nextDeposit = deposit > current.deposit ? deposit : current.deposit + const nextSettled = settled > current.settledOnChain ? settled : current.settledOnChain + if (payload.cumulativeAmount > current.highestVoucherAmount) { + return { ...current, - deposit, - settledOnChain: settled, + deposit: nextDeposit, + settledOnChain: nextSettled, highestVoucherAmount: payload.cumulativeAmount, highestVoucher: { channelId, @@ -566,7 +831,9 @@ async function handleVoucher(parameters: { signature: payload.signature, }, } - : current, + } + return { ...current, deposit: nextDeposit, settledOnChain: nextSettled } + })(), ) if (!updated) throw new ChannelNotFoundError({ reason: 'channel not found' }) return createSessionReceipt({ @@ -592,6 +859,7 @@ async function handleClose(parameters: { }): Promise { const { store, client, challenge, payload, chainId, escrow } = parameters const channelId = ChannelStore.normalizeChannelId(payload.channelId) + assertDescriptor(payload) const channel = await store.getChannel(channelId) if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' }) if (!ChannelStore.isPrecompileState(channel)) @@ -607,23 +875,19 @@ async function handleClose(parameters: { throw new VerificationFailedError({ reason: `close voucher amount must be >= ${channel.spent} (spent)`, }) - const isUntouchedZeroClose = - payload.cumulativeAmount === 0n && channel.spent === 0n && state.settled === 0n - if (!isUntouchedZeroClose && payload.cumulativeAmount <= state.settled) + if (payload.cumulativeAmount < state.settled) throw new VerificationFailedError({ - reason: `close voucher amount must be > ${state.settled} (on-chain settled)`, + reason: `close voucher amount must be >= ${state.settled} (on-chain settled)`, }) - if (payload.cumulativeAmount > state.deposit) - throw new AmountExceedsDepositError({ reason: 'close voucher amount exceeds on-chain deposit' }) const valid = await Voucher.verify( { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, channel.authorizedSigner, { chainId, verifyingContract: escrow }, ) if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) - const captureAmount = uint96( - payload.cumulativeAmount > state.settled ? payload.cumulativeAmount : state.settled, - ) + let captureAmount = uint96(channel.spent > state.settled ? channel.spent : state.settled) + if (captureAmount > state.deposit) + throw new AmountExceedsDepositError({ reason: 'close capture amount exceeds on-chain deposit' }) const pendingCloseStartedAt = BigInt(Math.floor(Date.now() / 1000) || 1) const previousCloseRequestedAt = channel.closeRequestedAt let pendingCloseMarked = false @@ -636,6 +900,18 @@ async function handleClose(parameters: { throw new VerificationFailedError({ reason: `close voucher amount must be >= ${current.spent} (spent)`, }) + const currentCaptureAmount = uint96( + current.spent > state.settled ? current.spent : state.settled, + ) + if (currentCaptureAmount > payload.cumulativeAmount) + throw new VerificationFailedError({ + reason: `close voucher amount must be >= ${currentCaptureAmount} (capture amount)`, + }) + if (currentCaptureAmount > state.deposit) + throw new AmountExceedsDepositError({ + reason: 'close capture amount exceeds on-chain deposit', + }) + captureAmount = currentCaptureAmount pendingCloseMarked = true return { ...current, closeRequestedAt: pendingCloseStartedAt } }) @@ -646,6 +922,7 @@ async function handleClose(parameters: { assertSettlementSender({ operation: 'close', channelId, + operator: channel.operator, payee: channel.payee, sender: account?.address, }) @@ -687,15 +964,20 @@ async function handleClose(parameters: { ? { ...current, finalized: true, - deposit: state.deposit, + closeRequestedAt: 0n, + deposit: 0n, settledOnChain: - settledToPayee > current.settledOnChain ? settledToPayee : current.settledOnChain, - highestVoucherAmount: payload.cumulativeAmount, - highestVoucher: { - channelId, - cumulativeAmount: payload.cumulativeAmount, - signature: payload.signature, - }, + captureAmount > current.settledOnChain ? captureAmount : current.settledOnChain, + ...(payload.cumulativeAmount > current.highestVoucherAmount + ? { + highestVoucherAmount: payload.cumulativeAmount, + highestVoucher: { + channelId, + cumulativeAmount: payload.cumulativeAmount, + signature: payload.signature, + }, + } + : {}), } : current, ) @@ -735,6 +1017,7 @@ export async function settle( assertSettlementSender({ operation: 'settle', channelId, + operator: channel.operator, payee: channel.payee, sender: account?.address, }) @@ -787,7 +1070,14 @@ export namespace session { getClient?: Client.getResolver.Parameters['getClient'] | undefined minVoucherDelta?: string | undefined recipient?: Address | undefined - store?: Store.Store | undefined + /** + * Atomic store backend for channel state. + * + * Session mutations must be linearizable across instances so spent, + * highest-voucher, top-up, and close/finalization updates cannot race. + * Use `Store.memory()` for tests or local single-process usage. + */ + store?: Store.AtomicStore | undefined suggestedDeposit?: string | undefined unitType?: string | undefined /** Account used for server-driven close transactions. Defaults to the client account. */ diff --git a/src/tempo/precompile/server/index.ts b/src/tempo/precompile/server/index.ts index 8d33198b..faa3a59d 100644 --- a/src/tempo/precompile/server/index.ts +++ b/src/tempo/precompile/server/index.ts @@ -1,2 +1,2 @@ export * as ChannelOps from './ChannelOps.js' -export { session, settle } from './Session.js' +export { charge, session, settle } from './Session.js' diff --git a/src/tempo/precompile/session/Client.test.ts b/src/tempo/precompile/session/Client.test.ts index aaa7d05e..286f982d 100644 --- a/src/tempo/precompile/session/Client.test.ts +++ b/src/tempo/precompile/session/Client.test.ts @@ -1,4 +1,4 @@ -import { type Address, createClient, custom, decodeFunctionData } from 'viem' +import { type Address, createClient, custom, decodeFunctionData, encodeFunctionResult } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { Transaction } from 'viem/tempo' import { describe, expect, test } from 'vp/test' @@ -26,6 +26,12 @@ const client = createClient({ if (args.method === 'eth_estimateGas') return '0x5208' if (args.method === 'eth_maxPriorityFeePerGas') return '0x1' if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' } + if (args.method === 'eth_call') + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'getChannelState', + result: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 }, + }) throw new Error(`unexpected rpc request: ${args.method}`) }, }), @@ -204,6 +210,34 @@ describe('precompile client session', () => { expect(updates).toEqual([100n, 200n]) }) + test('rejects descriptor recovery for closed or missing channels', async () => { + const closedClient = createClient({ + account, + chain: { id: chainId } as never, + transport: custom({ + async request(args) { + if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}` + if (args.method === 'eth_call') + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'getChannelState', + result: { settled: 0n, deposit: 0n, closeRequestedAt: 0 }, + }) + throw new Error(`unexpected rpc request: ${args.method}`) + }, + }), + }) + const method = session({ account, decimals: 0, deposit: '1000', getClient: () => closedClient }) + const channelId = Channel.computeId(descriptor, { chainId, escrow: tip20ChannelEscrow }) + + await expect( + method.createCredential({ + challenge: makeChallenge() as never, + context: { channelId, descriptor }, + }), + ).rejects.toThrow(/cannot be reused \(closed or not found on-chain\)/) + }) + test('rejects channel recovery without descriptor', async () => { const method = session({ account, decimals: 0, deposit: '1000', getClient: () => client }) @@ -215,6 +249,24 @@ describe('precompile client session', () => { ).rejects.toThrow('descriptor required to reuse precompile channel') }) + test('defaults precompile authorizedSigner to account access key address', async () => { + const accessKeyAddress = '0x0000000000000000000000000000000000000009' as Address + const accessKeyAccount = Object.assign({}, account, { accessKeyAddress }) + const method = session({ + account: accessKeyAccount, + decimals: 0, + deposit: '1000', + getClient: () => client, + }) + const payload = deserialize( + await method.createCredential({ challenge: makeChallenge() as never, context: {} }), + ) + + expect(payload.action).toBe('open') + if (payload.action !== 'open') throw new Error('expected open payload') + expect(payload.descriptor.authorizedSigner).toBe(accessKeyAddress) + }) + test('creates manual voucher credentials with descriptor payloads', async () => { const method = session({ account, getClient: () => client }) const credential = await method.createCredential({ diff --git a/src/tempo/precompile/session/Client.ts b/src/tempo/precompile/session/Client.ts index ea9a0a9a..020a63f8 100644 --- a/src/tempo/precompile/session/Client.ts +++ b/src/tempo/precompile/session/Client.ts @@ -9,6 +9,7 @@ import * as Client from '../../../viem/Client.js' import * as z from '../../../zod.js' import * as defaults from '../../internal/defaults.js' import * as Methods from '../../Methods.js' +import * as Chain from '../Chain.js' import * as Channel from '../Channel.js' import { createOpen, @@ -99,6 +100,10 @@ function parseContextAdditionalDeposit( return amount === undefined ? undefined : uint96(amount) } +function isSameAddress(a: Address, b: Address): boolean { + return a.toLowerCase() === b.toLowerCase() +} + /** Creates a client-side TIP-1034 precompile session payment method. */ export function session(parameters: session.Parameters = {}) { const { decimals = defaults.decimals } = parameters @@ -124,7 +129,9 @@ export function session(parameters: session.Parameters = {}) { account: viem_Account, context?: SessionContext, ): Promise { - const methodDetails = challenge.request.methodDetails as { chainId?: number } | undefined + const methodDetails = challenge.request.methodDetails as + | { chainId?: number; feePayer?: boolean } + | undefined const chainId = methodDetails?.chainId ?? 0 const client = await getClient({ chainId }) const payee = challenge.request.recipient as Address @@ -141,7 +148,17 @@ export function session(parameters: session.Parameters = {}) { const channelId = Channel.computeId(context.descriptor, { chainId, escrow }) if (context.channelId && context.channelId.toLowerCase() !== channelId.toLowerCase()) throw new Error('context channelId does not match descriptor') - const cumulativeAmount = parseContextAmount(context, decimals) ?? amount + if (!isSameAddress(context.descriptor.payee, payee)) + throw new Error('context descriptor payee does not match challenge') + if (!isSameAddress(context.descriptor.token, token)) + throw new Error('context descriptor token does not match challenge') + const state = await Chain.getChannelState(client, channelId, escrow) + if (state.deposit === 0n) + throw new Error(`Channel ${channelId} cannot be reused (closed or not found on-chain).`) + if (state.closeRequestedAt !== 0) + throw new Error(`Channel ${channelId} cannot be reused (pending close request).`) + const cumulativeAmount = + parseContextAmount(context, decimals) ?? uint96(state.settled + amount) payload = await createVoucherCredential(client, account, { chainId, cumulativeAmount, @@ -192,6 +209,7 @@ export function session(parameters: session.Parameters = {}) { chainId, deposit, escrow, + feePayer: methodDetails?.feePayer, initialAmount: amount, operator: parameters.operator, payee, @@ -268,7 +286,14 @@ export function session(parameters: session.Parameters = {}) { } } else { payload = createTopUpCredential( - await createTopUp(client, account, { additionalDeposit, chainId, descriptor, escrow }), + await createTopUp(client, account, { + additionalDeposit, + chainId, + descriptor, + escrow, + feePayer: (challenge.request.methodDetails as { feePayer?: boolean } | undefined) + ?.feePayer, + }), additionalDeposit, ) } @@ -341,7 +366,7 @@ export function session(parameters: session.Parameters = {}) { export declare namespace session { type Parameters = Account.getResolver.Parameters & Client.getResolver.Parameters & { - /** Address authorized to sign vouchers on behalf of the payer. */ + /** Address authorized to sign vouchers on behalf of the payer. Defaults to the account access key address when available, otherwise the account address. */ authorizedSigner?: Address | undefined /** Token decimals for parsing human-readable amounts (default: 6). */ decimals?: number | undefined diff --git a/src/tempo/precompile/session/SessionManager.test.ts b/src/tempo/precompile/session/SessionManager.test.ts index 5622cbf4..647451b5 100644 --- a/src/tempo/precompile/session/SessionManager.test.ts +++ b/src/tempo/precompile/session/SessionManager.test.ts @@ -1,14 +1,35 @@ -import type { Hex } from 'viem' +import { createClient, custom, type Hex } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' import { describe, expect, test, vi } from 'vp/test' import * as Challenge from '../../../Challenge.js' +import * as Credential from '../../../Credential.js' import { formatNeedVoucherEvent, parseEvent } from '../../session/Sse.js' import type { NeedVoucherEvent, SessionReceipt } from '../../session/Types.js' +import type { SessionCredentialPayload } from '../Types.js' import { sessionManager } from './SessionManager.js' const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex const challengeId = 'test-challenge-1' const realm = 'test.example.com' +const account = privateKeyToAccount( + '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) + +const client = createClient({ + account, + chain: { id: 4217 } as never, + transport: custom({ + async request(args) { + if (args.method === 'eth_chainId') return '0x1079' + if (args.method === 'eth_getTransactionCount') return '0x0' + if (args.method === 'eth_estimateGas') return '0x5208' + if (args.method === 'eth_maxPriorityFeePerGas') return '0x1' + if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' } + throw new Error(`unexpected rpc request: ${args.method}`) + }, + }), +}) function makeChallenge(overrides: Record = {}): Challenge.Challenge { return Challenge.from({ @@ -19,7 +40,7 @@ function makeChallenge(overrides: Record = {}): Challenge.Chall request: { amount: '1000000', currency: '0x20c0000000000000000000000000000000000001', - recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00', + recipient: '0x742d35cc6634c0532925a3b844bc9e7595f8fe00', decimals: 6, methodDetails: { escrowContract: '0x9d136eEa063eDE5418A6BC7bEafF009bBb6CFa70', @@ -172,6 +193,56 @@ describe('Session', () => { expect(receiptCb).toHaveBeenCalledOnce() expect(receiptCb.mock.calls[0]![0].units).toBe(2) }) + + test('posts precompile SSE top-up vouchers with the channel descriptor', async () => { + const needVoucher: NeedVoucherEvent = { + channelId, + requiredCumulative: '2000000', + acceptedCumulative: '1000000', + deposit: '10000000', + } + const events = [ + 'event: message\ndata: chunk1\n\n', + formatNeedVoucherEvent(needVoucher), + 'event: message\ndata: chunk2\n\n', + ] + const postedPayloads: SessionCredentialPayload[] = [] + let callCount = 0 + const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => { + callCount++ + const authorization = new Headers(init?.headers).get('Authorization') + if (authorization) { + postedPayloads.push( + Credential.deserialize(authorization).payload, + ) + } + if (callCount === 1) return Promise.resolve(make402Response()) + if (callCount === 2) return Promise.resolve(makeSseResponse(events)) + return Promise.resolve(makeOkResponse()) + }) + + const s = sessionManager({ + account, + client, + fetch: mockFetch as typeof globalThis.fetch, + maxDeposit: '10', + }) + + const iterable = await s.sse('https://api.example.com/stream') + const messages: string[] = [] + for await (const msg of iterable) messages.push(msg) + + expect(messages).toEqual(['chunk1', 'chunk2']) + expect(postedPayloads[0]?.action).toBe('open') + expect(postedPayloads[1]?.action).toBe('voucher') + const openPayload = postedPayloads[0] + const voucherPayload = postedPayloads[1] + if (openPayload?.action !== 'open' || voucherPayload?.action !== 'voucher') + throw new Error('expected open then voucher payloads') + expect(voucherPayload.channelId).toBe(openPayload.channelId) + expect(voucherPayload.descriptor).toEqual(openPayload.descriptor) + expect(voucherPayload.cumulativeAmount).toBe('2000000') + }) }) describe('error handling', () => { diff --git a/src/tempo/precompile/session/SessionManager.ts b/src/tempo/precompile/session/SessionManager.ts index 6e38e15d..566394d1 100644 --- a/src/tempo/precompile/session/SessionManager.ts +++ b/src/tempo/precompile/session/SessionManager.ts @@ -839,7 +839,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa export declare namespace sessionManager { type Parameters = Account.getResolver.Parameters & Client.getResolver.Parameters & { - /** Address authorized to sign vouchers. Defaults to the account address. */ + /** Address authorized to sign vouchers. Defaults to the account access key address when available, otherwise the account address. */ authorizedSigner?: Address | undefined /** Viem client instance. Shorthand for `getClient: () => client`. */ client?: import('viem').Client | undefined diff --git a/src/tempo/server/AtomicStore.test-d.ts b/src/tempo/server/AtomicStore.test-d.ts index 0e6117ef..8fecfb28 100644 --- a/src/tempo/server/AtomicStore.test-d.ts +++ b/src/tempo/server/AtomicStore.test-d.ts @@ -2,6 +2,7 @@ import { expectTypeOf, test } from 'vp/test' import { tempo } from '../../server/index.js' import * as Store from '../../Store.js' +import * as Tempo from '../index.js' test('tempo.charge store parameter requires AtomicStore', () => { type ChargeParameters = NonNullable[0]> @@ -32,3 +33,22 @@ test('tempo.session store parameter requires AtomicStore', () => { tempo.session({ store: nonAtomic }) tempo.session({ store: Store.memory() }) }) + +test('tempo precompile server session store parameter requires AtomicStore', () => { + type PrecompileSessionParameters = NonNullable< + Parameters[0] + > + expectTypeOf().toEqualTypeOf< + Store.AtomicStore | undefined + >() + + const nonAtomic = Store.from({ + get: async () => null, + put: async () => {}, + delete: async () => {}, + }) + + // @ts-expect-error — precompile session state updates require AtomicStore + Tempo.Precompile.Server.session({ store: nonAtomic }) + Tempo.Precompile.Server.session({ store: Store.memory() }) +}) From 69a00c5ed0b79aa9b35cbe9c0d391737cf69f4fe Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 10:19:56 +0200 Subject: [PATCH 11/26] Add devnet precompile test setup Amp-Thread-ID: https://ampcode.com/threads/T-019e257a-ed26-77a8-94da-ffa686eab0ed --- test/setup.ts | 87 +++++++++++++++++++++++++++++++++++++++++++++- test/tempo/viem.ts | 28 ++++++++++++--- 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/test/setup.ts b/test/setup.ts index 053547b7..757b3165 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -16,6 +16,9 @@ const setupTimeoutMs = 120_000 const warmupAttempts = 5 const warmupRetryDelayMs = 1_000 const warmupRequestTimeoutMs = 10_000 +const devnetFaucetAttempts = 3 +const devnetFaucetRetryDelayMs = 3_000 +const devnetFaucetRequestTimeoutMs = 30_000 const warmupClient = createClient({ account: accounts[0], @@ -34,6 +37,9 @@ const localnetSetupKey = node_crypto.createHash('sha256').update(rpcUrl).digest( const localnetSetupDir = node_path.join(node_os.tmpdir(), `mppx-localnet-setup-${localnetSetupKey}`) const localnetSetupDone = node_path.join(localnetSetupDir, 'done') const localnetSetupLock = node_path.join(localnetSetupDir, 'lock') +const devnetSetupKey = node_crypto.createHash('sha256').update(rpcUrl).digest('hex').slice(0, 16) +const devnetSetupDir = node_path.join(node_os.tmpdir(), `mppx-devnet-setup-${devnetSetupKey}`) +const devnetSetupLock = node_path.join(devnetSetupDir, 'lock') async function exists(path: string) { try { @@ -68,11 +74,36 @@ async function runLocalnetSetupLocked(fn: () => Promise) { } } +async function runDevnetSetupLocked(fn: () => Promise) { + await node_fs.mkdir(devnetSetupDir, { recursive: true }) + + for (;;) { + try { + await node_fs.mkdir(devnetSetupLock) + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + await sleep(250) + } + } + + try { + await fn() + } finally { + await node_fs.rm(devnetSetupLock, { recursive: true, force: true }) + } +} + type LocalnetSetupState = { done: boolean promise: Promise | undefined } +type DevnetSetupState = { + done: boolean + promise: Promise | undefined +} + const localnetSetupState = (() => { const globalState = globalThis as typeof globalThis & { __mppxLocalnetSetup__?: LocalnetSetupState @@ -83,6 +114,16 @@ const localnetSetupState = (() => { }) })() +const devnetSetupState = (() => { + const globalState = globalThis as typeof globalThis & { + __mppxDevnetSetup__?: DevnetSetupState + } + return (globalState.__mppxDevnetSetup__ ??= { + done: false, + promise: undefined, + }) +})() + async function warmupLocalnet() { let lastError: unknown @@ -100,8 +141,52 @@ async function warmupLocalnet() { throw lastError } +async function fundDevnetAccount(account: (typeof accounts)[number]) { + let lastError: unknown + + for (let attempt = 1; attempt <= devnetFaucetAttempts; attempt++) { + try { + await Actions.faucet.fundSync(client, { + account, + timeout: devnetFaucetRequestTimeoutMs, + }) + return + } catch (error) { + lastError = error + if (attempt === devnetFaucetAttempts) break + await sleep(devnetFaucetRetryDelayMs) + } + } + + throw lastError +} + beforeAll(async () => { - if (nodeEnv !== 'localnet') return + if (nodeEnv !== 'localnet' && nodeEnv !== 'devnet') return + + if (nodeEnv === 'devnet') { + if (devnetSetupState.done) return + if (devnetSetupState.promise) { + await devnetSetupState.promise + return + } + + devnetSetupState.promise = runDevnetSetupLocked(async () => { + // Fund deterministic test accounts used by the precompile/session suites. + // The devnet faucet funds the chain's configured test TIP-20 tokens. + for (const account of [accounts[0], accounts[1], accounts[2]]) + await fundDevnetAccount(account) + devnetSetupState.done = true + }) + + try { + await devnetSetupState.promise + } catch (error) { + devnetSetupState.promise = undefined + throw error + } + return + } if (localnetSetupState.done) return if (localnetSetupState.promise) { diff --git a/test/tempo/viem.ts b/test/tempo/viem.ts index 075517a7..d4f15c94 100644 --- a/test/tempo/viem.ts +++ b/test/tempo/viem.ts @@ -1,5 +1,11 @@ import type * as Hex from 'ox/Hex' -import { createClient, defineChain, type HttpTransportConfig, http as viem_http } from 'viem' +import { + createClient, + defineChain, + type Chain as viem_Chain, + type HttpTransportConfig, + http as viem_http, +} from 'viem' import { english, generateMnemonic, type LocalAccount, mnemonicToAccount } from 'viem/accounts' import { tempo, tempoDevnet, tempoLocalnet, tempoModerato } from 'viem/chains' import { Actions } from 'viem/tempo' @@ -29,14 +35,28 @@ export const accounts = Array.from({ length: 20 }, (_, i) => }), ) as unknown as FixedArray +function withRpcUrl(chain: chain): chain { + if (!import.meta.env.VITE_RPC_URL) return chain + return defineChain({ + ...chain, + rpcUrls: { + ...chain.rpcUrls, + default: { + ...chain.rpcUrls.default, + http: [rpcUrl], + }, + }, + }) as unknown as chain +} + export const chain = (() => { switch (nodeEnv) { case 'mainnet': - return tempo + return withRpcUrl(tempo) case 'testnet': - return tempoModerato + return withRpcUrl(tempoModerato) case 'devnet': - return tempoDevnet + return withRpcUrl(tempoDevnet) default: return defineChain({ ...tempoLocalnet, From b1b8d58624eed946aa7515814753f9eadaf01a14 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 11:20:37 +0200 Subject: [PATCH 12/26] Consolidate precompile changesets Amp-Thread-ID: https://ampcode.com/threads/T-019e257a-ed26-77a8-94da-ffa686eab0ed --- .changeset/precompile-fee-payer.md | 5 ----- .changeset/precompile-session-manager.md | 5 ----- .changeset/precompile-session-parity.md | 5 ----- .changeset/tip-1034-precompile-hash.md | 2 +- 4 files changed, 1 insertion(+), 16 deletions(-) delete mode 100644 .changeset/precompile-fee-payer.md delete mode 100644 .changeset/precompile-session-manager.md delete mode 100644 .changeset/precompile-session-parity.md diff --git a/.changeset/precompile-fee-payer.md b/.changeset/precompile-fee-payer.md deleted file mode 100644 index 40a7d591..00000000 --- a/.changeset/precompile-fee-payer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'mppx': patch ---- - -Added fee-payer and fee-token support for server-driven Tempo precompile settle and close transactions. diff --git a/.changeset/precompile-session-manager.md b/.changeset/precompile-session-manager.md deleted file mode 100644 index 9cb2ad98..00000000 --- a/.changeset/precompile-session-manager.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'mppx': patch ---- - -Added TIP-1034 precompile session manager exports and hardened precompile settle/close account validation. diff --git a/.changeset/precompile-session-parity.md b/.changeset/precompile-session-parity.md deleted file mode 100644 index e89ab0ef..00000000 --- a/.changeset/precompile-session-parity.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'mppx': patch ---- - -Fixed precompile session store atomicity, voucher-signing compatibility documentation, and finalized channel bookkeeping parity. diff --git a/.changeset/tip-1034-precompile-hash.md b/.changeset/tip-1034-precompile-hash.md index 1bf14b2d..be222041 100644 --- a/.changeset/tip-1034-precompile-hash.md +++ b/.changeset/tip-1034-precompile-hash.md @@ -2,4 +2,4 @@ 'mppx': patch --- -Added Tempo TIP-1034 precompile channel helpers for computing channel IDs, expiring nonce hashes, vouchers, ABI calldata, open/top-up validation, descriptor persistence, precompile session credential payload parsing, client credential builders, opt-in client sessions, and opt-in server session verification. +Added Tempo TIP-1034 precompile channel helpers, client credential builders, opt-in client sessions, opt-in server session verification, session manager exports, server-driven fee-payer settle/close support, devnet precompile test setup, and hardened precompile channel validation, store atomicity, voucher-signing compatibility, and finalized channel bookkeeping. From 7f6266fff9c63cd79ba35a8e2de3987d8844f021 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 11:22:58 +0200 Subject: [PATCH 13/26] Clarify precompile changeset opt-in API Amp-Thread-ID: https://ampcode.com/threads/T-019e257a-ed26-77a8-94da-ffa686eab0ed --- .changeset/tip-1034-precompile-hash.md | 31 +++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.changeset/tip-1034-precompile-hash.md b/.changeset/tip-1034-precompile-hash.md index be222041..6e89b537 100644 --- a/.changeset/tip-1034-precompile-hash.md +++ b/.changeset/tip-1034-precompile-hash.md @@ -2,4 +2,33 @@ 'mppx': patch --- -Added Tempo TIP-1034 precompile channel helpers, client credential builders, opt-in client sessions, opt-in server session verification, session manager exports, server-driven fee-payer settle/close support, devnet precompile test setup, and hardened precompile channel validation, store atomicity, voucher-signing compatibility, and finalized channel bookkeeping. +Added opt-in Tempo [TIP-1034](https://github.com/tempoxyz/tempo/blob/main/tips/tip-1034.md) precompile support for payment-channel sessions. + +The default `tempo.session(...)` method continued to use the existing session backend. Applications opted into the new precompile-backed flow explicitly by using `tempo.precompile.session(...)` on the client and `tempo.precompile.Server.session(...)` on the server. + +```ts +import { Mppx, tempo } from 'mppx/client' + +const client = Mppx.create({ + methods: [tempo.precompile.session({ account, maxDeposit: '10' })], +}) +``` + +```ts +import { Mppx, tempo } from 'mppx/server' + +const server = Mppx.create({ + methods: [ + tempo.precompile.Server.session({ + amount: '1', + chainId, + currency, + recipient, + store, + unitType: 'request', + }), + ], +}) +``` + +This added channel ID, expiring nonce hash, voucher, ABI calldata, open/top-up validation, descriptor persistence, credential payload parsing, client credential builder, session manager, server verification, and server-driven fee-payer settle/close helpers for TIP-1034 precompile channels. It also hardened precompile channel validation, atomic store updates, voucher-signing compatibility, finalized channel bookkeeping, and devnet precompile integration test setup. From 48ad11718240de111e22ee92cbee77dab33dd75e Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 15:04:48 +0200 Subject: [PATCH 14/26] chore: consolidate types --- src/tempo/precompile/Channel.ts | 22 +++++++--------------- src/tempo/precompile/Types.ts | 20 ++++++++++++++------ 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/tempo/precompile/Channel.ts b/src/tempo/precompile/Channel.ts index 82b79ab5..5ba5484f 100644 --- a/src/tempo/precompile/Channel.ts +++ b/src/tempo/precompile/Channel.ts @@ -1,5 +1,4 @@ -import type { Hex } from 'ox' -import { encodeAbiParameters, keccak256, type Account, type Address } from 'viem' +import { encodeAbiParameters, keccak256, type Account, type Address, type Hex } from 'viem' import { Transaction, type z_TransactionRequestTempo, @@ -7,6 +6,9 @@ import { } from 'viem/tempo' import { tip20ChannelEscrow } from './Constants.js' +import type { ChannelDescriptor } from './Types.js' + +export type { ChannelDescriptor } from './Types.js' export type ExpiringNonceTransaction = ( | z_TransactionSerializableTempo @@ -15,21 +17,11 @@ export type ExpiringNonceTransaction = ( feePayer?: Account | true | undefined } -export type ChannelDescriptor = { - payer: Address - payee: Address - operator: Address - token: Address - salt: Hex.Hex - authorizedSigner: Address - expiringNonceHash: Hex.Hex -} - /** Computes the TIP-1034 channel ID for a precompile channel descriptor. */ export function computeId( descriptor: ChannelDescriptor, parameters: { chainId: number; escrow?: Address | undefined }, -): Hex.Hex { +): Hex { return keccak256( encodeAbiParameters( [ @@ -68,10 +60,10 @@ export function computeId( export function computeExpiringNonceHash( transaction: ExpiringNonceTransaction, parameters: { sender: Address }, -): Hex.Hex { +): Hex { const getChannelOpenContextHash = Transaction.getChannelOpenContextHash as ( transaction: ExpiringNonceTransaction, options: { sender: Address }, - ) => Hex.Hex + ) => Hex return getChannelOpenContextHash(transaction, parameters) } diff --git a/src/tempo/precompile/Types.ts b/src/tempo/precompile/Types.ts index 3da45dff..1e4f5772 100644 --- a/src/tempo/precompile/Types.ts +++ b/src/tempo/precompile/Types.ts @@ -1,7 +1,5 @@ import type { Address, Hex } from 'viem' -import type * as Channel from './Channel.js' - const maxUint96 = (1n << 96n) - 1n declare const uint96Brand: unique symbol @@ -24,6 +22,16 @@ export function assertUint96(value: bigint): asserts value is Uint96 { uint96(value) } +export type ChannelDescriptor = { + payer: Address + payee: Address + operator: Address + token: Address + salt: Hex + authorizedSigner: Address + expiringNonceHash: Hex +} + /** TIP-1034 precompile open credential payload before amount branding. */ export type OpenCredentialPayload = { action: 'open' @@ -31,7 +39,7 @@ export type OpenCredentialPayload = { channelId: Hex transaction: Hex signature: Hex - descriptor: Channel.ChannelDescriptor + descriptor: ChannelDescriptor cumulativeAmount: string authorizedSigner?: Address | undefined } @@ -42,7 +50,7 @@ export type TopUpCredentialPayload = { type: 'transaction' channelId: Hex transaction: Hex - descriptor: Channel.ChannelDescriptor + descriptor: ChannelDescriptor additionalDeposit: string } @@ -50,7 +58,7 @@ export type TopUpCredentialPayload = { export type VoucherCredentialPayload = { action: 'voucher' channelId: Hex - descriptor: Channel.ChannelDescriptor + descriptor: ChannelDescriptor cumulativeAmount: string signature: Hex } @@ -59,7 +67,7 @@ export type VoucherCredentialPayload = { export type CloseCredentialPayload = { action: 'close' channelId: Hex - descriptor: Channel.ChannelDescriptor + descriptor: ChannelDescriptor cumulativeAmount: string signature: Hex } From 989589589033fa6bf443697a8c8f4ed1b21f0318 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 15:45:02 +0200 Subject: [PATCH 15/26] style: computeId.Parameters --- .../precompile/Chain.integration.test.ts | 2 +- src/tempo/precompile/Channel.test.ts | 23 +++--- src/tempo/precompile/Channel.ts | 82 ++++++++++--------- .../precompile/client/ChannelOps.test.ts | 2 +- src/tempo/precompile/client/ChannelOps.ts | 12 +-- src/tempo/precompile/server/ChannelOps.ts | 3 +- src/tempo/precompile/server/Session.ts | 4 +- src/tempo/precompile/session/Client.test.ts | 6 +- src/tempo/precompile/session/Client.ts | 4 +- 9 files changed, 67 insertions(+), 71 deletions(-) diff --git a/src/tempo/precompile/Chain.integration.test.ts b/src/tempo/precompile/Chain.integration.test.ts index 21a516da..6aeb662d 100644 --- a/src/tempo/precompile/Chain.integration.test.ts +++ b/src/tempo/precompile/Chain.integration.test.ts @@ -63,7 +63,7 @@ async function openChannel(parameters: { deposit?: bigint | undefined } = {}) { authorizedSigner: payer.address, expiringNonceHash, } satisfies Channel.ChannelDescriptor - expect(Channel.computeId(descriptor, { chainId: chain.id, escrow: tip20ChannelEscrow })).toBe( + expect(Channel.computeId({ ...descriptor, chainId: chain.id, escrow: tip20ChannelEscrow })).toBe( channelId, ) return { channelId, descriptor, deposit } diff --git a/src/tempo/precompile/Channel.test.ts b/src/tempo/precompile/Channel.test.ts index 76422100..741e4fc1 100644 --- a/src/tempo/precompile/Channel.test.ts +++ b/src/tempo/precompile/Channel.test.ts @@ -18,8 +18,8 @@ const chainId = 42431 describe('precompile Channel.computeId', () => { test('returns deterministic 32-byte hash for fixed inputs', () => { - const id = Channel.computeId(descriptor, { chainId }) - expect(Channel.computeId(descriptor, { chainId })).toBe(id) + const id = Channel.computeId({ ...descriptor, chainId }) + expect(Channel.computeId({ ...descriptor, chainId })).toBe(id) expect(id).toMatch(/^0x[0-9a-f]{64}$/) }) @@ -48,7 +48,7 @@ describe('precompile Channel.computeId', () => { BigInt(chainId), ], ) - expect(Channel.computeId(descriptor, { chainId })).toBe(Hash.keccak256(encoded)) + expect(Channel.computeId({ ...descriptor, chainId })).toBe(Hash.keccak256(encoded)) }) test.each([ @@ -60,26 +60,23 @@ describe('precompile Channel.computeId', () => { ['authorizedSigner', '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'], ['expiringNonceHash', '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'], ] as const)('changes when %s changes', (key, value) => { - expect(Channel.computeId({ ...descriptor, [key]: value }, { chainId })).not.toBe( - Channel.computeId(descriptor, { chainId }), + expect(Channel.computeId({ ...descriptor, [key]: value, chainId })).not.toBe( + Channel.computeId({ ...descriptor, chainId }), ) }) test('changes when escrow or chainId changes', () => { expect( - Channel.computeId(descriptor, { - chainId, - escrow: '0xffffffffffffffffffffffffffffffffffffffff', - }), - ).not.toBe(Channel.computeId(descriptor, { chainId })) - expect(Channel.computeId(descriptor, { chainId: 1 })).not.toBe( - Channel.computeId(descriptor, { chainId }), + Channel.computeId({ ...descriptor, chainId, escrow: '0xffffffffffffffffffffffffffffffffffffffff' }), + ).not.toBe(Channel.computeId({ ...descriptor, chainId })) + expect(Channel.computeId({ ...descriptor, chainId: 1 })).not.toBe( + Channel.computeId({ ...descriptor, chainId }), ) }) test('encodes chainId as uint256', () => { const largeChainId = 2 ** 32 - const id = Channel.computeId(descriptor, { chainId: largeChainId }) + const id = Channel.computeId({ ...descriptor, chainId: largeChainId }) const encoded = AbiParameters.encode( AbiParameters.from([ 'address payer', diff --git a/src/tempo/precompile/Channel.ts b/src/tempo/precompile/Channel.ts index 5ba5484f..adacb99a 100644 --- a/src/tempo/precompile/Channel.ts +++ b/src/tempo/precompile/Channel.ts @@ -1,53 +1,57 @@ -import { encodeAbiParameters, keccak256, type Account, type Address, type Hex } from 'viem' +import { AbiParameters, Hash } from "ox"; +import type { Account, Address, Hex } from "viem"; import { Transaction, type z_TransactionRequestTempo, type z_TransactionSerializableTempo, -} from 'viem/tempo' +} from "viem/tempo"; -import { tip20ChannelEscrow } from './Constants.js' -import type { ChannelDescriptor } from './Types.js' +import { tip20ChannelEscrow } from "./Constants.js"; +import type { ChannelDescriptor } from "./Types.js"; -export type { ChannelDescriptor } from './Types.js' +export type { ChannelDescriptor } from "./Types.js"; export type ExpiringNonceTransaction = ( | z_TransactionSerializableTempo | z_TransactionRequestTempo ) & { - feePayer?: Account | true | undefined -} + feePayer?: Account | true | undefined; +}; /** Computes the TIP-1034 channel ID for a precompile channel descriptor. */ -export function computeId( - descriptor: ChannelDescriptor, - parameters: { chainId: number; escrow?: Address | undefined }, -): Hex { - return keccak256( - encodeAbiParameters( - [ - { type: 'address' }, - { type: 'address' }, - { type: 'address' }, - { type: 'address' }, - { type: 'bytes32' }, - { type: 'address' }, - { type: 'bytes32' }, - { type: 'address' }, - { type: 'uint256' }, - ], - [ - descriptor.payer, - descriptor.payee, - descriptor.operator, - descriptor.token, - descriptor.salt, - descriptor.authorizedSigner, - descriptor.expiringNonceHash, - parameters.escrow ?? tip20ChannelEscrow, - BigInt(parameters.chainId), - ], - ), - ) +export function computeId(parameters: computeId.Parameters): Hex { + const encoded = AbiParameters.encode( + AbiParameters.from([ + "address payer", + "address payee", + "address operator", + "address token", + "bytes32 salt", + "address authorizedSigner", + "bytes32 expiringNonceHash", + "address escrow", + "uint256 chainId", + ]), + [ + parameters.payer, + parameters.payee, + parameters.operator, + parameters.token, + parameters.salt, + parameters.authorizedSigner, + parameters.expiringNonceHash, + parameters.escrow ?? tip20ChannelEscrow, + BigInt(parameters.chainId), + ], + ); + return Hash.keccak256(encoded); +} + +export declare namespace computeId { + type Parameters = ChannelDescriptor & { + chainId: number; + escrow?: Address | undefined; + }; } /** @@ -64,6 +68,6 @@ export function computeExpiringNonceHash( const getChannelOpenContextHash = Transaction.getChannelOpenContextHash as ( transaction: ExpiringNonceTransaction, options: { sender: Address }, - ) => Hex - return getChannelOpenContextHash(transaction, parameters) + ) => Hex; + return getChannelOpenContextHash(transaction, parameters); } diff --git a/src/tempo/precompile/client/ChannelOps.test.ts b/src/tempo/precompile/client/ChannelOps.test.ts index 9e99beb1..de1a7654 100644 --- a/src/tempo/precompile/client/ChannelOps.test.ts +++ b/src/tempo/precompile/client/ChannelOps.test.ts @@ -32,7 +32,7 @@ const descriptor = { expiringNonceHash: `0x${'22'.repeat(32)}` as Hex.Hex, } satisfies Channel.ChannelDescriptor -const channelId = Channel.computeId(descriptor, { chainId, escrow: tip20ChannelEscrow }) +const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow }) describe('precompile client ChannelOps credential builders', () => { test('creates an open credential from a signed open result', () => { diff --git a/src/tempo/precompile/client/ChannelOps.ts b/src/tempo/precompile/client/ChannelOps.ts index b9601e16..323e5b6f 100644 --- a/src/tempo/precompile/client/ChannelOps.ts +++ b/src/tempo/precompile/client/ChannelOps.ts @@ -87,7 +87,7 @@ export async function createOpen( salt, token: parameters.token, } satisfies Channel.ChannelDescriptor - const channelId = Channel.computeId(descriptor, { chainId: parameters.chainId, escrow }) + const channelId = Channel.computeId({ ...descriptor, chainId: parameters.chainId, escrow }) const voucherSignature = await Voucher.sign( client, account, @@ -132,10 +132,7 @@ export async function createVoucherCredential( }, ): Promise { const escrow = parameters.escrow ?? tip20ChannelEscrow - const channelId = Channel.computeId(parameters.descriptor, { - chainId: parameters.chainId, - escrow, - }) + const channelId = Channel.computeId({ ...parameters.descriptor, chainId: parameters.chainId, escrow }) const signature = await Voucher.sign( client, account, @@ -169,10 +166,7 @@ export async function createTopUp( }, ): Promise { const escrow = parameters.escrow ?? tip20ChannelEscrow - const channelId = Channel.computeId(parameters.descriptor, { - chainId: parameters.chainId, - escrow, - }) + const channelId = Channel.computeId({ ...parameters.descriptor, chainId: parameters.chainId, escrow }) const prepared = await prepareTransactionRequest(client, { account, calls: [ diff --git a/src/tempo/precompile/server/ChannelOps.ts b/src/tempo/precompile/server/ChannelOps.ts index b43dd938..274fe02e 100644 --- a/src/tempo/precompile/server/ChannelOps.ts +++ b/src/tempo/precompile/server/ChannelOps.ts @@ -107,7 +107,8 @@ export function descriptorFromOpen(parameters: { token: parameters.open.token, } satisfies Channel.ChannelDescriptor if (parameters.channelId) { - const computed = Channel.computeId(descriptor, { + const computed = Channel.computeId({ + ...descriptor, chainId: parameters.chainId, escrow: parameters.escrow ?? tip20ChannelEscrow, }) diff --git a/src/tempo/precompile/server/Session.ts b/src/tempo/precompile/server/Session.ts index 05bf39a5..44a73d00 100644 --- a/src/tempo/precompile/server/Session.ts +++ b/src/tempo/precompile/server/Session.ts @@ -94,7 +94,7 @@ function validateDescriptor(parameters: { currency: Address }) { const { descriptor, channelId, chainId, escrow, recipient, currency } = parameters - const computed = Channel.computeId(descriptor, { chainId, escrow }) + const computed = Channel.computeId({ ...descriptor, chainId, escrow }) if (computed.toLowerCase() !== channelId.toLowerCase()) throw new VerificationFailedError({ reason: 'credential channelId does not match descriptor' }) if (!isAddressEqual(descriptor.payee, recipient)) @@ -566,7 +566,7 @@ async function handleOpen(parameters: { }) if (emittedDeposit !== open.deposit) throw new VerificationFailedError({ reason: 'ChannelOpened deposit does not match calldata' }) - const confirmedChannelId = Channel.computeId(descriptor, { chainId, escrow }) + const confirmedChannelId = Channel.computeId({ ...descriptor, chainId, escrow }) if (confirmedChannelId.toLowerCase() !== emittedChannelId.toLowerCase()) throw new VerificationFailedError({ reason: 'descriptor does not match ChannelOpened channelId', diff --git a/src/tempo/precompile/session/Client.test.ts b/src/tempo/precompile/session/Client.test.ts index 286f982d..79db7d58 100644 --- a/src/tempo/precompile/session/Client.test.ts +++ b/src/tempo/precompile/session/Client.test.ts @@ -189,7 +189,7 @@ describe('precompile client session', () => { getClient: () => client, onChannelUpdate: (entry) => updates.push(entry.cumulativeAmount), }) - const channelId = Channel.computeId(descriptor, { chainId, escrow: tip20ChannelEscrow }) + const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow }) const recovered = deserialize( await method.createCredential({ challenge: makeChallenge() as never, @@ -228,7 +228,7 @@ describe('precompile client session', () => { }), }) const method = session({ account, decimals: 0, deposit: '1000', getClient: () => closedClient }) - const channelId = Channel.computeId(descriptor, { chainId, escrow: tip20ChannelEscrow }) + const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow }) await expect( method.createCredential({ @@ -281,7 +281,7 @@ describe('precompile client session', () => { const decoded = Credential.deserialize(credential) const payload = decoded.payload as Types.SessionCredentialPayload const cumulativeAmount = Types.uint96(250n) - const channelId = Channel.computeId(descriptor, { chainId, escrow: tip20ChannelEscrow }) + const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow }) expect(payload.action).toBe('voucher') if (payload.action !== 'voucher') throw new Error('expected voucher payload') diff --git a/src/tempo/precompile/session/Client.ts b/src/tempo/precompile/session/Client.ts index 020a63f8..2620c408 100644 --- a/src/tempo/precompile/session/Client.ts +++ b/src/tempo/precompile/session/Client.ts @@ -145,7 +145,7 @@ export function session(parameters: session.Parameters = {}) { if (!existing && context?.channelId && !context.descriptor) throw new Error('descriptor required to reuse precompile channel') if (!existing && context?.descriptor) { - const channelId = Channel.computeId(context.descriptor, { chainId, escrow }) + const channelId = Channel.computeId({ ...context.descriptor, chainId, escrow }) if (context.channelId && context.channelId.toLowerCase() !== channelId.toLowerCase()) throw new Error('context channelId does not match descriptor') if (!isSameAddress(context.descriptor.payee, payee)) @@ -244,7 +244,7 @@ export function session(parameters: session.Parameters = {}) { const action = context.action! const descriptor = context.descriptor if (!descriptor) throw new Error('descriptor required for precompile session action') - const channelId = Channel.computeId(descriptor, { chainId, escrow }) + const channelId = Channel.computeId({ ...descriptor, chainId, escrow }) let payload: SessionCredentialPayload switch (action) { From 4b283455fe3e21a19aa427caf1f915784d38ff9a Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 16:12:44 +0200 Subject: [PATCH 16/26] style: fmt --- .../precompile/Chain.integration.test.ts | 16 +- src/tempo/precompile/Channel.test.ts | 6 +- src/tempo/precompile/Channel.ts | 48 +++--- src/tempo/precompile/Types.ts | 14 ++ src/tempo/precompile/Voucher.test.ts | 65 +++++---- src/tempo/precompile/Voucher.ts | 61 ++++---- .../precompile/client/ChannelOps.test.ts | 9 +- src/tempo/precompile/client/ChannelOps.ts | 22 ++- .../server/Session.integration.test.ts | 2 +- src/tempo/precompile/server/Session.test.ts | 138 ++++++++++++++---- src/tempo/precompile/server/Session.ts | 6 +- src/tempo/precompile/session/Client.test.ts | 2 +- 12 files changed, 256 insertions(+), 133 deletions(-) diff --git a/src/tempo/precompile/Chain.integration.test.ts b/src/tempo/precompile/Chain.integration.test.ts index 6aeb662d..42ceca83 100644 --- a/src/tempo/precompile/Chain.integration.test.ts +++ b/src/tempo/precompile/Chain.integration.test.ts @@ -63,9 +63,13 @@ async function openChannel(parameters: { deposit?: bigint | undefined } = {}) { authorizedSigner: payer.address, expiringNonceHash, } satisfies Channel.ChannelDescriptor - expect(Channel.computeId({ ...descriptor, chainId: chain.id, escrow: tip20ChannelEscrow })).toBe( - channelId, - ) + expect( + Channel.computeId({ + ...descriptor, + chainId: chain.id, + escrow: tip20ChannelEscrow, + }), + ).toBe(channelId) return { channelId, descriptor, deposit } } @@ -85,7 +89,9 @@ describe.runIf(isPrecompileTestnet)('TIP-1034 precompile chain operations', () = }) test('topUp updates precompile channel state and emits TopUp', async () => { - const { channelId, descriptor, deposit } = await openChannel({ deposit: 1_000n }) + const { channelId, descriptor, deposit } = await openChannel({ + deposit: 1_000n, + }) const additionalDeposit = uint96(750n) const receipt = await sendPrecompileCall(Chain.encodeTopUp(descriptor, additionalDeposit)) @@ -101,7 +107,7 @@ describe.runIf(isPrecompileTestnet)('TIP-1034 precompile chain operations', () = test('settles a signed voucher against the descriptor', async () => { const { channelId, descriptor } = await openChannel({ deposit: 1_000n }) const cumulativeAmount = uint96(400n) - const signature = await Voucher.sign( + const signature = await Voucher.signVoucher( client, payer, { channelId, cumulativeAmount }, diff --git a/src/tempo/precompile/Channel.test.ts b/src/tempo/precompile/Channel.test.ts index 741e4fc1..8251467c 100644 --- a/src/tempo/precompile/Channel.test.ts +++ b/src/tempo/precompile/Channel.test.ts @@ -67,7 +67,11 @@ describe('precompile Channel.computeId', () => { test('changes when escrow or chainId changes', () => { expect( - Channel.computeId({ ...descriptor, chainId, escrow: '0xffffffffffffffffffffffffffffffffffffffff' }), + Channel.computeId({ + ...descriptor, + chainId, + escrow: '0xffffffffffffffffffffffffffffffffffffffff', + }), ).not.toBe(Channel.computeId({ ...descriptor, chainId })) expect(Channel.computeId({ ...descriptor, chainId: 1 })).not.toBe( Channel.computeId({ ...descriptor, chainId }), diff --git a/src/tempo/precompile/Channel.ts b/src/tempo/precompile/Channel.ts index adacb99a..41cd27f0 100644 --- a/src/tempo/precompile/Channel.ts +++ b/src/tempo/precompile/Channel.ts @@ -1,36 +1,36 @@ -import { AbiParameters, Hash } from "ox"; -import type { Account, Address, Hex } from "viem"; +import { AbiParameters, Hash } from 'ox' +import type { Account, Address, Hex } from 'viem' import { Transaction, type z_TransactionRequestTempo, type z_TransactionSerializableTempo, -} from "viem/tempo"; +} from 'viem/tempo' -import { tip20ChannelEscrow } from "./Constants.js"; -import type { ChannelDescriptor } from "./Types.js"; +import { tip20ChannelEscrow } from './Constants.js' +import type { ChannelDescriptor } from './Types.js' -export type { ChannelDescriptor } from "./Types.js"; +export type { ChannelDescriptor } from './Types.js' export type ExpiringNonceTransaction = ( | z_TransactionSerializableTempo | z_TransactionRequestTempo ) & { - feePayer?: Account | true | undefined; -}; + feePayer?: Account | true | undefined +} /** Computes the TIP-1034 channel ID for a precompile channel descriptor. */ export function computeId(parameters: computeId.Parameters): Hex { const encoded = AbiParameters.encode( AbiParameters.from([ - "address payer", - "address payee", - "address operator", - "address token", - "bytes32 salt", - "address authorizedSigner", - "bytes32 expiringNonceHash", - "address escrow", - "uint256 chainId", + 'address payer', + 'address payee', + 'address operator', + 'address token', + 'bytes32 salt', + 'address authorizedSigner', + 'bytes32 expiringNonceHash', + 'address escrow', + 'uint256 chainId', ]), [ parameters.payer, @@ -43,15 +43,15 @@ export function computeId(parameters: computeId.Parameters): Hex { parameters.escrow ?? tip20ChannelEscrow, BigInt(parameters.chainId), ], - ); - return Hash.keccak256(encoded); + ) + return Hash.keccak256(encoded) } export declare namespace computeId { type Parameters = ChannelDescriptor & { - chainId: number; - escrow?: Address | undefined; - }; + chainId: number + escrow?: Address | undefined + } } /** @@ -68,6 +68,6 @@ export function computeExpiringNonceHash( const getChannelOpenContextHash = Transaction.getChannelOpenContextHash as ( transaction: ExpiringNonceTransaction, options: { sender: Address }, - ) => Hex; - return getChannelOpenContextHash(transaction, parameters); + ) => Hex + return getChannelOpenContextHash(transaction, parameters) } diff --git a/src/tempo/precompile/Types.ts b/src/tempo/precompile/Types.ts index 1e4f5772..858a9e8e 100644 --- a/src/tempo/precompile/Types.ts +++ b/src/tempo/precompile/Types.ts @@ -32,6 +32,20 @@ export type ChannelDescriptor = { expiringNonceHash: Hex } +/** + * Voucher for cumulative payment. + * Cumulative monotonicity prevents replay attacks. + */ +export type Voucher = { + channelId: Hex + cumulativeAmount: Uint96 +} + +/** + * Signed voucher with EIP-712 signature. + */ +export type SignedVoucher = Voucher & { signature: Hex } + /** TIP-1034 precompile open credential payload before amount branding. */ export type OpenCredentialPayload = { action: 'open' diff --git a/src/tempo/precompile/Voucher.test.ts b/src/tempo/precompile/Voucher.test.ts index 4075c02d..09fccdca 100644 --- a/src/tempo/precompile/Voucher.test.ts +++ b/src/tempo/precompile/Voucher.test.ts @@ -7,7 +7,13 @@ import { Account as TempoAccount } from 'viem/tempo' import { describe, expect, test } from 'vp/test' import { uint96 } from './Types.js' -import { domain, parseVoucherFromPayload, sign, types, verify } from './Voucher.js' +import { + getVoucherDomain, + parseVoucherFromPayload, + signVoucher, + voucherTypes, + verifyVoucher, +} from './Voucher.js' const account = privateKeyToAccount( '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', @@ -25,7 +31,7 @@ const cumulativeAmount = uint96(1_000_000n) describe('Precompile Voucher', () => { test('sign and verify round-trip', async () => { - const signature = await sign( + const signature = await signVoucher( client, account, { channelId, cumulativeAmount }, @@ -37,7 +43,7 @@ describe('Precompile Voucher', () => { expect(signature).toMatch(/^0x/) expect(signature.length).toBe(132) - const isValid = verify({ channelId, cumulativeAmount, signature }, account.address, { + const isValid = verifyVoucher({ channelId, cumulativeAmount, signature }, account.address, { chainId, verifyingContract: escrowContract, }) @@ -45,7 +51,7 @@ describe('Precompile Voucher', () => { }) test('verify rejects wrong signer', async () => { - const signature = await sign( + const signature = await signVoucher( client, account, { channelId, cumulativeAmount }, @@ -56,7 +62,7 @@ describe('Precompile Voucher', () => { ) const wrongAddress = '0x0000000000000000000000000000000000000001' as const - const isValid = verify({ channelId, cumulativeAmount, signature }, wrongAddress, { + const isValid = verifyVoucher({ channelId, cumulativeAmount, signature }, wrongAddress, { chainId, verifyingContract: escrowContract, }) @@ -64,7 +70,7 @@ describe('Precompile Voucher', () => { }) test('verify rejects tampered amount', async () => { - const signature = await sign( + const signature = await signVoucher( client, account, { channelId, cumulativeAmount }, @@ -74,7 +80,7 @@ describe('Precompile Voucher', () => { }, ) - const isValid = verify( + const isValid = verifyVoucher( { channelId, cumulativeAmount: uint96(9_999_999n), signature }, account.address, { chainId, verifyingContract: escrowContract }, @@ -83,7 +89,7 @@ describe('Precompile Voucher', () => { }) test('verify rejects tampered channelId', async () => { - const signature = await sign( + const signature = await signVoucher( client, account, { channelId, cumulativeAmount }, @@ -95,7 +101,7 @@ describe('Precompile Voucher', () => { const wrongChannelId = '0x0000000000000000000000000000000000000000000000000000000000000099' as const - const isValid = verify( + const isValid = verifyVoucher( { channelId: wrongChannelId, cumulativeAmount, signature }, account.address, { chainId, verifyingContract: escrowContract }, @@ -104,7 +110,7 @@ describe('Precompile Voucher', () => { }) test('verify rejects wrong chain ID', async () => { - const signature = await sign( + const signature = await signVoucher( client, account, { channelId, cumulativeAmount }, @@ -114,7 +120,7 @@ describe('Precompile Voucher', () => { }, ) - const isValid = verify({ channelId, cumulativeAmount, signature }, account.address, { + const isValid = verifyVoucher({ channelId, cumulativeAmount, signature }, account.address, { chainId: 99999, verifyingContract: escrowContract, }) @@ -122,7 +128,7 @@ describe('Precompile Voucher', () => { }) test('verify returns false for invalid signature', () => { - const isValid = verify( + const isValid = verifyVoucher( { channelId, cumulativeAmount, signature: '0xdeadbeef' }, account.address, { chainId, verifyingContract: escrowContract }, @@ -157,7 +163,7 @@ describe('Precompile Voucher', () => { }) test('verify rejects wrong escrow contract', async () => { - const signature = await sign( + const signature = await signVoucher( client, account, { channelId, cumulativeAmount }, @@ -168,7 +174,7 @@ describe('Precompile Voucher', () => { ) const wrongEscrow = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as const - const isValid = verify({ channelId, cumulativeAmount, signature }, account.address, { + const isValid = verifyVoucher({ channelId, cumulativeAmount, signature }, account.address, { chainId, verifyingContract: wrongEscrow, }) @@ -177,7 +183,7 @@ describe('Precompile Voucher', () => { test('sign and verify round-trip with zero amount', async () => { const zeroAmount = uint96(0n) - const signature = await sign( + const signature = await signVoucher( client, account, { channelId, cumulativeAmount: zeroAmount }, @@ -188,7 +194,7 @@ describe('Precompile Voucher', () => { ) expect(signature).toMatch(/^0x/) - const isValid = verify( + const isValid = verifyVoucher( { channelId, cumulativeAmount: zeroAmount, signature }, account.address, { chainId, verifyingContract: escrowContract }, @@ -199,19 +205,24 @@ describe('Precompile Voucher', () => { test('verify rejects direct keychain wrapper signatures', async () => { const signature = await signTypedData(client, { account, - domain: domain(chainId, escrowContract), - types, + domain: getVoucherDomain(chainId, escrowContract), + types: voucherTypes, primaryType: 'Voucher', message: { channelId, cumulativeAmount }, }) const envelope = SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) const wrapped = SignatureEnvelope.serialize( - { inner: envelope, type: 'keychain', userAddress: account.address, version: 'v1' }, + { + inner: envelope, + type: 'keychain', + userAddress: account.address, + version: 'v1', + }, { magic: true }, ) expect( - verify({ channelId, cumulativeAmount, signature: wrapped }, account.address, { + verifyVoucher({ channelId, cumulativeAmount, signature: wrapped }, account.address, { chainId, verifyingContract: escrowContract, }), @@ -220,14 +231,16 @@ describe('Precompile Voucher', () => { test('sign rejects p256 keychain access-key voucher delegation explicitly', async () => { const rootAccount = TempoAccount.fromSecp256k1(Secp256k1.randomPrivateKey()) - const accessKey = TempoAccount.fromP256(P256.randomPrivateKey(), { access: rootAccount }) + const accessKey = TempoAccount.fromP256(P256.randomPrivateKey(), { + access: rootAccount, + }) const accessKeyClient = createClient({ account: accessKey, transport: http('http://127.0.0.1'), }) await expect( - sign( + signVoucher( accessKeyClient, accessKey, { channelId, cumulativeAmount }, @@ -241,20 +254,20 @@ describe('Precompile Voucher', () => { }) test('domain and type match TIP-1034', () => { - expect(domain(chainId, escrowContract)).toEqual({ + expect(getVoucherDomain(chainId, escrowContract)).toEqual({ name: 'TIP20 Channel Escrow', version: '1', chainId, verifyingContract: escrowContract, }) - expect(types.Voucher).toEqual([ + expect(voucherTypes.Voucher).toEqual([ { name: 'channelId', type: 'bytes32' }, { name: 'cumulativeAmount', type: 'uint96' }, ]) expect( hashTypedData({ - domain: domain(chainId, escrowContract), - types, + domain: getVoucherDomain(chainId, escrowContract), + types: voucherTypes, primaryType: 'Voucher', message: { channelId, cumulativeAmount }, }), diff --git a/src/tempo/precompile/Voucher.ts b/src/tempo/precompile/Voucher.ts index 426707ec..6c835fa0 100644 --- a/src/tempo/precompile/Voucher.ts +++ b/src/tempo/precompile/Voucher.ts @@ -6,44 +6,26 @@ import { signTypedData } from 'viem/actions' import * as TempoAddress from '../internal/address.js' import { tip20ChannelEscrow } from './Constants.js' -import type { Uint96 } from './Types.js' +import type { Voucher, SignedVoucher } from './Types.js' import { uint96 } from './Types.js' -const domainName = 'TIP20 Channel Escrow' -const domainVersion = '1' - -export type Voucher = { - channelId: Hex - cumulativeAmount: Uint96 -} - -export type SignedVoucher = Voucher & { signature: Hex } - -/** Parses a signed TIP-1034 voucher payload and brands its uint96 cumulative amount. */ -export function parseVoucherFromPayload( - channelId: Hex, - cumulativeAmount: string, - signature: Hex, -): SignedVoucher { - return { - channelId, - cumulativeAmount: uint96(BigInt(cumulativeAmount)), - signature, - } -} +/** Must match the on-chain TempoStreamChannel DOMAIN_SEPARATOR name. */ +const DOMAIN_NAME = 'TIP20 Channel Escrow' +/** Must match the on-chain TempoStreamChannel DOMAIN_SEPARATOR version. */ +const DOMAIN_VERSION = '1' /** EIP-712 domain for TIP-1034 channel escrow vouchers. */ -export function domain(chainId: number, verifyingContract: Address = tip20ChannelEscrow) { +export function getVoucherDomain(chainId: number, verifyingContract: Address = tip20ChannelEscrow) { return { - name: domainName, - version: domainVersion, + name: DOMAIN_NAME, + version: DOMAIN_VERSION, chainId, verifyingContract, } as const } /** EIP-712 voucher type for TIP-1034 channel escrow vouchers. */ -export const types = { +export const voucherTypes = { Voucher: [ { name: 'channelId', type: 'bytes32' }, { name: 'cumulativeAmount', type: 'uint96' }, @@ -58,7 +40,7 @@ export const types = { * precompile. p256/WebAuthn keychain wrappers are rejected; pass an explicit * secp256k1 authorized signer for voucher delegation. */ -export async function sign( +export async function signVoucher( client: Client, account: Account, voucher: Voucher, @@ -70,8 +52,8 @@ export async function sign( ): Promise { const signature = await signTypedData(client, { account, - domain: domain(parameters.chainId, parameters.verifyingContract), - types, + domain: getVoucherDomain(parameters.chainId, parameters.verifyingContract), + types: voucherTypes, primaryType: 'Voucher', message: voucher, }) @@ -100,7 +82,7 @@ export async function sign( * rejected because the precompile verifies vouchers with ecrecover against the * channel's authorized signer. */ -export function verify( +export function verifyVoucher( voucher: SignedVoucher, expectedSigner: Address, parameters: { chainId: number; verifyingContract?: Address | undefined }, @@ -110,8 +92,8 @@ export function verify( if (envelope.type === 'keychain') return false const payload = hashTypedData({ - domain: domain(parameters.chainId, parameters.verifyingContract), - types, + domain: getVoucherDomain(parameters.chainId, parameters.verifyingContract), + types: voucherTypes, primaryType: 'Voucher', message: { channelId: voucher.channelId, @@ -125,3 +107,16 @@ export function verify( return false } } + +/** Parses a signed TIP-1034 voucher payload and brands its uint96 cumulative amount. */ +export function parseVoucherFromPayload( + channelId: Hex, + cumulativeAmount: string, + signature: Hex, +): SignedVoucher { + return { + channelId, + cumulativeAmount: uint96(BigInt(cumulativeAmount)), + signature, + } +} diff --git a/src/tempo/precompile/client/ChannelOps.test.ts b/src/tempo/precompile/client/ChannelOps.test.ts index de1a7654..59501cc0 100644 --- a/src/tempo/precompile/client/ChannelOps.test.ts +++ b/src/tempo/precompile/client/ChannelOps.test.ts @@ -73,7 +73,7 @@ describe('precompile client ChannelOps credential builders', () => { expect(payload.descriptor).toEqual(descriptor) expect(payload.cumulativeAmount).toBe('250') expect( - Voucher.verify( + Voucher.verifyVoucher( { channelId, cumulativeAmount, signature: payload.signature }, descriptor.authorizedSigner, { @@ -110,7 +110,8 @@ describe('precompile client ChannelOps credential builders', () => { ...descriptor, authorizedSigner: zeroAddress, } - const zeroSignerChannelId = Channel.computeId(zeroSignerDescriptor, { + const zeroSignerChannelId = Channel.computeId({ + ...zeroSignerDescriptor, chainId, escrow: tip20ChannelEscrow, }) @@ -124,7 +125,7 @@ describe('precompile client ChannelOps credential builders', () => { expect(payload.channelId).toBe(zeroSignerChannelId) expect( - Voucher.verify( + Voucher.verifyVoucher( { channelId: zeroSignerChannelId, cumulativeAmount, signature: payload.signature }, descriptor.payer, { @@ -148,7 +149,7 @@ describe('precompile client ChannelOps credential builders', () => { expect(payload.channelId).toBe(channelId) expect(payload.cumulativeAmount).toBe('300') expect( - Voucher.verify( + Voucher.verifyVoucher( { channelId, cumulativeAmount, signature: payload.signature }, descriptor.authorizedSigner, { diff --git a/src/tempo/precompile/client/ChannelOps.ts b/src/tempo/precompile/client/ChannelOps.ts index 323e5b6f..eb84f3eb 100644 --- a/src/tempo/precompile/client/ChannelOps.ts +++ b/src/tempo/precompile/client/ChannelOps.ts @@ -87,8 +87,12 @@ export async function createOpen( salt, token: parameters.token, } satisfies Channel.ChannelDescriptor - const channelId = Channel.computeId({ ...descriptor, chainId: parameters.chainId, escrow }) - const voucherSignature = await Voucher.sign( + const channelId = Channel.computeId({ + ...descriptor, + chainId: parameters.chainId, + escrow, + }) + const voucherSignature = await Voucher.signVoucher( client, account, { channelId, cumulativeAmount: parameters.initialAmount }, @@ -132,8 +136,12 @@ export async function createVoucherCredential( }, ): Promise { const escrow = parameters.escrow ?? tip20ChannelEscrow - const channelId = Channel.computeId({ ...parameters.descriptor, chainId: parameters.chainId, escrow }) - const signature = await Voucher.sign( + const channelId = Channel.computeId({ + ...parameters.descriptor, + chainId: parameters.chainId, + escrow, + }) + const signature = await Voucher.signVoucher( client, account, { channelId, cumulativeAmount: parameters.cumulativeAmount }, @@ -166,7 +174,11 @@ export async function createTopUp( }, ): Promise { const escrow = parameters.escrow ?? tip20ChannelEscrow - const channelId = Channel.computeId({ ...parameters.descriptor, chainId: parameters.chainId, escrow }) + const channelId = Channel.computeId({ + ...parameters.descriptor, + chainId: parameters.chainId, + escrow, + }) const prepared = await prepareTransactionRequest(client, { account, calls: [ diff --git a/src/tempo/precompile/server/Session.integration.test.ts b/src/tempo/precompile/server/Session.integration.test.ts index 4c864b9a..6e4c5786 100644 --- a/src/tempo/precompile/server/Session.integration.test.ts +++ b/src/tempo/precompile/server/Session.integration.test.ts @@ -73,7 +73,7 @@ async function openRealChannel(deposit = 1_000n) { expiringNonceHash: opened.args.expiringNonceHash as Hex.Hex, } satisfies Channel.ChannelDescriptor const channelId = opened.args.channelId as Hex.Hex - expect(Channel.computeId(descriptor, { chainId: chain.id, escrow: tip20ChannelEscrow })).toBe( + expect(Channel.computeId({ ...descriptor, chainId: chain.id, escrow: tip20ChannelEscrow })).toBe( channelId, ) return { channelId, descriptor, deposit: uint96(deposit) } diff --git a/src/tempo/precompile/server/Session.test.ts b/src/tempo/precompile/server/Session.test.ts index 67ed51dc..a26b1e5f 100644 --- a/src/tempo/precompile/server/Session.test.ts +++ b/src/tempo/precompile/server/Session.test.ts @@ -202,8 +202,8 @@ async function createOpenCredential( authorizedSigner, expiringNonceHash, } satisfies Channel.ChannelDescriptor - const channelId = Channel.computeId(descriptor, { chainId, escrow }) - const signature = await Voucher.sign( + const channelId = Channel.computeId({ ...descriptor, chainId, escrow }) + const signature = await Voucher.signVoucher( signingClient, account, { channelId, cumulativeAmount: initialAmount }, @@ -359,7 +359,10 @@ describe('precompile server session unit guardrails', () => { await expect( method.verify({ - credential: { challenge: makeChallenge(payload.channelId), payload: smuggled }, + credential: { + challenge: makeChallenge(payload.channelId), + payload: smuggled, + }, request: makeRequest(payload.channelId) as never, }), ).rejects.toThrow(/does not match/) @@ -394,7 +397,10 @@ describe('precompile server session unit guardrails', () => { await expect( method.verify({ - credential: { challenge: makeChallenge(payload.channelId), payload: badSignaturePayload }, + credential: { + challenge: makeChallenge(payload.channelId), + payload: badSignaturePayload, + }, request: makeRequest(payload.channelId) as never, }), ).rejects.toThrow(/invalid voucher signature/) @@ -455,7 +461,9 @@ describe('precompile server session unit guardrails', () => { test('accepts settle sender matching a nonzero precompile operator', async () => { const { store } = createServer() - const openPayload = await createOpenCredential({ operator: wrongPayer.address }) + const openPayload = await createOpenCredential({ + operator: wrongPayer.address, + }) await persistPrecompileChannel(store, openPayload) const { settle } = await import('./Session.js') @@ -490,7 +498,9 @@ describe('precompile server session unit guardrails', () => { test('accepts precompile settle fee token options', async () => { const { store } = createServer() const openPayload = await createOpenCredential() - await persistPrecompileChannel(store, openPayload, { payee: payer.address }) + await persistPrecompileChannel(store, openPayload, { + payee: payer.address, + }) const client = createClient({ account: payer, chain: { id: chainId } as never, @@ -514,7 +524,9 @@ describe('precompile server session unit guardrails', () => { test('accepts settle account override matching the channel payee', async () => { const { store } = createServer() const openPayload = await createOpenCredential() - await persistPrecompileChannel(store, openPayload, { payee: wrongPayer.address }) + await persistPrecompileChannel(store, openPayload, { + payee: wrongPayer.address, + }) const client = createClient({ account: payer, chain: { id: chainId } as never, @@ -538,7 +550,9 @@ describe('precompile server session unit guardrails', () => { test('rejects precompile settle fee-payer policy violations', async () => { const { store } = createServer() const openPayload = await createOpenCredential() - await persistPrecompileChannel(store, openPayload, { payee: payer.address }) + await persistPrecompileChannel(store, openPayload, { + payee: payer.address, + }) const { settle } = await import('./Session.js') await expect( @@ -554,7 +568,10 @@ describe('precompile server session unit guardrails', () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenCredential() - await persistPrecompileChannel(store, openPayload, { payee: payer.address, spent: 150n }) + await persistPrecompileChannel(store, openPayload, { + payee: payer.address, + spent: 150n, + }) const method = session({ account: payer, amount: '1', @@ -574,7 +591,10 @@ describe('precompile server session unit guardrails', () => { await expect( method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload }, + credential: { + challenge: makeChallenge(openPayload.channelId), + payload, + }, request: makeRequest(openPayload.channelId) as never, }), ).rejects.toThrow(/close voucher amount must be >= 150 \(spent\)/) @@ -584,7 +604,9 @@ describe('precompile server session unit guardrails', () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenCredential() - await persistPrecompileChannel(store, openPayload, { payee: payer.address }) + await persistPrecompileChannel(store, openPayload, { + payee: payer.address, + }) const method = session({ account: payer, amount: '1', @@ -595,7 +617,11 @@ describe('precompile server session unit guardrails', () => { store: rawStore, unitType: 'request', getClient: () => - createStateClient(payer, { settled: 100n, deposit: 1_000n, closeRequestedAt: 0 }), + createStateClient(payer, { + settled: 100n, + deposit: 1_000n, + closeRequestedAt: 0, + }), }) const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { chainId, @@ -605,7 +631,10 @@ describe('precompile server session unit guardrails', () => { await expect( method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload }, + credential: { + challenge: makeChallenge(openPayload.channelId), + payload, + }, request: makeRequest(openPayload.channelId) as never, }), ).rejects.toThrow(/close voucher amount must be >= 100 \(on-chain settled\)/) @@ -615,7 +644,10 @@ describe('precompile server session unit guardrails', () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenCredential() - await persistPrecompileChannel(store, openPayload, { payee: payer.address, spent: 100n }) + await persistPrecompileChannel(store, openPayload, { + payee: payer.address, + spent: 100n, + }) const method = session({ account: payer, amount: '1', @@ -625,7 +657,12 @@ describe('precompile server session unit guardrails', () => { recipient: payee, store: rawStore, unitType: 'request', - getClient: () => createStateClient(payer, { settled: 0n, deposit: 99n, closeRequestedAt: 0 }), + getClient: () => + createStateClient(payer, { + settled: 0n, + deposit: 99n, + closeRequestedAt: 0, + }), }) const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { chainId, @@ -635,7 +672,10 @@ describe('precompile server session unit guardrails', () => { await expect( method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload }, + credential: { + challenge: makeChallenge(openPayload.channelId), + payload, + }, request: makeRequest(openPayload.channelId) as never, }), ).rejects.toThrow(/close capture amount exceeds on-chain deposit/) @@ -662,10 +702,16 @@ describe('precompile server session unit guardrails', () => { getClient: () => createStateClient(payer), }) - await persistPrecompileChannel(store, openPayload, { finalized: true, payee: payer.address }) + await persistPrecompileChannel(store, openPayload, { + finalized: true, + payee: payer.address, + }) await expect( method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload }, + credential: { + challenge: makeChallenge(openPayload.channelId), + payload, + }, request: makeRequest(openPayload.channelId) as never, }), ).rejects.toThrow(/channel is already finalized/) @@ -676,7 +722,10 @@ describe('precompile server session unit guardrails', () => { }) await expect( method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload }, + credential: { + challenge: makeChallenge(openPayload.channelId), + payload, + }, request: makeRequest(openPayload.channelId) as never, }), ).rejects.toThrow(/channel has a pending close request/) @@ -745,7 +794,10 @@ describe('precompile server session unit guardrails', () => { }) const receipt = await method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload: lowerVoucher }, + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: lowerVoucher, + }, request: makeRequest(openPayload.channelId) as never, }) @@ -758,7 +810,9 @@ describe('precompile server session unit guardrails', () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenCredential() - await persistPrecompileChannel(store, openPayload, { payee: payer.address }) + await persistPrecompileChannel(store, openPayload, { + payee: payer.address, + }) let observedPending = false const method = session({ account: payer, @@ -803,7 +857,10 @@ describe('precompile server session unit guardrails', () => { await expect( method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload }, + credential: { + challenge: makeChallenge(openPayload.channelId), + payload, + }, request: makeRequest(openPayload.channelId) as never, }), ).rejects.toThrow(/broadcast failed/) @@ -834,7 +891,10 @@ describe('precompile server session unit guardrails', () => { await expect( method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload }, + credential: { + challenge: makeChallenge(openPayload.channelId), + payload, + }, request: makeRequest(openPayload.channelId) as never, }), ).rejects.toThrow(/no account available/) @@ -844,7 +904,9 @@ describe('precompile server session unit guardrails', () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenCredential() - await persistPrecompileChannel(store, openPayload, { payee: wrongPayer.address }) + await persistPrecompileChannel(store, openPayload, { + payee: wrongPayer.address, + }) const method = session({ account: wrongPayer, amount: '1', @@ -864,7 +926,10 @@ describe('precompile server session unit guardrails', () => { await expect( method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload }, + credential: { + challenge: makeChallenge(openPayload.channelId), + payload, + }, request: makeRequest(openPayload.channelId) as never, }), ).rejects.toThrow(/eth_sendRawTransaction/) @@ -874,7 +939,9 @@ describe('precompile server session unit guardrails', () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenCredential() - await persistPrecompileChannel(store, openPayload, { payee: wrongPayer.address }) + await persistPrecompileChannel(store, openPayload, { + payee: wrongPayer.address, + }) const method = session({ account: wrongPayer, amount: '1', @@ -895,7 +962,10 @@ describe('precompile server session unit guardrails', () => { await expect( method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload }, + credential: { + challenge: makeChallenge(openPayload.channelId), + payload, + }, request: { ...makeRequest(openPayload.channelId), feePayer: payer, @@ -911,7 +981,9 @@ describe('precompile server session unit guardrails', () => { test('accepts server-driven close sender matching a nonzero precompile operator', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) - const openPayload = await createOpenCredential({ operator: wrongPayer.address }) + const openPayload = await createOpenCredential({ + operator: wrongPayer.address, + }) await persistPrecompileChannel(store, openPayload) const method = session({ account: wrongPayer, @@ -932,7 +1004,10 @@ describe('precompile server session unit guardrails', () => { await expect( method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload }, + credential: { + challenge: makeChallenge(openPayload.channelId), + payload, + }, request: makeRequest(openPayload.channelId) as never, }), ).rejects.toThrow(/eth_sendRawTransaction/) @@ -961,7 +1036,10 @@ describe('precompile server session unit guardrails', () => { await expect( method.verify({ - credential: { challenge: makeChallenge(openPayload.channelId), payload }, + credential: { + challenge: makeChallenge(openPayload.channelId), + payload, + }, request: makeRequest(openPayload.channelId) as never, }), ).rejects.toThrow(/tx sender .* is not the channel payee/) diff --git a/src/tempo/precompile/server/Session.ts b/src/tempo/precompile/server/Session.ts index 44a73d00..3fb022ec 100644 --- a/src/tempo/precompile/server/Session.ts +++ b/src/tempo/precompile/server/Session.ts @@ -529,7 +529,7 @@ async function handleOpen(parameters: { }) if (payload.cumulativeAmount > open.deposit) throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds open deposit' }) - const valid = await Voucher.verify( + const valid = await Voucher.verifyVoucher( { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, authorizedSigner(descriptor), { chainId, verifyingContract: escrow }, @@ -791,7 +791,7 @@ async function handleVoucher(parameters: { throw new VerificationFailedError({ reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher', }) - const valid = await Voucher.verify( + const valid = await Voucher.verifyVoucher( { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, channel.authorizedSigner, { chainId, verifyingContract: escrow }, @@ -879,7 +879,7 @@ async function handleClose(parameters: { throw new VerificationFailedError({ reason: `close voucher amount must be >= ${state.settled} (on-chain settled)`, }) - const valid = await Voucher.verify( + const valid = await Voucher.verifyVoucher( { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, channel.authorizedSigner, { chainId, verifyingContract: escrow }, diff --git a/src/tempo/precompile/session/Client.test.ts b/src/tempo/precompile/session/Client.test.ts index 79db7d58..75ab4059 100644 --- a/src/tempo/precompile/session/Client.test.ts +++ b/src/tempo/precompile/session/Client.test.ts @@ -290,7 +290,7 @@ describe('precompile client session', () => { expect(payload.cumulativeAmount).toBe('250') expect(decoded.source).toBe(`did:pkh:eip155:${chainId}:${account.address}`) expect( - Voucher.verify( + Voucher.verifyVoucher( { channelId, cumulativeAmount, signature: payload.signature }, descriptor.authorizedSigner, { chainId, verifyingContract: tip20ChannelEscrow }, From bea3fb344ac245850e134e9da7873f57cecfbe2c Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 16:28:04 +0200 Subject: [PATCH 17/26] Align precompile voucher signing inputs --- .changeset/precompile-voucher-inputs.md | 5 + .../precompile/Chain.integration.test.ts | 3 +- src/tempo/precompile/Voucher.test.ts | 110 +++++++++--------- src/tempo/precompile/Voucher.ts | 60 +++++----- .../precompile/client/ChannelOps.test.ts | 18 +-- src/tempo/precompile/client/ChannelOps.ts | 16 +-- src/tempo/precompile/server/Session.test.ts | 3 +- src/tempo/precompile/server/Session.ts | 9 +- src/tempo/precompile/session/Client.test.ts | 3 +- 9 files changed, 116 insertions(+), 111 deletions(-) create mode 100644 .changeset/precompile-voucher-inputs.md diff --git a/.changeset/precompile-voucher-inputs.md b/.changeset/precompile-voucher-inputs.md new file mode 100644 index 00000000..db6caacc --- /dev/null +++ b/.changeset/precompile-voucher-inputs.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Updated precompile voucher signing inputs to match legacy session voucher signing. diff --git a/src/tempo/precompile/Chain.integration.test.ts b/src/tempo/precompile/Chain.integration.test.ts index 42ceca83..0ea960b4 100644 --- a/src/tempo/precompile/Chain.integration.test.ts +++ b/src/tempo/precompile/Chain.integration.test.ts @@ -111,7 +111,8 @@ describe.runIf(isPrecompileTestnet)('TIP-1034 precompile chain operations', () = client, payer, { channelId, cumulativeAmount }, - { chainId: chain.id, verifyingContract: tip20ChannelEscrow }, + tip20ChannelEscrow, + chain.id, ) const receipt = await sendPrecompileCall( diff --git a/src/tempo/precompile/Voucher.test.ts b/src/tempo/precompile/Voucher.test.ts index 09fccdca..eabf5cd7 100644 --- a/src/tempo/precompile/Voucher.test.ts +++ b/src/tempo/precompile/Voucher.test.ts @@ -35,18 +35,18 @@ describe('Precompile Voucher', () => { client, account, { channelId, cumulativeAmount }, - { - chainId, - verifyingContract: escrowContract, - }, + escrowContract, + chainId, ) expect(signature).toMatch(/^0x/) expect(signature.length).toBe(132) - const isValid = verifyVoucher({ channelId, cumulativeAmount, signature }, account.address, { + const isValid = verifyVoucher( + escrowContract, chainId, - verifyingContract: escrowContract, - }) + { channelId, cumulativeAmount, signature }, + account.address, + ) expect(isValid).toBe(true) }) @@ -55,17 +55,17 @@ describe('Precompile Voucher', () => { client, account, { channelId, cumulativeAmount }, - { - chainId, - verifyingContract: escrowContract, - }, + escrowContract, + chainId, ) const wrongAddress = '0x0000000000000000000000000000000000000001' as const - const isValid = verifyVoucher({ channelId, cumulativeAmount, signature }, wrongAddress, { + const isValid = verifyVoucher( + escrowContract, chainId, - verifyingContract: escrowContract, - }) + { channelId, cumulativeAmount, signature }, + wrongAddress, + ) expect(isValid).toBe(false) }) @@ -74,16 +74,15 @@ describe('Precompile Voucher', () => { client, account, { channelId, cumulativeAmount }, - { - chainId, - verifyingContract: escrowContract, - }, + escrowContract, + chainId, ) const isValid = verifyVoucher( + escrowContract, + chainId, { channelId, cumulativeAmount: uint96(9_999_999n), signature }, account.address, - { chainId, verifyingContract: escrowContract }, ) expect(isValid).toBe(false) }) @@ -93,18 +92,17 @@ describe('Precompile Voucher', () => { client, account, { channelId, cumulativeAmount }, - { - chainId, - verifyingContract: escrowContract, - }, + escrowContract, + chainId, ) const wrongChannelId = '0x0000000000000000000000000000000000000000000000000000000000000099' as const const isValid = verifyVoucher( + escrowContract, + chainId, { channelId: wrongChannelId, cumulativeAmount, signature }, account.address, - { chainId, verifyingContract: escrowContract }, ) expect(isValid).toBe(false) }) @@ -114,24 +112,25 @@ describe('Precompile Voucher', () => { client, account, { channelId, cumulativeAmount }, - { - chainId, - verifyingContract: escrowContract, - }, + escrowContract, + chainId, ) - const isValid = verifyVoucher({ channelId, cumulativeAmount, signature }, account.address, { - chainId: 99999, - verifyingContract: escrowContract, - }) + const isValid = verifyVoucher( + escrowContract, + 99999, + { channelId, cumulativeAmount, signature }, + account.address, + ) expect(isValid).toBe(false) }) test('verify returns false for invalid signature', () => { const isValid = verifyVoucher( + escrowContract, + chainId, { channelId, cumulativeAmount, signature: '0xdeadbeef' }, account.address, - { chainId, verifyingContract: escrowContract }, ) expect(isValid).toBe(false) }) @@ -167,17 +166,17 @@ describe('Precompile Voucher', () => { client, account, { channelId, cumulativeAmount }, - { - chainId, - verifyingContract: escrowContract, - }, + escrowContract, + chainId, ) const wrongEscrow = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as const - const isValid = verifyVoucher({ channelId, cumulativeAmount, signature }, account.address, { + const isValid = verifyVoucher( + wrongEscrow, chainId, - verifyingContract: wrongEscrow, - }) + { channelId, cumulativeAmount, signature }, + account.address, + ) expect(isValid).toBe(false) }) @@ -187,17 +186,16 @@ describe('Precompile Voucher', () => { client, account, { channelId, cumulativeAmount: zeroAmount }, - { - chainId, - verifyingContract: escrowContract, - }, + escrowContract, + chainId, ) expect(signature).toMatch(/^0x/) const isValid = verifyVoucher( + escrowContract, + chainId, { channelId, cumulativeAmount: zeroAmount, signature }, account.address, - { chainId, verifyingContract: escrowContract }, ) expect(isValid).toBe(true) }) @@ -205,7 +203,7 @@ describe('Precompile Voucher', () => { test('verify rejects direct keychain wrapper signatures', async () => { const signature = await signTypedData(client, { account, - domain: getVoucherDomain(chainId, escrowContract), + domain: getVoucherDomain(escrowContract, chainId), types: voucherTypes, primaryType: 'Voucher', message: { channelId, cumulativeAmount }, @@ -222,10 +220,12 @@ describe('Precompile Voucher', () => { ) expect( - verifyVoucher({ channelId, cumulativeAmount, signature: wrapped }, account.address, { + verifyVoucher( + escrowContract, chainId, - verifyingContract: escrowContract, - }), + { channelId, cumulativeAmount, signature: wrapped }, + account.address, + ), ).toBe(false) }) @@ -244,17 +244,15 @@ describe('Precompile Voucher', () => { accessKeyClient, accessKey, { channelId, cumulativeAmount }, - { - authorizedSigner: accessKey.accessKeyAddress, - chainId, - verifyingContract: escrowContract, - }, + escrowContract, + chainId, + accessKey.accessKeyAddress, ), ).rejects.toThrow('TIP-1034 voucher signing only supports secp256k1 keychain access keys.') }) test('domain and type match TIP-1034', () => { - expect(getVoucherDomain(chainId, escrowContract)).toEqual({ + expect(getVoucherDomain(escrowContract, chainId)).toEqual({ name: 'TIP20 Channel Escrow', version: '1', chainId, @@ -266,7 +264,7 @@ describe('Precompile Voucher', () => { ]) expect( hashTypedData({ - domain: getVoucherDomain(chainId, escrowContract), + domain: getVoucherDomain(escrowContract, chainId), types: voucherTypes, primaryType: 'Voucher', message: { channelId, cumulativeAmount }, diff --git a/src/tempo/precompile/Voucher.ts b/src/tempo/precompile/Voucher.ts index 6c835fa0..e29b8905 100644 --- a/src/tempo/precompile/Voucher.ts +++ b/src/tempo/precompile/Voucher.ts @@ -5,26 +5,30 @@ import { hashTypedData } from 'viem' import { signTypedData } from 'viem/actions' import * as TempoAddress from '../internal/address.js' -import { tip20ChannelEscrow } from './Constants.js' import type { Voucher, SignedVoucher } from './Types.js' import { uint96 } from './Types.js' -/** Must match the on-chain TempoStreamChannel DOMAIN_SEPARATOR name. */ +/** Must match the on-chain TIP20 channel escrow DOMAIN_SEPARATOR name. */ const DOMAIN_NAME = 'TIP20 Channel Escrow' -/** Must match the on-chain TempoStreamChannel DOMAIN_SEPARATOR version. */ +/** Must match the on-chain TIP20 channel escrow DOMAIN_SEPARATOR version. */ const DOMAIN_VERSION = '1' -/** EIP-712 domain for TIP-1034 channel escrow vouchers. */ -export function getVoucherDomain(chainId: number, verifyingContract: Address = tip20ChannelEscrow) { +/** + * EIP-712 domain for voucher signing. + */ +export function getVoucherDomain(escrowContract: Address, chainId: number) { return { name: DOMAIN_NAME, version: DOMAIN_VERSION, chainId, - verifyingContract, + verifyingContract: escrowContract, } as const } -/** EIP-712 voucher type for TIP-1034 channel escrow vouchers. */ +/** + * EIP-712 types for voucher signing. + * Matches @tempo/stream-channels/voucher and on-chain VOUCHER_TYPEHASH. + */ export const voucherTypes = { Voucher: [ { name: 'channelId', type: 'bytes32' }, @@ -33,32 +37,29 @@ export const voucherTypes = { } as const /** - * Signs a TIP-1034 voucher. - * - * When `authorizedSigner` is a delegated access key, only secp256k1 keychain - * signatures can be unwrapped into the raw ECDSA signature accepted by the - * precompile. p256/WebAuthn keychain wrappers are rejected; pass an explicit - * secp256k1 authorized signer for voucher delegation. + * Sign a voucher with an account. */ export async function signVoucher( client: Client, account: Account, voucher: Voucher, - parameters: { - chainId: number - verifyingContract?: Address | undefined - authorizedSigner?: Address | undefined - }, + verifyingContract: Address, + chainId: number, + authorizedSigner?: Address | undefined, ): Promise { const signature = await signTypedData(client, { account, - domain: getVoucherDomain(parameters.chainId, parameters.verifyingContract), + domain: getVoucherDomain(verifyingContract, chainId), types: voucherTypes, primaryType: 'Voucher', message: voucher, }) - if (parameters.authorizedSigner) { + // When a separate authorizedSigner is used (e.g. access key), unwrap the + // keychain envelope — the escrow contract verifies raw ECDSA signatures + // against authorizedSigner, not keychain-wrapped ones. + // TODO: when TIP-1020 is implemented, we can remove this. + if (authorizedSigner) { const envelope = (() => { try { return SignatureEnvelope.from(signature as SignatureEnvelope.Serialized) @@ -76,23 +77,26 @@ export async function signVoucher( } /** - * Verifies a direct TIP-1034 voucher signature. + * Verify a voucher signature matches the expected signer. * - * Only raw secp256k1 signatures are accepted. Keychain wrapper signatures are - * rejected because the precompile verifies vouchers with ecrecover against the - * channel's authorized signer. + * Only accepts raw secp256k1 signatures — the escrow contract verifies + * via ecrecover. Keychain, p256, and webAuthn signatures are rejected. */ export function verifyVoucher( + escrowContract: Address, + chainId: number, voucher: SignedVoucher, expectedSigner: Address, - parameters: { chainId: number; verifyingContract?: Address | undefined }, ): boolean { try { const envelope = SignatureEnvelope.from(voucher.signature as SignatureEnvelope.Serialized) + + // Reject keychain signatures — the escrow contract verifies raw ECDSA + // signatures against authorizedSigner, not keychain-wrapped ones. if (envelope.type === 'keychain') return false const payload = hashTypedData({ - domain: getVoucherDomain(parameters.chainId, parameters.verifyingContract), + domain: getVoucherDomain(escrowContract, chainId), types: voucherTypes, primaryType: 'Voucher', message: { @@ -108,7 +112,9 @@ export function verifyVoucher( } } -/** Parses a signed TIP-1034 voucher payload and brands its uint96 cumulative amount. */ +/** + * Parse a voucher from credential payload. + */ export function parseVoucherFromPayload( channelId: Hex, cumulativeAmount: string, diff --git a/src/tempo/precompile/client/ChannelOps.test.ts b/src/tempo/precompile/client/ChannelOps.test.ts index 59501cc0..4dd3972b 100644 --- a/src/tempo/precompile/client/ChannelOps.test.ts +++ b/src/tempo/precompile/client/ChannelOps.test.ts @@ -74,12 +74,10 @@ describe('precompile client ChannelOps credential builders', () => { expect(payload.cumulativeAmount).toBe('250') expect( Voucher.verifyVoucher( + tip20ChannelEscrow, + chainId, { channelId, cumulativeAmount, signature: payload.signature }, descriptor.authorizedSigner, - { - chainId, - verifyingContract: tip20ChannelEscrow, - }, ), ).toBe(true) }) @@ -126,12 +124,10 @@ describe('precompile client ChannelOps credential builders', () => { expect(payload.channelId).toBe(zeroSignerChannelId) expect( Voucher.verifyVoucher( + tip20ChannelEscrow, + chainId, { channelId: zeroSignerChannelId, cumulativeAmount, signature: payload.signature }, descriptor.payer, - { - chainId, - verifyingContract: tip20ChannelEscrow, - }, ), ).toBe(true) }) @@ -150,12 +146,10 @@ describe('precompile client ChannelOps credential builders', () => { expect(payload.cumulativeAmount).toBe('300') expect( Voucher.verifyVoucher( + tip20ChannelEscrow, + chainId, { channelId, cumulativeAmount, signature: payload.signature }, descriptor.authorizedSigner, - { - chainId, - verifyingContract: tip20ChannelEscrow, - }, ), ).toBe(true) }) diff --git a/src/tempo/precompile/client/ChannelOps.ts b/src/tempo/precompile/client/ChannelOps.ts index eb84f3eb..5e5fd068 100644 --- a/src/tempo/precompile/client/ChannelOps.ts +++ b/src/tempo/precompile/client/ChannelOps.ts @@ -96,11 +96,9 @@ export async function createOpen( client, account, { channelId, cumulativeAmount: parameters.initialAmount }, - { - authorizedSigner: voucherAuthorizedSigner(authorizedSigner), - chainId: parameters.chainId, - verifyingContract: escrow, - }, + escrow, + parameters.chainId, + voucherAuthorizedSigner(authorizedSigner), ) const transaction = (await signTransaction(client, prepared as never)) as Hex.Hex @@ -145,11 +143,9 @@ export async function createVoucherCredential( client, account, { channelId, cumulativeAmount: parameters.cumulativeAmount }, - { - authorizedSigner: voucherAuthorizedSigner(parameters.descriptor.authorizedSigner), - chainId: parameters.chainId, - verifyingContract: escrow, - }, + escrow, + parameters.chainId, + voucherAuthorizedSigner(parameters.descriptor.authorizedSigner), ) return { diff --git a/src/tempo/precompile/server/Session.test.ts b/src/tempo/precompile/server/Session.test.ts index a26b1e5f..f31d8450 100644 --- a/src/tempo/precompile/server/Session.test.ts +++ b/src/tempo/precompile/server/Session.test.ts @@ -207,7 +207,8 @@ async function createOpenCredential( signingClient, account, { channelId, cumulativeAmount: initialAmount }, - { chainId, verifyingContract: escrow }, + escrow, + chainId, ) return { action: 'open', diff --git a/src/tempo/precompile/server/Session.ts b/src/tempo/precompile/server/Session.ts index 3fb022ec..26e75058 100644 --- a/src/tempo/precompile/server/Session.ts +++ b/src/tempo/precompile/server/Session.ts @@ -530,9 +530,10 @@ async function handleOpen(parameters: { if (payload.cumulativeAmount > open.deposit) throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds open deposit' }) const valid = await Voucher.verifyVoucher( + escrow, + chainId, { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, authorizedSigner(descriptor), - { chainId, verifyingContract: escrow }, ) if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) const receipt = await sendCredentialTransaction({ @@ -792,9 +793,10 @@ async function handleVoucher(parameters: { reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher', }) const valid = await Voucher.verifyVoucher( + escrow, + chainId, { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, channel.authorizedSigner, - { chainId, verifyingContract: escrow }, ) if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) if (payload.cumulativeAmount === channel.highestVoucherAmount) @@ -880,9 +882,10 @@ async function handleClose(parameters: { reason: `close voucher amount must be >= ${state.settled} (on-chain settled)`, }) const valid = await Voucher.verifyVoucher( + escrow, + chainId, { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, channel.authorizedSigner, - { chainId, verifyingContract: escrow }, ) if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) let captureAmount = uint96(channel.spent > state.settled ? channel.spent : state.settled) diff --git a/src/tempo/precompile/session/Client.test.ts b/src/tempo/precompile/session/Client.test.ts index 75ab4059..5df88679 100644 --- a/src/tempo/precompile/session/Client.test.ts +++ b/src/tempo/precompile/session/Client.test.ts @@ -291,9 +291,10 @@ describe('precompile client session', () => { expect(decoded.source).toBe(`did:pkh:eip155:${chainId}:${account.address}`) expect( Voucher.verifyVoucher( + tip20ChannelEscrow, + chainId, { channelId, cumulativeAmount, signature: payload.signature }, descriptor.authorizedSigner, - { chainId, verifyingContract: tip20ChannelEscrow }, ), ).toBe(true) }) From a8234444c601401a5e59552b2ed0b74ca3ac7cf5 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 17:11:53 +0200 Subject: [PATCH 18/26] Align precompile chain helpers with legacy API --- .changeset/precompile-bigint-amounts.md | 5 + .changeset/tip-1034-precompile-hash.md | 2 +- src/tempo/client/Methods.ts | 2 +- .../precompile/Chain.integration.test.ts | 29 +- src/tempo/precompile/Chain.test.ts | 90 ++--- src/tempo/precompile/Chain.ts | 342 ++++++++---------- src/tempo/precompile/Types.ts | 27 +- src/tempo/precompile/client/ChannelOps.ts | 21 +- .../server/Session.integration.test.ts | 21 +- src/tempo/precompile/server/Session.test.ts | 12 +- src/tempo/precompile/server/Session.ts | 6 +- src/tempo/precompile/session/Client.ts | 4 +- .../precompile/session/SessionManager.ts | 2 +- src/tempo/server/Methods.ts | 2 +- src/tempo/session/ChannelStore.ts | 4 +- 15 files changed, 252 insertions(+), 317 deletions(-) create mode 100644 .changeset/precompile-bigint-amounts.md diff --git a/.changeset/precompile-bigint-amounts.md b/.changeset/precompile-bigint-amounts.md new file mode 100644 index 00000000..6dbadb27 --- /dev/null +++ b/.changeset/precompile-bigint-amounts.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Changed TIP20EscrowChannel precompile chain helpers to use `*OnChain` names, accept plain bigint amounts, and validate uint96 bounds at encoding boundaries. diff --git a/.changeset/tip-1034-precompile-hash.md b/.changeset/tip-1034-precompile-hash.md index 6e89b537..1577dbb8 100644 --- a/.changeset/tip-1034-precompile-hash.md +++ b/.changeset/tip-1034-precompile-hash.md @@ -31,4 +31,4 @@ const server = Mppx.create({ }) ``` -This added channel ID, expiring nonce hash, voucher, ABI calldata, open/top-up validation, descriptor persistence, credential payload parsing, client credential builder, session manager, server verification, and server-driven fee-payer settle/close helpers for TIP-1034 precompile channels. It also hardened precompile channel validation, atomic store updates, voucher-signing compatibility, finalized channel bookkeeping, and devnet precompile integration test setup. +This added channel ID, expiring nonce hash, voucher, ABI calldata, open/top-up validation, descriptor persistence, credential payload parsing, client credential builder, session manager, server verification, and server-driven fee-payer settle/close helpers for TIP20EscrowChannel precompile channels. It also hardened precompile channel validation, atomic store updates, voucher-signing compatibility, finalized channel bookkeeping, and devnet precompile integration test setup. diff --git a/src/tempo/client/Methods.ts b/src/tempo/client/Methods.ts index ab09b66b..5254bbea 100644 --- a/src/tempo/client/Methods.ts +++ b/src/tempo/client/Methods.ts @@ -27,7 +27,7 @@ export namespace tempo { export const charge = charge_ /** Creates a client-side streaming session for managing payment channels. */ export const session = session_ - /** TIP-1034 precompile primitives for opt-in session implementations. */ + /** TIP20EscrowChannel precompile primitives for opt-in session implementations. */ export const precompile = Precompile_ /** Creates a Tempo `subscription` client method for recurring TIP-20 payments. */ export const subscription = subscription_ diff --git a/src/tempo/precompile/Chain.integration.test.ts b/src/tempo/precompile/Chain.integration.test.ts index 0ea960b4..87164976 100644 --- a/src/tempo/precompile/Chain.integration.test.ts +++ b/src/tempo/precompile/Chain.integration.test.ts @@ -1,5 +1,5 @@ import { Hex } from 'ox' -import { type Address, isAddressEqual, parseEventLogs, zeroAddress } from 'viem' +import { type Address, encodeFunctionData, isAddressEqual, parseEventLogs, zeroAddress } from 'viem' import { sendTransaction, waitForTransactionReceipt } from 'viem/actions' import { describe, expect, test } from 'vp/test' import { nodeEnv } from '~test/config.js' @@ -42,13 +42,10 @@ function getSingleEvent(receipt: { logs: readonly unknown[] }, name: string) { async function openChannel(parameters: { deposit?: bigint | undefined } = {}) { const deposit = uint96(parameters.deposit ?? 1_000n) const salt = Hex.random(32) - const data = Chain.encodeOpen({ - payee: payee.address, - operator: zeroAddress, - token: asset, - deposit, - salt, - authorizedSigner: payer.address, + const data = encodeFunctionData({ + abi: escrowAbi, + functionName: 'open', + args: [payee.address, zeroAddress, asset, deposit, salt, payer.address], }) const receipt = await sendPrecompileCall(data) const opened = getSingleEvent(receipt, 'ChannelOpened') @@ -73,7 +70,7 @@ async function openChannel(parameters: { deposit?: bigint | undefined } = {}) { return { channelId, descriptor, deposit } } -describe.runIf(isPrecompileTestnet)('TIP-1034 precompile chain operations', () => { +describe.runIf(isPrecompileTestnet)('TIP20EscrowChannel precompile chain operations', () => { test('opens a channel, parses ChannelOpened, and reads channel state', async () => { const { channelId, descriptor, deposit } = await openChannel() @@ -94,7 +91,13 @@ describe.runIf(isPrecompileTestnet)('TIP-1034 precompile chain operations', () = }) const additionalDeposit = uint96(750n) - const receipt = await sendPrecompileCall(Chain.encodeTopUp(descriptor, additionalDeposit)) + const receipt = await sendPrecompileCall( + encodeFunctionData({ + abi: escrowAbi, + functionName: 'topUp', + args: [descriptor, additionalDeposit], + }), + ) const topUp = getSingleEvent(receipt, 'TopUp') expect(topUp.args.channelId).toBe(channelId) expect(topUp.args.additionalDeposit).toBe(additionalDeposit) @@ -116,7 +119,11 @@ describe.runIf(isPrecompileTestnet)('TIP-1034 precompile chain operations', () = ) const receipt = await sendPrecompileCall( - Chain.encodeSettle(descriptor, cumulativeAmount, signature), + encodeFunctionData({ + abi: escrowAbi, + functionName: 'settle', + args: [descriptor, cumulativeAmount, signature], + }), payee, ) const settled = getSingleEvent(receipt, 'Settled') diff --git a/src/tempo/precompile/Chain.test.ts b/src/tempo/precompile/Chain.test.ts index 9f63a980..ca38da91 100644 --- a/src/tempo/precompile/Chain.test.ts +++ b/src/tempo/precompile/Chain.test.ts @@ -1,7 +1,6 @@ -import { decodeFunctionData, encodeFunctionData, erc20Abi } from 'viem' +import { encodeFunctionData, erc20Abi } from 'viem' import { describe, expect, test } from 'vp/test' -import * as Chain from './Chain.js' import { escrowAbi } from './escrow.abi.js' import * as ServerChannelOps from './server/ChannelOps.js' import * as Types from './Types.js' @@ -17,23 +16,20 @@ const descriptor = { } as const const deposit = Types.uint96(1_000_000n) -const cumulativeAmount = Types.uint96(500_000n) -const captureAmount = Types.uint96(400_000n) -const signature = '0x1234' as const -function expectDescriptor(actual: unknown) { - expect(actual).toEqual(descriptor) -} - -describe('precompile Chain encoders', () => { - test('encodeOpen round-trips through parseOpenCall', () => { - const data = Chain.encodeOpen({ - authorizedSigner: descriptor.authorizedSigner, - deposit, - operator: descriptor.operator, - payee: descriptor.payee, - salt: descriptor.salt, - token: descriptor.token, +describe('precompile open calldata parsing', () => { + test('parseOpenCall accepts TIP-1034 open calldata', () => { + const data = encodeFunctionData({ + abi: escrowAbi, + functionName: 'open', + args: [ + descriptor.payee, + descriptor.operator, + descriptor.token, + deposit, + descriptor.salt, + descriptor.authorizedSigner, + ], }) const open = ServerChannelOps.parseOpenCall({ data, @@ -65,13 +61,17 @@ describe('precompile Chain encoders', () => { 'Expected TIP-1034 open calldata', ) - const data = Chain.encodeOpen({ - authorizedSigner: descriptor.authorizedSigner, - deposit, - operator: descriptor.operator, - payee: descriptor.payee, - salt: descriptor.salt, - token: descriptor.token, + const data = encodeFunctionData({ + abi: escrowAbi, + functionName: 'open', + args: [ + descriptor.payee, + descriptor.operator, + descriptor.token, + deposit, + descriptor.salt, + descriptor.authorizedSigner, + ], }) expect(() => ServerChannelOps.parseOpenCall({ @@ -101,46 +101,6 @@ describe('precompile Chain encoders', () => { ServerChannelOps.parseOpenCall({ data, expected: { deposit: Types.uint96(1n) } }), ).toThrow('deposit does not match') }) - - test('encodes descriptor-based lifecycle calls', () => { - const settle = decodeFunctionData({ - abi: escrowAbi, - data: Chain.encodeSettle(descriptor, cumulativeAmount, signature), - }) - expect(settle.functionName).toBe('settle') - expectDescriptor(settle.args[0]) - expect(settle.args[1]).toBe(cumulativeAmount) - expect(settle.args[2]).toBe(signature) - - const topUp = decodeFunctionData({ - abi: escrowAbi, - data: Chain.encodeTopUp(descriptor, deposit), - }) - expect(topUp.functionName).toBe('topUp') - expectDescriptor(topUp.args[0]) - expect(topUp.args[1]).toBe(deposit) - - const close = decodeFunctionData({ - abi: escrowAbi, - data: Chain.encodeClose(descriptor, cumulativeAmount, captureAmount, signature), - }) - expect(close.functionName).toBe('close') - expectDescriptor(close.args[0]) - expect(close.args[1]).toBe(cumulativeAmount) - expect(close.args[2]).toBe(captureAmount) - expect(close.args[3]).toBe(signature) - - const requestClose = decodeFunctionData({ - abi: escrowAbi, - data: Chain.encodeRequestClose(descriptor), - }) - expect(requestClose.functionName).toBe('requestClose') - expectDescriptor(requestClose.args[0]) - - const withdraw = decodeFunctionData({ abi: escrowAbi, data: Chain.encodeWithdraw(descriptor) }) - expect(withdraw.functionName).toBe('withdraw') - expectDescriptor(withdraw.args[0]) - }) }) describe('precompile escrowAbi parity', () => { diff --git a/src/tempo/precompile/Chain.ts b/src/tempo/precompile/Chain.ts index ddf1f938..d5d86787 100644 --- a/src/tempo/precompile/Chain.ts +++ b/src/tempo/precompile/Chain.ts @@ -15,122 +15,35 @@ import { resolveFeeToken } from '../internal/fee-token.js' import type { ChannelDescriptor } from './Channel.js' import { tip20ChannelEscrow } from './Constants.js' import { escrowAbi } from './escrow.abi.js' -import type { Uint96 } from './Types.js' -import { uint96 } from './Types.js' -export type ChannelState = { - settled: Uint96 - deposit: Uint96 - closeRequestedAt: number -} +const UINT96_MAX = 2n ** 96n - 1n -export type Channel = { - descriptor: ChannelDescriptor - state: ChannelState +function assertUint96(amount: bigint): void { + if (amount < 0n || amount > UINT96_MAX) { + throw new VerificationFailedError({ reason: 'amount exceeds uint96 range' }) + } } -function stateFromTuple(state: { +/** + * On-chain channel state from the TIP20EscrowChannel precompile. + */ +export type ChannelState = { settled: bigint deposit: bigint closeRequestedAt: number -}): ChannelState { - return { - settled: uint96(state.settled), - deposit: uint96(state.deposit), - closeRequestedAt: state.closeRequestedAt, - } -} - -function descriptorTuple(descriptor: ChannelDescriptor) { - return { - payer: descriptor.payer, - payee: descriptor.payee, - operator: descriptor.operator, - token: descriptor.token, - salt: descriptor.salt, - authorizedSigner: descriptor.authorizedSigner, - expiringNonceHash: descriptor.expiringNonceHash, - } as const -} - -/** Encodes a TIP-1034 approve-less `open` call. */ -export function encodeOpen(parameters: { - payee: Address - operator: Address - token: Address - deposit: Uint96 - salt: Hex - authorizedSigner: Address -}): Hex { - return encodeFunctionData({ - abi: escrowAbi, - functionName: 'open', - args: [ - parameters.payee, - parameters.operator, - parameters.token, - parameters.deposit, - parameters.salt, - parameters.authorizedSigner, - ], - }) -} - -/** Encodes a descriptor-based TIP-1034 `settle` call. */ -export function encodeSettle( - descriptor: ChannelDescriptor, - cumulativeAmount: Uint96, - signature: Hex, -): Hex { - return encodeFunctionData({ - abi: escrowAbi, - functionName: 'settle', - args: [descriptorTuple(descriptor), cumulativeAmount, signature], - }) -} - -/** Encodes a descriptor-based TIP-1034 `topUp` call. */ -export function encodeTopUp(descriptor: ChannelDescriptor, additionalDeposit: Uint96): Hex { - return encodeFunctionData({ - abi: escrowAbi, - functionName: 'topUp', - args: [descriptorTuple(descriptor), additionalDeposit], - }) } -/** Encodes a descriptor-based TIP-1034 `close` call. */ -export function encodeClose( - descriptor: ChannelDescriptor, - cumulativeAmount: Uint96, - captureAmount: Uint96, - signature: Hex, -): Hex { - return encodeFunctionData({ - abi: escrowAbi, - functionName: 'close', - args: [descriptorTuple(descriptor), cumulativeAmount, captureAmount, signature], - }) -} - -/** Encodes a descriptor-based TIP-1034 `requestClose` call. */ -export function encodeRequestClose(descriptor: ChannelDescriptor): Hex { - return encodeFunctionData({ - abi: escrowAbi, - functionName: 'requestClose', - args: [descriptorTuple(descriptor)], - }) -} - -/** Encodes a descriptor-based TIP-1034 `withdraw` call. */ -export function encodeWithdraw(descriptor: ChannelDescriptor): Hex { - return encodeFunctionData({ - abi: escrowAbi, - functionName: 'withdraw', - args: [descriptorTuple(descriptor)], - }) +/** + * On-chain channel descriptor and state from the TIP20EscrowChannel precompile. + */ +export type Channel = { + descriptor: ChannelDescriptor + state: ChannelState } -/** Reads immutable descriptor and mutable state for a TIP-1034 channel. */ +/** + * Read channel descriptor and state from the TIP20EscrowChannel precompile. + */ export async function getChannel( client: Client, descriptor: ChannelDescriptor, @@ -148,7 +61,9 @@ export async function getChannel( } } -/** Reads mutable state for a TIP-1034 channel ID. */ +/** + * Read channel state from the TIP20EscrowChannel precompile. + */ export async function getChannelState( client: Client, channelId: Hex, @@ -163,7 +78,9 @@ export async function getChannelState( return stateFromTuple(state) } -/** Reads mutable states for TIP-1034 channel IDs in one precompile call. */ +/** + * Read channel states from the TIP20EscrowChannel precompile. + */ export async function getChannelStatesBatch( client: Client, channelIds: readonly Hex[], @@ -186,6 +103,137 @@ type SendOptions = { feeToken?: Address | undefined } +/** + * Submit a settle transaction on-chain. + */ +export async function settleOnChain( + client: Client, + descriptor: ChannelDescriptor, + cumulativeAmount: bigint, + signature: Hex, + escrow: Address = tip20ChannelEscrow, + options?: SendOptions, +): Promise { + assertUint96(cumulativeAmount) + const args = [descriptorTuple(descriptor), cumulativeAmount, signature] as const + return sendPrecompileTransaction( + client, + escrow, + encodeFunctionData({ abi: escrowAbi, functionName: 'settle', args }), + 'settle', + options, + ) +} + +/** + * Submit a top-up transaction on-chain. + */ +export async function topUpOnChain( + client: Client, + descriptor: ChannelDescriptor, + additionalDeposit: bigint, + escrow: Address = tip20ChannelEscrow, + options?: SendOptions, +): Promise { + assertUint96(additionalDeposit) + const args = [descriptorTuple(descriptor), additionalDeposit] as const + return sendPrecompileTransaction( + client, + escrow, + encodeFunctionData({ abi: escrowAbi, functionName: 'topUp', args }), + 'topUp', + options, + ) +} + +/** + * Submit a request-close transaction on-chain. + */ +export async function requestCloseOnChain( + client: Client, + descriptor: ChannelDescriptor, + escrow: Address = tip20ChannelEscrow, + options?: SendOptions, +): Promise { + const args = [descriptorTuple(descriptor)] as const + return sendPrecompileTransaction( + client, + escrow, + encodeFunctionData({ abi: escrowAbi, functionName: 'requestClose', args }), + 'requestClose', + options, + ) +} + +/** + * Submit a withdraw transaction on-chain. + */ +export async function withdrawOnChain( + client: Client, + descriptor: ChannelDescriptor, + escrow: Address = tip20ChannelEscrow, + options?: SendOptions, +): Promise { + const args = [descriptorTuple(descriptor)] as const + return sendPrecompileTransaction( + client, + escrow, + encodeFunctionData({ abi: escrowAbi, functionName: 'withdraw', args }), + 'withdraw', + options, + ) +} + +/** + * Submit a close transaction on-chain. + */ +export async function closeOnChain( + client: Client, + descriptor: ChannelDescriptor, + cumulativeAmount: bigint, + captureAmount: bigint, + signature: Hex, + escrow: Address = tip20ChannelEscrow, + options?: SendOptions, +): Promise { + assertUint96(cumulativeAmount) + assertUint96(captureAmount) + const args = [descriptorTuple(descriptor), cumulativeAmount, captureAmount, signature] as const + return sendPrecompileTransaction( + client, + escrow, + encodeFunctionData({ abi: escrowAbi, functionName: 'close', args }), + 'close', + options, + ) +} + +function stateFromTuple(state: { + settled: bigint + deposit: bigint + closeRequestedAt: number +}): ChannelState { + assertUint96(state.settled) + assertUint96(state.deposit) + return { + settled: state.settled, + deposit: state.deposit, + closeRequestedAt: state.closeRequestedAt, + } +} + +function descriptorTuple(descriptor: ChannelDescriptor) { + return { + payer: descriptor.payer, + payee: descriptor.payee, + operator: descriptor.operator, + token: descriptor.token, + salt: descriptor.salt, + authorizedSigner: descriptor.authorizedSigner, + expiringNonceHash: descriptor.expiringNonceHash, + } as const +} + function assertFeePayerPolicy( prepared: { gas?: bigint | undefined @@ -257,83 +305,3 @@ async function sendPrecompileTransaction( ...(options?.feeToken ? { feeToken: options.feeToken } : {}), } as never) } - -/** Broadcasts a descriptor-based TIP-1034 settle transaction with optional fee sponsorship. */ -export async function settle( - client: Client, - descriptor: ChannelDescriptor, - cumulativeAmount: Uint96, - signature: Hex, - escrow: Address = tip20ChannelEscrow, - options?: SendOptions, -): Promise { - return sendPrecompileTransaction( - client, - escrow, - encodeSettle(descriptor, cumulativeAmount, signature), - 'settle', - options, - ) -} - -/** Broadcasts a descriptor-based TIP-1034 top-up transaction with optional fee sponsorship. */ -export async function topUp( - client: Client, - descriptor: ChannelDescriptor, - additionalDeposit: Uint96, - escrow: Address = tip20ChannelEscrow, - options?: SendOptions, -): Promise { - return sendPrecompileTransaction( - client, - escrow, - encodeTopUp(descriptor, additionalDeposit), - 'topUp', - options, - ) -} - -/** Broadcasts a descriptor-based TIP-1034 request-close transaction with optional fee sponsorship. */ -export async function requestClose( - client: Client, - descriptor: ChannelDescriptor, - escrow: Address = tip20ChannelEscrow, - options?: SendOptions, -): Promise { - return sendPrecompileTransaction( - client, - escrow, - encodeRequestClose(descriptor), - 'requestClose', - options, - ) -} - -/** Broadcasts a descriptor-based TIP-1034 withdraw transaction with optional fee sponsorship. */ -export async function withdraw( - client: Client, - descriptor: ChannelDescriptor, - escrow: Address = tip20ChannelEscrow, - options?: SendOptions, -): Promise { - return sendPrecompileTransaction(client, escrow, encodeWithdraw(descriptor), 'withdraw', options) -} - -/** Broadcasts a descriptor-based TIP-1034 close transaction with optional fee sponsorship. */ -export async function close( - client: Client, - descriptor: ChannelDescriptor, - cumulativeAmount: Uint96, - captureAmount: Uint96, - signature: Hex, - escrow: Address = tip20ChannelEscrow, - options?: SendOptions, -): Promise { - return sendPrecompileTransaction( - client, - escrow, - encodeClose(descriptor, cumulativeAmount, captureAmount, signature), - 'close', - options, - ) -} diff --git a/src/tempo/precompile/Types.ts b/src/tempo/precompile/Types.ts index 858a9e8e..4ba0e1ea 100644 --- a/src/tempo/precompile/Types.ts +++ b/src/tempo/precompile/Types.ts @@ -1,25 +1,24 @@ import type { Address, Hex } from 'viem' const maxUint96 = (1n << 96n) - 1n -declare const uint96Brand: unique symbol -/** Bigint branded as already validated to fit the TIP-1034 `uint96` amount width. */ -export type Uint96 = bigint & { readonly [uint96Brand]: true } +/** Amount encoded by TIP-1034 as a `uint96` on-chain value. */ +export type Uint96 = bigint /** Returns whether a bigint can be encoded as a TIP-1034 `uint96` amount. */ export function isUint96(value: bigint): value is Uint96 { return value >= 0n && value <= maxUint96 } -/** Converts a bigint into a branded TIP-1034 `uint96` amount. */ +/** Converts a bigint into a TIP-1034 `uint96` amount after validating bounds. */ export function uint96(value: bigint): Uint96 { - if (!isUint96(value)) throw new Error(`Value ${value} is outside uint96 bounds.`) + assertUint96(value) return value } /** Asserts that a bigint can be encoded as a TIP-1034 `uint96` amount. */ -export function assertUint96(value: bigint): asserts value is Uint96 { - uint96(value) +export function assertUint96(value: bigint): void { + if (!isUint96(value)) throw new Error(`Value ${value} is outside uint96 bounds.`) } export type ChannelDescriptor = { @@ -46,7 +45,7 @@ export type Voucher = { */ export type SignedVoucher = Voucher & { signature: Hex } -/** TIP-1034 precompile open credential payload before amount branding. */ +/** TIP20EscrowChannel precompile open credential payload before amount branding. */ export type OpenCredentialPayload = { action: 'open' type: 'transaction' @@ -58,7 +57,7 @@ export type OpenCredentialPayload = { authorizedSigner?: Address | undefined } -/** TIP-1034 precompile top-up credential payload before amount branding. */ +/** TIP20EscrowChannel precompile top-up credential payload before amount branding. */ export type TopUpCredentialPayload = { action: 'topUp' type: 'transaction' @@ -68,7 +67,7 @@ export type TopUpCredentialPayload = { additionalDeposit: string } -/** TIP-1034 precompile voucher credential payload before amount branding. */ +/** TIP20EscrowChannel precompile voucher credential payload before amount branding. */ export type VoucherCredentialPayload = { action: 'voucher' channelId: Hex @@ -77,7 +76,7 @@ export type VoucherCredentialPayload = { signature: Hex } -/** TIP-1034 precompile close credential payload before amount branding. */ +/** TIP20EscrowChannel precompile close credential payload before amount branding. */ export type CloseCredentialPayload = { action: 'close' channelId: Hex @@ -86,7 +85,7 @@ export type CloseCredentialPayload = { signature: Hex } -/** TIP-1034 precompile session credential payload before amount branding. */ +/** TIP20EscrowChannel precompile session credential payload before amount branding. */ export type SessionCredentialPayload = | OpenCredentialPayload | TopUpCredentialPayload @@ -109,7 +108,7 @@ export type ParsedCloseCredentialPayload = Omit( p?: NoExtraKeys, ) { @@ -929,7 +929,7 @@ async function handleClose(parameters: { payee: channel.payee, sender: account?.address, }) - txHash = await Chain.close( + txHash = await Chain.closeOnChain( client, channel.descriptor, payload.cumulativeAmount, @@ -1025,7 +1025,7 @@ export async function settle( sender: account?.address, }) const amount = uint96(channel.highestVoucher.cumulativeAmount) - const txHash = await Chain.settle( + const txHash = await Chain.settleOnChain( client, channel.descriptor, amount, diff --git a/src/tempo/precompile/session/Client.ts b/src/tempo/precompile/session/Client.ts index 2620c408..cbad76fe 100644 --- a/src/tempo/precompile/session/Client.ts +++ b/src/tempo/precompile/session/Client.ts @@ -104,7 +104,7 @@ function isSameAddress(a: Address, b: Address): boolean { return a.toLowerCase() === b.toLowerCase() } -/** Creates a client-side TIP-1034 precompile session payment method. */ +/** Creates a client-side TIP20EscrowChannel precompile session payment method. */ export function session(parameters: session.Parameters = {}) { const { decimals = defaults.decimals } = parameters const maxDeposit = @@ -374,7 +374,7 @@ export declare namespace session { deposit?: string | undefined /** Maximum deposit in human-readable units. Caps the server suggestedDeposit and enables auto-management. */ maxDeposit?: string | undefined - /** TIP-1034 precompile address override. */ + /** TIP20EscrowChannel precompile address override. */ escrow?: Address | undefined /** Address authorized to operate the precompile channel on behalf of the payee. */ operator?: Address | undefined diff --git a/src/tempo/precompile/session/SessionManager.ts b/src/tempo/precompile/session/SessionManager.ts index 566394d1..5c112af5 100644 --- a/src/tempo/precompile/session/SessionManager.ts +++ b/src/tempo/precompile/session/SessionManager.ts @@ -845,7 +845,7 @@ export declare namespace sessionManager { client?: import('viem').Client | undefined /** Token decimals used to convert `maxDeposit` to raw units. Defaults to `6`. */ decimals?: number | undefined - /** TIP-1034 precompile address override. */ + /** TIP20EscrowChannel precompile address override. */ escrow?: Address | undefined fetch?: typeof globalThis.fetch | undefined /** Maximum deposit in human-readable units (e.g. `'10'` for 10 tokens). Converted to raw units via `decimals`. */ diff --git a/src/tempo/server/Methods.ts b/src/tempo/server/Methods.ts index 6578e894..ac331be4 100644 --- a/src/tempo/server/Methods.ts +++ b/src/tempo/server/Methods.ts @@ -30,7 +30,7 @@ export namespace tempo { export const charge = charge_ /** Creates a Tempo `session` method for session-based TIP-20 token payments. */ export const session = session_ - /** TIP-1034 precompile primitives for opt-in session implementations. */ + /** TIP20EscrowChannel precompile primitives for opt-in session implementations. */ export const precompile = Precompile_ /** Creates a Tempo `subscription` method for recurring TIP-20 token payments. */ export const subscription = subscription_ diff --git a/src/tempo/session/ChannelStore.ts b/src/tempo/session/ChannelStore.ts index 66192ae9..db5692e4 100644 --- a/src/tempo/session/ChannelStore.ts +++ b/src/tempo/session/ChannelStore.ts @@ -30,7 +30,7 @@ export interface ContractBackendState { backend?: 'contract' | undefined } -/** State for a TIP-1034 precompile-backed payment channel. */ +/** State for a TIP20EscrowChannel precompile-backed payment channel. */ export interface PrecompileBackendState { /** Channel backend. */ backend: 'precompile' @@ -79,7 +79,7 @@ export interface BaseState { units: number } -/** Returns whether a channel is backed by the TIP-1034 precompile. */ +/** Returns whether a channel is backed by the TIP20EscrowChannel precompile. */ export function isPrecompileState(state: State): state is BaseState & PrecompileBackendState { return state.backend === 'precompile' } From 096d34fed1281ed9b3136b92b66ce520fc05f40e Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 17:18:42 +0200 Subject: [PATCH 19/26] Handle stale test setup locks --- test/setup.ts | 71 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/test/setup.ts b/test/setup.ts index 757b3165..61489c44 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -13,6 +13,7 @@ import { rpcUrl } from './tempo/prool.js' import { accounts, asset, chain, client, fundAccount } from './tempo/viem.js' const setupTimeoutMs = 120_000 +const setupLockStaleAfterMs = 2 * setupTimeoutMs const warmupAttempts = 5 const warmupRetryDelayMs = 1_000 const warmupRequestTimeoutMs = 10_000 @@ -41,6 +42,12 @@ const devnetSetupKey = node_crypto.createHash('sha256').update(rpcUrl).digest('h const devnetSetupDir = node_path.join(node_os.tmpdir(), `mppx-devnet-setup-${devnetSetupKey}`) const devnetSetupLock = node_path.join(devnetSetupDir, 'lock') +type SetupLockMetadata = { + createdAt: number + pid: number + rpcUrl: string +} + async function exists(path: string) { try { await node_fs.access(path) @@ -50,20 +57,61 @@ async function exists(path: string) { } } -async function runLocalnetSetupLocked(fn: () => Promise) { - await node_fs.mkdir(localnetSetupDir, { recursive: true }) - if (await exists(localnetSetupDone)) return +async function writeSetupLockMetadata(path: string) { + const metadata = { + createdAt: Date.now(), + pid: process.pid, + rpcUrl, + } satisfies SetupLockMetadata + await node_fs.writeFile(node_path.join(path, 'metadata.json'), JSON.stringify(metadata)) +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} +async function isStaleSetupLock(path: string): Promise { + try { + const raw = await node_fs.readFile(node_path.join(path, 'metadata.json'), 'utf8') + const metadata = JSON.parse(raw) as Partial + if (typeof metadata.createdAt !== 'number') return true + if (Date.now() - metadata.createdAt > setupLockStaleAfterMs) return true + if (typeof metadata.pid !== 'number') return true + return !isProcessAlive(metadata.pid) + } catch { + return true + } +} + +async function acquireSetupLock(path: string, done?: string | undefined) { for (;;) { try { - await node_fs.mkdir(localnetSetupLock) - break + await node_fs.mkdir(path) + await writeSetupLockMetadata(path) + return } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error - if (await exists(localnetSetupDone)) return + if (done && (await exists(done))) return + if (await isStaleSetupLock(path)) { + await node_fs.rm(path, { recursive: true, force: true }) + continue + } await sleep(250) } } +} + +async function runLocalnetSetupLocked(fn: () => Promise) { + await node_fs.mkdir(localnetSetupDir, { recursive: true }) + if (await exists(localnetSetupDone)) return + + await acquireSetupLock(localnetSetupLock, localnetSetupDone) + if (await exists(localnetSetupDone)) return try { if (await exists(localnetSetupDone)) return @@ -76,16 +124,7 @@ async function runLocalnetSetupLocked(fn: () => Promise) { async function runDevnetSetupLocked(fn: () => Promise) { await node_fs.mkdir(devnetSetupDir, { recursive: true }) - - for (;;) { - try { - await node_fs.mkdir(devnetSetupLock) - break - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error - await sleep(250) - } - } + await acquireSetupLock(devnetSetupLock) try { await fn() From 7b48c9bfb5164093b9b7bc06321f801e2f4303f1 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 17:26:05 +0200 Subject: [PATCH 20/26] Simplify precompile credential payload types --- src/tempo/precompile/Types.test.ts | 83 +---------- src/tempo/precompile/Types.ts | 147 ++++++-------------- src/tempo/precompile/client/ChannelOps.ts | 16 +-- src/tempo/precompile/server/Session.test.ts | 6 +- src/tempo/precompile/server/Session.ts | 77 +++++----- 5 files changed, 88 insertions(+), 241 deletions(-) diff --git a/src/tempo/precompile/Types.test.ts b/src/tempo/precompile/Types.test.ts index c8533c99..1ae6885c 100644 --- a/src/tempo/precompile/Types.test.ts +++ b/src/tempo/precompile/Types.test.ts @@ -4,16 +4,6 @@ import * as Types from './Types.js' const maxUint96 = (1n << 96n) - 1n -const descriptor = { - payer: '0x0000000000000000000000000000000000000001', - payee: '0x0000000000000000000000000000000000000002', - operator: '0x0000000000000000000000000000000000000003', - token: '0x0000000000000000000000000000000000000004', - salt: `0x${'11'.repeat(32)}`, - authorizedSigner: '0x0000000000000000000000000000000000000005', - expiringNonceHash: `0x${'22'.repeat(32)}`, -} as const - describe('precompile Uint96', () => { test('accepts lower and upper bounds', () => { expect(Types.uint96(0n)).toBe(0n) @@ -29,77 +19,10 @@ describe('precompile Uint96', () => { expect(Types.isUint96(maxUint96 + 1n)).toBe(false) }) - test('assertUint96 narrows valid values and throws for invalid values', () => { - let amount: bigint = 1n + test('assertUint96 validates valid values and throws for invalid values', () => { + const amount: bigint = 1n Types.assertUint96(amount) - const branded: Types.Uint96 = amount - expect(branded).toBe(1n) + expect(amount).toBe(1n) expect(() => Types.assertUint96(maxUint96 + 1n)).toThrow('outside uint96 bounds') }) }) - -describe('precompile session credential payloads', () => { - test('brands open cumulative amounts at the payload boundary', () => { - const parsed = Types.parseCredentialPayload({ - action: 'open', - type: 'transaction', - channelId: `0x${'33'.repeat(32)}`, - transaction: '0x1234', - signature: '0xabcd', - descriptor, - cumulativeAmount: '10', - }) - - expect(parsed.action).toBe('open') - expect(parsed.cumulativeAmount).toBe(10n) - expect(Types.isUint96(parsed.cumulativeAmount)).toBe(true) - }) - - test('brands top-up additional deposits at the payload boundary', () => { - const parsed = Types.parseCredentialPayload({ - action: 'topUp', - type: 'transaction', - channelId: `0x${'44'.repeat(32)}`, - transaction: '0x1234', - descriptor, - additionalDeposit: maxUint96.toString(), - }) - - expect(parsed.action).toBe('topUp') - expect(parsed.additionalDeposit).toBe(maxUint96) - }) - - test('brands voucher cumulative amounts at the payload boundary', () => { - const parsed = Types.parseCredentialPayload({ - action: 'voucher', - channelId: `0x${'55'.repeat(32)}`, - signature: '0xabcd', - descriptor, - cumulativeAmount: maxUint96.toString(), - }) - - expect(parsed.action).toBe('voucher') - expect(parsed.cumulativeAmount).toBe(maxUint96) - }) - - test('brands close cumulative amounts at the payload boundary', () => { - const parsed = Types.parseCredentialPayload({ - action: 'close', - channelId: `0x${'66'.repeat(32)}`, - signature: '0xabcd', - descriptor, - cumulativeAmount: '1', - }) - - expect(parsed.action).toBe('close') - expect(parsed.cumulativeAmount).toBe(1n) - }) - - test('rejects malformed or overflowing cumulative amounts', () => { - expect(() => Types.parseUint96Amount('1.5')).toThrow('decimal string') - expect(() => Types.parseUint96Amount('-1')).toThrow('decimal string') - expect(() => Types.parseUint96Amount((maxUint96 + 1n).toString())).toThrow( - 'outside uint96 bounds', - ) - }) -}) diff --git a/src/tempo/precompile/Types.ts b/src/tempo/precompile/Types.ts index 4ba0e1ea..320bf5a7 100644 --- a/src/tempo/precompile/Types.ts +++ b/src/tempo/precompile/Types.ts @@ -2,21 +2,21 @@ import type { Address, Hex } from 'viem' const maxUint96 = (1n << 96n) - 1n -/** Amount encoded by TIP-1034 as a `uint96` on-chain value. */ +/** Amount encoded by TIP20EscrowChannel as a `uint96` on-chain value. */ export type Uint96 = bigint -/** Returns whether a bigint can be encoded as a TIP-1034 `uint96` amount. */ +/** Returns whether a bigint can be encoded as a TIP20EscrowChannel `uint96` amount. */ export function isUint96(value: bigint): value is Uint96 { return value >= 0n && value <= maxUint96 } -/** Converts a bigint into a TIP-1034 `uint96` amount after validating bounds. */ +/** Converts a bigint into a TIP20EscrowChannel `uint96` amount after validating bounds. */ export function uint96(value: bigint): Uint96 { assertUint96(value) return value } -/** Asserts that a bigint can be encoded as a TIP-1034 `uint96` amount. */ +/** Asserts that a bigint can be encoded as a TIP20EscrowChannel `uint96` amount. */ export function assertUint96(value: bigint): void { if (!isUint96(value)) throw new Error(`Value ${value} is outside uint96 bounds.`) } @@ -37,7 +37,7 @@ export type ChannelDescriptor = { */ export type Voucher = { channelId: Hex - cumulativeAmount: Uint96 + cumulativeAmount: bigint } /** @@ -45,108 +45,39 @@ export type Voucher = { */ export type SignedVoucher = Voucher & { signature: Hex } -/** TIP20EscrowChannel precompile open credential payload before amount branding. */ -export type OpenCredentialPayload = { - action: 'open' - type: 'transaction' - channelId: Hex - transaction: Hex - signature: Hex - descriptor: ChannelDescriptor - cumulativeAmount: string - authorizedSigner?: Address | undefined -} - -/** TIP20EscrowChannel precompile top-up credential payload before amount branding. */ -export type TopUpCredentialPayload = { - action: 'topUp' - type: 'transaction' - channelId: Hex - transaction: Hex - descriptor: ChannelDescriptor - additionalDeposit: string -} - -/** TIP20EscrowChannel precompile voucher credential payload before amount branding. */ -export type VoucherCredentialPayload = { - action: 'voucher' - channelId: Hex - descriptor: ChannelDescriptor - cumulativeAmount: string - signature: Hex -} - -/** TIP20EscrowChannel precompile close credential payload before amount branding. */ -export type CloseCredentialPayload = { - action: 'close' - channelId: Hex - descriptor: ChannelDescriptor - cumulativeAmount: string - signature: Hex -} - -/** TIP20EscrowChannel precompile session credential payload before amount branding. */ +/** + * TIP20EscrowChannel precompile session credential payload (discriminated union). + */ export type SessionCredentialPayload = - | OpenCredentialPayload - | TopUpCredentialPayload - | VoucherCredentialPayload - | CloseCredentialPayload - -export type ParsedOpenCredentialPayload = Omit & { - cumulativeAmount: Uint96 -} - -export type ParsedTopUpCredentialPayload = Omit & { - additionalDeposit: Uint96 -} - -export type ParsedVoucherCredentialPayload = Omit & { - cumulativeAmount: Uint96 -} - -export type ParsedCloseCredentialPayload = Omit & { - cumulativeAmount: Uint96 -} - -/** TIP20EscrowChannel precompile session credential payload after decimal amount parsing. */ -export type ParsedSessionCredentialPayload = - | ParsedOpenCredentialPayload - | ParsedTopUpCredentialPayload - | ParsedVoucherCredentialPayload - | ParsedCloseCredentialPayload - -export function parseCredentialPayload(payload: OpenCredentialPayload): ParsedOpenCredentialPayload -export function parseCredentialPayload( - payload: TopUpCredentialPayload, -): ParsedTopUpCredentialPayload -export function parseCredentialPayload( - payload: VoucherCredentialPayload, -): ParsedVoucherCredentialPayload -export function parseCredentialPayload( - payload: CloseCredentialPayload, -): ParsedCloseCredentialPayload -export function parseCredentialPayload( - payload: SessionCredentialPayload, -): ParsedSessionCredentialPayload -/** Parses decimal string amounts from a precompile session credential payload. */ -export function parseCredentialPayload( - payload: SessionCredentialPayload, -): ParsedSessionCredentialPayload { - if (payload.action === 'topUp') { - return { - ...payload, - additionalDeposit: parseUint96Amount(payload.additionalDeposit), + | { + action: 'open' + type: 'transaction' + channelId: Hex + transaction: Hex + signature: Hex + descriptor: ChannelDescriptor + cumulativeAmount: string + authorizedSigner?: Address | undefined + } + | { + action: 'topUp' + type: 'transaction' + channelId: Hex + transaction: Hex + descriptor: ChannelDescriptor + additionalDeposit: string + } + | { + action: 'voucher' + channelId: Hex + descriptor: ChannelDescriptor + cumulativeAmount: string + signature: Hex + } + | { + action: 'close' + channelId: Hex + descriptor: ChannelDescriptor + cumulativeAmount: string + signature: Hex } - } - - return { - ...payload, - cumulativeAmount: parseUint96Amount(payload.cumulativeAmount), - } -} - -/** Parses a decimal string into a TIP-1034 `uint96` amount. */ -export function parseUint96Amount(value: string): Uint96 { - if (!/^\d+$/.test(value)) throw new Error('Expected uint96 amount as a decimal string.') - return uint96(BigInt(value)) -} diff --git a/src/tempo/precompile/client/ChannelOps.ts b/src/tempo/precompile/client/ChannelOps.ts index 2b347d6f..1646df33 100644 --- a/src/tempo/precompile/client/ChannelOps.ts +++ b/src/tempo/precompile/client/ChannelOps.ts @@ -5,13 +5,7 @@ import { prepareTransactionRequest, signTransaction } from 'viem/actions' import * as Channel from '../Channel.js' import { tip20ChannelEscrow } from '../Constants.js' import { escrowAbi } from '../escrow.abi.js' -import type { - CloseCredentialPayload, - OpenCredentialPayload, - TopUpCredentialPayload, - Uint96, - VoucherCredentialPayload, -} from '../Types.js' +import type { SessionCredentialPayload, Uint96 } from '../Types.js' import * as Voucher from '../Voucher.js' export type OpenResult = { @@ -106,7 +100,7 @@ export async function createOpen( export function createOpenCredential( result: OpenResult, initialAmount: Uint96, -): OpenCredentialPayload { +): Extract { return { action: 'open', type: 'transaction', @@ -129,7 +123,7 @@ export async function createVoucherCredential( descriptor: Channel.ChannelDescriptor escrow?: Address | undefined }, -): Promise { +): Promise> { const escrow = parameters.escrow ?? tip20ChannelEscrow const channelId = Channel.computeId({ ...parameters.descriptor, @@ -196,7 +190,7 @@ export async function createTopUp( export function createTopUpCredential( result: TopUpResult, additionalDeposit: Uint96, -): TopUpCredentialPayload { +): Extract { return { action: 'topUp', type: 'transaction', @@ -217,7 +211,7 @@ export async function createCloseCredential( descriptor: Channel.ChannelDescriptor escrow?: Address | undefined }, -): Promise { +): Promise> { const voucher = await createVoucherCredential(client, account, parameters) return { action: 'close', diff --git a/src/tempo/precompile/server/Session.test.ts b/src/tempo/precompile/server/Session.test.ts index ec343b70..8b41b372 100644 --- a/src/tempo/precompile/server/Session.test.ts +++ b/src/tempo/precompile/server/Session.test.ts @@ -19,7 +19,7 @@ import * as Channel from '../Channel.js' import * as ClientOps from '../client/ChannelOps.js' import { tip20ChannelEscrow } from '../Constants.js' import { escrowAbi } from '../escrow.abi.js' -import type { OpenCredentialPayload } from '../Types.js' +import type { SessionCredentialPayload } from '../Types.js' import * as Types from '../Types.js' import * as Voucher from '../Voucher.js' import { session } from './Session.js' @@ -165,7 +165,7 @@ async function createOpenCredential( account?: typeof payer | undefined operator?: Address | undefined } = {}, -): Promise { +): Promise> { const account = parameters.account ?? payer const escrow = parameters.escrow ?? tip20ChannelEscrow const initialAmount = Types.uint96(parameters.initialAmount ?? 100n) @@ -222,7 +222,7 @@ async function createOpenCredential( async function persistPrecompileChannel( store: ChannelStore.ChannelStore, - payload: OpenCredentialPayload, + payload: Extract, overrides: Partial = {}, ) { await store.updateChannel(payload.channelId, () => ({ diff --git a/src/tempo/precompile/server/Session.ts b/src/tempo/precompile/server/Session.ts index aab43b0b..7f79e459 100644 --- a/src/tempo/precompile/server/Session.ts +++ b/src/tempo/precompile/server/Session.ts @@ -47,12 +47,7 @@ import * as Chain from '../Chain.js' import * as Channel from '../Channel.js' import { tip20ChannelEscrow } from '../Constants.js' import { escrowAbi } from '../escrow.abi.js' -import { - parseCredentialPayload, - type ParsedSessionCredentialPayload, - type SessionCredentialPayload, - uint96, -} from '../Types.js' +import { type SessionCredentialPayload, uint96 } from '../Types.js' import * as Voucher from '../Voucher.js' import * as ChannelOps from './ChannelOps.js' @@ -315,7 +310,7 @@ export function session( async verify({ credential, envelope, request }) { const { challenge, payload: rawPayload } = credential - const payload = parseCredentialPayload(rawPayload as SessionCredentialPayload) + const payload = rawPayload as SessionCredentialPayload const methodDetails = (request as typeof request & { methodDetails?: SessionMethodDetails }) .methodDetails if (!methodDetails) throw new VerificationFailedError({ reason: 'missing methodDetails' }) @@ -462,13 +457,14 @@ async function handleOpen(parameters: { store: ChannelStore.ChannelStore client: Parameters[0] challenge: Challenge.Challenge - payload: ParsedSessionCredentialPayload & { action: 'open' } + payload: Extract chainId: number escrow: Address feePayer?: viem_Account | undefined feePayerPolicy?: Partial | undefined }): Promise { const { store, client, challenge, payload, chainId, escrow } = parameters + const cumulativeAmount = uint96(BigInt(payload.cumulativeAmount)) assertDescriptor(payload) if ( payload.authorizedSigner !== undefined && @@ -527,12 +523,12 @@ async function handleOpen(parameters: { throw new VerificationFailedError({ reason: 'credential expiringNonceHash does not match transaction', }) - if (payload.cumulativeAmount > open.deposit) + if (cumulativeAmount > open.deposit) throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds open deposit' }) const valid = await Voucher.verifyVoucher( escrow, chainId, - { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, + { channelId, cumulativeAmount: cumulativeAmount, signature: payload.signature }, authorizedSigner(descriptor), ) if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) @@ -598,15 +594,15 @@ async function handleOpen(parameters: { settledOnChain: current && current.settledOnChain > state.settled ? current.settledOnChain : state.settled, highestVoucherAmount: - current?.highestVoucherAmount && current.highestVoucherAmount > payload.cumulativeAmount + current?.highestVoucherAmount && current.highestVoucherAmount > cumulativeAmount ? current.highestVoucherAmount - : payload.cumulativeAmount, + : cumulativeAmount, highestVoucher: - current?.highestVoucherAmount && current.highestVoucherAmount > payload.cumulativeAmount + current?.highestVoucherAmount && current.highestVoucherAmount > cumulativeAmount ? current.highestVoucher : { channelId: emittedChannelId, - cumulativeAmount: payload.cumulativeAmount, + cumulativeAmount: cumulativeAmount, signature: payload.signature, }, spent: current && current.spent > state.settled ? current.spent : state.settled, @@ -633,13 +629,14 @@ async function handleTopUp(parameters: { store: ChannelStore.ChannelStore client: Parameters[0] challenge: Challenge.Challenge - payload: ParsedSessionCredentialPayload & { action: 'topUp' } + payload: Extract chainId: number escrow: Address feePayer?: viem_Account | undefined feePayerPolicy?: Partial | undefined }): Promise { const { store, client, challenge, payload, chainId, escrow } = parameters + const additionalDeposit = uint96(BigInt(payload.additionalDeposit)) assertDescriptor(payload) const channelId = ChannelStore.normalizeChannelId(payload.channelId) const channel = await store.getChannel(channelId) @@ -670,14 +667,14 @@ async function handleTopUp(parameters: { }) ChannelOps.parseTopUpCall({ data: call.data!, - expected: { descriptor: channel.descriptor, additionalDeposit: payload.additionalDeposit }, + expected: { descriptor: channel.descriptor, additionalDeposit: additionalDeposit }, }) const receipt = await sendCredentialTransaction({ challengeExpires: challenge.expires, chainId, client, details: { - additionalDeposit: payload.additionalDeposit.toString(), + additionalDeposit: additionalDeposit.toString(), channelId, currency: channel.token, }, @@ -727,7 +724,7 @@ async function handleVoucher(parameters: { store: ChannelStore.ChannelStore client: Parameters[0] challenge: Challenge.Challenge - payload: ParsedSessionCredentialPayload & { action: 'voucher' } + payload: Extract chainId: number escrow: Address minVoucherDelta: bigint @@ -745,6 +742,7 @@ async function handleVoucher(parameters: { channelStateTtl, lastOnChainVerified, } = parameters + const cumulativeAmount = uint96(BigInt(payload.cumulativeAmount)) const channelId = ChannelStore.normalizeChannelId(payload.channelId) assertDescriptor(payload) const channel = await store.getChannel(channelId) @@ -782,24 +780,24 @@ async function handleVoucher(parameters: { throw new ChannelClosedError({ reason: 'channel has a pending close request' }) } if (deposit === 0n) throw new ChannelClosedError({ reason: 'channel deposit is zero (settled)' }) - if (payload.cumulativeAmount <= settled) + if (cumulativeAmount <= settled) throw new VerificationFailedError({ reason: 'voucher cumulativeAmount is below on-chain settled amount', }) - if (payload.cumulativeAmount > deposit) + if (cumulativeAmount > deposit) throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' }) - if (payload.cumulativeAmount < channel.highestVoucherAmount) + if (cumulativeAmount < channel.highestVoucherAmount) throw new VerificationFailedError({ reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher', }) const valid = await Voucher.verifyVoucher( escrow, chainId, - { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, + { channelId, cumulativeAmount: cumulativeAmount, signature: payload.signature }, channel.authorizedSigner, ) if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) - if (payload.cumulativeAmount === channel.highestVoucherAmount) + if (cumulativeAmount === channel.highestVoucherAmount) return createSessionReceipt({ challengeId: challenge.id, channelId, @@ -807,7 +805,7 @@ async function handleVoucher(parameters: { spent: channel.spent, units: channel.units, }) - const delta = payload.cumulativeAmount - channel.highestVoucherAmount + const delta = cumulativeAmount - channel.highestVoucherAmount if (delta < minVoucherDelta) throw new DeltaTooSmallError({ reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`, @@ -821,15 +819,15 @@ async function handleVoucher(parameters: { const nextDeposit = deposit > current.deposit ? deposit : current.deposit const nextSettled = settled > current.settledOnChain ? settled : current.settledOnChain - if (payload.cumulativeAmount > current.highestVoucherAmount) { + if (cumulativeAmount > current.highestVoucherAmount) { return { ...current, deposit: nextDeposit, settledOnChain: nextSettled, - highestVoucherAmount: payload.cumulativeAmount, + highestVoucherAmount: cumulativeAmount, highestVoucher: { channelId, - cumulativeAmount: payload.cumulativeAmount, + cumulativeAmount: cumulativeAmount, signature: payload.signature, }, } @@ -851,7 +849,7 @@ async function handleClose(parameters: { store: ChannelStore.ChannelStore client: Parameters[0] challenge: Challenge.Challenge - payload: ParsedSessionCredentialPayload & { action: 'close' } + payload: Extract chainId: number escrow: Address account?: viem_Account | undefined @@ -860,6 +858,7 @@ async function handleClose(parameters: { feeToken?: Address | undefined }): Promise { const { store, client, challenge, payload, chainId, escrow } = parameters + const cumulativeAmount = uint96(BigInt(payload.cumulativeAmount)) const channelId = ChannelStore.normalizeChannelId(payload.channelId) assertDescriptor(payload) const channel = await store.getChannel(channelId) @@ -871,20 +870,20 @@ async function handleClose(parameters: { const state = await Chain.getChannelState(client, channelId, escrow) if (state.closeRequestedAt !== 0) throw new ChannelClosedError({ reason: 'channel has a pending close request' }) - if (state.deposit === 0n && (payload.cumulativeAmount !== 0n || channel.spent !== 0n)) + if (state.deposit === 0n && (cumulativeAmount !== 0n || channel.spent !== 0n)) throw new ChannelClosedError({ reason: 'channel deposit is zero (settled)' }) - if (payload.cumulativeAmount < channel.spent) + if (cumulativeAmount < channel.spent) throw new VerificationFailedError({ reason: `close voucher amount must be >= ${channel.spent} (spent)`, }) - if (payload.cumulativeAmount < state.settled) + if (cumulativeAmount < state.settled) throw new VerificationFailedError({ reason: `close voucher amount must be >= ${state.settled} (on-chain settled)`, }) const valid = await Voucher.verifyVoucher( escrow, chainId, - { channelId, cumulativeAmount: payload.cumulativeAmount, signature: payload.signature }, + { channelId, cumulativeAmount: cumulativeAmount, signature: payload.signature }, channel.authorizedSigner, ) if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) @@ -899,14 +898,14 @@ async function handleClose(parameters: { if (current.finalized) throw new ChannelClosedError({ reason: 'channel is already finalized' }) if (current.closeRequestedAt !== 0n) throw new ChannelClosedError({ reason: 'channel has a pending close request' }) - if (payload.cumulativeAmount < current.spent) + if (cumulativeAmount < current.spent) throw new VerificationFailedError({ reason: `close voucher amount must be >= ${current.spent} (spent)`, }) const currentCaptureAmount = uint96( current.spent > state.settled ? current.spent : state.settled, ) - if (currentCaptureAmount > payload.cumulativeAmount) + if (currentCaptureAmount > cumulativeAmount) throw new VerificationFailedError({ reason: `close voucher amount must be >= ${currentCaptureAmount} (capture amount)`, }) @@ -932,7 +931,7 @@ async function handleClose(parameters: { txHash = await Chain.closeOnChain( client, channel.descriptor, - payload.cumulativeAmount, + cumulativeAmount, captureAmount, payload.signature, escrow, @@ -971,12 +970,12 @@ async function handleClose(parameters: { deposit: 0n, settledOnChain: captureAmount > current.settledOnChain ? captureAmount : current.settledOnChain, - ...(payload.cumulativeAmount > current.highestVoucherAmount + ...(cumulativeAmount > current.highestVoucherAmount ? { - highestVoucherAmount: payload.cumulativeAmount, + highestVoucherAmount: cumulativeAmount, highestVoucher: { channelId, - cumulativeAmount: payload.cumulativeAmount, + cumulativeAmount: cumulativeAmount, signature: payload.signature, }, } @@ -987,7 +986,7 @@ async function handleClose(parameters: { return createSessionReceipt({ challengeId: challenge.id, channelId, - acceptedCumulative: payload.cumulativeAmount, + acceptedCumulative: cumulativeAmount, spent: updated?.spent ?? channel.spent, units: updated?.units ?? channel.units, txHash, From 20b182df31b3d67a4209f6049d6305a83603147d Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 17:57:35 +0200 Subject: [PATCH 21/26] Align precompile client channel ops with legacy --- .../precompile/client/ChannelOps.test.ts | 83 ++----- src/tempo/precompile/client/ChannelOps.ts | 212 ++++++++---------- .../server/Session.integration.test.ts | 60 ++--- src/tempo/precompile/server/Session.test.ts | 127 ++++------- src/tempo/precompile/session/Client.test.ts | 7 +- src/tempo/precompile/session/Client.ts | 104 +++------ 6 files changed, 209 insertions(+), 384 deletions(-) diff --git a/src/tempo/precompile/client/ChannelOps.test.ts b/src/tempo/precompile/client/ChannelOps.test.ts index 4dd3972b..02177f07 100644 --- a/src/tempo/precompile/client/ChannelOps.test.ts +++ b/src/tempo/precompile/client/ChannelOps.test.ts @@ -35,40 +35,17 @@ const descriptor = { const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow }) describe('precompile client ChannelOps credential builders', () => { - test('creates an open credential from a signed open result', () => { - const initialAmount = Types.uint96(100n) - const payload = ChannelOps.createOpenCredential( - { - channelId, - descriptor, - transaction: '0x1234', - voucherSignature: '0xabcd', - }, - initialAmount, - ) - - expect(payload).toEqual({ - action: 'open', - type: 'transaction', - channelId, - transaction: '0x1234', - signature: '0xabcd', - descriptor, - cumulativeAmount: '100', - authorizedSigner: descriptor.authorizedSigner, - }) - }) - test('creates a verifiable voucher credential for an existing precompile channel', async () => { const cumulativeAmount = Types.uint96(250n) - const payload = await ChannelOps.createVoucherCredential(client, account, { - chainId, - cumulativeAmount, + const payload = await ChannelOps.createVoucherPayload( + client, + account, descriptor, - escrow: tip20ChannelEscrow, - }) + cumulativeAmount, + chainId, + ) + if (payload.action !== 'voucher') throw new Error('expected voucher payload') - expect(payload.action).toBe('voucher') expect(payload.channelId).toBe(channelId) expect(payload.descriptor).toEqual(descriptor) expect(payload.cumulativeAmount).toBe('250') @@ -82,27 +59,6 @@ describe('precompile client ChannelOps credential builders', () => { ).toBe(true) }) - test('creates a top-up credential from a signed top-up result', () => { - const additionalDeposit = Types.uint96(500n) - const payload = ChannelOps.createTopUpCredential( - { - channelId, - descriptor, - transaction: '0x5678', - }, - additionalDeposit, - ) - - expect(payload).toEqual({ - action: 'topUp', - type: 'transaction', - channelId, - transaction: '0x5678', - descriptor, - additionalDeposit: '500', - }) - }) - test('uses the payer as voucher signer when descriptor authorizedSigner is zero', async () => { const zeroSignerDescriptor = { ...descriptor, @@ -114,12 +70,14 @@ describe('precompile client ChannelOps credential builders', () => { escrow: tip20ChannelEscrow, }) const cumulativeAmount = Types.uint96(275n) - const payload = await ChannelOps.createVoucherCredential(client, account, { - chainId, + const payload = await ChannelOps.createVoucherPayload( + client, + account, + zeroSignerDescriptor, cumulativeAmount, - descriptor: zeroSignerDescriptor, - escrow: tip20ChannelEscrow, - }) + chainId, + ) + if (payload.action !== 'voucher') throw new Error('expected voucher payload') expect(payload.channelId).toBe(zeroSignerChannelId) expect( @@ -134,14 +92,15 @@ describe('precompile client ChannelOps credential builders', () => { test('creates a close credential with a verifiable voucher signature', async () => { const cumulativeAmount = Types.uint96(300n) - const payload = await ChannelOps.createCloseCredential(client, account, { - chainId, - cumulativeAmount, + const payload = await ChannelOps.createClosePayload( + client, + account, descriptor, - escrow: tip20ChannelEscrow, - }) + cumulativeAmount, + chainId, + ) + if (payload.action !== 'close') throw new Error('expected close payload') - expect(payload.action).toBe('close') expect(payload.channelId).toBe(channelId) expect(payload.cumulativeAmount).toBe('300') expect( diff --git a/src/tempo/precompile/client/ChannelOps.ts b/src/tempo/precompile/client/ChannelOps.ts index 1646df33..e0635d49 100644 --- a/src/tempo/precompile/client/ChannelOps.ts +++ b/src/tempo/precompile/client/ChannelOps.ts @@ -1,3 +1,11 @@ +/** + * Shared client-side precompile channel operations. + * + * Provides the low-level helpers that both `precompile.session()` and + * `precompile.sessionManager()` rely on: channel ID computation, + * transaction-bound descriptor construction, on-chain open/top-up payload + * construction, voucher/close payload serialization, and transaction signing. + */ import { Hex } from 'ox' import { encodeFunctionData, zeroAddress, type Account, type Address, type Client } from 'viem' import { prepareTransactionRequest, signTransaction } from 'viem/actions' @@ -5,22 +13,9 @@ import { prepareTransactionRequest, signTransaction } from 'viem/actions' import * as Channel from '../Channel.js' import { tip20ChannelEscrow } from '../Constants.js' import { escrowAbi } from '../escrow.abi.js' -import type { SessionCredentialPayload, Uint96 } from '../Types.js' +import type { SessionCredentialPayload } from '../Types.js' import * as Voucher from '../Voucher.js' -export type OpenResult = { - channelId: Hex.Hex - descriptor: Channel.ChannelDescriptor - transaction: Hex.Hex - voucherSignature: Hex.Hex -} - -export type TopUpResult = { - channelId: Hex.Hex - descriptor: Channel.ChannelDescriptor - transaction: Hex.Hex -} - function voucherAuthorizedSigner(address: Address): Address | undefined { return address.toLowerCase() === zeroAddress ? undefined : address } @@ -29,26 +24,73 @@ function defaultAuthorizedSigner(account: Account): Address { return (account as unknown as { accessKeyAddress?: Address }).accessKeyAddress ?? account.address } +/** Signs and creates a TIP-1034 voucher credential payload for an existing channel. */ +export async function createVoucherPayload( + client: Client, + account: Account, + descriptor: Channel.ChannelDescriptor, + cumulativeAmount: bigint, + chainId: number, +): Promise { + const channelId = Channel.computeId({ + ...descriptor, + chainId, + escrow: tip20ChannelEscrow, + }) + const signature = await Voucher.signVoucher( + client, + account, + { channelId, cumulativeAmount }, + tip20ChannelEscrow, + chainId, + voucherAuthorizedSigner(descriptor.authorizedSigner), + ) + + return { + action: 'voucher', + channelId, + descriptor, + cumulativeAmount: cumulativeAmount.toString(), + signature, + } +} + +/** Signs and creates a TIP-1034 close credential payload for an existing channel. */ +export async function createClosePayload( + client: Client, + account: Account, + descriptor: Channel.ChannelDescriptor, + cumulativeAmount: bigint, + chainId: number, +): Promise { + const voucher = await createVoucherPayload(client, account, descriptor, cumulativeAmount, chainId) + if (voucher.action !== 'voucher') throw new Error('expected voucher payload') + return { + action: 'close', + channelId: voucher.channelId, + descriptor, + cumulativeAmount: voucher.cumulativeAmount, + signature: voucher.signature, + } +} + /** - * Prepares and signs a one-call TIP-1034 channel-open transaction, computes the - * transaction-bound `expiringNonceHash` via viem, and signs the initial voucher. + * Prepares, signs, and creates a TIP-1034 open credential payload. */ -export async function createOpen( +export async function createOpenPayload( client: Client, account: Account, parameters: { authorizedSigner?: Address | undefined chainId: number - deposit: Uint96 - escrow?: Address | undefined + deposit: bigint feePayer?: boolean | undefined - initialAmount: Uint96 + initialAmount: bigint operator?: Address | undefined payee: Address token: Address }, -): Promise { - const escrow = parameters.escrow ?? tip20ChannelEscrow +): Promise { const authorizedSigner = parameters.authorizedSigner ?? defaultAuthorizedSigner(account) const operator = parameters.operator ?? '0x0000000000000000000000000000000000000000' const salt = Hex.random(32) @@ -60,7 +102,7 @@ export async function createOpen( }) const prepared = await prepareTransactionRequest(client, { account, - calls: [{ to: escrow, data: openData }], + calls: [{ to: tip20ChannelEscrow, data: openData }], ...(parameters.feePayer ? { feePayer: true } : {}), feeToken: parameters.token, } as never) @@ -81,143 +123,67 @@ export async function createOpen( const channelId = Channel.computeId({ ...descriptor, chainId: parameters.chainId, - escrow, + escrow: tip20ChannelEscrow, }) - const voucherSignature = await Voucher.signVoucher( + const signature = await Voucher.signVoucher( client, account, { channelId, cumulativeAmount: parameters.initialAmount }, - escrow, + tip20ChannelEscrow, parameters.chainId, voucherAuthorizedSigner(authorizedSigner), ) const transaction = (await signTransaction(client, prepared as never)) as Hex.Hex - return { channelId, descriptor, transaction, voucherSignature } -} - -/** Creates a TIP-1034 open credential payload from a signed open transaction. */ -export function createOpenCredential( - result: OpenResult, - initialAmount: Uint96, -): Extract { return { action: 'open', type: 'transaction', - channelId: result.channelId, - transaction: result.transaction, - signature: result.voucherSignature, - descriptor: result.descriptor, - cumulativeAmount: initialAmount.toString(), - authorizedSigner: result.descriptor.authorizedSigner, - } -} - -/** Signs and creates a TIP-1034 voucher credential payload for an existing channel. */ -export async function createVoucherCredential( - client: Client, - account: Account, - parameters: { - chainId: number - cumulativeAmount: Uint96 - descriptor: Channel.ChannelDescriptor - escrow?: Address | undefined - }, -): Promise> { - const escrow = parameters.escrow ?? tip20ChannelEscrow - const channelId = Channel.computeId({ - ...parameters.descriptor, - chainId: parameters.chainId, - escrow, - }) - const signature = await Voucher.signVoucher( - client, - account, - { channelId, cumulativeAmount: parameters.cumulativeAmount }, - escrow, - parameters.chainId, - voucherAuthorizedSigner(parameters.descriptor.authorizedSigner), - ) - - return { - action: 'voucher', channelId, - descriptor: parameters.descriptor, - cumulativeAmount: parameters.cumulativeAmount.toString(), + transaction, signature, + descriptor, + cumulativeAmount: parameters.initialAmount.toString(), + authorizedSigner: descriptor.authorizedSigner, } } -/** Prepares and signs a one-call TIP-1034 top-up transaction for an existing channel. */ -export async function createTopUp( +/** Prepares, signs, and creates a TIP-1034 top-up credential payload. */ +export async function createTopUpPayload( client: Client, account: Account, - parameters: { - additionalDeposit: Uint96 - chainId: number - descriptor: Channel.ChannelDescriptor - escrow?: Address | undefined - feePayer?: boolean | undefined - }, -): Promise { - const escrow = parameters.escrow ?? tip20ChannelEscrow + descriptor: Channel.ChannelDescriptor, + additionalDeposit: bigint, + chainId: number, + feePayer?: boolean | undefined, +): Promise { const channelId = Channel.computeId({ - ...parameters.descriptor, - chainId: parameters.chainId, - escrow, + ...descriptor, + chainId, + escrow: tip20ChannelEscrow, }) const prepared = await prepareTransactionRequest(client, { account, calls: [ { - to: escrow, + to: tip20ChannelEscrow, data: encodeFunctionData({ abi: escrowAbi, functionName: 'topUp', - args: [parameters.descriptor, parameters.additionalDeposit], + args: [descriptor, additionalDeposit], }), }, ], - ...(parameters.feePayer ? { feePayer: true } : {}), - feeToken: parameters.descriptor.token, + ...(feePayer ? { feePayer: true } : {}), + feeToken: descriptor.token, } as never) const transaction = (await signTransaction(client, prepared as never)) as Hex.Hex - return { channelId, descriptor: parameters.descriptor, transaction } -} - -/** Creates a TIP-1034 top-up credential payload from a signed top-up transaction. */ -export function createTopUpCredential( - result: TopUpResult, - additionalDeposit: Uint96, -): Extract { return { action: 'topUp', type: 'transaction', - channelId: result.channelId, - transaction: result.transaction, - descriptor: result.descriptor, + channelId, + transaction, + descriptor, additionalDeposit: additionalDeposit.toString(), } } - -/** Signs and creates a TIP-1034 close credential payload for an existing channel. */ -export async function createCloseCredential( - client: Client, - account: Account, - parameters: { - chainId: number - cumulativeAmount: Uint96 - descriptor: Channel.ChannelDescriptor - escrow?: Address | undefined - }, -): Promise> { - const voucher = await createVoucherCredential(client, account, parameters) - return { - action: 'close', - channelId: voucher.channelId, - descriptor: voucher.descriptor, - cumulativeAmount: voucher.cumulativeAmount, - signature: voucher.signature, - } -} diff --git a/src/tempo/precompile/server/Session.integration.test.ts b/src/tempo/precompile/server/Session.integration.test.ts index 77904741..727e6a7f 100644 --- a/src/tempo/precompile/server/Session.integration.test.ts +++ b/src/tempo/precompile/server/Session.integration.test.ts @@ -10,12 +10,10 @@ import * as ChannelStore from '../../session/ChannelStore.js' import { getChannelState } from '../Chain.js' import * as Channel from '../Channel.js' import { - createCloseCredential, - createOpen, - createOpenCredential, - createTopUp, - createTopUpCredential, - createVoucherCredential, + createClosePayload, + createOpenPayload, + createTopUpPayload, + createVoucherPayload, } from '../client/ChannelOps.js' import { tip20ChannelEscrow } from '../Constants.js' import { escrowAbi } from '../escrow.abi.js' @@ -90,14 +88,14 @@ describe.runIf(isPrecompileTestnet)('precompile server session chain integration unitType: 'request', getClient: () => client, }) - const open = await createOpen(client, payer, { + const payload = await createOpenPayload(client, payer, { chainId: chain.id, deposit: uint96(1_000n), initialAmount: uint96(100n), payee: payee.address, token: asset, }) - const payload = createOpenCredential(open, uint96(100n)) + if (payload.action !== 'open') throw new Error('expected open payload') const receipt = await method.verify({ credential: { @@ -157,14 +155,14 @@ describe.runIf(isPrecompileTestnet)('precompile server session chain integration unitType: 'request', getClient: () => client, }) - const open = await createOpen(client, payer, { + const openPayload = await createOpenPayload(client, payer, { chainId: chain.id, deposit: uint96(500n), initialAmount: uint96(100n), payee: payee.address, token: asset, }) - const openPayload = createOpenCredential(open, uint96(100n)) + if (openPayload.action !== 'open') throw new Error('expected open payload') await method.verify({ credential: { challenge: { @@ -182,12 +180,13 @@ describe.runIf(isPrecompileTestnet)('precompile server session chain integration } as never, }) - const topUp = await createTopUp(client, payer, { - additionalDeposit: uint96(700n), - chainId: chain.id, - descriptor: open.descriptor, - }) - const topUpPayload = createTopUpCredential(topUp, uint96(700n)) + const topUpPayload = await createTopUpPayload( + client, + payer, + openPayload.descriptor, + uint96(700n), + chain.id, + ) const receipt = await method.verify({ credential: { challenge: { @@ -256,12 +255,7 @@ describe.runIf(isPrecompileTestnet)('precompile server session chain integration unitType: 'request', getClient: () => client, }) - const payload = await createVoucherCredential(client, payer, { - chainId: chain.id, - cumulativeAmount: uint96(300n), - descriptor, - escrow: tip20ChannelEscrow, - }) + const payload = await createVoucherPayload(client, payer, descriptor, uint96(300n), chain.id) const receipt = await method.verify({ credential: { @@ -306,12 +300,8 @@ describe.runIf(isPrecompileTestnet)('precompile server session chain integration const store = ChannelStore.fromStore(rawStore as never) const { channelId, descriptor, deposit } = await openRealChannel(1_000n) - const voucher = await createVoucherCredential(client, payer, { - chainId: chain.id, - cumulativeAmount: uint96(250n), - descriptor, - escrow: tip20ChannelEscrow, - }) + const voucher = await createVoucherPayload(client, payer, descriptor, uint96(250n), chain.id) + if (voucher.action !== 'voucher') throw new Error('expected voucher payload') await store.updateChannel(channelId, () => ({ backend: 'precompile', channelId, @@ -392,12 +382,7 @@ describe.runIf(isPrecompileTestnet)('precompile server session chain integration unitType: 'request', getClient: () => client, }) - const payload = await createCloseCredential(client, payer, { - chainId: chain.id, - cumulativeAmount: uint96(300n), - descriptor, - escrow: tip20ChannelEscrow, - }) + const payload = await createClosePayload(client, payer, descriptor, uint96(300n), chain.id) const receipt = await method.verify({ credential: { @@ -460,12 +445,7 @@ describe.runIf(isPrecompileTestnet)('precompile server session chain integration unitType: 'request', getClient: () => client, }) - const payload = await createCloseCredential(client, payer, { - chainId: chain.id, - cumulativeAmount: uint96(300n), - descriptor, - escrow: tip20ChannelEscrow, - }) + const payload = await createClosePayload(client, payer, descriptor, uint96(300n), chain.id) const receipt = await method.verify({ credential: { diff --git a/src/tempo/precompile/server/Session.test.ts b/src/tempo/precompile/server/Session.test.ts index 8b41b372..eab9f5d6 100644 --- a/src/tempo/precompile/server/Session.test.ts +++ b/src/tempo/precompile/server/Session.test.ts @@ -157,7 +157,7 @@ function makeRequest(channelId?: Hex) { let saltCounter = 0 -async function createOpenCredential( +async function createOpenPayload( parameters: { deposit?: bigint | undefined initialAmount?: bigint | undefined @@ -335,7 +335,7 @@ describe('precompile server session unit guardrails', () => { test('rejects open transactions targeting the wrong address', async () => { const { method } = createServer() - const payload = await createOpenCredential({ escrow: wrongTarget }) + const payload = await createOpenPayload({ escrow: wrongTarget }) await expect( method.verify({ @@ -347,8 +347,8 @@ describe('precompile server session unit guardrails', () => { test('rejects smuggled extra calls in open transactions', async () => { const { method } = createServer() - const payload = await createOpenCredential() - const tampered = await createOpenCredential() + const payload = await createOpenPayload() + const tampered = await createOpenPayload() // Reuse a valid descriptor/signature, but submit a transaction whose calls // do not correspond to that descriptor. This exercises the same one-call / @@ -369,7 +369,7 @@ describe('precompile server session unit guardrails', () => { test('rejects descriptors that do not match the challenge channel ID', async () => { const { method } = createServer() - const payload = await createOpenCredential() + const payload = await createOpenPayload() const badDescriptor = { ...payload.descriptor, token: '0x0000000000000000000000000000000000000005' as Address, @@ -388,10 +388,10 @@ describe('precompile server session unit guardrails', () => { test('rejects invalid initial voucher signatures', async () => { const { method } = createServer() - const payload = await createOpenCredential() + const payload = await createOpenPayload() const badSignaturePayload = { ...payload, - signature: (await createOpenCredential({ account: wrongPayer })).signature, + signature: (await createOpenPayload({ account: wrongPayer })).signature, } await expect( @@ -407,7 +407,7 @@ describe('precompile server session unit guardrails', () => { test('rejects missing precompile descriptors with a verification error', async () => { const { method } = createServer() - const payload = await createOpenCredential() + const payload = await createOpenPayload() const { descriptor: _descriptor, ...payloadWithoutDescriptor } = payload await expect( @@ -423,7 +423,7 @@ describe('precompile server session unit guardrails', () => { test('rejects uint96 overflow in credential amount parsing', async () => { const { method } = createServer() - const payload = await createOpenCredential() + const payload = await createOpenPayload() await expect( method.verify({ @@ -438,7 +438,7 @@ describe('precompile server session unit guardrails', () => { test('rejects settle when no account is available', async () => { const { store } = createServer() - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload) const { settle } = await import('./Session.js') @@ -449,7 +449,7 @@ describe('precompile server session unit guardrails', () => { test('rejects settle when sender is not the channel payee or operator', async () => { const { store } = createServer() - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload) const { settle } = await import('./Session.js') @@ -460,7 +460,7 @@ describe('precompile server session unit guardrails', () => { test('accepts settle sender matching a nonzero precompile operator', async () => { const { store } = createServer() - const openPayload = await createOpenCredential({ + const openPayload = await createOpenPayload({ operator: wrongPayer.address, }) await persistPrecompileChannel(store, openPayload) @@ -483,7 +483,7 @@ describe('precompile server session unit guardrails', () => { test('precompile settle fee payer options still enforce payee sender policy', async () => { const { store } = createServer() - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload) const { settle } = await import('./Session.js') @@ -496,7 +496,7 @@ describe('precompile server session unit guardrails', () => { test('accepts precompile settle fee token options', async () => { const { store } = createServer() - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload, { payee: payer.address, }) @@ -522,7 +522,7 @@ describe('precompile server session unit guardrails', () => { test('accepts settle account override matching the channel payee', async () => { const { store } = createServer() - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload, { payee: wrongPayer.address, }) @@ -548,7 +548,7 @@ describe('precompile server session unit guardrails', () => { test('rejects precompile settle fee-payer policy violations', async () => { const { store } = createServer() - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload, { payee: payer.address, }) @@ -566,7 +566,7 @@ describe('precompile server session unit guardrails', () => { test('rejects close voucher below local spent', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload, { payee: payer.address, spent: 150n, @@ -582,11 +582,7 @@ describe('precompile server session unit guardrails', () => { unitType: 'request', getClient: () => createStateClient(payer), }) - const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(100n), - descriptor: openPayload.descriptor, - }) + const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) await expect( method.verify({ @@ -602,7 +598,7 @@ describe('precompile server session unit guardrails', () => { test('rejects close voucher below on-chain settled', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload, { payee: payer.address, }) @@ -622,11 +618,7 @@ describe('precompile server session unit guardrails', () => { closeRequestedAt: 0, }), }) - const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(99n), - descriptor: openPayload.descriptor, - }) + const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(99n), chainId) await expect( method.verify({ @@ -642,7 +634,7 @@ describe('precompile server session unit guardrails', () => { test('rejects close capture exceeding on-chain precompile deposit', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload, { payee: payer.address, spent: 100n, @@ -663,11 +655,7 @@ describe('precompile server session unit guardrails', () => { closeRequestedAt: 0, }), }) - const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(100n), - descriptor: openPayload.descriptor, - }) + const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) await expect( method.verify({ @@ -683,12 +671,8 @@ describe('precompile server session unit guardrails', () => { test('rejects close for locally finalized and pending precompile channels', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) - const openPayload = await createOpenCredential() - const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(100n), - descriptor: openPayload.descriptor, - }) + const openPayload = await createOpenPayload() + const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) const method = session({ account: payer, amount: '1', @@ -731,17 +715,10 @@ describe('precompile server session unit guardrails', () => { }) test('does not let a racing lower voucher regress highest accepted precompile voucher', async () => { - const openPayload = await createOpenCredential({ initialAmount: 100n }) - const lowerVoucher = await ClientOps.createVoucherCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(200n), - descriptor: openPayload.descriptor, - }) - const higherVoucher = await ClientOps.createVoucherCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(500n), - descriptor: openPayload.descriptor, - }) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + const lowerVoucher = await ClientOps.createVoucherPayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(200n), chainId) + const higherVoucher = await ClientOps.createVoucherPayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(500n), chainId) + if (higherVoucher.action !== 'voucher') throw new Error('expected voucher payload') const seedStore = ChannelStore.fromStore(Store.memory() as never) await persistPrecompileChannel(seedStore, openPayload, { @@ -808,7 +785,7 @@ describe('precompile server session unit guardrails', () => { test('marks pending precompile close before broadcast and restores it when broadcast fails', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload, { payee: payer.address, }) @@ -848,11 +825,7 @@ describe('precompile server session unit guardrails', () => { }), }), }) - const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(100n), - descriptor: openPayload.descriptor, - }) + const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) await expect( method.verify({ @@ -870,7 +843,7 @@ describe('precompile server session unit guardrails', () => { test('rejects server-driven close when no account is available', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload) const method = session({ amount: '1', @@ -882,11 +855,7 @@ describe('precompile server session unit guardrails', () => { unitType: 'request', getClient: () => createStateClient(null), }) - const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(100n), - descriptor: openPayload.descriptor, - }) + const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) await expect( method.verify({ @@ -902,7 +871,7 @@ describe('precompile server session unit guardrails', () => { test('accepts server-driven close account override matching the channel payee', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload, { payee: wrongPayer.address, }) @@ -917,11 +886,7 @@ describe('precompile server session unit guardrails', () => { unitType: 'request', getClient: () => createStateClient(payer), }) - const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(100n), - descriptor: openPayload.descriptor, - }) + const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) await expect( method.verify({ @@ -937,7 +902,7 @@ describe('precompile server session unit guardrails', () => { test('uses request-specified fee payer account for server-driven precompile close', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload, { payee: wrongPayer.address, }) @@ -953,11 +918,7 @@ describe('precompile server session unit guardrails', () => { unitType: 'request', getClient: () => createStateClient(payer), }) - const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(100n), - descriptor: openPayload.descriptor, - }) + const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) await expect( method.verify({ @@ -980,7 +941,7 @@ describe('precompile server session unit guardrails', () => { test('accepts server-driven close sender matching a nonzero precompile operator', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) - const openPayload = await createOpenCredential({ + const openPayload = await createOpenPayload({ operator: wrongPayer.address, }) await persistPrecompileChannel(store, openPayload) @@ -995,11 +956,7 @@ describe('precompile server session unit guardrails', () => { unitType: 'request', getClient: () => createStateClient(payer), }) - const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(100n), - descriptor: openPayload.descriptor, - }) + const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) await expect( method.verify({ @@ -1015,7 +972,7 @@ describe('precompile server session unit guardrails', () => { test('rejects server-driven close when sender is not the channel payee or operator', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) - const openPayload = await createOpenCredential() + const openPayload = await createOpenPayload() await persistPrecompileChannel(store, openPayload) const method = session({ amount: '1', @@ -1027,11 +984,7 @@ describe('precompile server session unit guardrails', () => { unitType: 'request', getClient: () => createStateClient(wrongPayer), }) - const payload = await ClientOps.createCloseCredential(createSigningClient(), payer, { - chainId, - cumulativeAmount: Types.uint96(100n), - descriptor: openPayload.descriptor, - }) + const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) await expect( method.verify({ diff --git a/src/tempo/precompile/session/Client.test.ts b/src/tempo/precompile/session/Client.test.ts index 5df88679..ab46866b 100644 --- a/src/tempo/precompile/session/Client.test.ts +++ b/src/tempo/precompile/session/Client.test.ts @@ -136,10 +136,9 @@ describe('precompile client session', () => { expect(openDeposit(payload)).toBe(1000n) }) - test('prefers local escrow override over challenge escrowContract', async () => { - const escrow = '0x0000000000000000000000000000000000000004' as Address + test('uses canonical precompile address for open transactions', async () => { const challengeEscrow = '0x0000000000000000000000000000000000000005' as Address - const method = session({ account, decimals: 0, deposit: '10', escrow, getClient: () => client }) + const method = session({ account, decimals: 0, deposit: '10', getClient: () => client }) const payload = deserialize( await method.createCredential({ challenge: makeChallenge({ @@ -153,7 +152,7 @@ describe('precompile client session', () => { const transaction = Transaction.deserialize(payload.transaction) if (!('calls' in transaction)) throw new Error('expected tempo calls') const calls = transaction.calls as readonly { to?: Address; data?: `0x${string}` }[] - expect(calls[0]!.to?.toLowerCase()).toBe(escrow.toLowerCase()) + expect(calls[0]!.to?.toLowerCase()).toBe(tip20ChannelEscrow.toLowerCase()) }) test('tracks cumulative amount and calls onChannelUpdate in auto mode', async () => { diff --git a/src/tempo/precompile/session/Client.ts b/src/tempo/precompile/session/Client.ts index cbad76fe..78878cac 100644 --- a/src/tempo/precompile/session/Client.ts +++ b/src/tempo/precompile/session/Client.ts @@ -7,17 +7,15 @@ import * as Method from '../../../Method.js' import * as Account from '../../../viem/Account.js' import * as Client from '../../../viem/Client.js' import * as z from '../../../zod.js' +import { resolveChainId } from '../../client/ChannelOps.js' import * as defaults from '../../internal/defaults.js' import * as Methods from '../../Methods.js' import * as Chain from '../Chain.js' import * as Channel from '../Channel.js' import { - createOpen, - createOpenCredential, - createTopUp, - createTopUpCredential, - createVoucherCredential, - type OpenResult, + createOpenPayload, + createTopUpPayload, + createVoucherPayload, } from '../client/ChannelOps.js' import { tip20ChannelEscrow } from '../Constants.js' import type { SessionCredentialPayload, Uint96 } from '../Types.js' @@ -129,10 +127,8 @@ export function session(parameters: session.Parameters = {}) { account: viem_Account, context?: SessionContext, ): Promise { - const methodDetails = challenge.request.methodDetails as - | { chainId?: number; feePayer?: boolean } - | undefined - const chainId = methodDetails?.chainId ?? 0 + const methodDetails = challenge.request.methodDetails as { feePayer?: boolean } | undefined + const chainId = resolveChainId(challenge) const client = await getClient({ chainId }) const payee = challenge.request.recipient as Address const token = challenge.request.currency as Address @@ -159,12 +155,7 @@ export function session(parameters: session.Parameters = {}) { throw new Error(`Channel ${channelId} cannot be reused (pending close request).`) const cumulativeAmount = parseContextAmount(context, decimals) ?? uint96(state.settled + amount) - payload = await createVoucherCredential(client, account, { - chainId, - cumulativeAmount, - descriptor: context.descriptor, - escrow, - }) + payload = await createVoucherPayload(client, account, context.descriptor, cumulativeAmount, chainId) const entry: ChannelEntry = { channelId, cumulativeAmount, @@ -178,12 +169,7 @@ export function session(parameters: session.Parameters = {}) { notifyUpdate(entry) } else if (existing?.opened) { const cumulativeAmount = uint96(existing.cumulativeAmount + amount) - payload = await createVoucherCredential(client, account, { - chainId, - cumulativeAmount, - descriptor: existing.descriptor, - escrow, - }) + payload = await createVoucherPayload(client, account, existing.descriptor, cumulativeAmount, chainId) existing.cumulativeAmount = cumulativeAmount notifyUpdate(existing) } else { @@ -204,28 +190,27 @@ export function session(parameters: session.Parameters = {}) { ) })(), ) - const open = await createOpen(client, account, { + payload = await createOpenPayload(client, account, { authorizedSigner: parameters.authorizedSigner, chainId, deposit, - escrow, feePayer: methodDetails?.feePayer, initialAmount: amount, operator: parameters.operator, payee, token, }) + if (payload.action !== 'open') throw new Error('expected open payload') const entry: ChannelEntry = { - channelId: open.channelId, + channelId: payload.channelId, cumulativeAmount: amount, - descriptor: open.descriptor, + descriptor: payload.descriptor, escrow, chainId, opened: true, } channels.set(key, entry) - channelIdToKey.set(open.channelId, key) - payload = createOpenCredential(open, amount) + channelIdToKey.set(payload.channelId, key) notifyUpdate(entry) } @@ -237,8 +222,7 @@ export function session(parameters: session.Parameters = {}) { account: viem_Account, context: SessionContext, ): Promise { - const methodDetails = challenge.request.methodDetails as { chainId?: number } | undefined - const chainId = methodDetails?.chainId ?? 0 + const chainId = resolveChainId(challenge) const client = await getClient({ chainId }) const escrow = resolveEscrow(challenge, parameters.escrow) const action = context.action! @@ -253,22 +237,18 @@ export function session(parameters: session.Parameters = {}) { const cumulativeAmount = parseContextAmount(context, decimals) if (cumulativeAmount === undefined) throw new Error('cumulativeAmount required for open action') - payload = createOpenCredential( - { - channelId, - descriptor, - transaction: context.transaction as `0x${string}`, - voucherSignature: ( - await createVoucherCredential(client, account, { - chainId, - cumulativeAmount, - descriptor, - escrow, - }) - ).signature, - } satisfies OpenResult, - cumulativeAmount, - ) + const voucher = await createVoucherPayload(client, account, descriptor, cumulativeAmount, chainId) + if (voucher.action !== 'voucher') throw new Error('expected voucher payload') + payload = { + action: 'open', + type: 'transaction', + channelId, + transaction: context.transaction as `0x${string}`, + signature: voucher.signature, + descriptor, + cumulativeAmount: cumulativeAmount.toString(), + authorizedSigner: descriptor.authorizedSigner, + } break } case 'topUp': { @@ -285,16 +265,13 @@ export function session(parameters: session.Parameters = {}) { additionalDeposit: additionalDeposit.toString(), } } else { - payload = createTopUpCredential( - await createTopUp(client, account, { - additionalDeposit, - chainId, - descriptor, - escrow, - feePayer: (challenge.request.methodDetails as { feePayer?: boolean } | undefined) - ?.feePayer, - }), + payload = await createTopUpPayload( + client, + account, + descriptor, additionalDeposit, + chainId, + (challenge.request.methodDetails as { feePayer?: boolean } | undefined)?.feePayer, ) } break @@ -303,24 +280,15 @@ export function session(parameters: session.Parameters = {}) { const cumulativeAmount = parseContextAmount(context, decimals) if (cumulativeAmount === undefined) throw new Error('cumulativeAmount required for voucher action') - payload = await createVoucherCredential(client, account, { - chainId, - cumulativeAmount, - descriptor, - escrow, - }) + payload = await createVoucherPayload(client, account, descriptor, cumulativeAmount, chainId) break } case 'close': { const cumulativeAmount = parseContextAmount(context, decimals) if (cumulativeAmount === undefined) throw new Error('cumulativeAmount required for close action') - const voucher = await createVoucherCredential(client, account, { - chainId, - cumulativeAmount, - descriptor, - escrow, - }) + const voucher = await createVoucherPayload(client, account, descriptor, cumulativeAmount, chainId) + if (voucher.action !== 'voucher') throw new Error('expected voucher payload') payload = { ...voucher, action: 'close' } break } @@ -344,7 +312,7 @@ export function session(parameters: session.Parameters = {}) { return Method.toClient(Methods.session, { context: sessionContextSchema, async createCredential({ challenge, context }) { - const chainId = challenge.request.methodDetails?.chainId ?? 0 + const chainId = resolveChainId(challenge) const client = await getClient({ chainId }) const account = getAccount(client, context) From 2ed882d087a5fba7d0a6bca498494b8b2e1833ad Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 18:40:34 +0200 Subject: [PATCH 22/26] Align precompile client session layout --- .changeset/precompile-bigint-amounts.md | 5 - .changeset/precompile-voucher-inputs.md | 5 - .changeset/tip-1034-precompile-hash.md | 2 +- src/tempo/precompile/client/ChannelOps.ts | 57 ++++- .../Client.test.ts => client/Session.test.ts} | 2 +- .../{session/Client.ts => client/Session.ts} | 197 +++++++++--------- src/tempo/precompile/client/index.ts | 1 + src/tempo/precompile/index.ts | 2 +- src/tempo/precompile/server/Session.test.ts | 97 +++++++-- .../precompile/session/SessionManager.ts | 4 +- src/tempo/precompile/session/index.ts | 1 - 11 files changed, 239 insertions(+), 134 deletions(-) delete mode 100644 .changeset/precompile-bigint-amounts.md delete mode 100644 .changeset/precompile-voucher-inputs.md rename src/tempo/precompile/{session/Client.test.ts => client/Session.test.ts} (99%) rename src/tempo/precompile/{session/Client.ts => client/Session.ts} (74%) diff --git a/.changeset/precompile-bigint-amounts.md b/.changeset/precompile-bigint-amounts.md deleted file mode 100644 index 6dbadb27..00000000 --- a/.changeset/precompile-bigint-amounts.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'mppx': patch ---- - -Changed TIP20EscrowChannel precompile chain helpers to use `*OnChain` names, accept plain bigint amounts, and validate uint96 bounds at encoding boundaries. diff --git a/.changeset/precompile-voucher-inputs.md b/.changeset/precompile-voucher-inputs.md deleted file mode 100644 index db6caacc..00000000 --- a/.changeset/precompile-voucher-inputs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'mppx': patch ---- - -Updated precompile voucher signing inputs to match legacy session voucher signing. diff --git a/.changeset/tip-1034-precompile-hash.md b/.changeset/tip-1034-precompile-hash.md index 1577dbb8..b5992b91 100644 --- a/.changeset/tip-1034-precompile-hash.md +++ b/.changeset/tip-1034-precompile-hash.md @@ -31,4 +31,4 @@ const server = Mppx.create({ }) ``` -This added channel ID, expiring nonce hash, voucher, ABI calldata, open/top-up validation, descriptor persistence, credential payload parsing, client credential builder, session manager, server verification, and server-driven fee-payer settle/close helpers for TIP20EscrowChannel precompile channels. It also hardened precompile channel validation, atomic store updates, voucher-signing compatibility, finalized channel bookkeeping, and devnet precompile integration test setup. +This added channel ID, expiring nonce hash, voucher, ABI calldata, open/top-up validation, descriptor persistence, credential payload parsing, client credential builder, session manager, server verification, and server-driven fee-payer settle/close helpers for TIP20EscrowChannel precompile channels. It also changed precompile chain helpers to use `*OnChain` names, updated amount APIs to accept plain bigint values while validating uint96 bounds at encoding boundaries, updated precompile voucher signing inputs to match legacy session voucher signing, aligned precompile client session modules with the legacy client layout, and hardened precompile channel validation, atomic store updates, voucher-signing compatibility, finalized channel bookkeeping, and devnet precompile integration test setup. diff --git a/src/tempo/precompile/client/ChannelOps.ts b/src/tempo/precompile/client/ChannelOps.ts index e0635d49..0d916856 100644 --- a/src/tempo/precompile/client/ChannelOps.ts +++ b/src/tempo/precompile/client/ChannelOps.ts @@ -10,18 +10,57 @@ import { Hex } from 'ox' import { encodeFunctionData, zeroAddress, type Account, type Address, type Client } from 'viem' import { prepareTransactionRequest, signTransaction } from 'viem/actions' +import type { Challenge } from '../../../Challenge.js' +import * as Credential from '../../../Credential.js' import * as Channel from '../Channel.js' import { tip20ChannelEscrow } from '../Constants.js' import { escrowAbi } from '../escrow.abi.js' import type { SessionCredentialPayload } from '../Types.js' import * as Voucher from '../Voucher.js' +export type ChannelEntry = { + channelId: Hex.Hex + cumulativeAmount: bigint + descriptor: Channel.ChannelDescriptor + escrow: Address + chainId: number + opened: boolean +} + function voucherAuthorizedSigner(address: Address): Address | undefined { return address.toLowerCase() === zeroAddress ? undefined : address } -function defaultAuthorizedSigner(account: Account): Address { - return (account as unknown as { accessKeyAddress?: Address }).accessKeyAddress ?? account.address +export function resolveEscrow( + challenge: { + request: { + methodDetails?: { escrow?: string | undefined; escrowContract?: string | undefined } + } + }, + escrowOverride?: Address | undefined, +): Address { + const methodDetails = challenge.request.methodDetails + const challengeEscrow = (methodDetails?.escrowContract ?? methodDetails?.escrow) as + | Address + | undefined + return escrowOverride ?? challengeEscrow ?? tip20ChannelEscrow +} + +export function serializeCredential( + challenge: Challenge, + payload: SessionCredentialPayload, + chainId: number, + account: Account, +): string { + return Credential.serialize({ + challenge, + payload, + source: `did:pkh:eip155:${chainId}:${account.address}`, + }) +} + +export function isSameAddress(a: Address, b: Address): boolean { + return a.toLowerCase() === b.toLowerCase() } /** Signs and creates a TIP-1034 voucher credential payload for an existing channel. */ @@ -91,14 +130,24 @@ export async function createOpenPayload( token: Address }, ): Promise { - const authorizedSigner = parameters.authorizedSigner ?? defaultAuthorizedSigner(account) + const authorizedSigner = + parameters.authorizedSigner ?? + (account as unknown as { accessKeyAddress?: Address }).accessKeyAddress ?? + account.address const operator = parameters.operator ?? '0x0000000000000000000000000000000000000000' const salt = Hex.random(32) const openData = encodeFunctionData({ abi: escrowAbi, functionName: 'open', - args: [parameters.payee, operator, parameters.token, parameters.deposit, salt, authorizedSigner], + args: [ + parameters.payee, + operator, + parameters.token, + parameters.deposit, + salt, + authorizedSigner, + ], }) const prepared = await prepareTransactionRequest(client, { account, diff --git a/src/tempo/precompile/session/Client.test.ts b/src/tempo/precompile/client/Session.test.ts similarity index 99% rename from src/tempo/precompile/session/Client.test.ts rename to src/tempo/precompile/client/Session.test.ts index ab46866b..d103bc07 100644 --- a/src/tempo/precompile/session/Client.test.ts +++ b/src/tempo/precompile/client/Session.test.ts @@ -10,7 +10,7 @@ import { tip20ChannelEscrow } from '../Constants.js' import { escrowAbi } from '../escrow.abi.js' import * as Types from '../Types.js' import * as Voucher from '../Voucher.js' -import { session } from './Client.js' +import { session } from './Session.js' const account = privateKeyToAccount( '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', diff --git a/src/tempo/precompile/session/Client.ts b/src/tempo/precompile/client/Session.ts similarity index 74% rename from src/tempo/precompile/session/Client.ts rename to src/tempo/precompile/client/Session.ts index 78878cac..592f9c05 100644 --- a/src/tempo/precompile/session/Client.ts +++ b/src/tempo/precompile/client/Session.ts @@ -1,8 +1,7 @@ -import { type Address, type Hex, parseUnits, type Account as viem_Account } from 'viem' +import { type Address, parseUnits, type Account as viem_Account } from 'viem' import { tempo as tempo_chain } from 'viem/chains' import type * as Challenge from '../../../Challenge.js' -import * as Credential from '../../../Credential.js' import * as Method from '../../../Method.js' import * as Account from '../../../viem/Account.js' import * as Client from '../../../viem/Client.js' @@ -12,23 +11,17 @@ import * as defaults from '../../internal/defaults.js' import * as Methods from '../../Methods.js' import * as Chain from '../Chain.js' import * as Channel from '../Channel.js' +import type { SessionCredentialPayload } from '../Types.js' +import { uint96 } from '../Types.js' import { createOpenPayload, createTopUpPayload, createVoucherPayload, -} from '../client/ChannelOps.js' -import { tip20ChannelEscrow } from '../Constants.js' -import type { SessionCredentialPayload, Uint96 } from '../Types.js' -import { uint96 } from '../Types.js' - -export type ChannelEntry = { - channelId: Hex - cumulativeAmount: Uint96 - descriptor: Channel.ChannelDescriptor - escrow: Address - chainId: number - opened: boolean -} + isSameAddress, + resolveEscrow, + serializeCredential, + type ChannelEntry, +} from './ChannelOps.js' export const sessionContextSchema = z.object({ account: z.optional(z.custom()), @@ -36,77 +29,23 @@ export const sessionContextSchema = z.object({ channelId: z.optional(z.string()), cumulativeAmount: z.optional(z.amount()), cumulativeAmountRaw: z.optional(z.string()), + transaction: z.optional(z.string()), + descriptor: z.optional(z.custom()), additionalDeposit: z.optional(z.amount()), additionalDepositRaw: z.optional(z.string()), depositRaw: z.optional(z.string()), - transaction: z.optional(z.string()), - descriptor: z.optional(z.custom()), }) export type SessionContext = z.infer -function serializeCredential( - challenge: Challenge.Challenge, - payload: SessionCredentialPayload, - chainId: number, - account: viem_Account, -): string { - return Credential.serialize({ - challenge, - payload, - source: `did:pkh:eip155:${chainId}:${account.address}`, - }) -} - -function channelKey(payee: Address, token: Address, escrow: Address): string { - return `${payee.toLowerCase()}:${token.toLowerCase()}:${escrow.toLowerCase()}` -} - -function resolveEscrow( - challenge: { - request: { - methodDetails?: { escrow?: string | undefined; escrowContract?: string | undefined } - } - }, - escrowOverride?: Address | undefined, -): Address { - const methodDetails = challenge.request.methodDetails - const challengeEscrow = (methodDetails?.escrowContract ?? methodDetails?.escrow) as - | Address - | undefined - return escrowOverride ?? challengeEscrow ?? tip20ChannelEscrow -} - -function parseAmount(value: string | undefined, decimals: number): bigint | undefined { - return value === undefined ? undefined : parseUnits(value, decimals) -} - -function parseContextAmount(context: SessionContext, decimals: number): Uint96 | undefined { - const amount = context.cumulativeAmountRaw - ? BigInt(context.cumulativeAmountRaw) - : parseAmount(context.cumulativeAmount, decimals) - return amount === undefined ? undefined : uint96(amount) -} - -function parseContextAdditionalDeposit( - context: SessionContext, - decimals: number, -): Uint96 | undefined { - const amount = context.additionalDepositRaw - ? BigInt(context.additionalDepositRaw) - : parseAmount(context.additionalDeposit, decimals) - return amount === undefined ? undefined : uint96(amount) -} - -function isSameAddress(a: Address, b: Address): boolean { - return a.toLowerCase() === b.toLowerCase() -} - -/** Creates a client-side TIP20EscrowChannel precompile session payment method. */ +/** + * Creates a precompile-backed session payment method for use with `Mppx.create()`. + * + * Supports both auto mode (set `deposit` to manage channels automatically) + * and manual mode (pass `context.action` with a channel descriptor to control each step). + */ export function session(parameters: session.Parameters = {}) { const { decimals = defaults.decimals } = parameters - const maxDeposit = - parameters.maxDeposit !== undefined ? parseUnits(parameters.maxDeposit, decimals) : undefined const getClient = Client.getResolver({ chain: tempo_chain, @@ -115,6 +54,9 @@ export function session(parameters: session.Parameters = {}) { }) const getAccount = Account.getResolver({ account: parameters.account }) + const maxDeposit = + parameters.maxDeposit !== undefined ? parseUnits(parameters.maxDeposit, decimals) : undefined + const channels = new Map() const channelIdToKey = new Map() @@ -122,6 +64,10 @@ export function session(parameters: session.Parameters = {}) { parameters.onChannelUpdate?.(entry) } + function channelKey(payee: Address, token: Address, escrow: Address): string { + return `${payee.toLowerCase()}:${token.toLowerCase()}:${escrow.toLowerCase()}` + } + async function autoManageCredential( challenge: Challenge.Challenge, account: viem_Account, @@ -130,17 +76,17 @@ export function session(parameters: session.Parameters = {}) { const methodDetails = challenge.request.methodDetails as { feePayer?: boolean } | undefined const chainId = resolveChainId(challenge) const client = await getClient({ chainId }) + const escrow = resolveEscrow(challenge, parameters.escrow) const payee = challenge.request.recipient as Address const token = challenge.request.currency as Address - const escrow = resolveEscrow(challenge, parameters.escrow) const amount = uint96(BigInt(challenge.request.amount as string)) const key = channelKey(payee, token, escrow) - const existing = channels.get(key) + let entry = channels.get(key) let payload: SessionCredentialPayload - if (!existing && context?.channelId && !context.descriptor) + if (!entry && context?.channelId && !context.descriptor) throw new Error('descriptor required to reuse precompile channel') - if (!existing && context?.descriptor) { + if (!entry && context?.descriptor) { const channelId = Channel.computeId({ ...context.descriptor, chainId, escrow }) if (context.channelId && context.channelId.toLowerCase() !== channelId.toLowerCase()) throw new Error('context channelId does not match descriptor') @@ -153,10 +99,21 @@ export function session(parameters: session.Parameters = {}) { throw new Error(`Channel ${channelId} cannot be reused (closed or not found on-chain).`) if (state.closeRequestedAt !== 0) throw new Error(`Channel ${channelId} cannot be reused (pending close request).`) + const contextCumulative = context.cumulativeAmountRaw + ? BigInt(context.cumulativeAmountRaw) + : context.cumulativeAmount + ? parseUnits(context.cumulativeAmount, decimals) + : undefined const cumulativeAmount = - parseContextAmount(context, decimals) ?? uint96(state.settled + amount) - payload = await createVoucherPayload(client, account, context.descriptor, cumulativeAmount, chainId) - const entry: ChannelEntry = { + contextCumulative === undefined ? uint96(state.settled + amount) : uint96(contextCumulative) + payload = await createVoucherPayload( + client, + account, + context.descriptor, + cumulativeAmount, + chainId, + ) + entry = { channelId, cumulativeAmount, descriptor: context.descriptor, @@ -167,11 +124,17 @@ export function session(parameters: session.Parameters = {}) { channels.set(key, entry) channelIdToKey.set(channelId, key) notifyUpdate(entry) - } else if (existing?.opened) { - const cumulativeAmount = uint96(existing.cumulativeAmount + amount) - payload = await createVoucherPayload(client, account, existing.descriptor, cumulativeAmount, chainId) - existing.cumulativeAmount = cumulativeAmount - notifyUpdate(existing) + } else if (entry?.opened) { + const cumulativeAmount = uint96(entry.cumulativeAmount + amount) + payload = await createVoucherPayload( + client, + account, + entry.descriptor, + cumulativeAmount, + chainId, + ) + entry.cumulativeAmount = cumulativeAmount + notifyUpdate(entry) } else { const suggestedDepositRaw = (challenge.request as { suggestedDeposit?: string }) .suggestedDeposit @@ -234,10 +197,21 @@ export function session(parameters: session.Parameters = {}) { switch (action) { case 'open': { if (!context.transaction) throw new Error('transaction required for open action') - const cumulativeAmount = parseContextAmount(context, decimals) - if (cumulativeAmount === undefined) + const cumulativeAmountRaw = context.cumulativeAmountRaw + ? BigInt(context.cumulativeAmountRaw) + : context.cumulativeAmount + ? parseUnits(context.cumulativeAmount, decimals) + : undefined + if (cumulativeAmountRaw === undefined) throw new Error('cumulativeAmount required for open action') - const voucher = await createVoucherPayload(client, account, descriptor, cumulativeAmount, chainId) + const cumulativeAmount = uint96(cumulativeAmountRaw) + const voucher = await createVoucherPayload( + client, + account, + descriptor, + cumulativeAmount, + chainId, + ) if (voucher.action !== 'voucher') throw new Error('expected voucher payload') payload = { action: 'open', @@ -252,9 +226,14 @@ export function session(parameters: session.Parameters = {}) { break } case 'topUp': { - const additionalDeposit = parseContextAdditionalDeposit(context, decimals) - if (additionalDeposit === undefined) + const additionalDepositRaw = context.additionalDepositRaw + ? BigInt(context.additionalDepositRaw) + : context.additionalDeposit + ? parseUnits(context.additionalDeposit, decimals) + : undefined + if (additionalDepositRaw === undefined) throw new Error('additionalDeposit required for topUp action') + const additionalDeposit = uint96(additionalDepositRaw) if (context.transaction) { payload = { action: 'topUp', @@ -277,17 +256,33 @@ export function session(parameters: session.Parameters = {}) { break } case 'voucher': { - const cumulativeAmount = parseContextAmount(context, decimals) - if (cumulativeAmount === undefined) + const cumulativeAmountRaw = context.cumulativeAmountRaw + ? BigInt(context.cumulativeAmountRaw) + : context.cumulativeAmount + ? parseUnits(context.cumulativeAmount, decimals) + : undefined + if (cumulativeAmountRaw === undefined) throw new Error('cumulativeAmount required for voucher action') + const cumulativeAmount = uint96(cumulativeAmountRaw) payload = await createVoucherPayload(client, account, descriptor, cumulativeAmount, chainId) break } case 'close': { - const cumulativeAmount = parseContextAmount(context, decimals) - if (cumulativeAmount === undefined) + const cumulativeAmountRaw = context.cumulativeAmountRaw + ? BigInt(context.cumulativeAmountRaw) + : context.cumulativeAmount + ? parseUnits(context.cumulativeAmount, decimals) + : undefined + if (cumulativeAmountRaw === undefined) throw new Error('cumulativeAmount required for close action') - const voucher = await createVoucherPayload(client, account, descriptor, cumulativeAmount, chainId) + const cumulativeAmount = uint96(cumulativeAmountRaw) + const voucher = await createVoucherPayload( + client, + account, + descriptor, + cumulativeAmount, + chainId, + ) if (voucher.action !== 'voucher') throw new Error('expected voucher payload') payload = { ...voucher, action: 'close' } break @@ -340,10 +335,10 @@ export declare namespace session { decimals?: number | undefined /** Initial deposit amount in human-readable units. */ deposit?: string | undefined - /** Maximum deposit in human-readable units. Caps the server suggestedDeposit and enables auto-management. */ - maxDeposit?: string | undefined /** TIP20EscrowChannel precompile address override. */ escrow?: Address | undefined + /** Maximum deposit in human-readable units. Caps the server suggestedDeposit and enables auto-management. */ + maxDeposit?: string | undefined /** Address authorized to operate the precompile channel on behalf of the payee. */ operator?: Address | undefined /** Called whenever channel state changes. */ diff --git a/src/tempo/precompile/client/index.ts b/src/tempo/precompile/client/index.ts index 68a40620..b7621b78 100644 --- a/src/tempo/precompile/client/index.ts +++ b/src/tempo/precompile/client/index.ts @@ -1 +1,2 @@ export * as ChannelOps from './ChannelOps.js' +export { session } from './Session.js' diff --git a/src/tempo/precompile/index.ts b/src/tempo/precompile/index.ts index df1f1ec5..6b8e25f3 100644 --- a/src/tempo/precompile/index.ts +++ b/src/tempo/precompile/index.ts @@ -1,5 +1,5 @@ export * as Client from './client/index.js' -export { session } from './session/Client.js' +export { session } from './client/Session.js' export { sessionManager } from './session/SessionManager.js' export * as Session from './session/index.js' export * as Chain from './Chain.js' diff --git a/src/tempo/precompile/server/Session.test.ts b/src/tempo/precompile/server/Session.test.ts index eab9f5d6..5083aa9d 100644 --- a/src/tempo/precompile/server/Session.test.ts +++ b/src/tempo/precompile/server/Session.test.ts @@ -14,7 +14,6 @@ import { describe, expect, test } from 'vp/test' import * as Store from '../../../Store.js' import * as ChannelStore from '../../session/ChannelStore.js' import type { SessionReceipt } from '../../session/Types.js' -import * as Chain from '../Chain.js' import * as Channel from '../Channel.js' import * as ClientOps from '../client/ChannelOps.js' import { tip20ChannelEscrow } from '../Constants.js' @@ -582,7 +581,13 @@ describe('precompile server session unit guardrails', () => { unitType: 'request', getClient: () => createStateClient(payer), }) - const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) + const payload = await ClientOps.createClosePayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(100n), + chainId, + ) await expect( method.verify({ @@ -618,7 +623,13 @@ describe('precompile server session unit guardrails', () => { closeRequestedAt: 0, }), }) - const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(99n), chainId) + const payload = await ClientOps.createClosePayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(99n), + chainId, + ) await expect( method.verify({ @@ -655,7 +666,13 @@ describe('precompile server session unit guardrails', () => { closeRequestedAt: 0, }), }) - const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) + const payload = await ClientOps.createClosePayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(100n), + chainId, + ) await expect( method.verify({ @@ -672,7 +689,13 @@ describe('precompile server session unit guardrails', () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) const openPayload = await createOpenPayload() - const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) + const payload = await ClientOps.createClosePayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(100n), + chainId, + ) const method = session({ account: payer, amount: '1', @@ -716,8 +739,20 @@ describe('precompile server session unit guardrails', () => { test('does not let a racing lower voucher regress highest accepted precompile voucher', async () => { const openPayload = await createOpenPayload({ initialAmount: 100n }) - const lowerVoucher = await ClientOps.createVoucherPayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(200n), chainId) - const higherVoucher = await ClientOps.createVoucherPayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(500n), chainId) + const lowerVoucher = await ClientOps.createVoucherPayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(200n), + chainId, + ) + const higherVoucher = await ClientOps.createVoucherPayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(500n), + chainId, + ) if (higherVoucher.action !== 'voucher') throw new Error('expected voucher payload') const seedStore = ChannelStore.fromStore(Store.memory() as never) @@ -825,7 +860,13 @@ describe('precompile server session unit guardrails', () => { }), }), }) - const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) + const payload = await ClientOps.createClosePayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(100n), + chainId, + ) await expect( method.verify({ @@ -855,7 +896,13 @@ describe('precompile server session unit guardrails', () => { unitType: 'request', getClient: () => createStateClient(null), }) - const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) + const payload = await ClientOps.createClosePayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(100n), + chainId, + ) await expect( method.verify({ @@ -886,7 +933,13 @@ describe('precompile server session unit guardrails', () => { unitType: 'request', getClient: () => createStateClient(payer), }) - const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) + const payload = await ClientOps.createClosePayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(100n), + chainId, + ) await expect( method.verify({ @@ -918,7 +971,13 @@ describe('precompile server session unit guardrails', () => { unitType: 'request', getClient: () => createStateClient(payer), }) - const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) + const payload = await ClientOps.createClosePayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(100n), + chainId, + ) await expect( method.verify({ @@ -956,7 +1015,13 @@ describe('precompile server session unit guardrails', () => { unitType: 'request', getClient: () => createStateClient(payer), }) - const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) + const payload = await ClientOps.createClosePayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(100n), + chainId, + ) await expect( method.verify({ @@ -984,7 +1049,13 @@ describe('precompile server session unit guardrails', () => { unitType: 'request', getClient: () => createStateClient(wrongPayer), }) - const payload = await ClientOps.createClosePayload(createSigningClient(), payer, openPayload.descriptor, Types.uint96(100n), chainId) + const payload = await ClientOps.createClosePayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(100n), + chainId, + ) await expect( method.verify({ diff --git a/src/tempo/precompile/session/SessionManager.ts b/src/tempo/precompile/session/SessionManager.ts index 5c112af5..432eee90 100644 --- a/src/tempo/precompile/session/SessionManager.ts +++ b/src/tempo/precompile/session/SessionManager.ts @@ -10,9 +10,9 @@ import { deserializeSessionReceipt } from '../../session/Receipt.js' import { parseEvent } from '../../session/Sse.js' import type { SessionCredentialPayload, SessionReceipt } from '../../session/Types.js' import * as Ws from '../../session/Ws.js' +import type { ChannelEntry } from '../client/ChannelOps.js' +import { session as sessionPlugin } from '../client/Session.js' import { uint96 } from '../Types.js' -import type { ChannelEntry } from './Client.js' -import { session as sessionPlugin } from './Client.js' type WebSocketConstructor = { new (url: string | URL, protocols?: string | string[]): WebSocket diff --git a/src/tempo/precompile/session/index.ts b/src/tempo/precompile/session/index.ts index 8af15a43..c47b1da0 100644 --- a/src/tempo/precompile/session/index.ts +++ b/src/tempo/precompile/session/index.ts @@ -1,2 +1 @@ -export { session } from './Client.js' export { sessionManager } from './SessionManager.js' From e6eb6c4b2f4dec3da832eb4317691f7e28104bb1 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 14 May 2026 18:47:24 +0200 Subject: [PATCH 23/26] Move precompile uint96 checks to payload helpers --- src/tempo/precompile/client/ChannelOps.ts | 26 ++++++------- src/tempo/precompile/client/Session.ts | 45 +++++++++++------------ 2 files changed, 33 insertions(+), 38 deletions(-) diff --git a/src/tempo/precompile/client/ChannelOps.ts b/src/tempo/precompile/client/ChannelOps.ts index 0d916856..25c1af7e 100644 --- a/src/tempo/precompile/client/ChannelOps.ts +++ b/src/tempo/precompile/client/ChannelOps.ts @@ -16,6 +16,7 @@ import * as Channel from '../Channel.js' import { tip20ChannelEscrow } from '../Constants.js' import { escrowAbi } from '../escrow.abi.js' import type { SessionCredentialPayload } from '../Types.js' +import { uint96 } from '../Types.js' import * as Voucher from '../Voucher.js' export type ChannelEntry = { @@ -76,10 +77,11 @@ export async function createVoucherPayload( chainId, escrow: tip20ChannelEscrow, }) + const amount = uint96(cumulativeAmount) const signature = await Voucher.signVoucher( client, account, - { channelId, cumulativeAmount }, + { channelId, cumulativeAmount: amount }, tip20ChannelEscrow, chainId, voucherAuthorizedSigner(descriptor.authorizedSigner), @@ -89,7 +91,7 @@ export async function createVoucherPayload( action: 'voucher', channelId, descriptor, - cumulativeAmount: cumulativeAmount.toString(), + cumulativeAmount: amount.toString(), signature, } } @@ -137,17 +139,12 @@ export async function createOpenPayload( const operator = parameters.operator ?? '0x0000000000000000000000000000000000000000' const salt = Hex.random(32) + const deposit = uint96(parameters.deposit) + const initialAmount = uint96(parameters.initialAmount) const openData = encodeFunctionData({ abi: escrowAbi, functionName: 'open', - args: [ - parameters.payee, - operator, - parameters.token, - parameters.deposit, - salt, - authorizedSigner, - ], + args: [parameters.payee, operator, parameters.token, deposit, salt, authorizedSigner], }) const prepared = await prepareTransactionRequest(client, { account, @@ -177,7 +174,7 @@ export async function createOpenPayload( const signature = await Voucher.signVoucher( client, account, - { channelId, cumulativeAmount: parameters.initialAmount }, + { channelId, cumulativeAmount: initialAmount }, tip20ChannelEscrow, parameters.chainId, voucherAuthorizedSigner(authorizedSigner), @@ -191,7 +188,7 @@ export async function createOpenPayload( transaction, signature, descriptor, - cumulativeAmount: parameters.initialAmount.toString(), + cumulativeAmount: initialAmount.toString(), authorizedSigner: descriptor.authorizedSigner, } } @@ -210,6 +207,7 @@ export async function createTopUpPayload( chainId, escrow: tip20ChannelEscrow, }) + const deposit = uint96(additionalDeposit) const prepared = await prepareTransactionRequest(client, { account, calls: [ @@ -218,7 +216,7 @@ export async function createTopUpPayload( data: encodeFunctionData({ abi: escrowAbi, functionName: 'topUp', - args: [descriptor, additionalDeposit], + args: [descriptor, deposit], }), }, ], @@ -233,6 +231,6 @@ export async function createTopUpPayload( channelId, transaction, descriptor, - additionalDeposit: additionalDeposit.toString(), + additionalDeposit: deposit.toString(), } } diff --git a/src/tempo/precompile/client/Session.ts b/src/tempo/precompile/client/Session.ts index 592f9c05..cb523af5 100644 --- a/src/tempo/precompile/client/Session.ts +++ b/src/tempo/precompile/client/Session.ts @@ -12,7 +12,6 @@ import * as Methods from '../../Methods.js' import * as Chain from '../Chain.js' import * as Channel from '../Channel.js' import type { SessionCredentialPayload } from '../Types.js' -import { uint96 } from '../Types.js' import { createOpenPayload, createTopUpPayload, @@ -79,7 +78,7 @@ export function session(parameters: session.Parameters = {}) { const escrow = resolveEscrow(challenge, parameters.escrow) const payee = challenge.request.recipient as Address const token = challenge.request.currency as Address - const amount = uint96(BigInt(challenge.request.amount as string)) + const amount = BigInt(challenge.request.amount as string) const key = channelKey(payee, token, escrow) let entry = channels.get(key) @@ -105,7 +104,7 @@ export function session(parameters: session.Parameters = {}) { ? parseUnits(context.cumulativeAmount, decimals) : undefined const cumulativeAmount = - contextCumulative === undefined ? uint96(state.settled + amount) : uint96(contextCumulative) + contextCumulative === undefined ? state.settled + amount : contextCumulative payload = await createVoucherPayload( client, account, @@ -125,7 +124,7 @@ export function session(parameters: session.Parameters = {}) { channelIdToKey.set(channelId, key) notifyUpdate(entry) } else if (entry?.opened) { - const cumulativeAmount = uint96(entry.cumulativeAmount + amount) + const cumulativeAmount = entry.cumulativeAmount + amount payload = await createVoucherPayload( client, account, @@ -138,21 +137,19 @@ export function session(parameters: session.Parameters = {}) { } else { const suggestedDepositRaw = (challenge.request as { suggestedDeposit?: string }) .suggestedDeposit - const deposit = uint96( - (() => { - if (context?.depositRaw) return BigInt(context.depositRaw) - if (parameters.deposit !== undefined) return parseUnits(parameters.deposit, decimals) - const suggestedDeposit = - suggestedDepositRaw !== undefined ? BigInt(suggestedDepositRaw) : undefined - if (suggestedDeposit !== undefined && maxDeposit !== undefined) - return suggestedDeposit < maxDeposit ? suggestedDeposit : maxDeposit - if (maxDeposit !== undefined) return maxDeposit - if (suggestedDeposit !== undefined) return suggestedDeposit - throw new Error( - 'No deposit amount available. Set `deposit`, `maxDeposit`, or ensure the server challenge includes `suggestedDeposit`.', - ) - })(), - ) + const deposit = (() => { + if (context?.depositRaw) return BigInt(context.depositRaw) + if (parameters.deposit !== undefined) return parseUnits(parameters.deposit, decimals) + const suggestedDeposit = + suggestedDepositRaw !== undefined ? BigInt(suggestedDepositRaw) : undefined + if (suggestedDeposit !== undefined && maxDeposit !== undefined) + return suggestedDeposit < maxDeposit ? suggestedDeposit : maxDeposit + if (maxDeposit !== undefined) return maxDeposit + if (suggestedDeposit !== undefined) return suggestedDeposit + throw new Error( + 'No deposit amount available. Set `deposit`, `maxDeposit`, or ensure the server challenge includes `suggestedDeposit`.', + ) + })() payload = await createOpenPayload(client, account, { authorizedSigner: parameters.authorizedSigner, chainId, @@ -204,7 +201,7 @@ export function session(parameters: session.Parameters = {}) { : undefined if (cumulativeAmountRaw === undefined) throw new Error('cumulativeAmount required for open action') - const cumulativeAmount = uint96(cumulativeAmountRaw) + const cumulativeAmount = cumulativeAmountRaw const voucher = await createVoucherPayload( client, account, @@ -233,7 +230,7 @@ export function session(parameters: session.Parameters = {}) { : undefined if (additionalDepositRaw === undefined) throw new Error('additionalDeposit required for topUp action') - const additionalDeposit = uint96(additionalDepositRaw) + const additionalDeposit = additionalDepositRaw if (context.transaction) { payload = { action: 'topUp', @@ -263,7 +260,7 @@ export function session(parameters: session.Parameters = {}) { : undefined if (cumulativeAmountRaw === undefined) throw new Error('cumulativeAmount required for voucher action') - const cumulativeAmount = uint96(cumulativeAmountRaw) + const cumulativeAmount = cumulativeAmountRaw payload = await createVoucherPayload(client, account, descriptor, cumulativeAmount, chainId) break } @@ -275,7 +272,7 @@ export function session(parameters: session.Parameters = {}) { : undefined if (cumulativeAmountRaw === undefined) throw new Error('cumulativeAmount required for close action') - const cumulativeAmount = uint96(cumulativeAmountRaw) + const cumulativeAmount = cumulativeAmountRaw const voucher = await createVoucherPayload( client, account, @@ -293,7 +290,7 @@ export function session(parameters: session.Parameters = {}) { if (key) { const entry = channels.get(key) if (entry && 'cumulativeAmount' in payload) { - const cumulativeAmount = uint96(BigInt(payload.cumulativeAmount)) + const cumulativeAmount = BigInt(payload.cumulativeAmount) entry.cumulativeAmount = entry.cumulativeAmount > cumulativeAmount ? entry.cumulativeAmount : cumulativeAmount if (payload.action === 'close') entry.opened = false From d51411310be9ee25a2f7333e72332b38a76c2204 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 15 May 2026 09:49:44 +0200 Subject: [PATCH 24/26] Extract precompile session chain helpers --- src/tempo/precompile/Chain.ts | 334 +++++++- src/tempo/precompile/server/Session.ts | 1025 +++++++++++------------- src/tempo/server/Session.ts | 2 +- 3 files changed, 797 insertions(+), 564 deletions(-) diff --git a/src/tempo/precompile/Chain.ts b/src/tempo/precompile/Chain.ts index d5d86787..906bec46 100644 --- a/src/tempo/precompile/Chain.ts +++ b/src/tempo/precompile/Chain.ts @@ -1,29 +1,42 @@ import type { Account, Address, Client, Hex } from 'viem' -import { encodeFunctionData } from 'viem' +import { encodeFunctionData, isAddressEqual, parseEventLogs } from 'viem' import { + call, prepareTransactionRequest, readContract, + sendRawTransaction, sendRawTransactionSync, - sendTransaction, + sendTransaction as sendViemTransaction, signTransaction, + waitForTransactionReceipt, } from 'viem/actions' import { Transaction } from 'viem/tempo' import { BadRequestError, VerificationFailedError } from '../../Errors.js' -import type * as FeePayer from '../internal/fee-payer.js' +import * as FeePayer from '../internal/fee-payer.js' import { resolveFeeToken } from '../internal/fee-token.js' +import * as ChannelUtils from './Channel.js' import type { ChannelDescriptor } from './Channel.js' import { tip20ChannelEscrow } from './Constants.js' import { escrowAbi } from './escrow.abi.js' +import * as ChannelOps from './server/ChannelOps.js' const UINT96_MAX = 2n ** 96n - 1n +/** viem client shape accepted by raw Tempo transaction actions. */ +export type TransactionClient = Parameters[0] + function assertUint96(amount: bigint): void { if (amount < 0n || amount > UINT96_MAX) { throw new VerificationFailedError({ reason: 'amount exceeds uint96 range' }) } } +function uint96(amount: bigint): bigint { + assertUint96(amount) + return amount +} + /** * On-chain channel state from the TIP20EscrowChannel precompile. */ @@ -208,6 +221,319 @@ export async function closeOnChain( ) } +/** Receipt event shape emitted by TIP20EscrowChannel precompile management calls. */ +export type ChannelReceiptEvent = { + args: { + channelId: Hex + expiringNonceHash?: Hex | undefined + deposit?: bigint | undefined + newDeposit?: bigint | undefined + newSettled?: bigint | undefined + settledToPayee?: bigint | undefined + refundedToPayer?: bigint | undefined + } +} + +/** + * Asserts that a deserialized transaction has an existing sender signature. + */ +export function assertSenderSigned( + transaction: ReturnType<(typeof Transaction)['deserialize']>, +): void { + if (!transaction.signature || !transaction.from) + throw new BadRequestError({ + reason: 'Transaction must be signed by the sender before fee payer co-signing', + }) +} + +/** Broadcast a raw serialized transaction. */ +export async function sendTransaction(client: TransactionClient, transaction: Hex) { + return sendRawTransaction(client, { serializedTransaction: transaction }) +} + +/** Wait for a receipt and reject reverted precompile transactions. */ +export async function waitForSuccessfulReceipt(client: TransactionClient, hash: Hex) { + const receipt = await waitForTransactionReceipt(client, { hash }) + if (receipt.status !== 'success') + throw new VerificationFailedError({ reason: 'precompile transaction reverted' }) + return receipt +} + +/** Extract exactly one channel event for a channel ID from a receipt. */ +export function getChannelEvent( + receipt: { logs: Parameters[0]['logs'] }, + name: 'ChannelOpened' | 'TopUp' | 'Settled' | 'ChannelClosed', + channelId: Hex, +): ChannelReceiptEvent { + const logs = parseEventLogs({ + abi: escrowAbi, + eventName: name, + logs: receipt.logs, + }) as ChannelReceiptEvent[] + const matches = logs.filter((log) => log.args.channelId.toLowerCase() === channelId.toLowerCase()) + if (matches.length !== 1) + throw new VerificationFailedError({ + reason: `expected one ${name} event for credential channelId in receipt`, + }) + return matches[0]! +} + +/** Broadcasts a client-signed management transaction, adding a fee-payer co-signature when requested. */ +export async function sendCredentialTransaction(parameters: { + challengeExpires?: string | undefined + chainId: number + client: TransactionClient + details: Record + expectedFeeToken?: Address | undefined + feePayer?: Account | undefined + feePayerPolicy?: Partial | undefined + label: 'open' | 'topUp' + serializedTransaction: Hex + transaction: ReturnType<(typeof Transaction)['deserialize']> +}) { + const { + challengeExpires, + chainId, + client, + details, + expectedFeeToken, + feePayer, + feePayerPolicy, + label, + serializedTransaction, + transaction, + } = parameters + + if (!feePayer) { + const txHash = await sendTransaction(client, serializedTransaction) + return waitForSuccessfulReceipt(client, txHash) + } + + if (!FeePayer.isTempoTransaction(serializedTransaction)) + throw new BadRequestError({ reason: 'Only Tempo (0x76/0x78) transactions are supported' }) + assertSenderSigned(transaction) + + await call(client, { + ...transaction, + account: transaction.from, + calls: transaction.calls ?? [], + feePayerSignature: undefined, + } as never) + + const sponsored = FeePayer.prepareSponsoredTransaction({ + account: feePayer, + challengeExpires, + chainId, + details, + expectedFeeToken, + policy: feePayerPolicy, + transaction: { + ...transaction, + ...(expectedFeeToken ? { feeToken: transaction.feeToken ?? expectedFeeToken } : {}), + }, + }) + const serialized = (await signTransaction(client, sponsored as never)) as Hex + const receipt = await sendRawTransactionSync(client, { + serializedTransaction: serialized as Transaction.TransactionSerializedTempo, + }) + if (receipt.status !== 'success') + throw new VerificationFailedError({ + reason: `${label} precompile transaction reverted: ${receipt.transactionHash}`, + }) + return receipt +} + +export type BroadcastOpenTransactionResult = { + txHash: Hex + descriptor: ChannelDescriptor + state: ChannelState + expiringNonceHash: Hex + openDeposit: bigint +} + +/** Broadcast and validate a client-signed TIP-1034 open transaction. */ +export async function broadcastOpenTransaction(parameters: { + challengeExpires?: string | undefined + chainId: number + client: TransactionClient + escrowContract: Address + expectedAuthorizedSigner: Address + expectedChannelId: Hex + expectedCurrency: Address + expectedExpiringNonceHash: Hex + expectedOperator: Address + expectedPayee: Address + expectedPayer: Address + feePayer?: Account | undefined + feePayerPolicy?: Partial | undefined + serializedTransaction: Hex +}): Promise { + const transaction = Transaction.deserialize( + parameters.serializedTransaction as Transaction.TransactionSerializedTempo, + ) + const calls = transaction.calls + if (calls.length !== 1) + throw new VerificationFailedError({ + reason: 'TIP-1034 open transaction must contain exactly one call', + }) + const call = calls[0]! + if (!call.to || !isAddressEqual(call.to, parameters.escrowContract)) + throw new VerificationFailedError({ + reason: 'TIP-1034 open transaction targets the wrong address', + }) + const payer = transaction.from ?? parameters.expectedPayer + const open = ChannelOps.parseOpenCall({ + data: call.data!, + expected: { + payee: parameters.expectedPayee, + token: parameters.expectedCurrency, + operator: parameters.expectedOperator, + authorizedSigner: parameters.expectedAuthorizedSigner, + }, + }) + const descriptor = ChannelOps.descriptorFromOpen({ + chainId: parameters.chainId, + escrow: parameters.escrowContract, + payer, + open, + expiringNonceHash: parameters.expectedExpiringNonceHash, + channelId: parameters.expectedChannelId, + }) + const expiringNonceHash = ChannelUtils.computeExpiringNonceHash( + transaction as ChannelUtils.ExpiringNonceTransaction, + { sender: payer }, + ) + if (expiringNonceHash.toLowerCase() !== descriptor.expiringNonceHash.toLowerCase()) + throw new VerificationFailedError({ + reason: 'credential expiringNonceHash does not match transaction', + }) + const receipt = await sendCredentialTransaction({ + challengeExpires: parameters.challengeExpires, + chainId: parameters.chainId, + client: parameters.client, + details: { + channelId: parameters.expectedChannelId, + currency: parameters.expectedCurrency, + recipient: parameters.expectedPayee, + }, + expectedFeeToken: parameters.expectedCurrency, + feePayer: parameters.feePayer, + feePayerPolicy: parameters.feePayerPolicy, + label: 'open', + serializedTransaction: parameters.serializedTransaction, + transaction, + }) + const opened = getChannelEvent(receipt, 'ChannelOpened', parameters.expectedChannelId) + const emittedChannelId = opened.args.channelId as Hex + const emittedExpiringNonceHash = opened.args.expiringNonceHash as Hex + const emittedDeposit = uint96(opened.args.deposit as bigint) + if (emittedChannelId.toLowerCase() !== parameters.expectedChannelId.toLowerCase()) + throw new VerificationFailedError({ + reason: 'ChannelOpened channelId does not match credential', + }) + if (emittedExpiringNonceHash.toLowerCase() !== descriptor.expiringNonceHash.toLowerCase()) + throw new VerificationFailedError({ + reason: 'ChannelOpened expiringNonceHash does not match descriptor', + }) + if (emittedDeposit !== open.deposit) + throw new VerificationFailedError({ reason: 'ChannelOpened deposit does not match calldata' }) + const confirmedChannelId = ChannelUtils.computeId({ + ...descriptor, + chainId: parameters.chainId, + escrow: parameters.escrowContract, + }) + if (confirmedChannelId.toLowerCase() !== emittedChannelId.toLowerCase()) + throw new VerificationFailedError({ + reason: 'descriptor does not match ChannelOpened channelId', + }) + const chainChannel = await getChannel(parameters.client, descriptor, parameters.escrowContract) + const state = chainChannel.state + if (state.deposit !== emittedDeposit || state.settled !== 0n || state.closeRequestedAt !== 0) + throw new VerificationFailedError({ + reason: 'on-chain channel state does not match open receipt', + }) + return { + txHash: receipt.transactionHash, + descriptor, + state, + expiringNonceHash: emittedExpiringNonceHash, + openDeposit: open.deposit, + } +} + +export type BroadcastTopUpTransactionResult = { + txHash: Hex + newDeposit: bigint + state: ChannelState +} + +/** Broadcast and validate a client-signed TIP-1034 top-up transaction. */ +export async function broadcastTopUpTransaction(parameters: { + additionalDeposit: bigint + challengeExpires?: string | undefined + chainId: number + client: TransactionClient + descriptor: ChannelDescriptor + escrowContract: Address + expectedCurrency: Address + expectedChannelId: Hex + feePayer?: Account | undefined + feePayerPolicy?: Partial | undefined + serializedTransaction: Hex +}): Promise { + const transaction = Transaction.deserialize( + parameters.serializedTransaction as Transaction.TransactionSerializedTempo, + ) + const calls = transaction.calls + if (calls.length !== 1) + throw new VerificationFailedError({ + reason: 'TIP-1034 topUp transaction must contain exactly one call', + }) + const call = calls[0]! + if (!call.to || !isAddressEqual(call.to, parameters.escrowContract)) + throw new VerificationFailedError({ + reason: 'TIP-1034 topUp transaction targets the wrong address', + }) + ChannelOps.parseTopUpCall({ + data: call.data!, + expected: { + descriptor: parameters.descriptor, + additionalDeposit: parameters.additionalDeposit, + }, + }) + const receipt = await sendCredentialTransaction({ + challengeExpires: parameters.challengeExpires, + chainId: parameters.chainId, + client: parameters.client, + details: { + additionalDeposit: parameters.additionalDeposit.toString(), + channelId: parameters.expectedChannelId, + currency: parameters.expectedCurrency, + }, + expectedFeeToken: parameters.expectedCurrency, + feePayer: parameters.feePayer, + feePayerPolicy: parameters.feePayerPolicy, + label: 'topUp', + serializedTransaction: parameters.serializedTransaction, + transaction, + }) + const toppedUp = getChannelEvent(receipt, 'TopUp', parameters.expectedChannelId) + const emittedChannelId = toppedUp.args.channelId as Hex + const newDeposit = uint96(toppedUp.args.newDeposit as bigint) + if (emittedChannelId.toLowerCase() !== parameters.expectedChannelId.toLowerCase()) + throw new VerificationFailedError({ reason: 'TopUp channelId does not match credential' }) + const state = await getChannelState( + parameters.client, + emittedChannelId, + parameters.escrowContract, + ) + if (state.deposit !== newDeposit) + throw new VerificationFailedError({ + reason: 'on-chain channel state does not match topUp receipt', + }) + return { txHash: receipt.transactionHash, newDeposit, state } +} + function stateFromTuple(state: { settled: bigint deposit: bigint @@ -298,7 +624,7 @@ async function sendPrecompileTransaction( return receipt.transactionHash } - return sendTransaction(client, { + return sendViemTransaction(client, { ...(options?.account ? { account: options.account } : {}), to, data, diff --git a/src/tempo/precompile/server/Session.ts b/src/tempo/precompile/server/Session.ts index 7f79e459..a1a7c68b 100644 --- a/src/tempo/precompile/server/Session.ts +++ b/src/tempo/precompile/server/Session.ts @@ -1,37 +1,36 @@ +/** + * Server-side TIP-1034 precompile session payment method for request/response flows. + * + * Handles the full TIP20EscrowChannel lifecycle (open, voucher, top-up, close) + * and one-shot settlement. Each incoming request carries a session credential + * with a cumulative voucher that the server validates and records. + */ import { type Address, type Hex, isAddressEqual, - parseEventLogs, parseUnits, zeroAddress, type Account as viem_Account, } from 'viem' -import { - call, - sendRawTransaction, - sendRawTransactionSync, - signTransaction, - waitForTransactionReceipt, -} from 'viem/actions' import { tempo as tempo_chain } from 'viem/chains' -import { Transaction } from 'viem/tempo' import { AmountExceedsDepositError, + BadRequestError, ChannelClosedError, ChannelNotFoundError, DeltaTooSmallError, InsufficientBalanceError, InvalidSignatureError, VerificationFailedError, - BadRequestError, } from '../../../Errors.js' import type { Challenge, Credential } from '../../../index.js' import type { LooseOmit, NoExtraKeys } from '../../../internal/types.js' import * as Method from '../../../Method.js' import * as Store from '../../../Store.js' import * as Client from '../../../viem/Client.js' +import type * as z from '../../../zod.js' import * as defaults from '../../internal/defaults.js' import * as FeePayer from '../../internal/fee-payer.js' import type * as types from '../../internal/types.js' @@ -40,216 +39,16 @@ import { captureRequestBodyProbe, isSessionContentRequest, } from '../../server/internal/request-body.js' +import * as Transport from '../../server/internal/transport.js' +import type { SessionMethodDetails } from '../../server/Session.js' import * as ChannelStore from '../../session/ChannelStore.js' import { createSessionReceipt } from '../../session/Receipt.js' import type { SessionReceipt } from '../../session/Types.js' import * as Chain from '../Chain.js' import * as Channel from '../Channel.js' import { tip20ChannelEscrow } from '../Constants.js' -import { escrowAbi } from '../escrow.abi.js' -import { type SessionCredentialPayload, uint96 } from '../Types.js' +import { type SessionCredentialPayload, type SignedVoucher, uint96 } from '../Types.js' import * as Voucher from '../Voucher.js' -import * as ChannelOps from './ChannelOps.js' - -type SessionMethodDetails = { - chainId: number - escrowContract?: Address | undefined - channelId?: Hex | undefined - feePayer?: boolean | undefined - minVoucherDelta?: string | undefined -} - -function authorizedSigner(descriptor: Channel.ChannelDescriptor): Address { - return isAddressEqual(descriptor.authorizedSigner, zeroAddress) - ? descriptor.payer - : descriptor.authorizedSigner -} - -function assertSameDescriptor(a: Channel.ChannelDescriptor, b: Channel.ChannelDescriptor) { - if ( - !isAddressEqual(a.payer, b.payer) || - !isAddressEqual(a.payee, b.payee) || - !isAddressEqual(a.operator, b.operator) || - !isAddressEqual(a.token, b.token) || - !isAddressEqual(a.authorizedSigner, b.authorizedSigner) || - a.salt.toLowerCase() !== b.salt.toLowerCase() || - a.expiringNonceHash.toLowerCase() !== b.expiringNonceHash.toLowerCase() - ) - throw new VerificationFailedError({ - reason: 'credential descriptor does not match stored channel', - }) -} - -function validateDescriptor(parameters: { - descriptor: Channel.ChannelDescriptor - channelId: Hex - chainId: number - escrow: Address - recipient: Address - currency: Address -}) { - const { descriptor, channelId, chainId, escrow, recipient, currency } = parameters - const computed = Channel.computeId({ ...descriptor, chainId, escrow }) - if (computed.toLowerCase() !== channelId.toLowerCase()) - throw new VerificationFailedError({ reason: 'credential channelId does not match descriptor' }) - if (!isAddressEqual(descriptor.payee, recipient)) - throw new VerificationFailedError({ reason: 'descriptor payee does not match challenge' }) - if (!isAddressEqual(descriptor.token, currency)) - throw new VerificationFailedError({ reason: 'descriptor token does not match challenge' }) -} - -async function sendTransaction(client: Parameters[0], transaction: Hex) { - return sendRawTransaction(client, { serializedTransaction: transaction }) -} - -async function waitForSuccessfulReceipt( - client: Parameters[0], - hash: Hex, -) { - const receipt = await waitForTransactionReceipt(client, { hash }) - if (receipt.status !== 'success') - throw new VerificationFailedError({ reason: 'precompile transaction reverted' }) - return receipt -} - -type ChannelReceiptEvent = { - args: { - channelId: Hex - expiringNonceHash?: Hex | undefined - deposit?: bigint | undefined - newDeposit?: bigint | undefined - newSettled?: bigint | undefined - settledToPayee?: bigint | undefined - refundedToPayer?: bigint | undefined - } -} - -function assertSenderSigned(transaction: ReturnType<(typeof Transaction)['deserialize']>): void { - if (!transaction.signature || !transaction.from) - throw new BadRequestError({ - reason: 'Transaction must be signed by the sender before fee payer co-signing', - }) -} - -function assertDescriptor(payload: { - descriptor?: Channel.ChannelDescriptor | undefined -}): asserts payload is { descriptor: Channel.ChannelDescriptor } { - if (!payload.descriptor) - throw new VerificationFailedError({ - reason: 'descriptor required for precompile session action', - }) -} - -function assertSettlementSender(parameters: { - operation: 'close' | 'settle' - channelId: Hex - operator: Address - payee: Address - sender: Address | undefined -}) { - const { operation, channelId, operator, payee, sender } = parameters - if (!sender) - throw new Error( - `Cannot ${operation} precompile channel ${channelId}: no account available. Pass an account override, or provide a getClient() that returns an account-bearing client.`, - ) - if (isAddressEqual(sender, payee)) return - if (!isAddressEqual(operator, zeroAddress) && isAddressEqual(sender, operator)) return - throw new BadRequestError({ - reason: - `Cannot ${operation} precompile channel ${channelId}: tx sender ${sender} is not the channel payee ${payee}` + - (isAddressEqual(operator, zeroAddress) ? '.' : ` or operator ${operator}.`) + - ' If using an access key, pass a Tempo access-key account whose address is the payee/operator wallet, not the raw delegated key address.', - }) -} - -function getClientAccount(client: { account?: viem_Account | undefined }) { - return client.account -} - -function getChannelEvent( - receipt: { logs: Parameters[0]['logs'] }, - name: 'ChannelOpened' | 'TopUp' | 'Settled' | 'ChannelClosed', - channelId: Hex, -): ChannelReceiptEvent { - const logs = parseEventLogs({ - abi: escrowAbi, - eventName: name, - logs: receipt.logs, - }) as ChannelReceiptEvent[] - const matches = logs.filter((log) => log.args.channelId.toLowerCase() === channelId.toLowerCase()) - if (matches.length !== 1) - throw new VerificationFailedError({ - reason: `expected one ${name} event for credential channelId in receipt`, - }) - return matches[0]! -} - -/** Broadcasts a client-signed management transaction, adding a fee-payer co-signature when requested. */ -async function sendCredentialTransaction(parameters: { - challengeExpires?: string | undefined - chainId: number - client: Parameters[0] - details: Record - expectedFeeToken?: Address | undefined - feePayer?: viem_Account | undefined - feePayerPolicy?: Partial | undefined - label: 'open' | 'topUp' - serializedTransaction: Hex - transaction: ReturnType<(typeof Transaction)['deserialize']> -}) { - const { - challengeExpires, - chainId, - client, - details, - expectedFeeToken, - feePayer, - feePayerPolicy, - label, - serializedTransaction, - transaction, - } = parameters - - if (!feePayer) { - const txHash = await sendTransaction(client, serializedTransaction) - return waitForSuccessfulReceipt(client, txHash) - } - - if (!FeePayer.isTempoTransaction(serializedTransaction)) - throw new BadRequestError({ - reason: 'Only Tempo (0x76/0x78) transactions are supported', - }) - assertSenderSigned(transaction) - - await call(client, { - ...transaction, - account: transaction.from, - calls: transaction.calls ?? [], - feePayerSignature: undefined, - } as never) - - const sponsored = FeePayer.prepareSponsoredTransaction({ - account: feePayer, - challengeExpires, - chainId, - details, - expectedFeeToken, - policy: feePayerPolicy, - transaction: { - ...transaction, - ...(expectedFeeToken ? { feeToken: transaction.feeToken ?? expectedFeeToken } : {}), - }, - }) - const serialized = (await signTransaction(client, sponsored as never)) as Hex - const receipt = await sendRawTransactionSync(client, { - serializedTransaction: serialized as Transaction.TransactionSerializedTempo, - }) - if (receipt.status !== 'success') - throw new VerificationFailedError({ - reason: `${label} precompile transaction reverted: ${receipt.transactionHash}`, - }) - return receipt -} /** Creates a server-side TIP20EscrowChannel precompile session payment method. */ export function session( @@ -275,8 +74,16 @@ export function session( rpcUrl: defaults.rpcUrl, }) + type Transport = parameters['sse'] extends false | undefined ? undefined : Transport.Sse + const transport = parameters.sse + ? Transport.sse({ + store, + ...(typeof parameters.sse === 'object' ? parameters.sse : undefined), + }) + : undefined + type Defaults = session.DeriveDefaults - return Method.toServer(Methods.session, { + return Method.toServer(Methods.session, { defaults: { amount, currency, @@ -286,12 +93,23 @@ export function session( unitType, } as unknown as Defaults, + // TODO: dedupe `{charge,session}.request` + transport: transport as never, + async request({ credential, request }) { - const chainId = request.chainId ?? parameters.chainId ?? (await getClient({})).chain?.id + // Extract chainId from request or default. + const chainId = await (async () => { + if (request.chainId) return request.chainId + if (parameters.chainId) return parameters.chainId + return (await getClient({})).chain?.id + })() if (!chainId) throw new Error('No chainId configured for tempo.precompile.session().') + + // Validate chainId. const client = await getClient({ chainId }) if (client.chain?.id !== chainId) throw new Error(`Client not configured with chainId ${chainId}.`) + // Extract feePayer. const resolvedFeePayer = (() => { if (request.feePayer === false) return credential ? false : undefined const account = @@ -303,7 +121,7 @@ export function session( return { ...request, chainId, - escrowContract: request.escrowContract ?? parameters.escrow ?? tip20ChannelEscrow, + escrowContract: request.escrowContract ?? parameters.escrowContract ?? tip20ChannelEscrow, feePayer: resolvedFeePayer, } }, @@ -311,11 +129,17 @@ export function session( async verify({ credential, envelope, request }) { const { challenge, payload: rawPayload } = credential const payload = rawPayload as SessionCredentialPayload - const methodDetails = (request as typeof request & { methodDetails?: SessionMethodDetails }) - .methodDetails + const resolvedRequest = (() => { + const parsed = Methods.session.schema.request.safeParse(request) + if (parsed.success) return parsed.data + // verifyCredential() passes the HMAC-bound challenge request, which is + // already in canonical output form and should not be transformed again. + return request as unknown as z.output + })() + const methodDetails = resolvedRequest.methodDetails as SessionMethodDetails | undefined if (!methodDetails) throw new VerificationFailedError({ reason: 'missing methodDetails' }) const chainId = methodDetails.chainId - const escrow = methodDetails.escrowContract ?? parameters.escrow ?? tip20ChannelEscrow + const escrow = methodDetails.escrowContract const client = await getClient({ chainId }) const requestAllowsFeePayer = request.feePayer !== false && @@ -396,6 +220,10 @@ export function session( throw new VerificationFailedError({ reason: 'unsupported precompile session action' }) } + // In the default HTTP request/response mode, each successful content + // request consumes one unit immediately after the credential is accepted. + // This keeps equal-voucher replays bounded by the voucher's remaining + // balance instead of serving repeated responses for free. if ( envelope && isSessionContentRequest(envelope.capturedRequest) && @@ -404,18 +232,29 @@ export function session( const charged = await charge( store, sessionReceipt.channelId as Hex, - BigInt((request as { amount?: string }).amount ?? challenge.request.amount), + BigInt(resolvedRequest.amount ?? challenge.request.amount), ) sessionReceipt = { ...sessionReceipt, spent: charged.spent.toString(), units: charged.units, } + if (parameters.sse) sessionReceipt = Transport.markPrepaidSessionTick(sessionReceipt) } return sessionReceipt }, + // This hook acts as a gate: when it returns a Response, `withReceipt()` + // in Mppx.ts short-circuits and returns that response directly without + // invoking the user's route handler. When it returns undefined, the + // user's handler runs normally and serves content. + // + // close and topUp are always gated (204) — they are pure management. + // + // open and voucher share the same captured-request classifier used + // during verification. Non-billable requests are treated as management + // updates; billable requests fall through to the application handler. respond({ credential, envelope, input }) { const { payload } = credential as Credential.Credential @@ -429,7 +268,145 @@ export function session( }) } -/** Charges a fulfilled request against a precompile-backed session channel. */ +export namespace session { + export type Defaults = LooseOmit, 'feePayer'> + + export type FeePayerPolicy = Partial + + export type Parameters = { + /** TTL in milliseconds for cached on-chain channel state. After this duration, the server re-queries on-chain state during voucher handling to detect forced close requests. @default 5_000 */ + channelStateTtl?: number | undefined + /** Override the fee-sponsor policy used for sponsored open/topUp transactions and server-driven close transactions. */ + feePayerPolicy?: FeePayerPolicy | undefined + /** Minimum voucher delta to accept (numeric string, default: "0"). */ + minVoucherDelta?: string | undefined + /** + * Atomic store backend for channel state. + * + * Session mutations must be linearizable across instances so spent, + * highest-voucher, top-up, and close/finalization updates cannot race. + * Use `Store.memory()` for tests or local single-process usage. + */ + store?: Store.AtomicStore | undefined + /** Enable SSE streaming. Pass `true` for defaults or an options object to configure SSE. */ + sse?: boolean | Transport.sse.Options | undefined + /** Tempo chain ID used for TIP-1034 channel escrow challenges. Defaults to the resolved client chain ID. Pass the Tempo testnet chain ID here instead of using legacy session's `testnet` boolean. */ + chainId?: number | undefined + + /** Account used for server-driven close and settle transactions. Defaults to the client account. */ + account?: viem_Account | undefined + /** Optional fee payer used to sponsor server-driven close transactions. */ + feePayer?: viem_Account | undefined + /** Optional fee token used for server-driven close transactions. */ + feeToken?: Address | undefined + } & Client.getResolver.Parameters & + Defaults + + export type DeriveDefaults = types.DeriveDefaults< + parameters, + Defaults + > +} + +function assertSettlementSender(parameters: { + operation: 'close' | 'settle' + channelId: Hex + operator: Address + payee: Address + sender: Address | undefined +}) { + const { operation, channelId, operator, payee, sender } = parameters + if (!sender) + throw new Error( + `Cannot ${operation} precompile channel ${channelId}: no account available. Pass an account override, or provide a getClient() that returns an account-bearing client.`, + ) + if (isAddressEqual(sender, payee)) return + if (!isAddressEqual(operator, zeroAddress) && isAddressEqual(sender, operator)) return + throw new BadRequestError({ + reason: + `Cannot ${operation} precompile channel ${channelId}: tx sender ${sender} is not the channel payee ${payee}` + + (isAddressEqual(operator, zeroAddress) ? '.' : ` or operator ${operator}.`) + + ' If using an access key, pass a Tempo access-key account whose address is the payee/operator wallet, not the raw delegated key address.', + }) +} + +/** Settles the highest accepted voucher for a precompile-backed session channel. */ +export async function settle( + store_: Store.Store | ChannelStore.ChannelStore, + client: Chain.TransactionClient, + channelId_: Hex, + options?: { + account?: viem_Account | undefined + candidateFeeTokens?: readonly Address[] | undefined + escrowContract?: Address | undefined + feePayer?: viem_Account | undefined + feePayerPolicy?: session.FeePayerPolicy | undefined + feeToken?: Address | undefined + }, +): Promise { + const store = 'getChannel' in store_ ? store_ : ChannelStore.fromStore(store_ as never) + const channelId = ChannelStore.normalizeChannelId(channelId_) + const channel = await store.getChannel(channelId) + if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' }) + if (!ChannelStore.isPrecompileState(channel)) + throw new VerificationFailedError({ reason: 'channel is not precompile-backed' }) + if (!channel.highestVoucher) throw new VerificationFailedError({ reason: 'no voucher to settle' }) + const escrow = options?.escrowContract ?? channel.escrowContract + const account = options?.account ?? getClientAccount(client) + assertSettlementSender({ + operation: 'settle', + channelId, + operator: channel.operator, + payee: channel.payee, + sender: account?.address, + }) + const amount = uint96(channel.highestVoucher.cumulativeAmount) + const txHash = await Chain.settleOnChain( + client, + channel.descriptor, + amount, + channel.highestVoucher.signature, + escrow, + account + ? { + account, + ...(options?.feePayer ? { feePayer: options.feePayer } : {}), + ...(options?.feePayerPolicy ? { feePayerPolicy: options.feePayerPolicy } : {}), + ...(options?.feeToken ? { feeToken: options.feeToken } : {}), + candidateFeeTokens: options?.candidateFeeTokens ?? [channel.token], + } + : undefined, + ) + const receipt = await Chain.waitForSuccessfulReceipt(client, txHash) + const settled = Chain.getChannelEvent(receipt, 'Settled', channelId) + const newSettled = uint96(settled.args.newSettled as bigint) + if (newSettled < amount) + throw new VerificationFailedError({ reason: 'Settled event is below voucher amount' }) + const state = await Chain.getChannelState(client, channelId, escrow) + if (state.settled !== newSettled) + throw new VerificationFailedError({ + reason: 'on-chain channel state does not match settle receipt', + }) + await store.updateChannel(channelId, (current) => + current + ? { + ...current, + settledOnChain: newSettled > current.settledOnChain ? newSettled : current.settledOnChain, + } + : current, + ) + return txHash +} + +/** + * Charge against a precompile-backed channel's balance. + * + * Exported so consumers can deduct from a channel outside the `session()` + * handler. + * + * Delegates to the shared `deductFromChannel` atomic helper and translates + * failure modes into typed errors (`InsufficientBalanceError`, `ChannelClosedError`). + */ export async function charge( store: ChannelStore.ChannelStore, channelId: Hex, @@ -447,21 +424,187 @@ export async function charge( throw new ChannelClosedError({ reason: 'channel has a pending close request' }) const available = result.channel.highestVoucherAmount - result.channel.spent throw new InsufficientBalanceError({ - reason: `requested ${amount}, available ${available}`, + reason: `requested ${amount}, available ${available}`, + }) + } + return result.channel +} + +function authorizedSigner(descriptor: Channel.ChannelDescriptor): Address { + return isAddressEqual(descriptor.authorizedSigner, zeroAddress) + ? descriptor.payer + : descriptor.authorizedSigner +} + +function getClientAccount(client: { account?: viem_Account | undefined }) { + return client.account +} + +function assertDescriptor(payload: { + descriptor?: Channel.ChannelDescriptor | undefined +}): asserts payload is { descriptor: Channel.ChannelDescriptor } { + if (!payload.descriptor) + throw new VerificationFailedError({ + reason: 'descriptor required for precompile session action', + }) +} + +function assertSameDescriptor(a: Channel.ChannelDescriptor, b: Channel.ChannelDescriptor) { + if ( + !isAddressEqual(a.payer, b.payer) || + !isAddressEqual(a.payee, b.payee) || + !isAddressEqual(a.operator, b.operator) || + !isAddressEqual(a.token, b.token) || + !isAddressEqual(a.authorizedSigner, b.authorizedSigner) || + a.salt.toLowerCase() !== b.salt.toLowerCase() || + a.expiringNonceHash.toLowerCase() !== b.expiringNonceHash.toLowerCase() + ) + throw new VerificationFailedError({ + reason: 'credential descriptor does not match stored channel', + }) +} + +/** + * Validate a TIP20EscrowChannel descriptor against the credential channel ID and expected payment destination. + */ +function validateChannelDescriptor( + descriptor: Channel.ChannelDescriptor, + channelId: Hex, + chainId: number, + escrow: Address, + recipient: Address, + currency: Address, +): void { + const computed = Channel.computeId({ ...descriptor, chainId, escrow }) + if (computed.toLowerCase() !== channelId.toLowerCase()) { + throw new VerificationFailedError({ reason: 'channel descriptor does not match channelId' }) + } + if (!isAddressEqual(descriptor.payee, recipient)) { + throw new VerificationFailedError({ + reason: 'channel descriptor payee does not match server destination', + }) + } + if (!isAddressEqual(descriptor.token, currency)) { + throw new VerificationFailedError({ + reason: 'channel descriptor token does not match server token', + }) + } +} + +/** + * Validate on-chain channel state. + */ +function validateChannelState(state: Chain.ChannelState, amount?: bigint): void { + if (state.deposit === 0n) { + throw new ChannelNotFoundError({ reason: 'channel not funded on-chain' }) + } + if (state.closeRequestedAt !== 0) { + throw new ChannelClosedError({ reason: 'channel has a pending close request' }) + } + if (amount !== undefined && state.deposit - state.settled < amount) { + throw new InsufficientBalanceError({ + reason: 'channel available balance insufficient for requested amount', }) } - return result.channel } +/** + * Shared logic for verifying an incremental voucher and updating channel state. + * Used by handleVoucher after descriptor and cache resolution. + */ +async function verifyAndAcceptVoucher(parameters: { + store: ChannelStore.ChannelStore + minVoucherDelta: bigint + challenge: Challenge.Challenge + channel: ChannelStore.State + voucher: SignedVoucher + channelState: Chain.ChannelState + methodDetails: SessionMethodDetails +}): Promise { + const { store, minVoucherDelta, challenge, channel, voucher, channelState, methodDetails } = + parameters + + validateChannelState(channelState) + if (voucher.cumulativeAmount <= channelState.settled) + throw new VerificationFailedError({ + reason: 'voucher cumulativeAmount is below on-chain settled amount', + }) + if (voucher.cumulativeAmount > channelState.deposit) + throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' }) + if (voucher.cumulativeAmount < channel.highestVoucherAmount) + throw new VerificationFailedError({ + reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher', + }) + const valid = await Voucher.verifyVoucher( + methodDetails.escrowContract, + methodDetails.chainId, + voucher, + channel.authorizedSigner, + ) + if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) + + // Idempotent replay: equal cumulative voucher is accepted without + // advancing channel state or charging additional value. + if (voucher.cumulativeAmount === channel.highestVoucherAmount) + return createSessionReceipt({ + challengeId: challenge.id, + channelId: voucher.channelId, + acceptedCumulative: channel.highestVoucherAmount, + spent: channel.spent, + units: channel.units, + }) + const delta = voucher.cumulativeAmount - channel.highestVoucherAmount + if (delta < minVoucherDelta) + throw new DeltaTooSmallError({ + reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`, + }) + const updated = await store.updateChannel(voucher.channelId, (current) => + (() => { + if (!current) throw new ChannelNotFoundError({ reason: 'channel not found' }) + if (current.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' }) + if (current.closeRequestedAt !== 0n) + throw new ChannelClosedError({ reason: 'channel has a pending close request' }) + + const nextDeposit = + channelState.deposit > current.deposit ? channelState.deposit : current.deposit + const nextSettled = + channelState.settled > current.settledOnChain + ? channelState.settled + : current.settledOnChain + if (voucher.cumulativeAmount > current.highestVoucherAmount) { + return { + ...current, + deposit: nextDeposit, + settledOnChain: nextSettled, + highestVoucherAmount: voucher.cumulativeAmount, + highestVoucher: voucher, + } + } + return { ...current, deposit: nextDeposit, settledOnChain: nextSettled } + })(), + ) + if (!updated) throw new ChannelNotFoundError({ reason: 'channel not found' }) + return createSessionReceipt({ + challengeId: challenge.id, + channelId: voucher.channelId, + acceptedCumulative: updated.highestVoucherAmount, + spent: updated.spent, + units: updated.units, + }) +} + +/** + * Handle 'open' action - verify voucher, create channel, and broadcast. + */ async function handleOpen(parameters: { store: ChannelStore.ChannelStore - client: Parameters[0] + client: Chain.TransactionClient challenge: Challenge.Challenge - payload: Extract + payload: SessionCredentialPayload & { action: 'open' } chainId: number escrow: Address feePayer?: viem_Account | undefined - feePayerPolicy?: Partial | undefined + feePayerPolicy?: session.FeePayerPolicy | undefined }): Promise { const { store, client, challenge, payload, chainId, escrow } = parameters const cumulativeAmount = uint96(BigInt(payload.cumulativeAmount)) @@ -474,56 +617,34 @@ async function handleOpen(parameters: { reason: 'credential authorizedSigner does not match descriptor', }) const channelId = ChannelStore.normalizeChannelId(payload.channelId) - validateDescriptor({ - descriptor: payload.descriptor, + validateChannelDescriptor( + payload.descriptor, channelId, chainId, escrow, - recipient: challenge.request.recipient as Address, - currency: challenge.request.currency as Address, - }) - - const transaction = Transaction.deserialize( - payload.transaction as Transaction.TransactionSerializedTempo, + challenge.request.recipient as Address, + challenge.request.currency as Address, ) - const calls = transaction.calls - if (calls.length !== 1) - throw new VerificationFailedError({ - reason: 'TIP-1034 open transaction must contain exactly one call', - }) - const call = calls[0]! - if (!call.to || !isAddressEqual(call.to, escrow)) - throw new VerificationFailedError({ - reason: 'TIP-1034 open transaction targets the wrong address', - }) - const payer = transaction.from ?? payload.descriptor.payer - const open = ChannelOps.parseOpenCall({ - data: call.data!, - expected: { - payee: challenge.request.recipient as Address, - token: challenge.request.currency as Address, - operator: payload.descriptor.operator, - authorizedSigner: payload.descriptor.authorizedSigner, - }, - }) - const descriptor = ChannelOps.descriptorFromOpen({ + + const result = await Chain.broadcastOpenTransaction({ + challengeExpires: challenge.expires, chainId, - escrow, - payer, - open, - expiringNonceHash: payload.descriptor.expiringNonceHash, - channelId, + client, + escrowContract: escrow, + expectedAuthorizedSigner: payload.descriptor.authorizedSigner, + expectedChannelId: channelId, + expectedCurrency: challenge.request.currency as Address, + expectedOperator: payload.descriptor.operator, + expectedPayee: challenge.request.recipient as Address, + expectedExpiringNonceHash: payload.descriptor.expiringNonceHash, + expectedPayer: payload.descriptor.payer, + feePayer: parameters.feePayer, + feePayerPolicy: parameters.feePayerPolicy, + serializedTransaction: payload.transaction, }) + const { descriptor, state } = result assertSameDescriptor(descriptor, payload.descriptor) - const expiringNonceHash = Channel.computeExpiringNonceHash( - transaction as Channel.ExpiringNonceTransaction, - { sender: payer }, - ) - if (expiringNonceHash.toLowerCase() !== descriptor.expiringNonceHash.toLowerCase()) - throw new VerificationFailedError({ - reason: 'credential expiringNonceHash does not match transaction', - }) - if (cumulativeAmount > open.deposit) + if (cumulativeAmount > result.openDeposit) throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds open deposit' }) const valid = await Voucher.verifyVoucher( escrow, @@ -532,54 +653,13 @@ async function handleOpen(parameters: { authorizedSigner(descriptor), ) if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) - const receipt = await sendCredentialTransaction({ - challengeExpires: challenge.expires, - chainId, - client, - details: { - channelId, - currency: challenge.request.currency as Address, - recipient: challenge.request.recipient as Address, - }, - expectedFeeToken: challenge.request.currency as Address, - feePayer: parameters.feePayer, - feePayerPolicy: parameters.feePayerPolicy, - label: 'open', - serializedTransaction: payload.transaction, - transaction, - }) - const txHash = receipt.transactionHash - const opened = getChannelEvent(receipt, 'ChannelOpened', channelId) - const emittedChannelId = opened.args.channelId as Hex - const emittedExpiringNonceHash = opened.args.expiringNonceHash as Hex - const emittedDeposit = uint96(opened.args.deposit as bigint) - if (emittedChannelId.toLowerCase() !== channelId.toLowerCase()) - throw new VerificationFailedError({ - reason: 'ChannelOpened channelId does not match credential', - }) - if (emittedExpiringNonceHash.toLowerCase() !== descriptor.expiringNonceHash.toLowerCase()) - throw new VerificationFailedError({ - reason: 'ChannelOpened expiringNonceHash does not match descriptor', - }) - if (emittedDeposit !== open.deposit) - throw new VerificationFailedError({ reason: 'ChannelOpened deposit does not match calldata' }) - const confirmedChannelId = Channel.computeId({ ...descriptor, chainId, escrow }) - if (confirmedChannelId.toLowerCase() !== emittedChannelId.toLowerCase()) - throw new VerificationFailedError({ - reason: 'descriptor does not match ChannelOpened channelId', - }) - const chainChannel = await Chain.getChannel(client, descriptor, escrow) - assertSameDescriptor(chainChannel.descriptor, descriptor) - const state = chainChannel.state - if (state.deposit !== emittedDeposit || state.settled !== 0n || state.closeRequestedAt !== 0) - throw new VerificationFailedError({ - reason: 'on-chain channel state does not match open receipt', - }) + const amount = challenge.request.amount ? BigInt(challenge.request.amount as string) : undefined + validateChannelState(state, amount) - const updated = await store.updateChannel(emittedChannelId, (current) => ({ + const updated = await store.updateChannel(channelId, (current) => ({ ...(current ?? {}), backend: 'precompile', - channelId: emittedChannelId, + channelId: channelId, chainId, escrowContract: escrow, closeRequestedAt: @@ -601,7 +681,7 @@ async function handleOpen(parameters: { current?.highestVoucherAmount && current.highestVoucherAmount > cumulativeAmount ? current.highestVoucher : { - channelId: emittedChannelId, + channelId: channelId, cumulativeAmount: cumulativeAmount, signature: payload.signature, }, @@ -612,7 +692,7 @@ async function handleOpen(parameters: { descriptor, operator: descriptor.operator, salt: descriptor.salt, - expiringNonceHash: emittedExpiringNonceHash, + expiringNonceHash: result.expiringNonceHash, })) if (!updated) throw new VerificationFailedError({ reason: 'failed to create channel' }) return createSessionReceipt({ @@ -621,19 +701,26 @@ async function handleOpen(parameters: { acceptedCumulative: updated.highestVoucherAmount, spent: updated.spent, units: updated.units, - txHash, + txHash: result.txHash, }) } +/** + * Handle 'topUp' action - broadcast topUp transaction and update channel deposit. + * + * Per spec Section 8.3.2, topUp payloads contain only the transaction and + * additionalDeposit — no voucher. The client must send a separate 'voucher' + * action to authorize spending the new funds. + */ async function handleTopUp(parameters: { store: ChannelStore.ChannelStore - client: Parameters[0] + client: Chain.TransactionClient challenge: Challenge.Challenge - payload: Extract + payload: SessionCredentialPayload & { action: 'topUp' } chainId: number escrow: Address feePayer?: viem_Account | undefined - feePayerPolicy?: Partial | undefined + feePayerPolicy?: session.FeePayerPolicy | undefined }): Promise { const { store, client, challenge, payload, chainId, escrow } = parameters const additionalDeposit = uint96(BigInt(payload.additionalDeposit)) @@ -644,59 +731,31 @@ async function handleTopUp(parameters: { if (!ChannelStore.isPrecompileState(channel)) throw new VerificationFailedError({ reason: 'channel is not precompile-backed' }) assertSameDescriptor(payload.descriptor, channel.descriptor) - validateDescriptor({ - descriptor: payload.descriptor, + validateChannelDescriptor( + payload.descriptor, channelId, chainId, escrow, - recipient: channel.payee, - currency: channel.token, - }) - const transaction = Transaction.deserialize( - payload.transaction as Transaction.TransactionSerializedTempo, + channel.payee, + channel.token, ) - const calls = transaction.calls - if (calls.length !== 1) - throw new VerificationFailedError({ - reason: 'TIP-1034 topUp transaction must contain exactly one call', - }) - const call = calls[0]! - if (!call.to || !isAddressEqual(call.to, escrow)) - throw new VerificationFailedError({ - reason: 'TIP-1034 topUp transaction targets the wrong address', - }) - ChannelOps.parseTopUpCall({ - data: call.data!, - expected: { descriptor: channel.descriptor, additionalDeposit: additionalDeposit }, - }) - const receipt = await sendCredentialTransaction({ + const result = await Chain.broadcastTopUpTransaction({ + additionalDeposit, challengeExpires: challenge.expires, chainId, client, - details: { - additionalDeposit: additionalDeposit.toString(), - channelId, - currency: channel.token, - }, - expectedFeeToken: channel.token, + descriptor: channel.descriptor, + escrowContract: escrow, + expectedChannelId: channelId, + expectedCurrency: channel.token, feePayer: parameters.feePayer, feePayerPolicy: parameters.feePayerPolicy, - label: 'topUp', serializedTransaction: payload.transaction, - transaction, }) - const txHash = receipt.transactionHash - const toppedUp = getChannelEvent(receipt, 'TopUp', channelId) - const emittedChannelId = toppedUp.args.channelId as Hex - const newDeposit = uint96(toppedUp.args.newDeposit as bigint) - if (emittedChannelId.toLowerCase() !== channelId.toLowerCase()) - throw new VerificationFailedError({ reason: 'TopUp channelId does not match credential' }) - const state = await Chain.getChannelState(client, emittedChannelId, escrow) - if (state.deposit !== newDeposit) - throw new VerificationFailedError({ - reason: 'on-chain channel state does not match topUp receipt', - }) - const updated = await store.updateChannel(emittedChannelId, (current) => + const { newDeposit, state } = result + validateChannelState(state) + + const updated = await store.updateChannel(channelId, (current) => current ? { ...current, @@ -716,15 +775,18 @@ async function handleTopUp(parameters: { acceptedCumulative: updated?.highestVoucherAmount ?? channel.highestVoucherAmount, spent: updated?.spent ?? channel.spent, units: updated?.units ?? channel.units, - txHash, + txHash: result.txHash, }) } +/** + * Handle 'voucher' action - verify and accept a new voucher. + */ async function handleVoucher(parameters: { store: ChannelStore.ChannelStore - client: Parameters[0] + client: Chain.TransactionClient challenge: Challenge.Challenge - payload: Extract + payload: SessionCredentialPayload & { action: 'voucher' } chainId: number escrow: Address minVoucherDelta: bigint @@ -742,8 +804,12 @@ async function handleVoucher(parameters: { channelStateTtl, lastOnChainVerified, } = parameters - const cumulativeAmount = uint96(BigInt(payload.cumulativeAmount)) const channelId = ChannelStore.normalizeChannelId(payload.channelId) + const voucher = Voucher.parseVoucherFromPayload( + channelId, + payload.cumulativeAmount, + payload.signature, + ) assertDescriptor(payload) const channel = await store.getChannel(channelId) if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' }) @@ -751,110 +817,61 @@ async function handleVoucher(parameters: { if (!ChannelStore.isPrecompileState(channel)) throw new VerificationFailedError({ reason: 'channel is not precompile-backed' }) assertSameDescriptor(payload.descriptor, channel.descriptor) - validateDescriptor({ - descriptor: payload.descriptor, + validateChannelDescriptor( + payload.descriptor, channelId, chainId, escrow, - recipient: channel.payee, - currency: channel.token, - }) - const stale = Date.now() - (lastOnChainVerified.get(channelId) ?? 0) > channelStateTtl - const state = stale ? await Chain.getChannelState(client, channelId, escrow) : undefined + channel.payee, + channel.token, + ) + const isStale = Date.now() - (lastOnChainVerified.get(channelId) ?? 0) > channelStateTtl + const state = isStale ? await Chain.getChannelState(client, channelId, escrow) : undefined if (state) lastOnChainVerified.set(channelId, Date.now()) - const deposit = state?.deposit ?? uint96(channel.deposit) - const settled = state?.settled ?? uint96(channel.settledOnChain) - const closeRequestedAt = state?.closeRequestedAt ?? Number(channel.closeRequestedAt) - if (closeRequestedAt !== 0) { + const channelState = { + deposit: state?.deposit ?? uint96(channel.deposit), + settled: state?.settled ?? uint96(channel.settledOnChain), + closeRequestedAt: state?.closeRequestedAt ?? Number(channel.closeRequestedAt), + } + if (channelState.closeRequestedAt !== 0) { + // Persist closeRequestedAt so the cached path detects force-close + // between re-queries. await store.updateChannel(channelId, (current) => current ? { ...current, closeRequestedAt: - BigInt(closeRequestedAt) > current.closeRequestedAt - ? BigInt(closeRequestedAt) + BigInt(channelState.closeRequestedAt) > current.closeRequestedAt + ? BigInt(channelState.closeRequestedAt) : current.closeRequestedAt, } : current, ) - throw new ChannelClosedError({ reason: 'channel has a pending close request' }) } - if (deposit === 0n) throw new ChannelClosedError({ reason: 'channel deposit is zero (settled)' }) - if (cumulativeAmount <= settled) - throw new VerificationFailedError({ - reason: 'voucher cumulativeAmount is below on-chain settled amount', - }) - if (cumulativeAmount > deposit) - throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' }) - if (cumulativeAmount < channel.highestVoucherAmount) - throw new VerificationFailedError({ - reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher', - }) - const valid = await Voucher.verifyVoucher( - escrow, - chainId, - { channelId, cumulativeAmount: cumulativeAmount, signature: payload.signature }, - channel.authorizedSigner, - ) - if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) - if (cumulativeAmount === channel.highestVoucherAmount) - return createSessionReceipt({ - challengeId: challenge.id, - channelId, - acceptedCumulative: channel.highestVoucherAmount, - spent: channel.spent, - units: channel.units, - }) - const delta = cumulativeAmount - channel.highestVoucherAmount - if (delta < minVoucherDelta) - throw new DeltaTooSmallError({ - reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`, - }) - const updated = await store.updateChannel(channelId, (current) => - (() => { - if (!current) throw new ChannelNotFoundError({ reason: 'channel not found' }) - if (current.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' }) - if (current.closeRequestedAt !== 0n) - throw new ChannelClosedError({ reason: 'channel has a pending close request' }) - - const nextDeposit = deposit > current.deposit ? deposit : current.deposit - const nextSettled = settled > current.settledOnChain ? settled : current.settledOnChain - if (cumulativeAmount > current.highestVoucherAmount) { - return { - ...current, - deposit: nextDeposit, - settledOnChain: nextSettled, - highestVoucherAmount: cumulativeAmount, - highestVoucher: { - channelId, - cumulativeAmount: cumulativeAmount, - signature: payload.signature, - }, - } - } - return { ...current, deposit: nextDeposit, settledOnChain: nextSettled } - })(), - ) - if (!updated) throw new ChannelNotFoundError({ reason: 'channel not found' }) - return createSessionReceipt({ - challengeId: challenge.id, - channelId, - acceptedCumulative: updated.highestVoucherAmount, - spent: updated.spent, - units: updated.units, + return verifyAndAcceptVoucher({ + store, + minVoucherDelta, + challenge, + channel, + voucher, + channelState, + methodDetails: { chainId, escrowContract: escrow }, }) } +/** + * Handle 'close' action - verify final voucher and close channel. + */ async function handleClose(parameters: { store: ChannelStore.ChannelStore - client: Parameters[0] + client: Chain.TransactionClient challenge: Challenge.Challenge - payload: Extract + payload: SessionCredentialPayload & { action: 'close' } chainId: number escrow: Address account?: viem_Account | undefined feePayer?: viem_Account | undefined - feePayerPolicy?: Partial | undefined + feePayerPolicy?: session.FeePayerPolicy | undefined feeToken?: Address | undefined }): Promise { const { store, client, challenge, payload, chainId, escrow } = parameters @@ -919,7 +936,7 @@ async function handleClose(parameters: { }) const account = parameters.account ?? getClientAccount(client) let txHash: Hex | undefined - let receipt: Awaited> + let receipt: Awaited> try { assertSettlementSender({ operation: 'close', @@ -945,7 +962,7 @@ async function handleClose(parameters: { } : undefined, ) - receipt = await waitForSuccessfulReceipt(client, txHash) + receipt = await Chain.waitForSuccessfulReceipt(client, txHash) } catch (error) { if (pendingCloseMarked) { await store.updateChannel(channelId, (current) => @@ -956,7 +973,7 @@ async function handleClose(parameters: { } throw error } - const closed = getChannelEvent(receipt, 'ChannelClosed', channelId) + const closed = Chain.getChannelEvent(receipt, 'ChannelClosed', channelId) const settledToPayee = uint96(closed.args.settledToPayee as bigint) const refundedToPayer = uint96(closed.args.refundedToPayer as bigint) if (settledToPayee > captureAmount || settledToPayee + refundedToPayer > state.deposit) @@ -992,113 +1009,3 @@ async function handleClose(parameters: { txHash, }) } - -/** Settles the highest accepted voucher for a precompile-backed session channel. */ -export async function settle( - store_: Store.Store | ChannelStore.ChannelStore, - client: Parameters[0], - channelId_: Hex, - options?: { - account?: viem_Account | undefined - candidateFeeTokens?: readonly Address[] | undefined - escrow?: Address | undefined - feePayer?: viem_Account | undefined - feePayerPolicy?: Partial | undefined - feeToken?: Address | undefined - }, -): Promise { - const store = 'getChannel' in store_ ? store_ : ChannelStore.fromStore(store_ as never) - const channelId = ChannelStore.normalizeChannelId(channelId_) - const channel = await store.getChannel(channelId) - if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' }) - if (!ChannelStore.isPrecompileState(channel)) - throw new VerificationFailedError({ reason: 'channel is not precompile-backed' }) - if (!channel.highestVoucher) throw new VerificationFailedError({ reason: 'no voucher to settle' }) - const escrow = options?.escrow ?? channel.escrowContract - const account = options?.account ?? getClientAccount(client) - assertSettlementSender({ - operation: 'settle', - channelId, - operator: channel.operator, - payee: channel.payee, - sender: account?.address, - }) - const amount = uint96(channel.highestVoucher.cumulativeAmount) - const txHash = await Chain.settleOnChain( - client, - channel.descriptor, - amount, - channel.highestVoucher.signature, - escrow, - account - ? { - account, - ...(options?.feePayer ? { feePayer: options.feePayer } : {}), - ...(options?.feePayerPolicy ? { feePayerPolicy: options.feePayerPolicy } : {}), - ...(options?.feeToken ? { feeToken: options.feeToken } : {}), - candidateFeeTokens: options?.candidateFeeTokens ?? [channel.token], - } - : undefined, - ) - const receipt = await waitForSuccessfulReceipt(client, txHash) - const settled = getChannelEvent(receipt, 'Settled', channelId) - const newSettled = uint96(settled.args.newSettled as bigint) - if (newSettled < amount) - throw new VerificationFailedError({ reason: 'Settled event is below voucher amount' }) - const state = await Chain.getChannelState(client, channelId, escrow) - if (state.settled !== newSettled) - throw new VerificationFailedError({ - reason: 'on-chain channel state does not match settle receipt', - }) - await store.updateChannel(channelId, (current) => - current - ? { - ...current, - settledOnChain: newSettled > current.settledOnChain ? newSettled : current.settledOnChain, - } - : current, - ) - return txHash -} - -export namespace session { - export type Parameters = { - amount?: string | undefined - chainId?: number | undefined - channelStateTtl?: number | undefined - currency?: Address | undefined - decimals?: number | undefined - escrow?: Address | undefined - getClient?: Client.getResolver.Parameters['getClient'] | undefined - minVoucherDelta?: string | undefined - recipient?: Address | undefined - /** - * Atomic store backend for channel state. - * - * Session mutations must be linearizable across instances so spent, - * highest-voucher, top-up, and close/finalization updates cannot race. - * Use `Store.memory()` for tests or local single-process usage. - */ - store?: Store.AtomicStore | undefined - suggestedDeposit?: string | undefined - unitType?: string | undefined - /** Account used for server-driven close transactions. Defaults to the client account. */ - account?: viem_Account | undefined - /** Optional fee payer used to sponsor server-driven close transactions. */ - feePayer?: viem_Account | undefined - /** Optional fee-payer policy limits for server-driven close transactions. */ - feePayerPolicy?: Partial | undefined - /** Optional fee token used for server-driven close transactions. */ - feeToken?: Address | undefined - } - - export type Defaults = LooseOmit< - Method.RequestDefaults, - 'feePayer' | 'escrowContract' - > - - export type DeriveDefaults = types.DeriveDefaults< - parameters, - Defaults - > -} diff --git a/src/tempo/server/Session.ts b/src/tempo/server/Session.ts index abb31009..73a9286c 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -57,7 +57,7 @@ import { captureRequestBodyProbe, isSessionContentRequest } from './internal/req import * as Transport from './internal/transport.js' /** Challenge methodDetails shape for session methods. */ -type SessionMethodDetails = { +export type SessionMethodDetails = { escrowContract: Address chainId: number channelId?: Hex | undefined From ab646077b4bae66433c7a9cc8c43f479dae8560b Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 15 May 2026 12:06:20 +0200 Subject: [PATCH 25/26] Harden precompile session parity --- .../precompile/Chain.integration.test.ts | 185 ++++ src/tempo/precompile/Chain.test.ts | 771 +++++++++++++- src/tempo/precompile/Chain.ts | 14 + src/tempo/precompile/client/Session.test.ts | 66 ++ src/tempo/precompile/server/Session.test.ts | 962 +++++++++++++++++- src/tempo/precompile/server/Session.ts | 21 +- 6 files changed, 2002 insertions(+), 17 deletions(-) diff --git a/src/tempo/precompile/Chain.integration.test.ts b/src/tempo/precompile/Chain.integration.test.ts index 87164976..a048a6ac 100644 --- a/src/tempo/precompile/Chain.integration.test.ts +++ b/src/tempo/precompile/Chain.integration.test.ts @@ -1,12 +1,14 @@ import { Hex } from 'ox' import { type Address, encodeFunctionData, isAddressEqual, parseEventLogs, zeroAddress } from 'viem' import { sendTransaction, waitForTransactionReceipt } from 'viem/actions' +import { Transaction } from 'viem/tempo' import { describe, expect, test } from 'vp/test' import { nodeEnv } from '~test/config.js' import { accounts, asset, chain, client } from '~test/tempo/viem.js' import * as Chain from './Chain.js' import * as Channel from './Channel.js' +import { createOpenPayload, createTopUpPayload } from './client/ChannelOps.js' import { tip20ChannelEscrow } from './Constants.js' import { escrowAbi } from './escrow.abi.js' import { uint96 } from './Types.js' @@ -15,6 +17,7 @@ import * as Voucher from './Voucher.js' const isPrecompileTestnet = nodeEnv === 'localnet' || nodeEnv === 'devnet' const payer = accounts[2] const payee = accounts[0] +const feePayer = accounts[1] async function sendPrecompileCall(data: Hex.Hex, account = payer) { const hash = await sendTransaction(client, { @@ -107,6 +110,188 @@ describe.runIf(isPrecompileTestnet)('TIP20EscrowChannel precompile chain operati expect(state.deposit).toBe(deposit + additionalDeposit) }) + test('broadcastOpenTransaction broadcasts a real client-signed open transaction', async () => { + const deposit = uint96(1_250n) + const payload = await createOpenPayload(client, payer, { + chainId: chain.id, + deposit, + initialAmount: uint96(100n), + payee: payee.address, + token: asset, + }) + if (payload.action !== 'open') throw new Error('expected open payload') + + const transaction = Transaction.deserialize( + payload.transaction as Transaction.TransactionSerializedTempo, + ) + const expiringNonceHash = Channel.computeExpiringNonceHash( + transaction as Channel.ExpiringNonceTransaction, + { sender: payer.address }, + ) + + const result = await Chain.broadcastOpenTransaction({ + chainId: chain.id, + client, + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: payload.descriptor.authorizedSigner, + expectedChannelId: payload.channelId, + expectedCurrency: asset, + expectedExpiringNonceHash: expiringNonceHash, + expectedOperator: payload.descriptor.operator, + expectedPayee: payee.address, + expectedPayer: payer.address, + serializedTransaction: payload.transaction, + }) + + expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/i) + expect(isAddressEqual(result.descriptor.payer, payload.descriptor.payer)).toBe(true) + expect(isAddressEqual(result.descriptor.payee, payload.descriptor.payee)).toBe(true) + expect(isAddressEqual(result.descriptor.operator, payload.descriptor.operator)).toBe(true) + expect(isAddressEqual(result.descriptor.token, payload.descriptor.token)).toBe(true) + expect( + isAddressEqual(result.descriptor.authorizedSigner, payload.descriptor.authorizedSigner), + ).toBe(true) + expect(result.descriptor.salt).toBe(payload.descriptor.salt) + expect(result.descriptor.expiringNonceHash).toBe(payload.descriptor.expiringNonceHash) + expect(result.expiringNonceHash).toBe(expiringNonceHash) + expect(result.openDeposit).toBe(deposit) + expect(result.state.deposit).toBe(deposit) + expect(result.state.settled).toBe(0n) + expect(result.state.closeRequestedAt).toBe(0) + }) + + test('broadcastOpenTransaction broadcasts a real fee-sponsored open transaction', async () => { + const deposit = uint96(1_300n) + const payload = await createOpenPayload(client, payer, { + chainId: chain.id, + deposit, + feePayer: true, + initialAmount: uint96(100n), + payee: payee.address, + token: asset, + }) + if (payload.action !== 'open') throw new Error('expected open payload') + + const result = await Chain.broadcastOpenTransaction({ + chainId: chain.id, + client, + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: payload.descriptor.authorizedSigner, + expectedChannelId: payload.channelId, + expectedCurrency: asset, + expectedExpiringNonceHash: payload.descriptor.expiringNonceHash, + expectedOperator: payload.descriptor.operator, + expectedPayee: payee.address, + expectedPayer: payer.address, + feePayer, + serializedTransaction: payload.transaction, + }) + + expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/i) + expect(result.openDeposit).toBe(deposit) + expect(result.state.deposit).toBe(deposit) + }) + + test('broadcastTopUpTransaction broadcasts a real client-signed top-up transaction', async () => { + const opened = await createOpenPayload(client, payer, { + chainId: chain.id, + deposit: uint96(1_000n), + initialAmount: uint96(100n), + payee: payee.address, + token: asset, + }) + if (opened.action !== 'open') throw new Error('expected open payload') + await Chain.broadcastOpenTransaction({ + chainId: chain.id, + client, + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: opened.descriptor.authorizedSigner, + expectedChannelId: opened.channelId, + expectedCurrency: asset, + expectedExpiringNonceHash: opened.descriptor.expiringNonceHash, + expectedOperator: opened.descriptor.operator, + expectedPayee: payee.address, + expectedPayer: payer.address, + serializedTransaction: opened.transaction, + }) + + const additionalDeposit = uint96(600n) + const topUp = await createTopUpPayload( + client, + payer, + opened.descriptor, + additionalDeposit, + chain.id, + ) + if (topUp.action !== 'topUp') throw new Error('expected topUp payload') + + const result = await Chain.broadcastTopUpTransaction({ + additionalDeposit, + chainId: chain.id, + client, + descriptor: opened.descriptor, + escrowContract: tip20ChannelEscrow, + expectedChannelId: opened.channelId, + expectedCurrency: asset, + serializedTransaction: topUp.transaction, + }) + + expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/i) + expect(result.newDeposit).toBe(1_600n) + expect(result.state.deposit).toBe(1_600n) + }) + + test('broadcastTopUpTransaction broadcasts a real fee-sponsored top-up transaction', async () => { + const opened = await createOpenPayload(client, payer, { + chainId: chain.id, + deposit: uint96(1_000n), + initialAmount: uint96(100n), + payee: payee.address, + token: asset, + }) + if (opened.action !== 'open') throw new Error('expected open payload') + await Chain.broadcastOpenTransaction({ + chainId: chain.id, + client, + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: opened.descriptor.authorizedSigner, + expectedChannelId: opened.channelId, + expectedCurrency: asset, + expectedExpiringNonceHash: opened.descriptor.expiringNonceHash, + expectedOperator: opened.descriptor.operator, + expectedPayee: payee.address, + expectedPayer: payer.address, + serializedTransaction: opened.transaction, + }) + + const additionalDeposit = uint96(700n) + const topUp = await createTopUpPayload( + client, + payer, + opened.descriptor, + additionalDeposit, + chain.id, + true, + ) + if (topUp.action !== 'topUp') throw new Error('expected topUp payload') + + const result = await Chain.broadcastTopUpTransaction({ + additionalDeposit, + chainId: chain.id, + client, + descriptor: opened.descriptor, + escrowContract: tip20ChannelEscrow, + expectedChannelId: opened.channelId, + expectedCurrency: asset, + feePayer, + serializedTransaction: topUp.transaction, + }) + + expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/i) + expect(result.newDeposit).toBe(1_700n) + expect(result.state.deposit).toBe(1_700n) + }) + test('settles a signed voucher against the descriptor', async () => { const { channelId, descriptor } = await openChannel({ deposit: 1_000n }) const cumulativeAmount = uint96(400n) diff --git a/src/tempo/precompile/Chain.test.ts b/src/tempo/precompile/Chain.test.ts index ca38da91..aee082d6 100644 --- a/src/tempo/precompile/Chain.test.ts +++ b/src/tempo/precompile/Chain.test.ts @@ -1,6 +1,20 @@ -import { encodeFunctionData, erc20Abi } from 'viem' +import { + createClient, + custom, + encodeAbiParameters, + encodeEventTopics, + encodeFunctionData, + encodeFunctionResult, + erc20Abi, + zeroAddress, +} from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { Transaction } from 'viem/tempo' import { describe, expect, test } from 'vp/test' +import * as Chain from './Chain.js' +import * as Channel from './Channel.js' +import { tip20ChannelEscrow } from './Constants.js' import { escrowAbi } from './escrow.abi.js' import * as ServerChannelOps from './server/ChannelOps.js' import * as Types from './Types.js' @@ -16,6 +30,223 @@ const descriptor = { } as const const deposit = Types.uint96(1_000_000n) +const chainId = 42431 +const txHash = `0x${'ab'.repeat(32)}` as const +const feePayer = privateKeyToAccount( + '0x59c6995e998f97a5a0044966f0945389d1fc6e60e7346d6c36c49d32f75b9a1b', +) +const mockFeePayer = { + ...feePayer, + async signTransaction() { + return `0x76${'00'.repeat(32)}` as `0x${string}` + }, +} + +function createMockClient( + parameters: { + channel?: { descriptor: Channel.ChannelDescriptor; state: Chain.ChannelState } | undefined + receipt?: Record | null | undefined + rpcMethods?: string[] | undefined + } = {}, +) { + return createClient({ + account: feePayer, + chain: { id: chainId } as never, + transport: custom({ + async request(args) { + parameters.rpcMethods?.push(args.method) + if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}` + if (args.method === 'eth_sendRawTransaction') return txHash + if (args.method === 'eth_sendRawTransactionSync') return parameters.receipt ?? receipt([]) + if (args.method === 'eth_getTransactionReceipt') return parameters.receipt ?? null + if (args.method === 'eth_call') { + const data = (args.params as [{ data?: `0x${string}` }])[0].data + const channel = parameters.channel + if (!channel || !data) return '0x' + const selector = data.slice(0, 10) + const getChannelSelector = encodeFunctionData({ + abi: escrowAbi, + functionName: 'getChannel', + args: [channel.descriptor], + }).slice(0, 10) + if (selector === getChannelSelector) + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'getChannel', + result: { descriptor: channel.descriptor, state: channel.state }, + }) + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'getChannelState', + result: channel.state, + }) + } + throw new Error(`unexpected rpc request: ${args.method}`) + }, + }), + }) +} + +function receipt(logs: readonly Record[]) { + return { + blockHash: `0x${'01'.repeat(32)}`, + blockNumber: '0x1', + contractAddress: null, + cumulativeGasUsed: '0x1', + effectiveGasPrice: '0x1', + from: descriptor.payer, + gasUsed: '0x1', + logs, + logsBloom: `0x${'00'.repeat(256)}`, + status: '0x1', + to: tip20ChannelEscrow, + transactionHash: txHash, + transactionIndex: '0x0', + type: '0x76', + } +} + +function openedLog(parameters: { + channelId: `0x${string}` + expiringNonceHash: `0x${string}` + deposit?: bigint | undefined +}) { + return { + address: tip20ChannelEscrow, + data: encodeAbiParameters( + [ + { type: 'address' }, + { type: 'address' }, + { type: 'address' }, + { type: 'bytes32' }, + { type: 'bytes32' }, + { type: 'uint96' }, + ], + [ + descriptor.operator, + descriptor.token, + descriptor.authorizedSigner, + descriptor.salt, + parameters.expiringNonceHash, + parameters.deposit ?? deposit, + ], + ), + topics: encodeEventTopics({ + abi: escrowAbi, + eventName: 'ChannelOpened', + args: { channelId: parameters.channelId, payer: descriptor.payer, payee: descriptor.payee }, + }), + } +} + +function topUpLog(parameters: { channelId: `0x${string}`; newDeposit: bigint }) { + return { + address: tip20ChannelEscrow, + data: encodeAbiParameters( + [{ type: 'uint96' }, { type: 'uint96' }], + [deposit, parameters.newDeposit], + ), + topics: encodeEventTopics({ + abi: escrowAbi, + eventName: 'TopUp', + args: { channelId: parameters.channelId, payer: descriptor.payer, payee: descriptor.payee }, + }), + } +} + +async function createSerializedTransaction(parameters: { + calls: { to: `0x${string}`; data: `0x${string}` }[] + gas?: bigint | undefined + signed?: boolean | undefined +}) { + return (await Transaction.serialize({ + chainId, + calls: parameters.calls, + feeToken: descriptor.token, + nonce: 0, + ...(parameters.gas !== undefined ? { gas: parameters.gas } : {}), + ...(parameters.signed + ? { + maxFeePerGas: 1n, + maxPriorityFeePerGas: 1n, + nonceKey: 1n, + validBefore: Math.floor(Date.now() / 1_000) + 600, + } + : {}), + ...(parameters.signed + ? { + signature: { + r: `0x${'01'.repeat(32)}` as `0x${string}`, + s: `0x${'02'.repeat(32)}` as `0x${string}`, + yParity: 0, + }, + } + : {}), + } as never)) as `0x${string}` +} + +async function createOpenTransaction( + parameters: { + authorizedSigner?: `0x${string}` | undefined + gas?: bigint | undefined + operator?: `0x${string}` | undefined + payee?: `0x${string}` | undefined + signed?: boolean | undefined + token?: `0x${string}` | undefined + to?: `0x${string}` | undefined + } = {}, +) { + const data = encodeFunctionData({ + abi: escrowAbi, + functionName: 'open', + args: [ + parameters.payee ?? descriptor.payee, + parameters.operator ?? descriptor.operator, + parameters.token ?? descriptor.token, + deposit, + descriptor.salt, + parameters.authorizedSigner ?? descriptor.authorizedSigner, + ], + }) + return createSerializedTransaction({ + calls: [{ to: parameters.to ?? tip20ChannelEscrow, data }], + gas: parameters.gas, + signed: parameters.signed, + }) +} + +async function createTopUpTransaction( + parameters: { + additionalDeposit?: bigint | undefined + descriptor_?: Channel.ChannelDescriptor | undefined + gas?: bigint | undefined + signed?: boolean | undefined + to?: `0x${string}` | undefined + } = {}, +) { + const data = encodeFunctionData({ + abi: escrowAbi, + functionName: 'topUp', + args: [ + parameters.descriptor_ ?? descriptor, + Types.uint96(parameters.additionalDeposit ?? deposit), + ], + }) + return createSerializedTransaction({ + calls: [{ to: parameters.to ?? tip20ChannelEscrow, data }], + gas: parameters.gas, + signed: parameters.signed, + }) +} + +function expectedExpiringNonceHash(serializedTransaction: `0x${string}`) { + return Channel.computeExpiringNonceHash( + Transaction.deserialize( + serializedTransaction as Transaction.TransactionSerializedTempo, + ) as Channel.ExpiringNonceTransaction, + { sender: descriptor.payer }, + ) +} describe('precompile open calldata parsing', () => { test('parseOpenCall accepts TIP-1034 open calldata', () => { @@ -103,6 +334,544 @@ describe('precompile open calldata parsing', () => { }) }) +describe('precompile broadcastOpenTransaction', () => { + test('rejects transactions with extra calls', async () => { + const serializedTransaction = await createSerializedTransaction({ + calls: [ + { + to: tip20ChannelEscrow, + data: encodeFunctionData({ + abi: escrowAbi, + functionName: 'open', + args: [ + descriptor.payee, + descriptor.operator, + descriptor.token, + deposit, + descriptor.salt, + descriptor.authorizedSigner, + ], + }), + }, + { + to: descriptor.token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [tip20ChannelEscrow, deposit], + }), + }, + ], + }) + + await expect( + Chain.broadcastOpenTransaction({ + chainId, + client: createMockClient(), + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: descriptor.authorizedSigner, + expectedChannelId: `0x${'11'.repeat(32)}`, + expectedCurrency: descriptor.token, + expectedExpiringNonceHash: `0x${'aa'.repeat(32)}`, + expectedOperator: descriptor.operator, + expectedPayee: descriptor.payee, + expectedPayer: descriptor.payer, + serializedTransaction, + }), + ).rejects.toThrow('TIP-1034 open transaction must contain exactly one call') + }) + + test('rejects open transactions targeting the wrong escrow contract', async () => { + const serializedTransaction = await createOpenTransaction({ to: descriptor.token }) + + await expect( + Chain.broadcastOpenTransaction({ + chainId, + client: createMockClient(), + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: descriptor.authorizedSigner, + expectedChannelId: `0x${'11'.repeat(32)}`, + expectedCurrency: descriptor.token, + expectedExpiringNonceHash: expectedExpiringNonceHash(serializedTransaction), + expectedOperator: descriptor.operator, + expectedPayee: descriptor.payee, + expectedPayer: descriptor.payer, + serializedTransaction, + }), + ).rejects.toThrow('TIP-1034 open transaction targets the wrong address') + }) + + test('rejects open calldata mismatches before broadcasting', async () => { + const serializedTransaction = await createOpenTransaction({ payee: zeroAddress }) + + await expect( + Chain.broadcastOpenTransaction({ + chainId, + client: createMockClient(), + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: descriptor.authorizedSigner, + expectedChannelId: `0x${'11'.repeat(32)}`, + expectedCurrency: descriptor.token, + expectedExpiringNonceHash: expectedExpiringNonceHash(serializedTransaction), + expectedOperator: descriptor.operator, + expectedPayee: descriptor.payee, + expectedPayer: descriptor.payer, + serializedTransaction, + }), + ).rejects.toThrow('payee does not match') + }) + + test('fee-payer rejects non-Tempo open transactions', async () => { + await expect( + Chain.broadcastOpenTransaction({ + chainId, + client: createMockClient(), + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: descriptor.authorizedSigner, + expectedChannelId: `0x${'11'.repeat(32)}`, + expectedCurrency: descriptor.token, + expectedExpiringNonceHash: `0x${'aa'.repeat(32)}`, + expectedOperator: descriptor.operator, + expectedPayee: descriptor.payee, + expectedPayer: descriptor.payer, + feePayer: { address: descriptor.payee } as never, + serializedTransaction: '0x1234', + }), + ).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported') + }) + + test('fee-payer rejects unsigned open transactions', async () => { + const serializedTransaction = await createOpenTransaction() + const expiringNonceHash = expectedExpiringNonceHash(serializedTransaction) + const expectedDescriptor = { ...descriptor, expiringNonceHash } + const channelId = Channel.computeId({ + ...expectedDescriptor, + chainId, + escrow: tip20ChannelEscrow, + }) + + await expect( + Chain.broadcastOpenTransaction({ + chainId, + client: createMockClient(), + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: descriptor.authorizedSigner, + expectedChannelId: channelId, + expectedCurrency: descriptor.token, + expectedExpiringNonceHash: expiringNonceHash, + expectedOperator: descriptor.operator, + expectedPayee: descriptor.payee, + expectedPayer: descriptor.payer, + feePayer: { address: descriptor.payee } as never, + serializedTransaction, + }), + ).rejects.toThrow('Transaction must be signed by the sender before fee payer co-signing') + }) + + test('fee-payer rejects open transactions whose gas budget exceeds sponsor policy', async () => { + const serializedTransaction = await createOpenTransaction({ gas: 2_000_001n, signed: true }) + const transaction = Transaction.deserialize( + serializedTransaction as Transaction.TransactionSerializedTempo, + ) + const payer = transaction.from! + const expiringNonceHash = Channel.computeExpiringNonceHash( + transaction as Channel.ExpiringNonceTransaction, + { sender: payer }, + ) + const expectedDescriptor = { ...descriptor, payer, expiringNonceHash } + const channelId = Channel.computeId({ + ...expectedDescriptor, + chainId, + escrow: tip20ChannelEscrow, + }) + + await expect( + Chain.broadcastOpenTransaction({ + chainId, + client: createMockClient(), + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: descriptor.authorizedSigner, + expectedChannelId: channelId, + expectedCurrency: descriptor.token, + expectedExpiringNonceHash: expiringNonceHash, + expectedOperator: descriptor.operator, + expectedPayee: descriptor.payee, + expectedPayer: payer, + feePayer, + feePayerPolicy: { maxGas: 2_000_000n }, + serializedTransaction, + }), + ).rejects.toThrow('fee-sponsored transaction gas exceeds sponsor policy') + }) + + test('fee-payer simulates open before raw broadcast', async () => { + const rpcMethods: string[] = [] + const serializedTransaction = await createOpenTransaction({ gas: 100_000n, signed: true }) + const transaction = Transaction.deserialize( + serializedTransaction as Transaction.TransactionSerializedTempo, + ) + const payer = transaction.from! + const expiringNonceHash = Channel.computeExpiringNonceHash( + transaction as Channel.ExpiringNonceTransaction, + { sender: payer }, + ) + const expectedDescriptor = { ...descriptor, payer, expiringNonceHash } + const channelId = Channel.computeId({ + ...expectedDescriptor, + chainId, + escrow: tip20ChannelEscrow, + }) + const state = { settled: 0n, deposit, closeRequestedAt: 0 } + + await Chain.broadcastOpenTransaction({ + chainId, + client: createMockClient({ + channel: { descriptor: expectedDescriptor, state }, + receipt: receipt([openedLog({ channelId, expiringNonceHash })]), + rpcMethods, + }), + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: descriptor.authorizedSigner, + expectedChannelId: channelId, + expectedCurrency: descriptor.token, + expectedExpiringNonceHash: expiringNonceHash, + expectedOperator: descriptor.operator, + expectedPayee: descriptor.payee, + expectedPayer: payer, + feePayer: mockFeePayer, + serializedTransaction, + }) + + const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransactionSync') + const simulationIndex = rpcMethods.indexOf('eth_call') + expect(broadcastIndex).toBeGreaterThan(-1) + expect(simulationIndex).toBeGreaterThan(-1) + expect(simulationIndex).toBeLessThan(broadcastIndex) + }) + + test('rejects expiring nonce hash mismatches before broadcasting', async () => { + const serializedTransaction = await createOpenTransaction() + + const wrongExpiringNonceHash = `0x${'bb'.repeat(32)}` as const + const expectedChannelId = Channel.computeId({ + ...descriptor, + chainId, + escrow: tip20ChannelEscrow, + expiringNonceHash: wrongExpiringNonceHash, + }) + + await expect( + Chain.broadcastOpenTransaction({ + chainId, + client: createMockClient(), + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: descriptor.authorizedSigner, + expectedChannelId, + expectedCurrency: descriptor.token, + expectedExpiringNonceHash: wrongExpiringNonceHash, + expectedOperator: descriptor.operator, + expectedPayee: descriptor.payee, + expectedPayer: descriptor.payer, + serializedTransaction, + }), + ).rejects.toThrow('credential expiringNonceHash does not match transaction') + }) + + test('returns tx hash, descriptor, event fields, and read-back state on success', async () => { + const serializedTransaction = await createOpenTransaction() + const expiringNonceHash = expectedExpiringNonceHash(serializedTransaction) + const expectedDescriptor = { ...descriptor, expiringNonceHash } + const channelId = Channel.computeId({ + ...expectedDescriptor, + chainId, + escrow: tip20ChannelEscrow, + }) + const state = { settled: 0n, deposit, closeRequestedAt: 0 } + + const result = await Chain.broadcastOpenTransaction({ + chainId, + client: createMockClient({ + channel: { descriptor: expectedDescriptor, state }, + receipt: receipt([openedLog({ channelId, expiringNonceHash })]), + }), + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: descriptor.authorizedSigner, + expectedChannelId: channelId, + expectedCurrency: descriptor.token, + expectedExpiringNonceHash: expiringNonceHash, + expectedOperator: descriptor.operator, + expectedPayee: descriptor.payee, + expectedPayer: descriptor.payer, + serializedTransaction, + }) + + expect(result).toEqual({ + txHash, + descriptor: expectedDescriptor, + state, + expiringNonceHash, + openDeposit: deposit, + }) + }) + + test('rejects ChannelOpened receipt deposit mismatches', async () => { + const serializedTransaction = await createOpenTransaction() + const expiringNonceHash = expectedExpiringNonceHash(serializedTransaction) + const expectedDescriptor = { ...descriptor, expiringNonceHash } + const channelId = Channel.computeId({ + ...expectedDescriptor, + chainId, + escrow: tip20ChannelEscrow, + }) + + await expect( + Chain.broadcastOpenTransaction({ + chainId, + client: createMockClient({ + channel: { + descriptor: expectedDescriptor, + state: { settled: 0n, deposit, closeRequestedAt: 0 }, + }, + receipt: receipt([openedLog({ channelId, expiringNonceHash, deposit: deposit + 1n })]), + }), + escrowContract: tip20ChannelEscrow, + expectedAuthorizedSigner: descriptor.authorizedSigner, + expectedChannelId: channelId, + expectedCurrency: descriptor.token, + expectedExpiringNonceHash: expiringNonceHash, + expectedOperator: descriptor.operator, + expectedPayee: descriptor.payee, + expectedPayer: descriptor.payer, + serializedTransaction, + }), + ).rejects.toThrow('ChannelOpened deposit does not match calldata') + }) +}) + +describe('precompile broadcastTopUpTransaction', () => { + test('rejects transactions with extra calls', async () => { + const serializedTransaction = await createSerializedTransaction({ + calls: [ + { + to: tip20ChannelEscrow, + data: encodeFunctionData({ + abi: escrowAbi, + functionName: 'topUp', + args: [descriptor, deposit], + }), + }, + { + to: descriptor.token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [tip20ChannelEscrow, deposit], + }), + }, + ], + }) + + await expect( + Chain.broadcastTopUpTransaction({ + additionalDeposit: deposit, + chainId, + client: createMockClient(), + descriptor, + escrowContract: tip20ChannelEscrow, + expectedChannelId: `0x${'11'.repeat(32)}`, + expectedCurrency: descriptor.token, + serializedTransaction, + }), + ).rejects.toThrow('TIP-1034 topUp transaction must contain exactly one call') + }) + + test('rejects top-up transactions targeting the wrong escrow contract', async () => { + const serializedTransaction = await createTopUpTransaction({ to: descriptor.token }) + + await expect( + Chain.broadcastTopUpTransaction({ + additionalDeposit: deposit, + chainId, + client: createMockClient(), + descriptor, + escrowContract: tip20ChannelEscrow, + expectedChannelId: `0x${'11'.repeat(32)}`, + expectedCurrency: descriptor.token, + serializedTransaction, + }), + ).rejects.toThrow('TIP-1034 topUp transaction targets the wrong address') + }) + + test('rejects top-up calldata descriptor mismatches before broadcasting', async () => { + const serializedTransaction = await createTopUpTransaction({ + descriptor_: { ...descriptor, payee: zeroAddress }, + }) + + await expect( + Chain.broadcastTopUpTransaction({ + additionalDeposit: deposit, + chainId, + client: createMockClient(), + descriptor, + escrowContract: tip20ChannelEscrow, + expectedChannelId: `0x${'11'.repeat(32)}`, + expectedCurrency: descriptor.token, + serializedTransaction, + }), + ).rejects.toThrow('descriptor does not match') + }) + + test('fee-payer rejects non-Tempo top-up transactions', async () => { + await expect( + Chain.broadcastTopUpTransaction({ + additionalDeposit: deposit, + chainId, + client: createMockClient(), + descriptor, + escrowContract: tip20ChannelEscrow, + expectedChannelId: `0x${'11'.repeat(32)}`, + expectedCurrency: descriptor.token, + feePayer: { address: descriptor.payee } as never, + serializedTransaction: '0x1234', + }), + ).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported') + }) + + test('fee-payer rejects unsigned top-up transactions', async () => { + const serializedTransaction = await createTopUpTransaction() + + await expect( + Chain.broadcastTopUpTransaction({ + additionalDeposit: deposit, + chainId, + client: createMockClient(), + descriptor, + escrowContract: tip20ChannelEscrow, + expectedChannelId: Channel.computeId({ + ...descriptor, + chainId, + escrow: tip20ChannelEscrow, + }), + expectedCurrency: descriptor.token, + feePayer: { address: descriptor.payee } as never, + serializedTransaction, + }), + ).rejects.toThrow('Transaction must be signed by the sender before fee payer co-signing') + }) + + test('fee-payer rejects top-up transactions whose gas budget exceeds sponsor policy', async () => { + const serializedTransaction = await createTopUpTransaction({ gas: 2_000_001n, signed: true }) + + await expect( + Chain.broadcastTopUpTransaction({ + additionalDeposit: deposit, + chainId, + client: createMockClient(), + descriptor, + escrowContract: tip20ChannelEscrow, + expectedChannelId: Channel.computeId({ + ...descriptor, + chainId, + escrow: tip20ChannelEscrow, + }), + expectedCurrency: descriptor.token, + feePayer, + feePayerPolicy: { maxGas: 2_000_000n }, + serializedTransaction, + }), + ).rejects.toThrow('fee-sponsored transaction gas exceeds sponsor policy') + }) + + test('fee-payer simulates top-up before raw broadcast', async () => { + const rpcMethods: string[] = [] + const serializedTransaction = await createTopUpTransaction({ gas: 100_000n, signed: true }) + const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow }) + const newDeposit = deposit * 2n + await Chain.broadcastTopUpTransaction({ + additionalDeposit: deposit, + chainId, + client: createMockClient({ + channel: { descriptor, state: { settled: 0n, deposit: newDeposit, closeRequestedAt: 0 } }, + receipt: receipt([topUpLog({ channelId, newDeposit })]), + rpcMethods, + }), + descriptor, + escrowContract: tip20ChannelEscrow, + expectedChannelId: channelId, + expectedCurrency: descriptor.token, + feePayer: mockFeePayer, + serializedTransaction, + }) + + const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransactionSync') + const simulationIndex = rpcMethods.indexOf('eth_call') + expect(broadcastIndex).toBeGreaterThan(-1) + expect(simulationIndex).toBeGreaterThan(-1) + expect(simulationIndex).toBeLessThan(broadcastIndex) + }) + + test('rejects top-up calldata amount mismatches before broadcasting', async () => { + const serializedTransaction = await createTopUpTransaction({ additionalDeposit: 1n }) + + await expect( + Chain.broadcastTopUpTransaction({ + additionalDeposit: deposit, + chainId, + client: createMockClient(), + descriptor, + escrowContract: tip20ChannelEscrow, + expectedChannelId: `0x${'11'.repeat(32)}`, + expectedCurrency: descriptor.token, + serializedTransaction, + }), + ).rejects.toThrow('topUp deposit does not match credential') + }) + + test('returns tx hash, new deposit, and read-back state on success', async () => { + const serializedTransaction = await createTopUpTransaction() + const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow }) + const newDeposit = deposit * 2n + const state = { settled: 0n, deposit: newDeposit, closeRequestedAt: 0 } + + const result = await Chain.broadcastTopUpTransaction({ + additionalDeposit: deposit, + chainId, + client: createMockClient({ + channel: { descriptor, state }, + receipt: receipt([topUpLog({ channelId, newDeposit })]), + }), + descriptor, + escrowContract: tip20ChannelEscrow, + expectedChannelId: channelId, + expectedCurrency: descriptor.token, + serializedTransaction, + }) + + expect(result).toEqual({ txHash, newDeposit, state }) + }) + + test('rejects top-up receipt/readback deposit mismatches', async () => { + const serializedTransaction = await createTopUpTransaction() + const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow }) + + await expect( + Chain.broadcastTopUpTransaction({ + additionalDeposit: deposit, + chainId, + client: createMockClient({ + channel: { descriptor, state: { settled: 0n, deposit: deposit, closeRequestedAt: 0 } }, + receipt: receipt([topUpLog({ channelId, newDeposit: deposit * 2n })]), + }), + descriptor, + escrowContract: tip20ChannelEscrow, + expectedChannelId: channelId, + expectedCurrency: descriptor.token, + serializedTransaction, + }), + ).rejects.toThrow('on-chain channel state does not match topUp receipt') + }) +}) + describe('precompile escrowAbi parity', () => { test('contains all TIP-1034 functions and events', () => { const functions = escrowAbi.filter((item) => item.type === 'function').map((item) => item.name) diff --git a/src/tempo/precompile/Chain.ts b/src/tempo/precompile/Chain.ts index 906bec46..336d2c16 100644 --- a/src/tempo/precompile/Chain.ts +++ b/src/tempo/precompile/Chain.ts @@ -367,7 +367,13 @@ export async function broadcastOpenTransaction(parameters: { feePayer?: Account | undefined feePayerPolicy?: Partial | undefined serializedTransaction: Hex + beforeBroadcast?: + | ((result: Omit) => Promise | void) + | undefined }): Promise { + if (parameters.feePayer && !FeePayer.isTempoTransaction(parameters.serializedTransaction)) + throw new BadRequestError({ reason: 'Only Tempo (0x76/0x78) transactions are supported' }) + const transaction = Transaction.deserialize( parameters.serializedTransaction as Transaction.TransactionSerializedTempo, ) @@ -407,6 +413,11 @@ export async function broadcastOpenTransaction(parameters: { throw new VerificationFailedError({ reason: 'credential expiringNonceHash does not match transaction', }) + await parameters.beforeBroadcast?.({ + descriptor, + expiringNonceHash, + openDeposit: open.deposit, + }) const receipt = await sendCredentialTransaction({ challengeExpires: parameters.challengeExpires, chainId: parameters.chainId, @@ -481,6 +492,9 @@ export async function broadcastTopUpTransaction(parameters: { feePayerPolicy?: Partial | undefined serializedTransaction: Hex }): Promise { + if (parameters.feePayer && !FeePayer.isTempoTransaction(parameters.serializedTransaction)) + throw new BadRequestError({ reason: 'Only Tempo (0x76/0x78) transactions are supported' }) + const transaction = Transaction.deserialize( parameters.serializedTransaction as Transaction.TransactionSerializedTempo, ) diff --git a/src/tempo/precompile/client/Session.test.ts b/src/tempo/precompile/client/Session.test.ts index d103bc07..11dd7f9b 100644 --- a/src/tempo/precompile/client/Session.test.ts +++ b/src/tempo/precompile/client/Session.test.ts @@ -266,6 +266,72 @@ describe('precompile client session', () => { expect(payload.descriptor.authorizedSigner).toBe(accessKeyAddress) }) + test('manual open requires transaction', async () => { + const method = session({ account, getClient: () => client }) + + await expect( + method.createCredential({ + challenge: makeChallenge() as never, + context: { action: 'open', descriptor, cumulativeAmountRaw: '100' }, + }), + ).rejects.toThrow('transaction required for open action') + }) + + test('manual open requires cumulativeAmount', async () => { + const method = session({ account, getClient: () => client }) + + await expect( + method.createCredential({ + challenge: makeChallenge() as never, + context: { action: 'open', descriptor, transaction: '0x1234' }, + }), + ).rejects.toThrow('cumulativeAmount required for open action') + }) + + test('manual topUp requires additionalDeposit', async () => { + const method = session({ account, getClient: () => client }) + + await expect( + method.createCredential({ + challenge: makeChallenge() as never, + context: { action: 'topUp', descriptor, transaction: '0x1234' }, + }), + ).rejects.toThrow('additionalDeposit required for topUp action') + }) + + test('manual voucher requires cumulativeAmount', async () => { + const method = session({ account, getClient: () => client }) + + await expect( + method.createCredential({ + challenge: makeChallenge() as never, + context: { action: 'voucher', descriptor }, + }), + ).rejects.toThrow('cumulativeAmount required for voucher action') + }) + + test('manual close requires cumulativeAmount', async () => { + const method = session({ account, getClient: () => client }) + + await expect( + method.createCredential({ + challenge: makeChallenge() as never, + context: { action: 'close', descriptor }, + }), + ).rejects.toThrow('cumulativeAmount required for close action') + }) + + test('manual actions require descriptors', async () => { + const method = session({ account, getClient: () => client }) + + await expect( + method.createCredential({ + challenge: makeChallenge() as never, + context: { action: 'voucher', cumulativeAmountRaw: '100' }, + }), + ).rejects.toThrow('descriptor required for precompile session action') + }) + test('creates manual voucher credentials with descriptor payloads', async () => { const method = session({ account, getClient: () => client }) const credential = await method.createCredential({ diff --git a/src/tempo/precompile/server/Session.test.ts b/src/tempo/precompile/server/Session.test.ts index 5083aa9d..1f842537 100644 --- a/src/tempo/precompile/server/Session.test.ts +++ b/src/tempo/precompile/server/Session.test.ts @@ -2,6 +2,8 @@ import { type Address, createClient, custom, + encodeAbiParameters, + encodeEventTopics, encodeFunctionData, encodeFunctionResult, type Hex, @@ -21,7 +23,7 @@ import { escrowAbi } from '../escrow.abi.js' import type { SessionCredentialPayload } from '../Types.js' import * as Types from '../Types.js' import * as Voucher from '../Voucher.js' -import { session } from './Session.js' +import { charge, session } from './Session.js' const payer = privateKeyToAccount( '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', @@ -35,6 +37,7 @@ const token = '0x0000000000000000000000000000000000000003' as Address const wrongTarget = '0x0000000000000000000000000000000000000004' as Address type RpcCall = { method: string; params?: unknown } +type ChainState = { settled: bigint; deposit: bigint; closeRequestedAt: number } function createSigningClient(account = payer) { return createClient({ @@ -57,6 +60,11 @@ function createServerClient( calls: RpcCall[] = [], account: typeof payer | null = payer, _eventChannelId: Hex = `0x${'00'.repeat(32)}` as Hex, + options: { + descriptor?: Channel.ChannelDescriptor + receipt?: Record + state?: ChainState + } = {}, ) { return createClient({ ...(account ? { account } : {}), @@ -70,13 +78,30 @@ function createServerClient( if (args.method === 'eth_maxPriorityFeePerGas') return '0x1' if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' } if (args.method === 'eth_sendRawTransaction') return `0x${'aa'.repeat(32)}` + if (args.method === 'eth_getTransactionReceipt') return options.receipt ?? null if (args.method === 'eth_sendTransaction') return `0x${'bb'.repeat(32)}` - if (args.method === 'eth_call') + if (args.method === 'eth_call') { + const state = options.state ?? { settled: 100n, deposit: 1_000n, closeRequestedAt: 0 } + const data = (args.params as [{ data?: Hex }])[0].data + const getChannelSelector = options.descriptor + ? encodeFunctionData({ + abi: escrowAbi, + functionName: 'getChannel', + args: [options.descriptor], + }).slice(0, 10) + : undefined + if (data && getChannelSelector && data.slice(0, 10) === getChannelSelector) + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'getChannel', + result: { descriptor: options.descriptor!, state }, + }) return encodeFunctionResult({ abi: escrowAbi, functionName: 'getChannelState', - result: { settled: 100n, deposit: 1_000n, closeRequestedAt: 0 }, + result: state, }) + } throw new Error(`unexpected rpc request: ${args.method}`) }, }), @@ -85,7 +110,7 @@ function createServerClient( function createStateClient( account: typeof payer | null = payer, - state: { settled: bigint; deposit: bigint; closeRequestedAt: number } = { + state: ChainState = { settled: 0n, deposit: 1_000n, closeRequestedAt: 0, @@ -163,6 +188,7 @@ async function createOpenPayload( escrow?: Address | undefined account?: typeof payer | undefined operator?: Address | undefined + authorizedSigner?: Address | undefined } = {}, ): Promise> { const account = parameters.account ?? payer @@ -171,7 +197,7 @@ async function createOpenPayload( const deposit = Types.uint96(parameters.deposit ?? 1_000n) const salt = `0x${(++saltCounter).toString(16).padStart(64, '0')}` as Hex const operator = parameters.operator ?? zeroAddress - const authorizedSigner = account.address + const authorizedSigner = parameters.authorizedSigner ?? account.address const data = encodeFunctionData({ abi: escrowAbi, functionName: 'open', @@ -219,6 +245,129 @@ async function createOpenPayload( } } +function transactionReceipt(logs: readonly Record[]) { + return { + blockHash: `0x${'01'.repeat(32)}`, + blockNumber: '0x1', + contractAddress: null, + cumulativeGasUsed: '0x1', + effectiveGasPrice: '0x1', + from: payer.address, + gasUsed: '0x1', + logs, + logsBloom: `0x${'00'.repeat(256)}`, + status: '0x1', + to: tip20ChannelEscrow, + transactionHash: `0x${'aa'.repeat(32)}`, + transactionIndex: '0x0', + type: '0x76', + } +} + +function openedLog(payload: Extract) { + return { + address: tip20ChannelEscrow, + data: encodeAbiParameters( + [ + { type: 'address' }, + { type: 'address' }, + { type: 'address' }, + { type: 'bytes32' }, + { type: 'bytes32' }, + { type: 'uint96' }, + ], + [ + payload.descriptor.operator, + payload.descriptor.token, + payload.descriptor.authorizedSigner, + payload.descriptor.salt, + payload.descriptor.expiringNonceHash, + 1_000n, + ], + ), + topics: encodeEventTopics({ + abi: escrowAbi, + eventName: 'ChannelOpened', + args: { + channelId: payload.channelId, + payer: payload.descriptor.payer, + payee: payload.descriptor.payee, + }, + }), + } +} + +function settledLog(channelId: Hex, newSettled: bigint) { + return { + address: tip20ChannelEscrow, + data: encodeAbiParameters( + [{ type: 'uint96' }, { type: 'uint96' }, { type: 'uint96' }], + [newSettled, newSettled, newSettled], + ), + topics: encodeEventTopics({ + abi: escrowAbi, + eventName: 'Settled', + args: { channelId, payer: payer.address, payee: payer.address }, + }), + } +} + +function closedLog(channelId: Hex, settledToPayee: bigint, refundedToPayer: bigint) { + return { + address: tip20ChannelEscrow, + data: encodeAbiParameters( + [{ type: 'uint96' }, { type: 'uint96' }], + [settledToPayee, refundedToPayer], + ), + topics: encodeEventTopics({ + abi: escrowAbi, + eventName: 'ChannelClosed', + args: { channelId, payer: payer.address, payee: payer.address }, + }), + } +} + +function topUpLog( + payload: Extract, + newDeposit: bigint, +) { + return { + address: tip20ChannelEscrow, + data: encodeAbiParameters([{ type: 'uint96' }, { type: 'uint96' }], [1_000n, newDeposit]), + topics: encodeEventTopics({ + abi: escrowAbi, + eventName: 'TopUp', + args: { channelId: payload.channelId, payer: payer.address, payee }, + }), + } +} + +async function createTopUpPayload( + descriptor: Channel.ChannelDescriptor, + additionalDeposit = 500n, +): Promise> { + const data = encodeFunctionData({ + abi: escrowAbi, + functionName: 'topUp', + args: [descriptor, Types.uint96(additionalDeposit)], + }) + const transaction = (await Transaction.serialize({ + chainId, + calls: [{ to: tip20ChannelEscrow, data }], + feeToken: token, + nonce: 0, + })) as Hex + const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow }) + return { + action: 'topUp', + type: 'transaction', + channelId, + descriptor, + transaction, + additionalDeposit: additionalDeposit.toString(), + } +} + async function persistPrecompileChannel( store: ChannelStore.ChannelStore, payload: Extract, @@ -341,7 +490,7 @@ describe('precompile server session unit guardrails', () => { credential: { challenge: makeChallenge(payload.channelId), payload }, request: makeRequest(payload.channelId) as never, }), - ).rejects.toThrow(/channelId does not match descriptor|wrong address/) + ).rejects.toThrow(/descriptor does not match channelId|wrong address/) }) test('rejects smuggled extra calls in open transactions', async () => { @@ -382,7 +531,7 @@ describe('precompile server session unit guardrails', () => { }, request: makeRequest(payload.channelId) as never, }), - ).rejects.toThrow(/channelId does not match descriptor/) + ).rejects.toThrow(/descriptor does not match channelId/) }) test('rejects invalid initial voucher signatures', async () => { @@ -737,6 +886,659 @@ describe('precompile server session unit guardrails', () => { ).rejects.toThrow(/channel has a pending close request/) }) + test('accepts valid precompile open with voucher and stores state', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + const method = session({ + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => + createServerClient([], payer, openPayload.channelId, { + descriptor: openPayload.descriptor, + receipt: transactionReceipt([openedLog(openPayload)]), + state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 }, + }), + }) + + const receipt = (await method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: openPayload, + }, + request: makeRequest(openPayload.channelId) as never, + })) as SessionReceipt + + const stored = await store.getChannel(openPayload.channelId) + expect(receipt.acceptedCumulative).toBe('100') + expect(stored?.backend).toBe('precompile') + expect(stored?.deposit).toBe(1_000n) + expect(stored?.highestVoucherAmount).toBe(100n) + if (!stored || !ChannelStore.isPrecompileState(stored)) + throw new Error('expected precompile state') + expect(stored.descriptor).toEqual(openPayload.descriptor) + }) + + test('reopening existing precompile channel with higher voucher updates highest voucher only', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { + highestVoucherAmount: 100n, + spent: 75n, + units: 3, + }) + const reopenPayload = { ...openPayload, cumulativeAmount: '250' } + reopenPayload.signature = await Voucher.signVoucher( + createSigningClient(), + payer, + { channelId: openPayload.channelId, cumulativeAmount: 250n }, + tip20ChannelEscrow, + chainId, + ) + const method = session({ + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => + createServerClient([], payer, openPayload.channelId, { + descriptor: openPayload.descriptor, + receipt: transactionReceipt([openedLog(openPayload)]), + state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 }, + }), + }) + + const receipt = (await method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: reopenPayload, + }, + request: makeRequest(openPayload.channelId) as never, + })) as SessionReceipt + + const stored = await store.getChannel(openPayload.channelId) + expect(receipt.acceptedCumulative).toBe('250') + expect(receipt.spent).toBe('75') + expect(receipt.units).toBe(3) + expect(stored?.highestVoucherAmount).toBe(250n) + expect(stored?.spent).toBe(75n) + expect(stored?.units).toBe(3) + }) + + test('reopening existing precompile channel with same voucher preserves accounting', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { + highestVoucherAmount: 100n, + spent: 75n, + units: 3, + }) + const method = session({ + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => + createServerClient([], payer, openPayload.channelId, { + descriptor: openPayload.descriptor, + receipt: transactionReceipt([openedLog(openPayload)]), + state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 }, + }), + }) + + const receipt = (await method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: openPayload, + }, + request: makeRequest(openPayload.channelId) as never, + })) as SessionReceipt + + const stored = await store.getChannel(openPayload.channelId) + expect(receipt.acceptedCumulative).toBe('100') + expect(receipt.spent).toBe('75') + expect(receipt.units).toBe(3) + expect(stored?.highestVoucherAmount).toBe(100n) + expect(stored?.spent).toBe(75n) + expect(stored?.units).toBe(3) + }) + + test('case-variant precompile channelId does not reset open accounting', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { spent: 75n, units: 3 }) + const mixedCaseChannelId = openPayload.channelId.replace(/[a-f]/g, (char) => + char.toUpperCase(), + ) as Hex + const method = session({ + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => + createServerClient([], payer, openPayload.channelId, { + descriptor: openPayload.descriptor, + receipt: transactionReceipt([openedLog(openPayload)]), + state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 }, + }), + }) + + const receipt = (await method.verify({ + credential: { + challenge: makeChallenge(mixedCaseChannelId), + payload: { ...openPayload, channelId: mixedCaseChannelId }, + }, + request: makeRequest(mixedCaseChannelId) as never, + })) as SessionReceipt + + const stored = await store.getChannel(openPayload.channelId) + expect(receipt.spent).toBe('75') + expect(receipt.units).toBe(3) + expect(stored?.spent).toBe(75n) + expect(stored?.units).toBe(3) + }) + + test('uses payer as precompile voucher signer when authorized signer is zero', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ + authorizedSigner: zeroAddress, + initialAmount: 100n, + }) + expect(openPayload.descriptor.authorizedSigner).toBe(zeroAddress) + const method = session({ + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => + createServerClient([], payer, openPayload.channelId, { + descriptor: openPayload.descriptor, + receipt: transactionReceipt([openedLog(openPayload)]), + state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 }, + }), + }) + + const receipt = (await method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: openPayload, + }, + request: makeRequest(openPayload.channelId) as never, + })) as SessionReceipt + + const stored = await store.getChannel(openPayload.channelId) + expect(receipt.acceptedCumulative).toBe('100') + expect(stored?.authorizedSigner).toBe(payer.address) + }) + + test('accepts precompile top-up and preserves spent accounting', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { + deposit: 1_000n, + spent: 125n, + units: 4, + }) + const topUpPayload = await createTopUpPayload(openPayload.descriptor, 500n) + const method = session({ + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => + createServerClient([], payer, topUpPayload.channelId, { + receipt: transactionReceipt([topUpLog(topUpPayload, 1_500n)]), + state: { settled: 0n, deposit: 1_500n, closeRequestedAt: 0 }, + }), + }) + + const receipt = (await method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: topUpPayload, + }, + request: makeRequest(openPayload.channelId) as never, + })) as SessionReceipt + + const stored = await store.getChannel(openPayload.channelId) + expect(receipt.spent).toBe('125') + expect(receipt.units).toBe(4) + expect(stored?.deposit).toBe(1_500n) + expect(stored?.spent).toBe(125n) + expect(stored?.units).toBe(4) + }) + + test('rejects precompile top-up when on-chain state has pending close', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { deposit: 1_000n }) + const topUpPayload = await createTopUpPayload(openPayload.descriptor, 500n) + const method = session({ + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => + createServerClient([], payer, topUpPayload.channelId, { + receipt: transactionReceipt([topUpLog(topUpPayload, 1_500n)]), + state: { settled: 0n, deposit: 1_500n, closeRequestedAt: 1 }, + }), + }) + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: topUpPayload, + }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/pending close request/) + }) + + test('rejects precompile top-up on unknown channel', async () => { + const { method } = createServer() + const openPayload = await createOpenPayload({ initialAmount: 100n }) + const topUpPayload = await createTopUpPayload(openPayload.descriptor, 500n) + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: topUpPayload, + }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/channel not found/) + }) + + test('rejects precompile top-up descriptor mismatches', async () => { + const { method, store } = createServer() + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload) + const badDescriptor = { ...openPayload.descriptor, payee: wrongTarget } + const topUpPayload = await createTopUpPayload(badDescriptor, 500n) + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: { ...topUpPayload, channelId: openPayload.channelId }, + }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/descriptor does not match/) + }) + + test('accepts increasing precompile voucher and stores accounting state', async () => { + const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { + highestVoucherAmount: 100n, + spent: 100n, + units: 1, + }) + const voucher = await ClientOps.createVoucherPayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(250n), + chainId, + ) + + const receipt = (await method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: voucher, + }, + request: makeRequest(openPayload.channelId) as never, + })) as SessionReceipt + + const stored = await store.getChannel(openPayload.channelId) + expect(receipt.acceptedCumulative).toBe('250') + expect(receipt.spent).toBe('100') + expect(receipt.units).toBe(1) + expect(stored?.highestVoucherAmount).toBe(250n) + expect(stored?.spent).toBe(100n) + expect(stored?.units).toBe(1) + }) + + test('accepts exact precompile voucher replay idempotently', async () => { + const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + const voucher = await ClientOps.createVoucherPayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(250n), + chainId, + ) + if (voucher.action !== 'voucher') throw new Error('expected voucher payload') + await persistPrecompileChannel(store, openPayload, { + highestVoucherAmount: 250n, + highestVoucher: { + channelId: openPayload.channelId, + cumulativeAmount: 250n, + signature: voucher.signature, + }, + spent: 250n, + units: 2, + }) + + const receipt = (await method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: voucher, + }, + request: makeRequest(openPayload.channelId) as never, + })) as SessionReceipt + + const stored = await store.getChannel(openPayload.channelId) + expect(receipt.acceptedCumulative).toBe('250') + expect(receipt.spent).toBe('250') + expect(receipt.units).toBe(2) + expect(stored?.highestVoucherAmount).toBe(250n) + expect(stored?.units).toBe(2) + }) + + test('rejects lower precompile voucher replay', async () => { + const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { + highestVoucherAmount: 500n, + spent: 500n, + units: 5, + }) + const voucher = await ClientOps.createVoucherPayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(250n), + chainId, + ) + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: voucher, + }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow( + /strictly greater than highest accepted voucher|non-increasing voucher|voucher replay/, + ) + }) + + test('rejects precompile voucher below minVoucherDelta', async () => { + const { method, store } = createServer({ + channelStateTtl: Number.MAX_SAFE_INTEGER, + minVoucherDelta: '200', + }) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { highestVoucherAmount: 100n }) + const voucher = await ClientOps.createVoucherPayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(250n), + chainId, + ) + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: voucher, + }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/voucher delta 150 below minimum 200/) + }) + + test('rejects stale or hijacked precompile voucher signatures', async () => { + const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { highestVoucherAmount: 100n }) + const signature = await Voucher.signVoucher( + createSigningClient(wrongPayer), + wrongPayer, + { channelId: openPayload.channelId, cumulativeAmount: 250n }, + tip20ChannelEscrow, + chainId, + ) + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: { + action: 'voucher', + channelId: openPayload.channelId, + cumulativeAmount: '250', + descriptor: openPayload.descriptor, + signature, + }, + }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/invalid voucher signature/) + }) + + test('rejects precompile voucher exceeding deposit', async () => { + const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { deposit: 300n }) + const voucher = await ClientOps.createVoucherPayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(350n), + chainId, + ) + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: voucher, + }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/exceeds.*deposit|insufficient channel deposit/) + }) + + test('rejects precompile voucher when on-chain state has pending close', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { closeRequestedAt: 0n }) + const voucher = await ClientOps.createVoucherPayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(250n), + chainId, + ) + const method = session({ + amount: '1', + chainId, + channelStateTtl: 0, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => + createStateClient(payer, { settled: 0n, deposit: 1_000n, closeRequestedAt: 1 }), + }) + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: voucher, + }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/pending close request/) + }) + + test('rejects precompile voucher when on-chain deposit is zero', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { deposit: 1_000n }) + const voucher = await ClientOps.createVoucherPayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(250n), + chainId, + ) + const method = session({ + amount: '1', + chainId, + channelStateTtl: 0, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => createStateClient(payer, { settled: 0n, deposit: 0n, closeRequestedAt: 0 }), + }) + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: voucher, + }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/deposit is zero|channel deposit is zero|not found/) + }) + + test('rejects precompile voucher on unknown channel', async () => { + const { method } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER }) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + const voucher = await ClientOps.createVoucherPayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(250n), + chainId, + ) + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: voucher, + }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/unknown channel|not found/) + }) + + describe('respond', () => { + function respond(action: SessionCredentialPayload['action'], input: Request) { + const { method } = createServer() + return method.respond!({ + credential: { + challenge: makeChallenge(`0x${'01'.repeat(32)}` as Hex), + payload: { action }, + }, + input, + } as never) + } + + test('returns 204 for close management requests', () => { + const result = respond('close', new Request('http://localhost', { method: 'GET' })) + expect(result).toBeInstanceOf(Response) + expect((result as Response).status).toBe(204) + }) + + test('returns 204 for top-up management requests', () => { + const result = respond('topUp', new Request('http://localhost', { method: 'POST' })) + expect(result).toBeInstanceOf(Response) + expect((result as Response).status).toBe(204) + }) + + test('returns 204 for open POST management requests', () => { + const result = respond('open', new Request('http://localhost', { method: 'POST' })) + expect(result).toBeInstanceOf(Response) + expect((result as Response).status).toBe(204) + }) + + test('returns 204 for voucher POST management requests', () => { + const result = respond('voucher', new Request('http://localhost', { method: 'POST' })) + expect(result).toBeInstanceOf(Response) + expect((result as Response).status).toBe(204) + }) + + test('lets open and voucher GET content requests through', () => { + expect(respond('open', new Request('http://localhost', { method: 'GET' }))).toBeUndefined() + expect(respond('voucher', new Request('http://localhost', { method: 'GET' }))).toBeUndefined() + }) + + test('lets open and voucher POST content requests with bodies through', () => { + expect( + respond( + 'open', + new Request('http://localhost', { method: 'POST', headers: { 'content-length': '1' } }), + ), + ).toBeUndefined() + expect( + respond( + 'voucher', + new Request('http://localhost', { + method: 'POST', + headers: { 'transfer-encoding': 'chunked' }, + }), + ), + ).toBeUndefined() + }) + + test('returns 204 for voucher POST with content-length zero', () => { + const result = respond( + 'voucher', + new Request('http://localhost', { method: 'POST', headers: { 'content-length': '0' } }), + ) + expect(result).toBeInstanceOf(Response) + expect((result as Response).status).toBe(204) + }) + }) + test('does not let a racing lower voucher regress highest accepted precompile voucher', async () => { const openPayload = await createOpenPayload({ initialAmount: 100n }) const lowerVoucher = await ClientOps.createVoucherPayload( @@ -881,6 +1683,152 @@ describe('precompile server session unit guardrails', () => { expect((await store.getChannel(openPayload.channelId))!.closeRequestedAt).toBe(0n) }) + test('precompile settle returns txHash when channel disappears before final write', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { payee: payer.address }) + const client = createServerClient([], payer, openPayload.channelId, { + receipt: transactionReceipt([settledLog(openPayload.channelId, 100n)]), + state: { settled: 100n, deposit: 1_000n, closeRequestedAt: 0 }, + }) + let deleted = false + const disappearingStore = { + async get(key: string) { + return rawStore.get(key) + }, + async put(key: string, value: unknown) { + return rawStore.put(key, value) + }, + async delete(key: string) { + return rawStore.delete(key) + }, + async update( + key: string, + fn: (current: unknown | null) => Store.Change, + ): Promise { + if (!deleted) { + deleted = true + return rawStore.update(key, fn) + } + const change = fn(null) + return change.result + }, + } as Store.AtomicStore + + const { settle } = await import('./Session.js') + await expect(settle(disappearingStore, client, openPayload.channelId)).resolves.toBe( + `0x${'aa'.repeat(32)}`, + ) + }) + + test('precompile close still returns receipt when channel disappears before final write', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { payee: payer.address, spent: 100n }) + const closeSignature = await Voucher.signVoucher( + createSigningClient(), + payer, + { channelId: openPayload.channelId, cumulativeAmount: 100n }, + tip20ChannelEscrow, + chainId, + ) + const method = session({ + account: payer, + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => + createServerClient([], payer, openPayload.channelId, { + receipt: transactionReceipt([closedLog(openPayload.channelId, 100n, 900n)]), + state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 }, + }), + }) + let deleteBeforeFinalWrite = false + const originalUpdate = store.updateChannel.bind(store) + store.updateChannel = (async (channelId, fn) => { + if (deleteBeforeFinalWrite) return fn(null as never) as never + const result = await originalUpdate(channelId, fn) + deleteBeforeFinalWrite = true + return result + }) as typeof store.updateChannel + + const receipt = (await method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: { + action: 'close', + channelId: openPayload.channelId, + cumulativeAmount: '100', + descriptor: openPayload.descriptor, + signature: closeSignature, + }, + }, + request: makeRequest(openPayload.channelId) as never, + })) as SessionReceipt + + expect(receipt.txHash).toBe(`0x${'aa'.repeat(32)}`) + expect(receipt.spent).toBe('100') + }) + + test('rejects close when precompile channel is finalized on-chain', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { payee: payer.address, spent: 100n }) + const closeSignature = await Voucher.signVoucher( + createSigningClient(), + payer, + { channelId: openPayload.channelId, cumulativeAmount: 100n }, + tip20ChannelEscrow, + chainId, + ) + const method = session({ + account: payer, + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => createStateClient(payer, { settled: 0n, deposit: 0n, closeRequestedAt: 0 }), + }) + + await expect( + method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: { + action: 'close', + channelId: openPayload.channelId, + cumulativeAmount: '100', + descriptor: openPayload.descriptor, + signature: closeSignature, + }, + }, + request: makeRequest(openPayload.channelId) as never, + }), + ).rejects.toThrow(/channel deposit is zero/) + }) + + test('pending precompile close blocks concurrent charges', async () => { + const { store } = createServer() + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload, { + closeRequestedAt: 1n, + highestVoucherAmount: 500n, + spent: 100n, + }) + + await expect(charge(store, openPayload.channelId, 1n)).rejects.toThrow(/pending close request/) + }) + test('rejects server-driven close when no account is available', async () => { const rawStore = Store.memory() const store = ChannelStore.fromStore(rawStore as never) diff --git a/src/tempo/precompile/server/Session.ts b/src/tempo/precompile/server/Session.ts index a1a7c68b..015884b6 100644 --- a/src/tempo/precompile/server/Session.ts +++ b/src/tempo/precompile/server/Session.ts @@ -641,18 +641,21 @@ async function handleOpen(parameters: { feePayer: parameters.feePayer, feePayerPolicy: parameters.feePayerPolicy, serializedTransaction: payload.transaction, + async beforeBroadcast(prepared) { + assertSameDescriptor(prepared.descriptor, payload.descriptor) + if (cumulativeAmount > prepared.openDeposit) + throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds open deposit' }) + const valid = await Voucher.verifyVoucher( + escrow, + chainId, + { channelId, cumulativeAmount: cumulativeAmount, signature: payload.signature }, + authorizedSigner(prepared.descriptor), + ) + if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) + }, }) const { descriptor, state } = result assertSameDescriptor(descriptor, payload.descriptor) - if (cumulativeAmount > result.openDeposit) - throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds open deposit' }) - const valid = await Voucher.verifyVoucher( - escrow, - chainId, - { channelId, cumulativeAmount: cumulativeAmount, signature: payload.signature }, - authorizedSigner(descriptor), - ) - if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) const amount = challenge.request.amount ? BigInt(challenge.request.amount as string) : undefined validateChannelState(state, amount) From 31cc340472ad1f5e2212f23fe1c31506e744404c Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 15 May 2026 16:06:52 +0200 Subject: [PATCH 26/26] Add precompile session HTTP SSE parity --- src/tempo/precompile/server/Session.test.ts | 342 +++++++++++++++++++- 1 file changed, 340 insertions(+), 2 deletions(-) diff --git a/src/tempo/precompile/server/Session.test.ts b/src/tempo/precompile/server/Session.test.ts index 1f842537..b7b7e224 100644 --- a/src/tempo/precompile/server/Session.test.ts +++ b/src/tempo/precompile/server/Session.test.ts @@ -1,3 +1,5 @@ +import { Challenge, Credential } from 'mppx' +import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server' import { type Address, createClient, @@ -15,11 +17,13 @@ import { describe, expect, test } from 'vp/test' import * as Store from '../../../Store.js' import * as ChannelStore from '../../session/ChannelStore.js' +import { deserializeSessionReceipt } from '../../session/Receipt.js' import type { SessionReceipt } from '../../session/Types.js' import * as Channel from '../Channel.js' import * as ClientOps from '../client/ChannelOps.js' import { tip20ChannelEscrow } from '../Constants.js' import { escrowAbi } from '../escrow.abi.js' +import { sessionManager as precompileSessionManager } from '../session/SessionManager.js' import type { SessionCredentialPayload } from '../Types.js' import * as Types from '../Types.js' import * as Voucher from '../Voucher.js' @@ -264,7 +268,10 @@ function transactionReceipt(logs: readonly Record[]) { } } -function openedLog(payload: Extract) { +function openedLog( + payload: Extract, + deposit = 1_000n, +) { return { address: tip20ChannelEscrow, data: encodeAbiParameters( @@ -282,7 +289,7 @@ function openedLog(payload: Extract { }) }) + describe('default HTTP auto-billing', () => { + function createRoute(rawStore: Store.AtomicStore) { + return Mppx_server.create({ + methods: [ + tempo_server.precompile.Server.session({ + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => createStateClient(payer), + }), + ], + realm: 'api.example.com', + secretKey: 'secret', + }).session({ amount: '1', decimals: 0, unitType: 'request' }) + } + + test('GET content flow charges once and rejects same-voucher replay', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 1n }) + await persistPrecompileChannel(store, openPayload, { + highestVoucherAmount: 1n, + spent: 0n, + units: 0, + }) + const route = createRoute(rawStore) + const voucher = await ClientOps.createVoucherPayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(1n), + chainId, + ) + + const serve = async (request: Request) => { + const result = await route(request) + if (result.status === 402) return result.challenge + return result.withReceipt(new Response('paid-content')) + } + + const first = await route(new Request('https://api.example.com/resource')) + expect(first.status).toBe(402) + if (first.status !== 402) throw new Error('expected challenge') + + const paid = await serve( + new Request('https://api.example.com/resource', { + headers: { + Authorization: Credential.serialize({ + challenge: Challenge.fromResponse(first.challenge), + payload: voucher, + }), + }, + }), + ) + expect(paid.status).toBe(200) + expect(await paid.text()).toBe('paid-content') + const receipt = deserializeSessionReceipt(paid.headers.get('Payment-Receipt') as string) + expect(receipt.acceptedCumulative).toBe('1') + expect(receipt.spent).toBe('1') + expect(receipt.units).toBe(1) + + const replayChallenge = await route(new Request('https://api.example.com/resource')) + expect(replayChallenge.status).toBe(402) + if (replayChallenge.status !== 402) throw new Error('expected challenge') + + const replay = await serve( + new Request('https://api.example.com/resource', { + headers: { + Authorization: Credential.serialize({ + challenge: Challenge.fromResponse(replayChallenge.challenge), + payload: voucher, + }), + }, + }), + ) + expect(replay.status).toBe(402) + expect(replay.headers.get('Payment-Receipt')).toBeNull() + }) + + test('POST content flow charges once and rejects same-voucher replay', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 1n }) + await persistPrecompileChannel(store, openPayload) + const route = createRoute(rawStore) + const voucher = await ClientOps.createVoucherPayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(1n), + chainId, + ) + const makeRequest = (authorization?: string) => + new Request('https://api.example.com/resource', { + method: 'POST', + body: '{}', + headers: { + 'content-length': '2', + 'content-type': 'application/json', + ...(authorization ? { Authorization: authorization } : {}), + }, + }) + + const first = await route(makeRequest()) + expect(first.status).toBe(402) + if (first.status !== 402) throw new Error('expected challenge') + + const result = await route( + makeRequest( + Credential.serialize({ + challenge: Challenge.fromResponse(first.challenge), + payload: voucher, + }), + ), + ) + expect(result.status).toBe(200) + if (result.status !== 200) throw new Error('expected paid response') + const paid = result.withReceipt(new Response('paid-content')) + const receipt = deserializeSessionReceipt(paid.headers.get('Payment-Receipt') as string) + expect(receipt.spent).toBe('1') + expect(receipt.units).toBe(1) + + const replayChallenge = await route(makeRequest()) + expect(replayChallenge.status).toBe(402) + if (replayChallenge.status !== 402) throw new Error('expected challenge') + const replay = await route( + makeRequest( + Credential.serialize({ + challenge: Challenge.fromResponse(replayChallenge.challenge), + payload: voucher, + }), + ), + ) + expect(replay.status).toBe(402) + if (replay.status !== 402) throw new Error('expected challenge') + expect(replay.challenge.headers.get('Payment-Receipt')).toBeNull() + }) + + test('verification errors do not include Payment-Receipt', async () => { + const rawStore = Store.memory() + const store = ChannelStore.fromStore(rawStore as never) + const openPayload = await createOpenPayload({ initialAmount: 100n }) + await persistPrecompileChannel(store, openPayload) + const route = createRoute(rawStore) + const first = await route(new Request('https://api.example.com/resource')) + expect(first.status).toBe(402) + if (first.status !== 402) throw new Error('expected challenge') + + const failed = await route( + new Request('https://api.example.com/resource', { + headers: { + Authorization: Credential.serialize({ + challenge: Challenge.fromResponse(first.challenge), + payload: { + action: 'voucher', + channelId: openPayload.channelId, + cumulativeAmount: '100', + descriptor: openPayload.descriptor, + signature: (await createOpenPayload({ account: wrongPayer })).signature, + }, + }), + }, + }), + ) + + expect(failed.status).toBe(402) + if (failed.status !== 402) throw new Error('expected challenge') + expect(failed.challenge.headers.get('Payment-Receipt')).toBeNull() + }) + }) + + describe('SSE parity', () => { + function createManagedSseFetch( + options: { amount?: string; maxDeposit?: bigint; unitType?: 'request' | 'token' } = {}, + ) { + const rawStore = Store.memory() + let currentPayload: SessionCredentialPayload | undefined + let voucherPosts = 0 + const amount = options.amount ?? '1' + const maxDeposit = options.maxDeposit ?? 3n + const unitType = options.unitType ?? 'token' + const route = Mppx_server.create({ + methods: [ + tempo_server.precompile.Server.session({ + amount, + chainId, + currency: token, + decimals: 0, + recipient: payer.address, + sse: true, + store: rawStore, + unitType, + getClient: () => { + const payload = currentPayload + if (payload?.action === 'open') { + return createServerClient([], payer, payload.channelId, { + descriptor: payload.descriptor, + receipt: transactionReceipt([openedLog(payload, maxDeposit)]), + state: { settled: 0n, deposit: maxDeposit, closeRequestedAt: 0 }, + }) + } + if (payload?.action === 'close') { + return createServerClient([], payer, payload.channelId, { + receipt: transactionReceipt([ + closedLog(payload.channelId, BigInt(payload.cumulativeAmount), 0n), + ]), + state: { settled: 0n, deposit: maxDeposit, closeRequestedAt: 0 }, + }) + } + return createStateClient(payer, { + settled: 0n, + deposit: maxDeposit, + closeRequestedAt: 0, + }) + }, + }), + ], + realm: 'api.example.com', + secretKey: 'secret', + }).session({ amount, decimals: 0, unitType }) + + const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + currentPayload = undefined + if (request.headers.has('Authorization')) { + try { + currentPayload = Credential.fromRequest(request).payload + if (currentPayload.action === 'voucher') voucherPosts++ + } catch {} + } + + const result = await route(request) + if (result.status === 402) return result.challenge + if (currentPayload?.action === 'voucher') return new Response(null, { status: 200 }) + + if (request.headers.get('Accept')?.includes('text/event-stream')) { + if (unitType === 'request') { + const encoder = new TextEncoder() + return result.withReceipt( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: message\ndata: chunk-1\n\n')) + controller.enqueue(encoder.encode('event: message\ndata: chunk-2\n\n')) + controller.enqueue(encoder.encode('event: message\ndata: chunk-3\n\n')) + controller.close() + }, + }), + { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } }, + ), + ) + } + + return result.withReceipt(async function* (stream) { + await stream.charge() + yield 'chunk-1' + await stream.charge() + yield 'chunk-2' + await stream.charge() + yield 'chunk-3' + }) + } + + return result.withReceipt(new Response('ok')) + } + + return { + fetch, + rawStore, + get voucherPosts() { + return voucherPosts + }, + } + } + + test('open -> stream -> need-voucher -> resume -> close', async () => { + const harness = createManagedSseFetch({ maxDeposit: 3n }) + const manager = precompileSessionManager({ + account: payer, + client: createSigningClient(), + decimals: 0, + fetch: harness.fetch, + maxDeposit: '3', + }) + + const chunks: string[] = [] + const stream = await manager.sse('https://api.example.com/stream') + for await (const chunk of stream) chunks.push(chunk) + + expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3']) + expect(harness.voucherPosts).toBeGreaterThan(0) + + const closeReceipt = await manager.close() + expect(closeReceipt?.status).toBe('success') + expect(closeReceipt?.spent).toBe('3') + + const channelId = manager.channelId + expect(channelId).toBeTruthy() + const persisted = await ChannelStore.fromStore(harness.rawStore as never).getChannel( + channelId!, + ) + expect(persisted?.finalized).toBe(true) + }) + + test('unitType=request auto-metered SSE responses charge once across the stream', async () => { + const harness = createManagedSseFetch({ maxDeposit: 1n, unitType: 'request' }) + const manager = precompileSessionManager({ + account: payer, + client: createSigningClient(), + decimals: 0, + fetch: harness.fetch, + maxDeposit: '1', + }) + + const chunks: string[] = [] + const stream = await manager.sse('https://api.example.com/stream') + for await (const chunk of stream) chunks.push(chunk) + + expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3']) + expect(harness.voucherPosts).toBe(0) + + const closeReceipt = await manager.close() + expect(closeReceipt?.status).toBe('success') + expect(closeReceipt?.spent).toBe('1') + }) + }) + test('does not let a racing lower voucher regress highest accepted precompile voucher', async () => { const openPayload = await createOpenPayload({ initialAmount: 100n }) const lowerVoucher = await ClientOps.createVoucherPayload(