accounts
Version:
Tempo Accounts SDK
636 lines (586 loc) • 18.5 kB
text/typescript
import { Hex } from 'ox'
import { type Address, parseUnits } from 'viem'
import { sendTransactionSync } from 'viem/actions'
import { Actions, Addresses, Tick } from 'viem/tempo'
import { afterAll, 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 { exchange } from './exchange.js'
/**
* Wire shape of the quote response. Amounts are decimal strings — we mirror
* that here instead of importing a schema (which the handler no longer
* exports).
*/
type QuoteResponseWire = {
pairToken: { address: Address; amount: string; name: string; symbol: string }
token: { address: Address; amount: string; name: string; symbol: string }
slippage: number
type: 'buy' | 'sell'
calls: readonly { to: Address; data: Hex.Hex }[]
}
const liquidityProvider = accounts[0]!
/**
* Sets up a fresh `(base, quote)` pair on the DEX with a sell-side wall on
* `base` so buys/sells from a counterparty can fill at ~1:1.
*/
async function setupPair(options: { liquidity?: bigint } = {}): Promise<{
base: Address
quote: Address
}> {
const { liquidity = parseUnits('1000', 6) } = options
const rpc = getClient({ account: liquidityProvider })
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,
})
const fundAmount = liquidity * 10n
await sendTransactionSync(rpc, {
calls: [
Actions.token.grantRoles.call({
token: base,
role: 'issuer',
to: liquidityProvider.address,
}),
Actions.token.grantRoles.call({
token: quote,
role: 'issuer',
to: liquidityProvider.address,
}),
Actions.token.mint.call({ token: base, to: liquidityProvider.address, amount: fundAmount }),
Actions.token.mint.call({ token: quote, to: liquidityProvider.address, amount: fundAmount }),
Actions.token.approve.call({
token: base,
spender: Addresses.stablecoinDex,
amount: fundAmount,
}),
Actions.token.approve.call({
token: quote,
spender: Addresses.stablecoinDex,
amount: fundAmount,
}),
],
})
await Actions.dex.createPairSync(rpc, { base })
await Actions.dex.placeSync(rpc, {
token: base,
amount: liquidity,
type: 'sell',
tick: Tick.fromPrice('1.001'),
})
return { base, quote }
}
async function fundAccount(token: Address, to: Address, amount: bigint) {
const rpc = getClient({ account: liquidityProvider })
await Actions.token.mintSync(rpc, { token, to, amount })
}
describe('default', () => {
let server: Server
let base: Address
let quote: Address
beforeAll(async () => {
const pair = await setupPair()
base = pair.base
quote = pair.quote
server = await createServer(
exchange({
chains: [chain],
transports: { [chain.id]: http() },
}).listener,
)
})
afterAll(() => {
server.close()
})
test('behavior: type=sell returns expected response shape', async () => {
// Sell 1 quote for base.
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'sell',
token: quote,
pairToken: base,
amount: '1',
slippage: 0.01,
}),
})
expect(response.status).toBe(200)
const decoded = (await response.json()) as QuoteResponseWire
const { calls, ...rest } = decoded
// Token addresses change per test run — snapshot the rest.
expect({
...rest,
pairToken: { ...rest.pairToken, address: '<pair-token>' as Address },
token: { ...rest.token, address: '<token>' as Address },
}).toMatchInlineSnapshot(`
{
"pairToken": {
"address": "<pair-token>",
"amount": "0.98901",
"name": "Test Base",
"symbol": "TBASE",
},
"slippage": 0.01,
"token": {
"address": "<token>",
"amount": "1",
"name": "Test Quote",
"symbol": "TQUOTE",
},
"type": "sell",
}
`)
// approve(token) + sell(dex)
expect(calls).toHaveLength(2)
expect(calls[0]!.to.toLowerCase()).toBe(quote.toLowerCase())
expect(calls[1]!.to.toLowerCase()).toBe(Addresses.stablecoinDex.toLowerCase())
})
test('behavior: type=buy returns expected response shape', async () => {
// Buy 1 base, paying with quote.
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'buy',
token: base,
pairToken: quote,
amount: '1',
slippage: 0.01,
}),
})
expect(response.status).toBe(200)
const decoded = (await response.json()) as QuoteResponseWire
const { calls, ...rest } = decoded
expect({
...rest,
pairToken: { ...rest.pairToken, address: '<pair-token>' as Address },
token: { ...rest.token, address: '<token>' as Address },
}).toMatchInlineSnapshot(`
{
"pairToken": {
"address": "<pair-token>",
"amount": "1.01101",
"name": "Test Quote",
"symbol": "TQUOTE",
},
"slippage": 0.01,
"token": {
"address": "<token>",
"amount": "1",
"name": "Test Base",
"symbol": "TBASE",
},
"type": "buy",
}
`)
expect(calls).toHaveLength(2)
// approve is on the spent token (pairToken == quote for type=buy).
expect(calls[0]!.to.toLowerCase()).toBe(quote.toLowerCase())
expect(calls[1]!.to.toLowerCase()).toBe(Addresses.stablecoinDex.toLowerCase())
})
test('behavior: returned calls execute the swap end-to-end', async () => {
const trader = accounts[5]!
// Fund trader with 100 quote (selling 1 quote) + alphaUsd for fees,
// and set alphaUsd as the trader's fee token so they can pay gas.
await fundAccount(quote, trader.address, parseUnits('100', 6))
await fundAccount(addresses.alphaUsd, trader.address, parseUnits('100', 6))
await Actions.fee.setUserToken(getClient({ account: trader }), {
token: addresses.alphaUsd,
})
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'sell',
token: quote,
pairToken: base,
amount: '1',
slippage: 0.05,
}),
}).then((res) => res.json())
// Submit the calls as the trader.
const traderClient = getClient({ account: trader })
const hash = await sendTransactionSync(traderClient, {
calls: response.calls,
})
expect(hash).toBeDefined()
// Verify trader received base tokens.
const baseBalance = await Actions.token.getBalance(getClient(), {
account: trader.address,
token: base,
})
expect(baseBalance).toBeGreaterThan(0n)
})
test('error: schema validation rejects malformed body', async () => {
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// Missing `type`.
body: JSON.stringify({
token: quote,
pairToken: base,
amount: '1',
slippage: 0.01,
}),
})
expect(response.status).toBe(400)
expect(await response.json()).toMatchInlineSnapshot(`
{
"error": "Invalid request body",
"issues": [
{
"message": "expected one of: "buy", "sell"",
"path": "type",
},
],
}
`)
})
test('error: missing pair surfaces an ExecutionError-shaped 400', async () => {
// A valid token address that isn't paired with `base`.
const orphan = '0xdeadbeef00000000000000000000000000000000' as Address
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'sell',
token: orphan,
pairToken: base,
amount: '1',
slippage: 0.01,
}),
})
expect(response.status).toBe(400)
const body = (await response.json()) as {
error: string
data?: { message?: string }
}
// Token addresses are nondeterministic — snapshot just error name + message.
expect({ error: body.error, message: body.data?.message }).toMatchInlineSnapshot(`
{
"error": "InvalidToken",
"message": "This token is not supported on the exchange.",
}
`)
})
test('error: schema rejects invalid `type` literal', async () => {
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'invalid',
token: quote,
pairToken: base,
amount: '1',
slippage: 0.01,
}),
})
expect(response.status).toBe(400)
expect(await response.json()).toMatchInlineSnapshot(`
{
"error": "Invalid request body",
"issues": [
{
"message": "expected one of: "buy", "sell"",
"path": "type",
},
],
}
`)
})
test('error: schema rejects non-string amount', async () => {
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// `amount` must be a decimal string, not a number.
body: JSON.stringify({
type: 'sell',
token: quote,
pairToken: base,
amount: 1,
slippage: 0.01,
}),
})
expect(response.status).toBe(400)
expect(await response.json()).toMatchInlineSnapshot(`
{
"error": "Invalid request body",
"issues": [
{
"message": "expected string",
"path": "amount",
},
],
}
`)
})
test('error: schema rejects missing `token` field', async () => {
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'sell',
pairToken: base,
amount: '1',
slippage: 0.01,
}),
})
expect(response.status).toBe(400)
expect(await response.json()).toMatchInlineSnapshot(`
{
"error": "Invalid request body",
"issues": [
{
"message": "expected string",
"path": "token",
},
],
}
`)
})
test('error: invalid decimal amount surfaces parse error', async () => {
// `parseUnits('not-a-number', ...)` throws — non-revert error path
// (no `data` field, just a plain message).
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'sell',
token: quote,
pairToken: base,
amount: 'not-a-number',
slippage: 0.01,
}),
})
expect(response.status).toBe(400)
const body = (await response.json()) as { error: string; data?: unknown }
expect(body.data).toBeUndefined()
expect(body.error).toMatch(/decimal|number/i)
})
test('error: same token/pairToken reverts', async () => {
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'sell',
token: base,
pairToken: base,
amount: '1',
slippage: 0.01,
}),
})
expect(response.status).toBe(400)
const body = (await response.json()) as {
error: string
data?: { message?: string }
}
expect({ error: body.error, message: body.data?.message }).toMatchInlineSnapshot(`
{
"error": "IdenticalTokens",
"message": "Cannot swap a token for itself — input and output tokens must be different.",
}
`)
})
test('error: unsupported chainId is rejected', async () => {
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'sell',
chainId: 999_999,
token: quote,
pairToken: base,
amount: '1',
slippage: 0.01,
}),
})
expect(response.status).toBe(400)
expect(await response.json()).toMatchInlineSnapshot(`
{
"error": "Chain 999999 is not supported.",
}
`)
})
test('error: insufficient liquidity surfaces revert', async () => {
// Pair has ~1000 base of sell-side liquidity. Asking to buy 999_999
// base far exceeds it.
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'buy',
token: base,
pairToken: quote,
amount: '999999',
slippage: 0.01,
}),
})
expect(response.status).toBe(400)
const body = (await response.json()) as {
error: string
data?: { message?: string }
}
expect({ error: body.error, message: body.data?.message }).toMatchInlineSnapshot(`
{
"error": "InsufficientLiquidity",
"message": "Not enough liquidity in the order book to fill this trade.",
}
`)
})
})
describe('symbol resolution', () => {
let server: Server
let base: Address
let quote: Address
beforeAll(async () => {
const pair = await setupPair()
base = pair.base
quote = pair.quote
server = await createServer(
exchange({
chains: [chain],
transports: { [chain.id]: http() },
resolveTokens: () => [
{ address: quote, decimals: 6, name: 'Test Quote', symbol: 'TQUOTE' },
{ address: base, decimals: 6, name: 'Test Base', symbol: 'TBASE' },
],
}).listener,
)
})
afterAll(() => {
server.close()
})
test('behavior: resolves symbols via resolveTokens', async () => {
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'sell',
token: 'TQUOTE',
pairToken: 'TBASE',
amount: '1',
slippage: 0.01,
}),
})
expect(response.status).toBe(200)
const decoded = (await response.json()) as QuoteResponseWire
const { calls, ...rest } = decoded
expect({
...rest,
pairToken: { ...rest.pairToken, address: '<pair-token>' as Address },
token: { ...rest.token, address: '<token>' as Address },
}).toMatchInlineSnapshot(`
{
"pairToken": {
"address": "<pair-token>",
"amount": "0.98901",
"name": "Test Base",
"symbol": "TBASE",
},
"slippage": 0.01,
"token": {
"address": "<token>",
"amount": "1",
"name": "Test Quote",
"symbol": "TQUOTE",
},
"type": "sell",
}
`)
// Resolved addresses match the freshly-created pair.
expect(decoded.token.address.toLowerCase()).toBe(quote.toLowerCase())
expect(decoded.pairToken.address.toLowerCase()).toBe(base.toLowerCase())
expect(calls).toHaveLength(2)
})
test('error: unknown symbol surfaces a not-found error', async () => {
const response = await fetch(`${server.url}/exchange/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'sell',
token: 'TQUOTE',
pairToken: 'NOPE',
amount: '1',
slippage: 0.01,
}),
})
expect(response.status).toBe(400)
expect(await response.json()).toMatchInlineSnapshot(`
{
"error": "Token "NOPE" not found",
}
`)
})
})
describe('GET /exchange/tokens', () => {
let server: Server
let base: Address
let quote: Address
beforeAll(async () => {
const pair = await setupPair()
base = pair.base
quote = pair.quote
server = await createServer(
exchange({
chains: [chain],
resolveTokens: () => [
{ address: quote, decimals: 6, name: 'Test Quote', symbol: 'TQUOTE' },
{ address: base, decimals: 6, name: 'Test Base', symbol: 'TBASE' },
],
transports: { [chain.id]: http() },
}).listener,
)
})
afterAll(() => {
server.close()
})
test('behavior: returns the resolved tokens for the default chain', async () => {
const response = await fetch(`${server.url}/exchange/tokens`)
expect(response.status).toBe(200)
const body = (await response.json()) as {
tokens: { address: Address; decimals: number; name: string; symbol: string }[]
}
// Pair addresses change per run — assert symbols/names/decimals only.
expect(body.tokens).toHaveLength(2)
expect(body.tokens.map(({ address, ...rest }) => rest)).toMatchInlineSnapshot(`
[
{
"decimals": 6,
"name": "Test Quote",
"symbol": "TQUOTE",
},
{
"decimals": 6,
"name": "Test Base",
"symbol": "TBASE",
},
]
`)
expect(body.tokens[0]!.address.toLowerCase()).toBe(quote.toLowerCase())
expect(body.tokens[1]!.address.toLowerCase()).toBe(base.toLowerCase())
})
test('behavior: `?chainId=` selects the configured chain', async () => {
const response = await fetch(`${server.url}/exchange/tokens?chainId=${chain.id}`)
expect(response.status).toBe(200)
const body = (await response.json()) as {
tokens: readonly unknown[]
}
expect(body.tokens).toHaveLength(2)
})
test('error: unsupported chainId is rejected', async () => {
const response = await fetch(`${server.url}/exchange/tokens?chainId=999999`)
expect(response.status).toBe(400)
expect(await response.json()).toMatchInlineSnapshot(`
{
"error": "Chain 999999 is not supported.",
}
`)
})
})