UNPKG

accounts

Version:

Tempo Accounts SDK

475 lines (429 loc) 14.8 kB
import { Hex } from 'ox' import { type Address, type Chain, type Client, createClient, formatUnits, http, parseUnits, type Transport, } from 'viem' import { tempo, tempoDevnet, tempoModerato } from 'viem/chains' import { Actions, Addresses } from 'viem/tempo' import * as z from 'zod/mini' import * as ExecutionError from '../../../core/ExecutionError.js' import * as u from '../../../core/zod/utils.js' import { type Handler, from } from '../../Handler.js' import * as Kv from '../../Kv.js' import * as Hono from '../hono.js' import { cached } from '../kv.js' import * as Tokenlist from '../tokenlist.js' /** Default cache TTL in seconds (10 minutes). */ const defaultCacheTtl = 10 * 60 /** Zod schemas for the exchange handler's request and response payloads. */ export namespace schema { const token = z.object({ address: u.address(), decimals: z.number(), logoUri: z.optional(z.string()), name: z.string(), symbol: z.string(), }) /** Schemas for `POST /exchange/quote`. */ export namespace quote { /** Request body schema. */ export const parameters = z.object({ amount: z.string(), chainId: z.optional(z.number()), pairToken: z.string(), slippage: z.number(), token: z.string(), type: z.union([z.literal('buy'), z.literal('sell')]), }) const side = z.object({ address: u.address(), amount: z.string(), name: z.string(), symbol: z.string(), }) /** Response body schema. */ export const returns = z.object({ calls: z.readonly( z.array( z.object({ data: u.hex(), to: u.address(), }), ), ), pairToken: side, slippage: z.number(), token: side, type: z.union([z.literal('buy'), z.literal('sell')]), }) } /** Schemas for `GET /exchange/tokens`. */ export namespace tokens { /** Query string schema. `chainId` is a decimal string when present. */ export const parameters = z.object({ chainId: z.optional(z.string()), }) /** Response body schema. */ export const returns = z.object({ tokens: z.readonly(z.array(token)), }) } } /** * Instantiates a stablecoin-exchange handler that returns swap quotes plus * the matching `calls` (approve + buy/sell) for the Tempo Stablecoin DEX. * * Exposes 2 endpoints: * - `GET /exchange/tokens` — list known tokens for a chain (defaults to the * first configured chain; pass `?chainId=N` to pick a different one). * - `POST /exchange/quote` — return a quote and ready-to-submit calls. * * The returned value is a Hono app augmented with a Node `listener`. The full * route schema is preserved on the type so consumers can derive a typed RPC * client with `hc<ReturnType<typeof exchange>>(url)`. * * @example * ```ts * import { Handler } from 'accounts/server' * * const handler = Handler.exchange() * * // Plug handler into your server framework of choice: * createServer(handler.listener) * ``` * * @example * Typed RPC client * * ```ts * import { hc } from 'hono/client' * import { Handler } from 'accounts/server' * * const handler = Handler.exchange() * type Handler = typeof handler * * const client = hc<Handler>('https://example.com') * const res = await client.exchange.quote.$post({ * json: { * amount: '1', * pairToken: 'pathUSD', * slippage: 0.01, * token: 'USDC', * type: 'sell', * }, * }) * if (res.ok) { * const { calls, pairToken, token } = await res.json() * // fully typed * } * ``` * * @param options - Options. * @returns Request handler. */ export function exchange<const path extends string = '/exchange'>( options: exchange.Options<path> = {}, ) { const { cacheTtl = defaultCacheTtl, chains = [tempo, tempoModerato, tempoDevnet], kv = Kv.memory(), onRequest, path = '/exchange' as path, resolveTokens, transports = {}, ...rest } = options const getTokens = (chainId: number) => Tokenlist.fetch(chainId, kv, { cacheTtl, resolver: resolveTokens }) const clients = new Map<number, Client>() for (const chain of chains) { const transport = transports[chain.id] ?? http() clients.set( chain.id, createClient({ batch: { multicall: { deployless: true } }, chain, transport }), ) } const router = from(rest) const app = router .get(`${path}/tokens`, Hono.validate('query', schema.tokens.parameters), async (c) => { try { await onRequest?.(c.req.raw) const { chainId: chainIdStr } = c.req.valid('query') const chainId = chainIdStr ? Number(chainIdStr) : chains[0]!.id const chain = chains.find((c) => c.id === chainId) if (!chain) throw new Error(`Chain ${chainId} is not supported.`) const tokens = await getTokens(chain.id) // Cache for `cacheTtl` and allow stale-while-revalidate for an // additional full TTL window so CDNs/browsers can serve immediately // while a fresh copy is fetched in the background. c.header( 'Cache-Control', `public, max-age=${cacheTtl}, s-maxage=${cacheTtl}, stale-while-revalidate=${cacheTtl}`, ) return c.json( z.encode(schema.tokens.returns, { tokens }) as z.output<typeof schema.tokens.returns>, ) } catch (error) { return c.json({ error: (error as Error).message }, 400) } }) .post(`${path}/quote`, Hono.validate('json', schema.quote.parameters), async (c) => { try { await onRequest?.(c.req.raw) const { amount, chainId, pairToken, slippage, token, type } = c.req.valid('json') const chain = chainId ? chains.find((c) => c.id === chainId) : chains[0] if (!chain) throw new Error(`Chain ${chainId} is not supported.`) const client = clients.get(chain.id)! // Resolve `token` and `pairToken` to addresses + metadata in parallel. const tokens = await getTokens(chain.id) const [tokenInfo, pairTokenInfo] = await Promise.all([ resolveToken(client, { kv, ref: token, tokens }), resolveToken(client, { kv, ref: pairToken, tokens }), ]) // `amount` is always denominated in `token` units. For `buy`, that // means the exact `token` to receive (exact-out); for `sell`, the // exact `token` to spend (exact-in). const amount_ = parseUnits(amount, tokenInfo.decimals) // `buy` spends `pairToken` to receive `token`; `sell` spends `token` // to receive `pairToken`. const inInfo = type === 'buy' ? pairTokenInfo : tokenInfo const outInfo = type === 'buy' ? tokenInfo : pairTokenInfo const result = type === 'buy' ? await quoteBuy(client, { amount: amount_, input: inInfo.address, output: outInfo.address, slippage, }) : await quoteSell(client, { amount: amount_, input: inInfo.address, output: outInfo.address, slippage, }) // Map the trade-direction-relative amounts back onto the // `token`/`pairToken` axis the caller speaks in. const tokenAmount = type === 'buy' ? result.outputAmount : result.inputAmount const pairTokenAmount = type === 'buy' ? result.inputAmount : result.outputAmount return c.json( z.encode(schema.quote.returns, { calls: result.calls, pairToken: { address: pairTokenInfo.address, amount: formatUnits(pairTokenAmount, pairTokenInfo.decimals), name: pairTokenInfo.name, symbol: pairTokenInfo.symbol, }, slippage, token: { address: tokenInfo.address, amount: formatUnits(tokenAmount, tokenInfo.decimals), name: tokenInfo.name, symbol: tokenInfo.symbol, }, type, }) as z.output<typeof schema.quote.returns>, ) } catch (error) { const revert = ExecutionError.parse(error as Error) // Only surface decoded reverts (named ABI errors). Anything else // (network errors, unknown symbols) falls through to the plain // message path. if (revert && revert.errorName !== 'unknown') return c.json({ data: ExecutionError.serialize(revert), error: revert.errorName }, 400) return c.json({ error: (error as Error).message }, 400) } }) return app as Handler<typeof app> } export declare namespace exchange { /** Options for `exchange()`. */ export type Options<path extends string = string> = from.Options & { /** * TTL in seconds for cached tokenlist responses. On-chain token metadata * is cached without expiry (immutable per address). * @default 600 (10 minutes) */ cacheTtl?: number | undefined /** * Supported chains. The first chain is used to resolve the client. * @default [tempo, tempoModerato, tempoDevnet] */ chains?: readonly [Chain, ...Chain[]] | undefined /** * Kv store used to cache network responses. Provide `Kv.cloudflare(env.KV)` * for cross-instance caching, or omit for an in-process LRU. * @default Kv.memory() */ kv?: Kv.Kv | undefined /** Function to call before handling the request. */ onRequest?: ((request: Request) => void | Promise<void>) | undefined /** Path prefix for the exchange endpoints. @default '/exchange' */ path?: path | undefined /** * Resolves the list of known tokens for a chain. Used to resolve symbol * references (e.g. `"USDC.e"`) to addresses + metadata. Address references * are matched against this list first, falling back to on-chain metadata. * @default Fetches `https://tokenlist.tempo.xyz/list/:chainId` */ resolveTokens?: ((chainId: number) => readonly Token[] | Promise<readonly Token[]>) | undefined /** Transports keyed by chain ID. Defaults to `http()` per chain. */ transports?: Record<number, Transport> | undefined } /** Resolved token metadata. */ export type Token = { /** Token address. */ address: Address /** Token decimals. */ decimals: number /** Token logo URI. */ logoUri?: string | undefined /** Token name. */ name: string /** Token symbol. */ symbol: string } } type Token = exchange.Token /** * Resolves a token reference to an address + metadata. * * If `ref` looks like a hex address (`0x...`), it is matched against `tokens` * by address; on miss, metadata is fetched on-chain and cached forever. * Otherwise it's treated as a symbol and matched against `tokens` by symbol. */ async function resolveToken(client: Client, options: resolveToken.Parameters): Promise<Token> { const { kv, ref, tokens } = options const chainId = client.chain!.id if (isAddress(ref)) { const refLower = ref.toLowerCase() const found = tokens.find((t) => t.address.toLowerCase() === refLower) if (found) return found return await cached(kv, `metadata:${chainId}:${refLower}`, async () => { const meta = await Actions.token.getMetadata(client, { token: ref }).catch(() => undefined) return { address: ref, decimals: meta?.decimals ?? 6, name: meta?.name ?? '', symbol: meta?.symbol ?? '', } }) } const found = tokens.find((t) => t.symbol === ref) if (!found) throw new Error(`Token "${ref}" not found`) return found } declare namespace resolveToken { /** Parameters for `resolveToken()`. */ type Parameters = { /** Kv used to cache on-chain metadata fetches. */ kv: Kv.Kv /** Token reference: a hex address (`0x...`) or symbol. */ ref: string /** Known tokens for the chain (used for fast symbol/address lookup). */ tokens: readonly Token[] } } function isAddress(value: string): value is Address { return /^0x[0-9a-fA-F]{40}$/.test(value) } /** Result of a quote helper, before encoding for the response. */ type QuoteResult = { calls: readonly { data: Hex.Hex; to: Address }[] inputAmount: bigint outputAmount: bigint } async function quoteBuy(client: Client, options: quoteBuy.Parameters): Promise<QuoteResult> { const { amount, input, output, slippage } = options // exact-out: amount = exact `output` to receive. const quoteIn = await Actions.dex.getBuyQuote(client, { amountOut: amount, tokenIn: input, tokenOut: output, }) const maxAmountIn = applySlippage(quoteIn, slippage, 'up') const approve = Actions.token.approve.call({ amount: maxAmountIn, spender: Addresses.stablecoinDex, token: input, }) const buy = Actions.dex.buy.call({ amountOut: amount, maxAmountIn, tokenIn: input, tokenOut: output, }) return { calls: [toCall(approve), toCall(buy)], inputAmount: maxAmountIn, outputAmount: amount, } } declare namespace quoteBuy { /** Parameters for `quoteBuy()`. */ type Parameters = { /** Exact `output` amount to receive. */ amount: bigint /** Token spent. */ input: Address /** Token received. */ output: Address /** Slippage tolerance (e.g. `0.05` = 5%). */ slippage: number } } async function quoteSell(client: Client, options: quoteSell.Parameters): Promise<QuoteResult> { const { amount, input, output, slippage } = options // exact-in: amount = exact `input` to spend. const quoteOut = await Actions.dex.getSellQuote(client, { amountIn: amount, tokenIn: input, tokenOut: output, }) const minAmountOut = applySlippage(quoteOut, slippage, 'down') const approve = Actions.token.approve.call({ amount, spender: Addresses.stablecoinDex, token: input, }) const sell = Actions.dex.sell.call({ amountIn: amount, minAmountOut, tokenIn: input, tokenOut: output, }) return { calls: [toCall(approve), toCall(sell)], inputAmount: amount, outputAmount: minAmountOut, } } declare namespace quoteSell { /** Parameters for `quoteSell()`. */ type Parameters = { /** Exact `input` amount to spend. */ amount: bigint /** Token spent. */ input: Address /** Token received. */ output: Address /** Slippage tolerance (e.g. `0.05` = 5%). */ slippage: number } } function applySlippage(amount: bigint, slippage: number, dir: 'up' | 'down') { const bps = BigInt(Math.round(slippage * 10_000)) if (dir === 'up') return amount + (amount * bps) / 10_000n return amount - (amount * bps) / 10_000n } function toCall(call: { data: Hex.Hex; to: Address }) { return { data: call.data, to: call.to } }