UNPKG

accounts

Version:

Tempo Accounts SDK

312 lines 12.1 kB
import { Hex } from 'ox'; import { createClient, formatUnits, http, parseUnits, } 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 { 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 var schema; (function (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`. */ let quote; (function (quote) { /** Request body schema. */ quote.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. */ quote.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')]), }); })(quote = schema.quote || (schema.quote = {})); /** Schemas for `GET /exchange/tokens`. */ let tokens; (function (tokens) { /** Query string schema. `chainId` is a decimal string when present. */ tokens.parameters = z.object({ chainId: z.optional(z.string()), }); /** Response body schema. */ tokens.returns = z.object({ tokens: z.readonly(z.array(token)), }); })(tokens = schema.tokens || (schema.tokens = {})); })(schema || (schema = {})); /** * 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(options = {}) { const { cacheTtl = defaultCacheTtl, chains = [tempo, tempoModerato, tempoDevnet], kv = Kv.memory(), onRequest, path = '/exchange', resolveTokens, transports = {}, ...rest } = options; const getTokens = (chainId) => Tokenlist.fetch(chainId, kv, { cacheTtl, resolver: resolveTokens }); const clients = new Map(); 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 })); } catch (error) { return c.json({ error: 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, })); } catch (error) { const revert = ExecutionError.parse(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.message }, 400); } }); return app; } /** * 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, options) { 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; } function isAddress(value) { return /^0x[0-9a-fA-F]{40}$/.test(value); } async function quoteBuy(client, options) { 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, }; } async function quoteSell(client, options) { 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, }; } function applySlippage(amount, slippage, dir) { const bps = BigInt(Math.round(slippage * 10_000)); if (dir === 'up') return amount + (amount * bps) / 10000n; return amount - (amount * bps) / 10000n; } function toCall(call) { return { data: call.data, to: call.to }; } //# sourceMappingURL=exchange.js.map