Skip to content

Commit efb7230

Browse files
committed
fix: validate autoswap session management calls
1 parent 054c4f6 commit efb7230

2 files changed

Lines changed: 162 additions & 17 deletions

File tree

src/tempo/session/precompile/Chain.test.ts

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
type Hex,
1313
} from 'viem'
1414
import { privateKeyToAccount } from 'viem/accounts'
15-
import { Transaction } from 'viem/tempo'
15+
import { Abis, Addresses, Transaction } from 'viem/tempo'
1616
import { describe, expect, test } from 'vp/test'
1717

1818
import { VerificationFailedError } from '../../../Errors.js'
@@ -36,6 +36,7 @@ const descriptor = {
3636
const deposit = Types.uint96(1_000_000n)
3737
const chainId = 42431
3838
const txHash = `0x${'ab'.repeat(32)}` as const
39+
const sourceToken = '0x6666666666666666666666666666666666666666' as const
3940
const feePayer = privateKeyToAccount(
4041
'0x59c6995e998f97a5a0044966f0945389d1fc6e60e7346d6c36c49d32f75b9a1b',
4142
)
@@ -192,6 +193,28 @@ async function createSerializedTransaction(parameters: {
192193
} as never)) as `0x${string}`
193194
}
194195

196+
function autoSwapCalls(amountOut: bigint = deposit) {
197+
const maxAmountIn = amountOut + 1_000n
198+
return [
199+
{
200+
to: sourceToken,
201+
data: encodeFunctionData({
202+
abi: Abis.tip20,
203+
functionName: 'approve',
204+
args: [Addresses.stablecoinDex, maxAmountIn],
205+
}),
206+
},
207+
{
208+
to: Addresses.stablecoinDex,
209+
data: encodeFunctionData({
210+
abi: Abis.stablecoinDex,
211+
functionName: 'swapExactAmountOut',
212+
args: [sourceToken, descriptor.token, amountOut, maxAmountIn],
213+
}),
214+
},
215+
] as const
216+
}
217+
195218
async function createOpenTransaction(
196219
parameters: {
197220
authorizedSigner?: `0x${string}` | undefined
@@ -201,6 +224,7 @@ async function createOpenTransaction(
201224
signed?: boolean | undefined
202225
token?: `0x${string}` | undefined
203226
to?: `0x${string}` | undefined
227+
prefixCalls?: readonly { to: `0x${string}`; data: `0x${string}` }[] | undefined
204228
} = {},
205229
) {
206230
const data = encodeFunctionData({
@@ -216,7 +240,7 @@ async function createOpenTransaction(
216240
],
217241
})
218242
return createSerializedTransaction({
219-
calls: [{ to: parameters.to ?? tip20ChannelEscrow, data }],
243+
calls: [...(parameters.prefixCalls ?? []), { to: parameters.to ?? tip20ChannelEscrow, data }],
220244
gas: parameters.gas,
221245
signed: parameters.signed,
222246
})
@@ -229,6 +253,7 @@ async function createTopUpTransaction(
229253
gas?: bigint | undefined
230254
signed?: boolean | undefined
231255
to?: `0x${string}` | undefined
256+
prefixCalls?: readonly { to: `0x${string}`; data: `0x${string}` }[] | undefined
232257
} = {},
233258
) {
234259
const data = encodeFunctionData({
@@ -240,7 +265,7 @@ async function createTopUpTransaction(
240265
],
241266
})
242267
return createSerializedTransaction({
243-
calls: [{ to: parameters.to ?? tip20ChannelEscrow, data }],
268+
calls: [...(parameters.prefixCalls ?? []), { to: parameters.to ?? tip20ChannelEscrow, data }],
244269
gas: parameters.gas,
245270
signed: parameters.signed,
246271
})
@@ -446,7 +471,31 @@ describe('precompile broadcastOpenTransaction', () => {
446471
expectedPayer: descriptor.payer,
447472
serializedTransaction,
448473
}),
449-
).rejects.toThrow('TIP-1034 open transaction must contain exactly one call')
474+
).rejects.toThrow(
475+
'TIP-1034 open transaction must contain one management call, optionally preceded by an auto-swap',
476+
)
477+
})
478+
479+
test('rejects an auto-swap whose output does not match the open deposit', async () => {
480+
const serializedTransaction = await createOpenTransaction({
481+
prefixCalls: autoSwapCalls(deposit + 1n),
482+
})
483+
484+
await expect(
485+
Chain.broadcastOpenTransaction({
486+
chainId,
487+
client: createMockClient(),
488+
escrowContract: tip20ChannelEscrow,
489+
expectedAuthorizedSigner: descriptor.authorizedSigner,
490+
expectedChannelId: `0x${'11'.repeat(32)}`,
491+
expectedCurrency: descriptor.token,
492+
expectedExpiringNonceHash: expectedExpiringNonceHash(serializedTransaction),
493+
expectedOperator: descriptor.operator,
494+
expectedPayee: descriptor.payee,
495+
expectedPayer: descriptor.payer,
496+
serializedTransaction,
497+
}),
498+
).rejects.toThrow('TIP-1034 open auto-swap output amount does not match channel deposit')
450499
})
451500

452501
test('rejects open transactions targeting the wrong escrow contract', async () => {
@@ -715,8 +764,8 @@ describe('precompile broadcastOpenTransaction', () => {
715764
).rejects.toThrow('credential expiringNonceHash does not match transaction')
716765
})
717766

718-
test('returns tx hash, descriptor, event fields, and read-back state on success', async () => {
719-
const serializedTransaction = await createOpenTransaction()
767+
test('accepts an exact-output auto-swap before open', async () => {
768+
const serializedTransaction = await createOpenTransaction({ prefixCalls: autoSwapCalls() })
720769
const expiringNonceHash = expectedExpiringNonceHash(serializedTransaction)
721770
const expectedDescriptor = { ...descriptor, expiringNonceHash }
722771
const channelId = Channel.computeId({
@@ -858,7 +907,9 @@ describe('precompile broadcastTopUpTransaction', () => {
858907
expectedCurrency: descriptor.token,
859908
serializedTransaction,
860909
}),
861-
).rejects.toThrow('TIP-1034 topUp transaction must contain exactly one call')
910+
).rejects.toThrow(
911+
'TIP-1034 topUp transaction must contain one management call, optionally preceded by an auto-swap',
912+
)
862913
})
863914

864915
test('rejects top-up transactions targeting the wrong escrow contract', async () => {
@@ -1025,8 +1076,8 @@ describe('precompile broadcastTopUpTransaction', () => {
10251076
).rejects.toThrow('topUp deposit does not match credential')
10261077
})
10271078

1028-
test('returns tx hash, new deposit, and read-back state on success', async () => {
1029-
const serializedTransaction = await createTopUpTransaction()
1079+
test('accepts an exact-output auto-swap before top-up', async () => {
1080+
const serializedTransaction = await createTopUpTransaction({ prefixCalls: autoSwapCalls() })
10301081
const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
10311082
const newDeposit = deposit * 2n
10321083
const state = { settled: 0n, deposit: newDeposit, closeRequestedAt: 0 }

src/tempo/session/precompile/Chain.ts

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Account, Address, Client, Hex } from 'viem'
2-
import { encodeFunctionData, isAddressEqual, parseEventLogs } from 'viem'
2+
import { decodeFunctionData, encodeFunctionData, isAddressEqual, parseEventLogs } from 'viem'
33
import {
44
call,
55
prepareTransactionRequest,
@@ -10,7 +10,7 @@ import {
1010
signTransaction,
1111
waitForTransactionReceipt,
1212
} from 'viem/actions'
13-
import { Transaction } from 'viem/tempo'
13+
import { Abis, Addresses, Transaction } from 'viem/tempo'
1414

1515
import { BadRequestError, VerificationFailedError } from '../../../Errors.js'
1616
import * as FeePayer from '../../internal/fee-payer.js'
@@ -401,6 +401,7 @@ export type ChannelTransactionOptions = {
401401

402402
type ParsedPrecompileCredentialTransaction = {
403403
call: Transaction.TransactionTempo['calls'][number] & { data: Hex; to: Address }
404+
prefixCalls: readonly Transaction.TransactionTempo['calls'][number][]
404405
transaction: ReturnType<(typeof Transaction)['deserialize']>
405406
}
406407

@@ -418,11 +419,11 @@ function parsePrecompileCredentialTransaction(parameters: {
418419
serializedTransaction as Transaction.TransactionSerializedTempo,
419420
)
420421
const calls = transaction.calls
421-
if (calls.length !== 1)
422+
if (calls.length !== 1 && calls.length !== 3)
422423
throw new VerificationFailedError({
423-
reason: `TIP-1034 ${label} transaction must contain exactly one call`,
424+
reason: `TIP-1034 ${label} transaction must contain one management call, optionally preceded by an auto-swap`,
424425
})
425-
const call = calls[0]!
426+
const call = calls.at(-1)!
426427
if (!call.to || !isAddressEqual(call.to, escrowContract))
427428
throw new VerificationFailedError({
428429
reason: `TIP-1034 ${label} transaction targets the wrong address`,
@@ -431,7 +432,88 @@ function parsePrecompileCredentialTransaction(parameters: {
431432
throw new VerificationFailedError({
432433
reason: `TIP-1034 ${label} transaction is missing calldata`,
433434
})
434-
return { transaction, call: { ...call, data: call.data, to: call.to } }
435+
return {
436+
transaction,
437+
call: { ...call, data: call.data, to: call.to },
438+
prefixCalls: calls.slice(0, -1),
439+
}
440+
}
441+
442+
function validateAutoSwapPrefix(parameters: {
443+
amountOut: bigint
444+
currency: Address
445+
label: 'open' | 'topUp'
446+
prefixCalls: readonly Transaction.TransactionTempo['calls'][number][]
447+
}) {
448+
const { amountOut, currency, label, prefixCalls } = parameters
449+
if (prefixCalls.length === 0) return
450+
451+
const fail = (reason: string): never => {
452+
throw new VerificationFailedError({ reason: `TIP-1034 ${label} auto-swap ${reason}` })
453+
}
454+
if (prefixCalls.length !== 2) fail('must contain exactly approve and swap calls')
455+
456+
const approveCall = prefixCalls[0]!
457+
const swapCall = prefixCalls[1]!
458+
const approveTo = approveCall.to
459+
const approveData = approveCall.data
460+
const swapTo = swapCall.to
461+
const swapData = swapCall.data
462+
if (!approveTo || !approveData || !swapTo || !swapData)
463+
fail('call is missing a target or calldata')
464+
const checkedApproveTo = approveTo as Address
465+
const checkedApproveData = approveData as Hex
466+
const checkedSwapTo = swapTo as Address
467+
const checkedSwapData = swapData as Hex
468+
if ((approveCall.value ?? 0n) !== 0n || (swapCall.value ?? 0n) !== 0n)
469+
fail('calls must not transfer native value')
470+
if (!isAddressEqual(checkedSwapTo, Addresses.stablecoinDex)) fail('targets the wrong DEX')
471+
472+
const approve = (() => {
473+
try {
474+
return decodeFunctionData({ abi: Abis.tip20, data: checkedApproveData })
475+
} catch {
476+
return fail('approval calldata is invalid')
477+
}
478+
})()
479+
const swap = (() => {
480+
try {
481+
return decodeFunctionData({ abi: Abis.stablecoinDex, data: checkedSwapData })
482+
} catch {
483+
return fail('swap calldata is invalid')
484+
}
485+
})()
486+
if (approve.functionName !== 'approve' || swap.functionName !== 'swapExactAmountOut')
487+
fail('must contain approve followed by swapExactAmountOut')
488+
489+
const [spender, approvedAmount] = approve.args as readonly [Address, bigint]
490+
const [tokenIn, tokenOut, swapAmountOut, maxAmountIn] = swap.args as readonly [
491+
Address,
492+
Address,
493+
bigint,
494+
bigint,
495+
]
496+
if (!isAddressEqual(checkedApproveTo, tokenIn)) fail('approval token does not match swap input')
497+
if (!isAddressEqual(spender, Addresses.stablecoinDex)) fail('approval spender is not the DEX')
498+
if (approvedAmount !== maxAmountIn) fail('approval amount does not match swap maximum input')
499+
if (!isAddressEqual(tokenOut, currency)) fail('output token does not match channel currency')
500+
if (swapAmountOut !== amountOut) fail('output amount does not match channel deposit')
501+
if (isAddressEqual(tokenIn, tokenOut)) fail('input and output tokens must differ')
502+
503+
const canonicalApprove = encodeFunctionData({
504+
abi: Abis.tip20,
505+
functionName: 'approve',
506+
args: [spender, approvedAmount],
507+
})
508+
const canonicalSwap = encodeFunctionData({
509+
abi: Abis.stablecoinDex,
510+
functionName: 'swapExactAmountOut',
511+
args: [tokenIn, tokenOut, swapAmountOut, maxAmountIn],
512+
})
513+
if (checkedApproveData.toLowerCase() !== canonicalApprove.toLowerCase())
514+
fail('approval calldata is not canonical')
515+
if (checkedSwapData.toLowerCase() !== canonicalSwap.toLowerCase())
516+
fail('swap calldata is not canonical')
435517
}
436518

437519
async function simulateTempoTransaction(client: Client, request: unknown) {
@@ -796,7 +878,7 @@ export type BroadcastOpenTransactionParameters = {
796878
export async function broadcastOpenTransaction(
797879
parameters: BroadcastOpenTransactionParameters,
798880
): Promise<BroadcastOpenTransactionResult> {
799-
const { transaction, call } = parsePrecompileCredentialTransaction({
881+
const { transaction, call, prefixCalls } = parsePrecompileCredentialTransaction({
800882
escrowContract: parameters.escrowContract,
801883
feePayer: parameters.feePayer,
802884
label: 'open',
@@ -812,6 +894,12 @@ export async function broadcastOpenTransaction(
812894
authorizedSigner: parameters.expectedAuthorizedSigner,
813895
},
814896
})
897+
validateAutoSwapPrefix({
898+
amountOut: open.deposit,
899+
currency: parameters.expectedCurrency,
900+
label: 'open',
901+
prefixCalls,
902+
})
815903
const descriptor = ChannelOps.descriptorFromOpen({
816904
chainId: parameters.chainId,
817905
escrow: parameters.escrowContract,
@@ -920,7 +1008,7 @@ export type BroadcastTopUpTransactionParameters = {
9201008
export async function broadcastTopUpTransaction(
9211009
parameters: BroadcastTopUpTransactionParameters,
9221010
): Promise<BroadcastTopUpTransactionResult> {
923-
const { transaction, call } = parsePrecompileCredentialTransaction({
1011+
const { transaction, call, prefixCalls } = parsePrecompileCredentialTransaction({
9241012
escrowContract: parameters.escrowContract,
9251013
feePayer: parameters.feePayer,
9261014
label: 'topUp',
@@ -933,6 +1021,12 @@ export async function broadcastTopUpTransaction(
9331021
additionalDeposit: parameters.additionalDeposit,
9341022
},
9351023
})
1024+
validateAutoSwapPrefix({
1025+
amountOut: parameters.additionalDeposit,
1026+
currency: parameters.expectedCurrency,
1027+
label: 'topUp',
1028+
prefixCalls,
1029+
})
9361030
const receipt = await sendCredentialTransaction({
9371031
challengeExpires: parameters.challengeExpires,
9381032
chainId: parameters.chainId,

0 commit comments

Comments
 (0)