diff --git a/.changeset/warm-pandas-climb.md b/.changeset/warm-pandas-climb.md new file mode 100644 index 00000000..c17e1cc3 --- /dev/null +++ b/.changeset/warm-pandas-climb.md @@ -0,0 +1,5 @@ +--- +'mppx': minor +--- + +Added `onSessionSettlement` hook for visibility into on-chain session settlement transactions, with chain-agnostic context including trigger, txHash, channelId, cumulative amount, and incremental delta. diff --git a/src/tempo/server/index.ts b/src/tempo/server/index.ts index c41a99cf..7b5d9f37 100644 --- a/src/tempo/server/index.ts +++ b/src/tempo/server/index.ts @@ -4,5 +4,9 @@ export * as Ws from '../session/server/Ws.js' export { charge } from './Charge.js' export { sessionLegacy, settleLegacy, tempo } from './Methods.js' export { session, settle, settleBatch } from '../session/server/Session.js' -export type { SettlementSchedule } from '../session/server/Session.js' +export type { + OnSessionSettlement, + SessionSettlementContext, + SettlementSchedule, +} from '../session/server/Session.js' export { renew as renewSubscription, subscription } from './Subscription.js' diff --git a/src/tempo/session/server/CredentialVerification.ts b/src/tempo/session/server/CredentialVerification.ts index 1daaa072..cbd4228f 100644 --- a/src/tempo/session/server/CredentialVerification.ts +++ b/src/tempo/session/server/CredentialVerification.ts @@ -30,7 +30,7 @@ import { import * as Voucher from '../precompile/Voucher.js' import * as ChannelStore from './ChannelStore.js' import { getChallengePaymentFields } from './RequestState.js' -import { assertSettlementSender, getClientAccount } from './Settlement.js' +import { assertSettlementSender, getClientAccount, type OnSessionSettlement } from './Settlement.js' /** Returns the effective voucher signer for a TIP-1034 descriptor. */ export function authorizedSigner(descriptor: Channel.ChannelDescriptor): Address { @@ -344,6 +344,8 @@ export type VerifyCredentialPayloadParameters = { lastOnChainVerified: Map /** Minimum allowed voucher delta in raw units. */ minVoucherDelta: bigint + /** Callback invoked after an on-chain settlement or close transaction is confirmed. */ + onSessionSettlement?: OnSessionSettlement | undefined /** Discriminated session credential payload to verify. */ payload: SessionCredentialPayload /** Server-side channel store. */ @@ -747,6 +749,21 @@ async function handleCloseCredential( signature: payload.signature, }), ) + if (parameters.onSessionSettlement && txHash) { + try { + await parameters.onSessionSettlement( + Object.freeze({ + txHash, + channelId, + trigger: 'close' as const, + amount: settledToPayee, + delta: settledToPayee - state.settled, + }), + ) + } catch { + // Errors are isolated — observers cannot break the settlement flow. + } + } return createSessionReceipt({ challengeId: challenge.id, channelId, diff --git a/src/tempo/session/server/Session.test.ts b/src/tempo/session/server/Session.test.ts index a33f993b..1b43638f 100644 --- a/src/tempo/session/server/Session.test.ts +++ b/src/tempo/session/server/Session.test.ts @@ -3337,3 +3337,403 @@ describe('precompile server session unit guardrails', () => { ).rejects.toThrow(/tx sender .* is not the channel payee/) }) }) + +describe('onSessionSettlement', () => { + function createSettleClient(channelId: Hex, settledAmount: bigint) { + return createClient({ + account: payer, + chain: testChain, + 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' } + if (args.method === 'eth_sendRawTransaction') return `0x${'cc'.repeat(32)}` + if (args.method === 'eth_sendTransaction') return `0x${'cc'.repeat(32)}` + if (args.method === 'eth_getTransactionReceipt') + return transactionReceipt([settledLog(channelId, settledAmount)]) + if (args.method === 'eth_call') { + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'getChannelState', + result: { settled: settledAmount, deposit: 1_000n, closeRequestedAt: 0 }, + }) + } + throw new Error(`unexpected rpc request: ${args.method}`) + }, + }), + }) + } + + test('fires onSessionSettlement for explicit settle() calls', async () => { + const events: { trigger: string; txHash: string; amount: bigint; delta: bigint }[] = [] + const store = Store.memory() + const openPayload = await createOpenPayload() + await persistPrecompileChannel(channelStore(store), openPayload, { + payee: payer.address, + spent: 100n, + highestVoucherAmount: 100n, + highestVoucher: { + channelId: openPayload.channelId, + cumulativeAmount: 100n, + signature: '0x1234', + }, + }) + + const { settle } = await import('./Session.js') + const client = createSettleClient(openPayload.channelId, 100n) + await settle(store, client, openPayload.channelId, { + onSessionSettlement: (ctx) => { + events.push({ + trigger: ctx.trigger, + txHash: ctx.txHash, + amount: ctx.amount, + delta: ctx.delta, + }) + }, + }) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + trigger: 'settle', + txHash: `0x${'cc'.repeat(32)}`, + amount: 100n, + delta: 100n, + }) + }) + + test('fires onSessionSettlement for auto-scheduled settlements', async () => { + const events: { trigger: string; channelId: string; amount: bigint; delta: bigint }[] = [] + const rawStore = Store.memory() + const openPayload = await createOpenPayload() + const store = channelStore(rawStore) + await persistPrecompileChannel(store, openPayload, { + payee: payer.address, + spent: 500n, + units: 10, + highestVoucherAmount: 500n, + highestVoucher: { + channelId: openPayload.channelId, + cumulativeAmount: 500n, + signature: '0x1234', + }, + }) + + const { maybeSettleScheduled } = await import('./Settlement.js') + const client = createSettleClient(openPayload.channelId, 500n) + const channel = await store.getChannel(openPayload.channelId) + + await maybeSettleScheduled({ + account: payer, + channel: channel!, + client, + schedule: { units: 5 }, + store, + onSessionSettlement: (ctx) => { + events.push({ + trigger: ctx.trigger, + channelId: ctx.channelId, + amount: ctx.amount, + delta: ctx.delta, + }) + }, + }) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + trigger: 'scheduled', + channelId: openPayload.channelId, + amount: 500n, + delta: 500n, + }) + }) + + test('delta reflects incremental scheduled settlement with prior on-chain settlement', async () => { + const events: { trigger: string; amount: bigint; delta: bigint }[] = [] + const rawStore = Store.memory() + const openPayload = await createOpenPayload() + const store = channelStore(rawStore) + await persistPrecompileChannel(store, openPayload, { + payee: payer.address, + spent: 500n, + units: 10, + settledOnChain: 200n, + highestVoucherAmount: 500n, + highestVoucher: { + channelId: openPayload.channelId, + cumulativeAmount: 500n, + signature: '0x1234', + }, + }) + + const { maybeSettleScheduled } = await import('./Settlement.js') + const client = createSettleClient(openPayload.channelId, 500n) + const channel = await store.getChannel(openPayload.channelId) + + await maybeSettleScheduled({ + account: payer, + channel: channel!, + client, + schedule: { units: 5 }, + store, + onSessionSettlement: (ctx) => { + events.push({ trigger: ctx.trigger, amount: ctx.amount, delta: ctx.delta }) + }, + }) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + trigger: 'scheduled', + amount: 500n, + delta: 300n, + }) + }) + + test('fires onSessionSettlement for cooperative close', async () => { + const events: { trigger: string; txHash: string; amount: bigint; delta: bigint }[] = [] + const openPayload = await createOpenPayload() + const closedAmount = 100n + const refundedAmount = 900n + const rawStore = Store.memory() + + const closeClient = createClient({ + account: payer, + chain: testChain, + 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' } + if (args.method === 'eth_sendRawTransaction') return `0x${'dd'.repeat(32)}` + if (args.method === 'eth_sendTransaction') return `0x${'dd'.repeat(32)}` + if (args.method === 'eth_getTransactionReceipt') + return transactionReceipt([ + closedLog(openPayload.channelId, closedAmount, refundedAmount), + ]) + 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}`) + }, + }), + }) + + const method = session({ + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => closeClient, + onSessionSettlement: (ctx) => { + events.push({ + trigger: ctx.trigger, + txHash: ctx.txHash, + amount: ctx.amount, + delta: ctx.delta, + }) + }, + }) + + await persistPrecompileChannel(channelStore(rawStore), openPayload, { + payee: payer.address, + spent: 100n, + highestVoucherAmount: 100n, + highestVoucher: { + channelId: openPayload.channelId, + cumulativeAmount: 100n, + signature: '0x1234', + }, + }) + + const closeSignature = await Voucher.signVoucher( + createSigningClient(), + payer, + { channelId: openPayload.channelId, cumulativeAmount: 100n }, + tip20ChannelEscrow, + chainId, + ) + + await method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: { + action: 'close', + channelId: openPayload.channelId, + descriptor: openPayload.descriptor, + cumulativeAmount: '100', + signature: closeSignature, + }, + }, + request: verifyRequest(openPayload.channelId), + }) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + trigger: 'close', + txHash: `0x${'dd'.repeat(32)}`, + amount: closedAmount, + delta: closedAmount, + }) + }) + + test('delta reflects incremental settlement when channel has prior on-chain settlement', async () => { + const events: { trigger: string; amount: bigint; delta: bigint }[] = [] + const store = Store.memory() + const openPayload = await createOpenPayload() + await persistPrecompileChannel(channelStore(store), openPayload, { + payee: payer.address, + spent: 300n, + settledOnChain: 200n, + highestVoucherAmount: 300n, + highestVoucher: { + channelId: openPayload.channelId, + cumulativeAmount: 300n, + signature: '0x1234', + }, + }) + + const { settle } = await import('./Session.js') + const client = createSettleClient(openPayload.channelId, 300n) + await settle(store, client, openPayload.channelId, { + onSessionSettlement: (ctx) => { + events.push({ trigger: ctx.trigger, amount: ctx.amount, delta: ctx.delta }) + }, + }) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + trigger: 'settle', + amount: 300n, + delta: 100n, + }) + }) + + test('delta reflects incremental close when channel has prior on-chain settlement', async () => { + const events: { trigger: string; amount: bigint; delta: bigint }[] = [] + const openPayload = await createOpenPayload() + const priorSettled = 50n + const closedAmount = 100n + const refundedAmount = 900n + const rawStore = Store.memory() + + const closeClient = createClient({ + account: payer, + chain: testChain, + 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' } + if (args.method === 'eth_sendRawTransaction') return `0x${'dd'.repeat(32)}` + if (args.method === 'eth_sendTransaction') return `0x${'dd'.repeat(32)}` + if (args.method === 'eth_getTransactionReceipt') + return transactionReceipt([ + closedLog(openPayload.channelId, closedAmount, refundedAmount), + ]) + if (args.method === 'eth_call') { + return encodeFunctionResult({ + abi: escrowAbi, + functionName: 'getChannelState', + result: { settled: priorSettled, deposit: 1_000n, closeRequestedAt: 0 }, + }) + } + throw new Error(`unexpected rpc request: ${args.method}`) + }, + }), + }) + + const method = session({ + amount: '1', + chainId, + currency: token, + decimals: 0, + recipient: payee, + store: rawStore, + unitType: 'request', + getClient: () => closeClient, + onSessionSettlement: (ctx) => { + events.push({ trigger: ctx.trigger, amount: ctx.amount, delta: ctx.delta }) + }, + }) + + await persistPrecompileChannel(channelStore(rawStore), openPayload, { + payee: payer.address, + spent: 100n, + settledOnChain: priorSettled, + highestVoucherAmount: 100n, + highestVoucher: { + channelId: openPayload.channelId, + cumulativeAmount: 100n, + signature: '0x1234', + }, + }) + + const closeSignature = await Voucher.signVoucher( + createSigningClient(), + payer, + { channelId: openPayload.channelId, cumulativeAmount: 100n }, + tip20ChannelEscrow, + chainId, + ) + + await method.verify({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload: { + action: 'close', + channelId: openPayload.channelId, + descriptor: openPayload.descriptor, + cumulativeAmount: '100', + signature: closeSignature, + }, + }, + request: verifyRequest(openPayload.channelId), + }) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + trigger: 'close', + amount: closedAmount, + delta: closedAmount - priorSettled, + }) + }) + + test('isolates errors in onSessionSettlement handler', async () => { + const store = Store.memory() + const openPayload = await createOpenPayload() + await persistPrecompileChannel(channelStore(store), openPayload, { + payee: payer.address, + spent: 100n, + highestVoucherAmount: 100n, + highestVoucher: { + channelId: openPayload.channelId, + cumulativeAmount: 100n, + signature: '0x1234', + }, + }) + + const { settle } = await import('./Session.js') + const client = createSettleClient(openPayload.channelId, 100n) + + await expect( + settle(store, client, openPayload.channelId, { + onSessionSettlement: () => { + throw new Error('observer exploded') + }, + }), + ).resolves.toBe(`0x${'cc'.repeat(32)}`) + }) +}) diff --git a/src/tempo/session/server/Session.ts b/src/tempo/session/server/Session.ts index 2291c4a0..ccf78680 100644 --- a/src/tempo/session/server/Session.ts +++ b/src/tempo/session/server/Session.ts @@ -44,10 +44,16 @@ import { import { respondToSessionCredential } from './RequestState.js' import { applyVerifiedHttpAccounting, chargeSessionChannel } from './Settlement.js' import { maybeSettleScheduled } from './Settlement.js' -import { resolveSettlementSchedule, type SettlementSchedule } from './Settlement.js' +import { + resolveSettlementSchedule, + type OnSessionSettlement, + type SettlementSchedule, +} from './Settlement.js' /** Server-side automatic settlement schedule. */ export type { SettlementSchedule } from './Settlement.js' +/** Server-side settlement event hook types. */ +export type { OnSessionSettlement, SessionSettlementContext } from './Settlement.js' /** Server-side hook types for request-identity channel bootstrap. */ export type { ResolveSessionChannelId, @@ -312,6 +318,7 @@ export function session( unitType, } = parameters const settlementSchedule = resolveSettlementSchedule(parameters.settlementSchedule, decimals) + const onSessionSettlement = parameters.onSessionSettlement const store = ChannelStore.fromStore(rawStore) const lastOnChainVerified = new Map() @@ -424,6 +431,7 @@ export function session( feeToken: parameters.feeToken, lastOnChainVerified, minVoucherDelta: context.minVoucherDelta, + onSessionSettlement, payload, store, }) @@ -444,6 +452,7 @@ export function session( ...(typeof context.feePayer === 'object' ? { feePayer: context.feePayer } : {}), feePayerPolicy: parameters.feePayerPolicy, feeToken: parameters.feeToken, + onSessionSettlement, schedule: settlementSchedule, store, channel, @@ -510,6 +519,8 @@ export namespace session { chainId?: number | undefined /** TIP20EscrowChannel precompile address override. */ escrowContract?: Address | undefined + /** Callback invoked after any on-chain settlement or close transaction is confirmed. */ + onSessionSettlement?: OnSessionSettlement | undefined /** Server-owned automatic settlement cadence. Clients do not receive or control this schedule. */ settlementSchedule?: SettlementSchedule | undefined diff --git a/src/tempo/session/server/Settlement.ts b/src/tempo/session/server/Settlement.ts index 3446cdd2..4783b2a0 100644 --- a/src/tempo/session/server/Settlement.ts +++ b/src/tempo/session/server/Settlement.ts @@ -16,6 +16,7 @@ import { InsufficientBalanceError, VerificationFailedError, } from '../../../Errors.js' +import type { MaybePromise } from '../../../internal/types.js' import type * as Method from '../../../Method.js' import * as Store from '../../../Store.js' import type * as FeePayer from '../../internal/fee-payer.js' @@ -146,6 +147,23 @@ export type SettlementProgress = { units: number } +/** Context emitted when an on-chain settlement or close transaction is confirmed. */ +export type SessionSettlementContext = Readonly<{ + /** On-chain transaction hash (or signature on Solana). */ + txHash: Hex + /** Channel ID that was settled. */ + channelId: Hex + /** The trigger that caused settlement. */ + trigger: 'settle' | 'close' | 'scheduled' + /** Cumulative amount settled on-chain to the payee (raw token units). */ + amount: bigint + /** Incremental amount settled in this transaction (raw token units). */ + delta: bigint +}> + +/** Callback invoked after an on-chain settlement or close transaction is confirmed. */ +export type OnSessionSettlement = (context: SessionSettlementContext) => MaybePromise + /** Inputs used to mark a channel after automatic scheduled settlement succeeds. */ export type MarkSettlementCompleteParameters = { channelId: ChannelStore.State['channelId'] @@ -340,6 +358,8 @@ export type SettlementTransactionOptions = { feePayerPolicy?: Partial | undefined /** Optional fee token override for settlement. */ feeToken?: Address | undefined + /** Callback invoked after the settlement transaction is confirmed. */ + onSessionSettlement?: OnSessionSettlement | undefined } /** Inputs for applying a server-owned automatic settlement schedule. */ @@ -356,6 +376,8 @@ export type MaybeSettleScheduledParameters = { feePayerPolicy?: Partial | undefined /** Optional fee token override for settlement. */ feeToken?: Address | undefined + /** Callback invoked after the scheduled settlement transaction is confirmed. */ + onSessionSettlement?: OnSessionSettlement | undefined /** Resolved server-owned settlement cadence. */ schedule: ResolvedSettlementSchedule | undefined /** Server-side channel store. */ @@ -400,6 +422,9 @@ export async function maybeSettleScheduled( ...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}), ...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}), ...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}), + onSessionSettlement: parameters.onSessionSettlement + ? (ctx) => parameters.onSessionSettlement!({ ...ctx, trigger: 'scheduled' }) + : undefined, }) await markSettlementComplete({ channelId: channel.channelId, store }) return txHash @@ -466,6 +491,15 @@ export async function settle( } : current, ) + if (options?.onSessionSettlement) { + await emitSessionSettlement(options.onSessionSettlement, { + txHash, + channelId, + trigger: 'settle', + amount: newSettled, + delta: newSettled - channel.settledOnChain, + }) + } return txHash } @@ -480,3 +514,14 @@ export async function settleBatch( for (const channelId of channelIds) hashes.push(await settle(store, client, channelId, options)) return hashes } + +async function emitSessionSettlement( + onSessionSettlement: OnSessionSettlement, + context: SessionSettlementContext, +): Promise { + try { + await onSessionSettlement(Object.freeze(context)) + } catch { + // Errors are isolated — observers cannot break the settlement flow. + } +} diff --git a/src/tempo/session/server/index.ts b/src/tempo/session/server/index.ts index d892f7b2..312cd06f 100644 --- a/src/tempo/session/server/index.ts +++ b/src/tempo/session/server/index.ts @@ -3,8 +3,10 @@ export { charge, session, settle, settleBatch } from './Session.js' export * as Sse from './Sse.js' /** Server-side automatic settlement schedule. */ export type { + OnSessionSettlement, ResolveSessionChannelId, ResolveSessionChannelIdParameters, SessionChannelIdRequest, + SessionSettlementContext, SettlementSchedule, } from './Session.js'