accounts
Version:
Tempo Accounts SDK
312 lines • 12.1 kB
JavaScript
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