accounts
Version:
Tempo Accounts SDK
1,568 lines (1,398 loc) • 62.8 kB
text/typescript
import type { RpcRequest } from 'ox'
import { SignatureEnvelope, TxEnvelopeTempo } from 'ox/tempo'
import { parseUnits, type Address, type BaseError } from 'viem'
import { fillTransaction, sendTransactionSync } from 'viem/actions'
import { tempo, tempoModerato } from 'viem/chains'
import { Actions, Addresses, Capabilities, Tick, Transaction, VirtualAddress } from 'viem/tempo'
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vp/test'
import { accounts, addresses, chain, getClient, http } from '../../../../test/config.js'
import { createServer, type Server } from '../../../../test/utils.js'
import { relay } from './relay.js'
const userAccount = accounts[9]!
const feePayerAccount = accounts[0]!
const recipient = accounts[7]!
/**
* Tokens the relay handler probes for fee-token resolution. The default
* `resolveTokens` fetches `tokenlist.tempo.xyz`, which doesn't know about
* the localnet chain — so tests inject this list explicitly.
*/
const localnetTokens = [
{
address: '0x20c0000000000000000000000000000000000000',
decimals: 6,
name: 'pathUSD',
symbol: 'pathUSD',
},
{
address: '0x20c0000000000000000000000000000000000001',
decimals: 6,
name: 'alphaUSD',
symbol: 'alphaUSD',
},
{
address: '0x20c0000000000000000000000000000000000002',
decimals: 6,
name: 'betaUSD',
symbol: 'betaUSD',
},
{
address: '0x20c0000000000000000000000000000000000003',
decimals: 6,
name: 'thetaUSD',
symbol: 'thetaUSD',
},
] as const
/** Case-insensitive lookup into balanceDiffs keyed by address. */
function findDiffs(
balanceDiffs: Capabilities.FillTransactionCapabilities['balanceDiffs'],
address: string,
) {
return Object.entries(balanceDiffs ?? {}).find(
([addr]) => addr.toLowerCase() === address.toLowerCase(),
)?.[1]
}
/** Extracts relay virtual-address metadata while viem's public type catches up. */
function virtualAddresses(capabilities: Capabilities.FillTransactionCapabilities | undefined) {
return (
capabilities as
| (Capabilities.FillTransactionCapabilities & {
virtualAddresses?: Record<Address, Address | null> | undefined
})
| undefined
)?.virtualAddresses
}
/** A simple transfer call for tests that just need a valid transaction. */
const transferCall = () =>
Actions.token.transfer.call({
token: addresses.alphaUsd,
to: recipient.address,
amount: 1n,
})
beforeAll(async () => {
// Fund userAccount with alphaUsd for fees + transfers.
const rpc = getClient()
await Actions.token.mintSync(rpc, {
account: accounts[0]!,
token: addresses.alphaUsd,
amount: parseUnits('100', 6),
to: userAccount.address,
})
await Actions.fee.setUserToken(rpc, { account: userAccount, token: addresses.alphaUsd })
})
describe('default', () => {
let client: ReturnType<typeof getClient<typeof chain>>
let server: Server
beforeAll(async () => {
server = await createServer(
relay({
chains: [chain],
transports: { [chain.id]: http() },
}).listener,
)
client = getClient({ transport: http(server.url) })
})
afterAll(() => {
server.close()
})
test('default: returns filled transaction with capabilities', async () => {
const { transaction } = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
})
expect(transaction.gas).toBeDefined()
expect(transaction.nonce).toBeDefined()
})
test('behavior: proxies other methods to RPC node', async () => {
const chainId = await client.request({ method: 'eth_chainId' })
expect(Number(chainId)).toMatchInlineSnapshot(`${chain.id}`)
})
test('behavior: surfaces upstream RPC errors as JSON-RPC errors', async () => {
// grantRoles reverts with Unauthorized when caller is not an admin
// (eth_call defaults `from` to the zero address). We expect the relay to
// forward the revert as a structured JSON-RPC error response (HTTP 200
// with `error.code`/`error.data`), not a 500.
const call = Actions.token.grantRoles.call({
token: addresses.alphaUsd,
role: 'issuer',
to: recipient.address,
})
const response = await fetch(server.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'eth_call',
params: [{ to: call.to, data: call.data }, 'latest'],
}),
})
expect(response.status).toBe(200)
const body = (await response.json()) as {
id: number
jsonrpc: string
error: { code: number; message: string; data?: string }
}
// Drop `message` from the snapshot — it embeds the upstream RPC URL/port
// and viem version, which are nondeterministic.
const { message: _message, ...errorRest } = body.error
expect({ ...body, error: errorRest }).toMatchInlineSnapshot(`
{
"error": {
"code": 3,
"data": "0x82b42900",
},
"id": 1,
"jsonrpc": "2.0",
}
`)
})
test('behavior: returns actionable error for eth_signRawTransaction without feePayer', async () => {
const response = await fetch(server.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'eth_signRawTransaction',
params: ['0x00'],
}),
})
expect(response.status).toBe(200)
const body = (await response.json()) as {
id: number
jsonrpc: string
error: { code: number; message: string }
}
expect(body.error.code).toBe(-32601)
expect(body.error.message).toContain('fee payer')
expect(body.error.message).toContain('Handler.relay()')
})
test('behavior: handles JSON-RPC batch requests', async () => {
const response = await fetch(server.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([
{ jsonrpc: '2.0', id: 1, method: 'eth_chainId', params: [] },
{ jsonrpc: '2.0', id: 2, method: 'eth_chainId', params: [] },
]),
})
expect(response.status).toBe(200)
const body = (await response.json()) as { id: number; result: string }[]
expect(Array.isArray(body)).toBe(true)
expect(body).toHaveLength(2)
expect(body[0]!.id).toBe(1)
expect(body[1]!.id).toBe(2)
expect(Number(body[0]!.result)).toBe(chain.id)
expect(Number(body[1]!.result)).toBe(chain.id)
})
})
describe('behavior: with feePayer', () => {
let server: Server
let client: ReturnType<typeof getClient<typeof chain>>
let requests: RpcRequest.RpcRequest[] = []
beforeAll(async () => {
server = await createServer(
relay({
chains: [chain],
transports: { [chain.id]: http() },
feePayer: {
account: feePayerAccount,
name: 'Test Sponsor',
url: 'https://test.com',
},
onRequest: async (request) => {
requests.push(request)
},
}).listener,
)
client = getClient({ transport: http(server.url) })
})
afterAll(() => {
server.close()
})
afterEach(() => {
requests = []
})
test('default: returns sponsored tx with feePayerSignature', async () => {
const { transaction } = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
})
expect(transaction.feePayerSignature).toBeDefined()
expect(requests.map(({ method }) => method)).toMatchInlineSnapshot(`
[
"eth_fillTransaction",
]
`)
})
test('behavior: returns sponsor capabilities', async () => {
const result = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
})
const meta = result.capabilities
expect(meta?.sponsored).toBe(true)
expect(meta?.sponsor).toMatchInlineSnapshot(`
{
"address": "${feePayerAccount.address}",
"name": "Test Sponsor",
"url": "https://test.com",
}
`)
})
test('behavior: sponsored tx can be signed and broadcast', async () => {
const { transaction } = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
})
const serialized = (await Transaction.serialize(transaction as never)) as `0x76${string}`
const envelope = TxEnvelopeTempo.deserialize(serialized)
const signature = await userAccount.sign({
hash: TxEnvelopeTempo.getSignPayload(envelope),
})
const signed = TxEnvelopeTempo.serialize(envelope, {
signature: SignatureEnvelope.from(signature),
})
const receipt = (await getClient().request({
method: 'eth_sendRawTransactionSync' as never,
params: [signed],
})) as { feePayer?: string | undefined }
expect(receipt.feePayer).toBe(feePayerAccount.address.toLowerCase())
})
test('behavior: missing from returns error capability when errors capability is enabled', async () => {
const result = await fillTransaction(client, {
calls: [transferCall()],
capabilities: { errors: true },
})
expect(result.capabilities).toMatchInlineSnapshot(`
{
"error": {
"errorName": "unknown",
"message": "unknown account",
},
"sponsored": false,
}
`)
})
})
describe('behavior: with feePayer.feeToken', () => {
let server: Server
let client: ReturnType<typeof getClient<typeof chain>>
const sponsorFeeToken = '0x20c0000000000000000000000000000000000000' as const // pathUSD
beforeAll(async () => {
server = await createServer(
relay({
chains: [chain],
transports: { [chain.id]: http() },
feePayer: {
account: feePayerAccount,
feeToken: sponsorFeeToken,
},
resolveTokens: () => localnetTokens,
}).listener,
)
client = getClient({ transport: http(server.url) })
})
afterAll(() => {
server.close()
})
test('default: sponsor.feeToken is used when request omits feeToken', async () => {
const { transaction } = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
})
expect(transaction.feeToken?.toLowerCase()).toBe(sponsorFeeToken)
expect(transaction.feePayerSignature).toBeDefined()
})
test('behavior: sponsor.feeToken overrides request feeToken on sponsored fills', async () => {
const { transaction } = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
feeToken: addresses.alphaUsd as Address,
})
expect(transaction.feeToken?.toLowerCase()).toBe(sponsorFeeToken)
expect(transaction.feePayerSignature).toBeDefined()
})
test('behavior: request feeToken wins when sponsorship is opted out via feePayer:false', async () => {
const { transaction } = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
feePayer: false as never,
feeToken: addresses.alphaUsd as Address,
})
expect(transaction.feeToken?.toLowerCase()).toBe(addresses.alphaUsd.toLowerCase())
expect(transaction.feePayerSignature).toBeUndefined()
})
test('behavior: broadcast tx receipt records the sponsor.feeToken', async () => {
// Fill via relay (where override kicks in), then sign + broadcast manually.
// viem's `sendTransactionSync` with a hydrated account does local prep and
// would skip the relay's `eth_fillTransaction`, bypassing the override.
const { transaction } = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
feeToken: addresses.alphaUsd as Address,
})
expect(transaction.feeToken?.toLowerCase()).toBe(sponsorFeeToken)
const serialized = (await Transaction.serialize(transaction as never)) as `0x76${string}`
const envelope = TxEnvelopeTempo.deserialize(serialized)
const signature = await userAccount.sign({
hash: TxEnvelopeTempo.getSignPayload(envelope),
})
const signed = TxEnvelopeTempo.serialize(envelope, {
signature: SignatureEnvelope.from(signature),
})
const receipt = (await getClient().request({
method: 'eth_sendRawTransactionSync' as never,
params: [signed],
})) as { feePayer?: string | undefined; feeToken?: string | undefined }
expect(receipt.feePayer).toBe(feePayerAccount.address.toLowerCase())
expect(receipt.feeToken?.toLowerCase()).toBe(sponsorFeeToken)
})
})
describe('behavior: with app-provided feePayer URL', () => {
let appServer: Server
let walletServer: Server
let client: ReturnType<typeof getClient<typeof chain>>
beforeAll(async () => {
// App relay: has a fee payer account and signs transactions.
appServer = await createServer(
relay({
chains: [chain],
transports: { [chain.id]: http() },
feePayer: {
account: feePayerAccount,
name: 'App Sponsor',
url: 'https://app.example.com',
},
}).listener,
)
// Wallet relay: no fee payer configured — proxies to app relay.
walletServer = await createServer(
relay({
chains: [chain],
transports: { [chain.id]: http() },
}).listener,
)
client = getClient({ transport: http(walletServer.url) })
})
afterAll(() => {
appServer.close()
walletServer.close()
})
test('default: proxies fill to app relay and returns sponsored tx', async () => {
const { transaction } = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
feePayer: appServer.url as never,
})
expect(transaction.feePayerSignature).toBeDefined()
expect(transaction.gas).toBeDefined()
})
test('behavior: relays sponsor metadata from app relay', async () => {
const result = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
feePayer: appServer.url as never,
})
expect(result.capabilities?.sponsored).toBe(true)
expect(result.capabilities?.sponsor).toMatchInlineSnapshot(`
{
"address": "${feePayerAccount.address}",
"name": "App Sponsor",
"url": "https://app.example.com",
}
`)
})
test('behavior: sponsored tx from app relay can be signed and broadcast', async () => {
const { transaction } = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
feePayer: appServer.url as never,
})
const serialized = (await Transaction.serialize(transaction as never)) as `0x76${string}`
const envelope = TxEnvelopeTempo.deserialize(serialized)
const signature = await userAccount.sign({
hash: TxEnvelopeTempo.getSignPayload(envelope),
})
const signed = TxEnvelopeTempo.serialize(envelope, {
signature: SignatureEnvelope.from(signature),
})
const receipt = (await getClient().request({
method: 'eth_sendRawTransactionSync' as never,
params: [signed],
})) as { feePayer?: string | undefined }
expect(receipt.feePayer).toBe(feePayerAccount.address.toLowerCase())
})
})
describe('behavior: with app-provided feePayer URL + autoSwap', () => {
let appServer: Server
let walletServer: Server
let client: ReturnType<typeof getClient<typeof chain>>
beforeAll(async () => {
// App relay sponsors fees AND has `features: 'all'` so it can recover
// from InsufficientBalance via autoSwap.
appServer = await createServer(
relay({
chains: [chain],
features: 'all',
transports: { [chain.id]: http() },
feePayer: {
account: feePayerAccount,
name: 'App Sponsor',
url: 'https://app.example.com',
},
}).listener,
)
// Wallet relay forwards to the app relay; also has features:'all' so its
// own fill() can detect upstream `capabilities.error` as InsufficientBalance.
walletServer = await createServer(
relay({
chains: [chain],
features: 'all',
transports: { [chain.id]: http() },
}).listener,
)
client = getClient({ transport: http(walletServer.url) })
})
afterAll(() => {
appServer.close()
walletServer.close()
})
test('behavior: autoSwap recovers when external feePayer surfaces InsufficientBalance', async () => {
const sender = accounts[6]!
// Token pair + DEX liquidity. Use alphaUsd as the quote token so the
// relay can swap alphaUsd → base to cover the deficit.
const rpc = getClient({ account: accounts[0]! })
const { token: base } = await Actions.token.createSync(rpc, {
name: 'External Swap Base',
symbol: 'EXTBASE',
currency: 'USD',
quoteToken: addresses.alphaUsd,
})
await sendTransactionSync(rpc, {
calls: [
Actions.token.grantRoles.call({ token: base, role: 'issuer', to: rpc.account!.address }),
Actions.token.mint.call({
token: base,
to: rpc.account!.address,
amount: parseUnits('10000', 6),
}),
Actions.token.mint.call({
token: addresses.alphaUsd,
to: rpc.account!.address,
amount: parseUnits('10000', 6),
}),
Actions.token.approve.call({
token: base,
spender: Addresses.stablecoinDex,
amount: parseUnits('10000', 6),
}),
Actions.token.approve.call({
token: addresses.alphaUsd,
spender: Addresses.stablecoinDex,
amount: parseUnits('10000', 6),
}),
],
})
await Actions.dex.createPairSync(rpc, { base })
await Actions.dex.placeSync(rpc, {
token: base,
amount: parseUnits('500', 6),
type: 'sell',
tick: Tick.fromPrice('1.001'),
})
// Give sender alphaUsd (fee + swap source) but NO base tokens.
await Actions.token.mintSync(rpc, {
token: addresses.alphaUsd,
amount: parseUnits('1000', 6),
to: sender.address,
})
await Actions.fee.setUserToken(getClient({ account: sender }), { token: addresses.alphaUsd })
// Sender attempts to transfer base via the wallet relay, which forwards
// to the app relay. The app relay returns 200 with capabilities.error =
// InsufficientBalance and a stub tx; the wallet relay must convert that
// into a synthetic throw so its own fill() autoSwap branch can recover.
const transferAmount = parseUnits('5', 6)
const result = await fillTransaction(client, {
account: sender.address,
...Actions.token.transfer.call({
token: base,
to: accounts[7]!.address,
amount: transferAmount,
}),
feePayer: appServer.url as never,
})
const { transaction, capabilities } = result
// Tx is filled with the swap calls prepended (approve + buy + transfer).
expect(transaction.calls).toHaveLength(3)
expect(transaction.feePayerSignature).toBeDefined()
// autoSwap metadata is surfaced.
expect(capabilities?.autoSwap?.slippage).toBe(0.05)
expect(capabilities?.autoSwap?.maxIn.symbol).toBe('AlphaUSD')
expect(capabilities?.autoSwap?.minOut.symbol).toBe('EXTBASE')
expect(capabilities?.autoSwap?.minOut.formatted).toBe('5')
})
})
describe('behavior: app-provided feePayer URL bypasses wallet validate', () => {
let appServer: Server
let walletServer: Server
let client: ReturnType<typeof getClient<typeof chain>>
beforeAll(async () => {
// App relay is the authoritative sponsor — it has its own fee payer
// account and signs sponsored transactions.
appServer = await createServer(
relay({
chains: [chain],
transports: { [chain.id]: http() },
feePayer: {
account: feePayerAccount,
name: 'App Sponsor',
url: 'https://app.example.com',
},
}).listener,
)
// Wallet relay has its own fee payer with a `validate` that ALWAYS
// rejects. This guards the wallet's own fee payer; it must NOT gate
// sponsorship when the dapp supplies its own external feePayer URL.
walletServer = await createServer(
relay({
chains: [chain],
features: 'all',
transports: { [chain.id]: http() },
feePayer: {
account: feePayerAccount,
name: 'Wallet Sponsor',
validate: () => false,
},
}).listener,
)
client = getClient({ transport: http(walletServer.url) })
})
afterAll(() => {
appServer.close()
walletServer.close()
})
test('behavior: external feePayer URL is sponsored even when wallet validate rejects', async () => {
const result = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
feePayer: appServer.url as never,
})
expect(result.transaction.feePayerSignature).toBeDefined()
expect(result.transaction.maxFeePerGas).toBeDefined()
expect(result.transaction.maxFeePerGas).not.toBe(0n)
expect(result.capabilities?.sponsored).toBe(true)
expect(result.capabilities?.sponsor?.name).toBe('App Sponsor')
})
})
describe('behavior: chainId path parameter', () => {
let server: Server
let client: ReturnType<typeof getClient<typeof chain>>
beforeAll(async () => {
server = await createServer(
relay({
chains: [chain],
transports: { [chain.id]: http() },
}).listener,
)
client = getClient({ transport: http(`${server.url}/${chain.id}`) })
})
afterAll(() => {
server.close()
})
test('default: proxies RPC methods via /:chainId path', async () => {
const chainId = await client.request({ method: 'eth_chainId' })
expect(Number(chainId)).toMatchInlineSnapshot(`${chain.id}`)
})
test('behavior: fills transaction via /:chainId path', async () => {
const { transaction } = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
})
expect(transaction.gas).toBeDefined()
expect(transaction.nonce).toBeDefined()
})
test('behavior: handles batch requests via /:chainId path', async () => {
const response = await fetch(`${server.url}/${chain.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([
{ jsonrpc: '2.0', id: 1, method: 'eth_chainId', params: [] },
{ jsonrpc: '2.0', id: 2, method: 'eth_chainId', params: [] },
]),
})
expect(response.status).toBe(200)
const body = (await response.json()) as { id: number; result: string }[]
expect(body).toHaveLength(2)
expect(Number(body[0]!.result)).toBe(chain.id)
expect(Number(body[1]!.result)).toBe(chain.id)
})
})
describe('behavior: capabilities', () => {
let server: Server
let client: ReturnType<typeof getClient<typeof chain>>
beforeAll(async () => {
server = await createServer(
relay({
chains: [chain],
features: 'all',
transports: { [chain.id]: http() },
}).listener,
)
client = getClient({ transport: http(server.url) })
})
afterAll(() => {
server.close()
})
test('default: returns fee and sponsored info', async () => {
const result = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
})
const meta = result.capabilities
expect(meta?.fee).toBeDefined()
expect(meta?.fee?.decimals).toBe(6)
expect(meta?.fee?.symbol).toBe('AlphaUSD')
expect(meta?.sponsored).toBe(false)
})
test('behavior: token transfer produces balance diffs', async () => {
const sender = accounts[6]!
const recipient = accounts[7]!
const token = addresses.alphaUsd
// Mint tokens to sender (enough for transfer + fee).
const rpc = getClient()
await Actions.token.mintSync(rpc, {
account: accounts[0]!,
token,
amount: 1_000_000n,
to: sender.address,
})
// Set fee token so relay doesn't need pathUSD balance.
await Actions.fee.setUserToken(rpc, { account: sender, token })
const { data, to: callTo } = Actions.token.transfer.call({
token,
to: recipient.address,
amount: 100n,
})
const result = await fillTransaction(client, {
account: sender.address,
to: callTo,
data,
})
const meta = result.capabilities
const senderDiffs = findDiffs(meta?.balanceDiffs, sender.address)!
const tokenDiff = senderDiffs.find((d) => d.address.toLowerCase() === token.toLowerCase())!
expect(tokenDiff.decimals).toBe(6)
expect(tokenDiff.direction).toBe('outgoing')
expect(tokenDiff.formatted).toBe('0.0001')
expect(tokenDiff.symbol).toBe('AlphaUSD')
expect(tokenDiff.value).toBe('0x64')
})
test('behavior: resolves direct virtual-address targets', async () => {
const virtualAddress = VirtualAddress.from({
masterId: '0xffffffff',
userTag: '0x000000000001',
})
const result = await fillTransaction(client, {
account: userAccount.address,
to: virtualAddress,
})
expect(virtualAddresses(result.capabilities)).toMatchInlineSnapshot(`
{
"0xfffffffffdfdfdfdfdfdfdfdfdfd000000000001": null,
}
`)
})
test('behavior: resolves TIP-20 memo transfer virtual-address recipients', async () => {
const virtualAddress = VirtualAddress.from({
masterId: '0xfffffffe',
userTag: '0x000000000002',
})
const result = await fillTransaction(client, {
account: userAccount.address,
calls: [
Actions.token.transfer.call({
amount: 1n,
memo: '0x01',
to: virtualAddress,
token: addresses.alphaUsd,
}),
],
capabilities: { errors: true },
})
expect(virtualAddresses(result.capabilities)).toMatchInlineSnapshot(`
{
"0xfffffffefdfdfdfdfdfdfdfdfdfd000000000002": null,
}
`)
})
test('behavior: approve + dex swap + transfer produces balance diffs', async () => {
const sender = accounts[8]!
const recipient = accounts[7]!
// Set up token pair + DEX liquidity.
const rpc = getClient({ account: accounts[0]! })
const { token: quote } = await Actions.token.createSync(rpc, {
name: 'Test Quote',
symbol: 'TQUOTE',
currency: 'USD',
})
const { token: base } = await Actions.token.createSync(rpc, {
name: 'Test Base',
symbol: 'TBASE',
currency: 'USD',
quoteToken: quote,
})
await sendTransactionSync(rpc, {
calls: [
Actions.token.grantRoles.call({ token: base, role: 'issuer', to: rpc.account!.address }),
Actions.token.grantRoles.call({ token: quote, role: 'issuer', to: rpc.account!.address }),
Actions.token.mint.call({
token: base,
to: rpc.account!.address,
amount: parseUnits('10000', 6),
}),
Actions.token.mint.call({
token: quote,
to: rpc.account!.address,
amount: parseUnits('10000', 6),
}),
Actions.token.approve.call({
token: base,
spender: Addresses.stablecoinDex,
amount: parseUnits('10000', 6),
}),
Actions.token.approve.call({
token: quote,
spender: Addresses.stablecoinDex,
amount: parseUnits('10000', 6),
}),
],
})
await Actions.dex.createPairSync(rpc, { base })
await Actions.dex.placeSync(rpc, {
token: base,
amount: parseUnits('500', 6),
type: 'sell',
tick: Tick.fromPrice('1.001'),
})
// Fund sender with quote tokens + fee tokens.
await Actions.token.mintSync(rpc, {
token: quote,
amount: parseUnits('1000', 6),
to: sender.address,
})
await Actions.token.mintSync(rpc, {
token: addresses.alphaUsd,
amount: parseUnits('1000', 6),
to: sender.address,
})
await Actions.fee.setUserToken(getClient({ account: sender }), { token: addresses.alphaUsd })
const buyAmount = parseUnits('10', 6)
const result = await fillTransaction(client, {
account: sender.address,
calls: [
Actions.token.approve.call({
token: quote,
spender: Addresses.stablecoinDex,
amount: parseUnits('100', 6),
}),
Actions.dex.buy.call({
tokenIn: quote,
tokenOut: base,
amountOut: buyAmount,
maxAmountIn: parseUnits('100', 6),
}),
Actions.token.transfer.call({
token: base,
to: recipient.address,
amount: buyAmount,
}),
],
})
const meta = result.capabilities
const diffs = findDiffs(meta?.balanceDiffs, sender.address)!
expect(diffs).toHaveLength(1)
const quoteDiff = diffs[0]!
expect(quoteDiff.address.toLowerCase()).toBe(quote.toLowerCase())
expect(quoteDiff.direction).toBe('outgoing')
expect(quoteDiff.symbol).toBe('TQUOTE')
expect(quoteDiff.name).toBe('Test Quote')
expect(quoteDiff.decimals).toBe(6)
// No base diff — bought and immediately transferred out (net zero).
expect(diffs.find((d) => d.address.toLowerCase() === base.toLowerCase())).toBeUndefined()
})
test('behavior: approval covered by transfer is suppressed', async () => {
const sender = accounts[6]!
const recipient = accounts[7]!
const token = addresses.alphaUsd
// approve(100) + transfer(100) to same spender → approval fully covered.
const result = await fillTransaction(client, {
account: sender.address,
calls: [
Actions.token.approve.call({
token,
spender: recipient.address,
amount: 100n,
}),
Actions.token.transfer.call({
token,
to: recipient.address,
amount: 100n,
}),
],
})
const meta = result.capabilities
const diffs = findDiffs(meta?.balanceDiffs, sender.address)!
const tokenDiff = diffs.find((d) => d.address.toLowerCase() === token.toLowerCase())!
// Only the transfer shows — approval is fully covered.
expect(tokenDiff.value).toBe('0x64')
expect(tokenDiff.direction).toBe('outgoing')
})
test('behavior: uncovered approval shows as outgoing', async () => {
const sender = accounts[6]!
const spender = accounts[7]!
const token = addresses.alphaUsd
// approve(200) + transfer(50) to same spender → 150 uncovered approval.
const result = await fillTransaction(client, {
account: sender.address,
calls: [
Actions.token.approve.call({
token,
spender: spender.address,
amount: 200n,
}),
Actions.token.transfer.call({
token,
to: spender.address,
amount: 50n,
}),
],
})
const meta = result.capabilities
const diffs = findDiffs(meta?.balanceDiffs, sender.address)!
const tokenDiff = diffs.find((d) => d.address.toLowerCase() === token.toLowerCase())!
// transfer(50) + uncovered approval(150) = 200 outgoing.
expect(tokenDiff.value).toBe('0xc8')
expect(tokenDiff.direction).toBe('outgoing')
})
})
describe('behavior: AMM resolution', () => {
let server: Server
let client: ReturnType<typeof getClient<typeof chain>>
beforeAll(async () => {
server = await createServer(
relay({
chains: [chain],
features: 'all',
transports: { [chain.id]: http() },
}).listener,
)
client = getClient({ transport: http(server.url) })
})
afterAll(() => {
server.close()
})
test('behavior: prepends swap calls on InsufficientBalance', async () => {
const sender = accounts[4]!
// Set up token pair + DEX liquidity.
// Use alphaUsd as the quote token so the relay can swap alphaUsd → base.
const rpc = getClient({ account: accounts[0]! })
const { token: base } = await Actions.token.createSync(rpc, {
name: 'Swap Base',
symbol: 'SWBASE',
currency: 'USD',
quoteToken: addresses.alphaUsd,
})
await sendTransactionSync(rpc, {
calls: [
Actions.token.grantRoles.call({ token: base, role: 'issuer', to: rpc.account!.address }),
Actions.token.mint.call({
token: base,
to: rpc.account!.address,
amount: parseUnits('10000', 6),
}),
Actions.token.mint.call({
token: addresses.alphaUsd,
to: rpc.account!.address,
amount: parseUnits('10000', 6),
}),
Actions.token.approve.call({
token: base,
spender: Addresses.stablecoinDex,
amount: parseUnits('10000', 6),
}),
Actions.token.approve.call({
token: addresses.alphaUsd,
spender: Addresses.stablecoinDex,
amount: parseUnits('10000', 6),
}),
],
})
await Actions.dex.createPairSync(rpc, { base })
await Actions.dex.placeSync(rpc, {
token: base,
amount: parseUnits('500', 6),
type: 'sell',
tick: Tick.fromPrice('1.001'),
})
// Give sender alphaUsd (fee token) but NO base tokens.
await Actions.token.mintSync(rpc, {
token: addresses.alphaUsd,
amount: parseUnits('1000', 6),
to: sender.address,
})
await Actions.fee.setUserToken(getClient({ account: sender }), { token: addresses.alphaUsd })
// Sender tries to transfer base tokens they don't have.
// Relay should detect InsufficientBalance, swap alphaUsd → base via DEX, and retry.
const transferAmount = parseUnits('5', 6)
const result = await fillTransaction(client, {
account: sender.address,
...Actions.token.transfer.call({
token: base,
to: accounts[7]!.address,
amount: transferAmount,
}),
})
// Should succeed — relay auto-swapped quote → base.
const { transaction, capabilities } = result
expect(transaction.gas).toBeDefined()
expect(transaction.nonce).toBeDefined()
expect(transaction.feeToken).toBe(addresses.alphaUsd)
expect(transaction.calls).toHaveLength(3) // approve + swap + transfer
const m = capabilities
expect(m?.sponsored).toBe(false)
expect(m?.fee?.decimals).toBe(6)
expect(m?.fee?.symbol).toBe('AlphaUSD')
// Balance diffs exclude swap tokens — only the user's transfer shows.
const diffs = findDiffs(m?.balanceDiffs, sender.address)!
expect(diffs).toHaveLength(1)
expect(diffs[0]!.direction).toBe('outgoing')
expect(diffs[0]!.formatted).toBe('5')
expect(diffs[0]!.symbol).toBe('SWBASE')
expect(diffs[0]!.address.toLowerCase()).toBe(base.toLowerCase())
// autoSwap reports the injected AMM swap.
expect(m?.autoSwap?.slippage).toBe(0.05)
expect(m?.autoSwap?.maxIn.formatted).toBe('5.25')
expect(m?.autoSwap?.maxIn.symbol).toBe('AlphaUSD')
expect(m?.autoSwap?.maxIn.token.toLowerCase()).toBe(addresses.alphaUsd.toLowerCase())
expect(m?.autoSwap?.minOut.formatted).toBe('5')
expect(m?.autoSwap?.minOut.symbol).toBe('SWBASE')
expect(m?.autoSwap?.minOut.token.toLowerCase()).toBe(base.toLowerCase())
})
test('behavior: custom slippage is applied to autoSwap', async () => {
const sender = accounts[2]!
// Set up token pair + DEX liquidity.
const rpc = getClient({ account: accounts[0]! })
const { token: base } = await Actions.token.createSync(rpc, {
name: 'Slippage Base',
symbol: 'SLPBASE',
currency: 'USD',
quoteToken: addresses.alphaUsd,
})
await sendTransactionSync(rpc, {
calls: [
Actions.token.grantRoles.call({ token: base, role: 'issuer', to: rpc.account!.address }),
Actions.token.mint.call({
token: base,
to: rpc.account!.address,
amount: parseUnits('10000', 6),
}),
Actions.token.mint.call({
token: addresses.alphaUsd,
to: rpc.account!.address,
amount: parseUnits('10000', 6),
}),
Actions.token.approve.call({
token: base,
spender: Addresses.stablecoinDex,
amount: parseUnits('10000', 6),
}),
Actions.token.approve.call({
token: addresses.alphaUsd,
spender: Addresses.stablecoinDex,
amount: parseUnits('10000', 6),
}),
],
})
await Actions.dex.createPairSync(rpc, { base })
await Actions.dex.placeSync(rpc, {
token: base,
amount: parseUnits('500', 6),
type: 'sell',
tick: Tick.fromPrice('1.001'),
})
// Give sender alphaUsd but NO base tokens.
await Actions.token.mintSync(rpc, {
token: addresses.alphaUsd,
amount: parseUnits('1000', 6),
to: sender.address,
})
await Actions.fee.setUserToken(getClient({ account: sender }), { token: addresses.alphaUsd })
// Create relay with custom 2% slippage.
const customServer = await createServer(
relay({
chains: [chain],
features: 'all',
transports: { [chain.id]: http() },
autoSwap: { slippage: 0.02 },
}).listener,
)
const customClient = getClient({ transport: http(customServer.url) })
const result = await fillTransaction(customClient, {
account: sender.address,
...Actions.token.transfer.call({
token: base,
to: accounts[7]!.address,
amount: parseUnits('10', 6),
}),
})
customServer.close()
const m = result.capabilities
expect(m?.autoSwap?.slippage).toBe(0.02)
// 10 + 2% = 10.2
expect(m?.autoSwap?.maxIn.formatted).toBe('10.2')
expect(m?.autoSwap?.minOut.formatted).toBe('10')
})
test('behavior: autoSwap disabled throws InsufficientBalance instead of swapping', async () => {
const sender = accounts[3]!
// Set up token pair + DEX liquidity.
const rpc = getClient({ account: accounts[0]! })
const { token: base } = await Actions.token.createSync(rpc, {
name: 'No Swap Base',
symbol: 'NSWBASE',
currency: 'USD',
quoteToken: addresses.alphaUsd,
})
await sendTransactionSync(rpc, {
calls: [
Actions.token.grantRoles.call({ token: base, role: 'issuer', to: rpc.account!.address }),
Actions.token.mint.call({
token: base,
to: rpc.account!.address,
amount: parseUnits('10000', 6),
}),
Actions.token.approve.call({
token: base,
spender: Addresses.stablecoinDex,
amount: parseUnits('10000', 6),
}),
],
})
await Actions.dex.createPairSync(rpc, { base })
await Actions.dex.placeSync(rpc, {
token: base,
amount: parseUnits('500', 6),
type: 'sell',
tick: Tick.fromPrice('1.001'),
})
// Give sender alphaUsd but NO base tokens.
await Actions.token.mintSync(rpc, {
token: addresses.alphaUsd,
amount: parseUnits('1000', 6),
to: sender.address,
})
await Actions.fee.setUserToken(getClient({ account: sender }), { token: addresses.alphaUsd })
// Create relay with autoSwap disabled.
const customServer = await createServer(
relay({
chains: [chain],
features: 'all',
transports: { [chain.id]: http() },
autoSwap: false,
}).listener,
)
const customClient = getClient({ transport: http(customServer.url) })
// Should return error capability instead of auto-swapping.
const result = await fillTransaction(customClient, {
account: sender.address,
calls: [
Actions.token.transfer.call({
token: base,
to: accounts[7]!.address,
amount: parseUnits('5', 6),
}),
],
capabilities: { errors: true },
})
const error = result.capabilities?.error
expect({ ...error, data: undefined }).toMatchInlineSnapshot(`
{
"abiItem": {
"inputs": [
{
"name": "available",
"type": "uint256",
},
{
"name": "required",
"type": "uint256",
},
{
"name": "token",
"type": "address",
},
],
"name": "InsufficientBalance",
"type": "error",
},
"data": undefined,
"errorName": "InsufficientBalance",
"message": "Insufficient balance. Required: 5000000, available: 0.",
}
`)
customServer.close()
})
})
describe('behavior: conditional sponsoring', () => {
let server: Server
let client: ReturnType<typeof getClient<typeof chain>>
beforeAll(async () => {
// Fund accounts[3] with alphaUsd so transfers succeed.
const rpc = getClient()
await Actions.token.mintSync(rpc, {
account: accounts[0]!,
token: addresses.alphaUsd,
amount: parseUnits('100', 6),
to: accounts[3]!.address,
})
await Actions.fee.setUserToken(rpc, { account: accounts[3]!, token: addresses.alphaUsd })
server = await createServer(
relay({
chains: [chain],
transports: { [chain.id]: http() },
feePayer: {
account: feePayerAccount,
name: 'Test Sponsor',
url: 'https://test.com',
validate: (request) => request.from?.toLowerCase() !== accounts[3]!.address.toLowerCase(),
},
}).listener,
)
client = getClient({ transport: http(server.url) })
})
afterAll(() => {
server.close()
})
test('behavior: approved tx is sponsored and can be broadcast', async () => {
const { transaction } = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
})
expect(transaction.feePayerSignature).toBeDefined()
const serialized = (await Transaction.serialize(transaction as never)) as `0x76${string}`
const envelope = TxEnvelopeTempo.deserialize(serialized)
const signature = await userAccount.sign({
hash: TxEnvelopeTempo.getSignPayload(envelope),
})
const signed = TxEnvelopeTempo.serialize(envelope, {
signature: SignatureEnvelope.from(signature),
})
const receipt = (await getClient().request({
method: 'eth_sendRawTransactionSync' as never,
params: [signed],
})) as { feePayer?: string | undefined }
expect(receipt.feePayer).toBe(feePayerAccount.address.toLowerCase())
})
test('behavior: rejected tx is not sponsored and can be self-paid', async () => {
const sender = accounts[3]!
const result = await fillTransaction(client, {
account: sender.address,
calls: [transferCall()],
})
expect(result.transaction.feePayerSignature).toBeUndefined()
const meta = result.capabilities
expect(meta?.sponsored).toBe(false)
expect(meta?.sponsor).toBeUndefined()
const serialized = (await Transaction.serialize(result.transaction as never)) as `0x76${string}`
const envelope = TxEnvelopeTempo.deserialize(serialized)
const signature = await sender.sign({
hash: TxEnvelopeTempo.getSignPayload(envelope),
})
const signed = TxEnvelopeTempo.serialize(envelope, {
signature: SignatureEnvelope.from(signature),
})
const receipt = (await getClient().request({
method: 'eth_sendRawTransactionSync' as never,
params: [signed],
})) as { feePayer?: string | undefined }
// Sender pays their own fee — no external fee payer.
expect(receipt.feePayer).not.toBe(feePayerAccount.address.toLowerCase())
})
})
describe('behavior: path A — guaranteed sponsorship (no validate)', () => {
let server: Server
let client: ReturnType<typeof getClient<typeof chain>>
let requests: RpcRequest.RpcRequest[] = []
beforeAll(async () => {
server = await createServer(
relay({
chains: [chain],
features: 'all',
resolveTokens: () => localnetTokens,
transports: { [chain.id]: http() },
feePayer: {
account: feePayerAccount,
name: 'Path A Sponsor',
},
onRequest: async (request) => {
requests.push(request)
},
}).listener,
)
client = getClient({ transport: http(server.url) })
})
afterAll(() => {
server.close()
})
afterEach(() => {
requests = []
})
test('behavior: skips fee token resolution and sponsors', async () => {
const result = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
})
expect(result.transaction.feePayerSignature).toBeDefined()
expect(result.capabilities?.sponsored).toBe(true)
expect(result.capabilities?.sponsor?.name).toBe('Path A Sponsor')
// Only one fill request — no fee token resolution round-trip.
expect(requests.filter((r) => r.method === 'eth_fillTransaction')).toHaveLength(1)
})
test('behavior: returns fee even when no feeToken in request', async () => {
const result = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
})
expect(result.capabilities?.fee).toBeDefined()
expect(result.capabilities?.fee?.decimals).toBeTypeOf('number')
expect(result.capabilities?.fee?.symbol).toBeTypeOf('string')
expect(result.capabilities?.fee?.formatted).toBeTypeOf('string')
})
test('behavior: simulate and sign run concurrently with fill', async () => {
const result = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
})
// All capabilities are present despite parallel execution.
expect(result.transaction.feePayerSignature).toBeDefined()
expect(result.capabilities?.sponsored).toBe(true)
})
test('behavior: defaults feeToken to chain default when caller omits it', async () => {
// Without the default, the broadcast envelope has no feeToken and
// the chain falls back to the sender's account token, which often
// lacks FeeAMM liquidity.
const result = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
})
expect(result.transaction.feeToken?.toLowerCase()).toBe(
localnetTokens[0]!.address.toLowerCase(),
)
})
test('behavior: preserves caller-supplied feeToken', async () => {
const result = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
feeToken: addresses.alphaUsd as Address,
})
expect(result.transaction.feeToken?.toLowerCase()).toBe(addresses.alphaUsd.toLowerCase())
})
})
describe('behavior: path B — conditional sponsorship (validate)', () => {
let server: Server
let client: ReturnType<typeof getClient<typeof chain>>
let requests: RpcRequest.RpcRequest[] = []
// Reject accounts[3], approve everyone else.
const rejectedSender = accounts[3]!
beforeAll(async () => {
const rpc = getClient()
await Actions.token.mintSync(rpc, {
account: accounts[0]!,
token: addresses.alphaUsd,
amount: parseUnits('100', 6),
to: rejectedSender.address,
})
await Actions.fee.setUserToken(rpc, { account: rejectedSender, token: addresses.alphaUsd })
server = await createServer(
relay({
chains: [chain],
features: 'all',
transports: { [chain.id]: http() },
feePayer: {
account: feePayerAccount,
name: 'Path B Sponsor',
validate: (request) =>
request.from?.toLowerCase() !== rejectedSender.address.toLowerCase(),
},
onRequest: async (request) => {
requests.push(request)
},
}).listener,
)
client = getClient({ transport: http(server.url) })
})
afterAll(() => {
server.close()
})
afterEach(() => {
requests = []
})
test('behavior: approved sender gets sponsorship with parallel fills', async () => {
const result = await fillTransaction(client, {
account: userAccount.address,
calls: [transferCall()],
})
expect(result.transaction.feePayerSignature).toBeDefined()
expect(result.capabilities?.sponsored).toBe(true)
expect(result.capabilities?.sponsor?.name).toBe('Path B Sponsor')
})
test('behavior: rejected sender gets unsponsored tx from parallel fill', async () => {
const result = await fillTransaction(client, {
account: rejectedSender.address,
calls: [transferCall()],
})
expect(result.transaction.feePayerSignature).toBeUndefined()
expect(result.capabilities?.sponsored).toBe(false)
expect(result.capabilities?.sponsor).toBeUndefined()
})
test('behavior: rejected tx can be signed and broadcast by sender', async () => {
const result = await fillTransaction(client, {
account: rejectedSender.address,
calls: [transferCall()],
})
const serialized = (await Transaction.serialize(result.transaction as never)) as `0x76${string}`
const envelope = TxEnvelopeTempo.deserialize(serialized)
const signature = await rejectedSender.sign({
hash: TxEnvelopeTempo.getSignPayload(envelope),
})
const signed = TxEnvelopeTempo.serialize(envelope, {
signature: SignatureEnvelope.from(signature),
})
const receipt = (await getClient().request({
method: 'eth_sendRawTransactionSync' as never,
params: [signed],
})) as { feePayer?: string | undefined }
expect(receipt.feePayer).not.toBe(feePayerAccount.address.toLowerCase())
})
})
describe('behavior: path C — no sponsorship', () => {
let server: Server
let client: ReturnType<typeof getClient<typeof chain>>
let requests: RpcRequest.RpcRequest[] = []
beforeAll(async () => {
server = await createServer(
relay({
chains: [chain],
features: 'all',
transports: { [chain.id]: http() },
onRequest: async (request) => {
requests.push(request)
},
}).listener,
)
client = getClient({ transport: http(server.url) })
})
afterAll(() => {
server.close()
})
afterEach(() =>