diff --git a/Dockerfile b/Dockerfile index a84f0af..29de964 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,45 +1,39 @@ -# Build stage +# Build stage — bun for install + bundle FROM oven/bun:1 AS builder WORKDIR /app -# Copy package files -COPY package.json bun.lock ./ - # Install dependencies +COPY package.json bun.lock ./ RUN bun install --frozen-lockfile -# Copy source code +# Copy source and typecheck COPY src ./src COPY tsconfig.json ./ - -# Type check RUN bun run typecheck -# Production stage -FROM oven/bun:1-slim +# Bundle to Node-compatible JS in /app/dist +RUN bun run build + +# Production stage — pure node runtime +FROM node:24-slim WORKDIR /app -# Copy package files and install production dependencies -COPY package.json bun.lock ./ -RUN bun install --frozen-lockfile --production +# curl is only needed for the healthcheck below +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* -# Copy source code -COPY src ./src -COPY tsconfig.json ./ +# Copy only the bundled output +COPY --from=builder /app/dist ./dist +COPY package.json ./ -# Set default environment variables ENV PORT=3000 ENV NODE_ENV=production -# Expose the port EXPOSE 3000 -# Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:3000/health || exit 1 -# Run the server -CMD ["bun", "run", "src/index.ts"] - +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index 18865c4..70c7f32 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A RESTful API service that generates unsigned transactions for cross-chain swaps - **Multi-chain Support**: Build transactions for Solana, Sui, and 10+ EVM chains - **Multiple Bridge Protocols**: SWIFT, MCTP, Fast MCTP, Wormhole, and more - **Quote Fetching**: Get competitive quotes with automatic route optimization +- **Token Discovery**: Fetch the Mayan token list per chain or across every chain - **Permit Support**: EIP-2612 permit signatures for gasless token approvals - **Monochain Swaps**: Single-chain token swaps with DEX aggregation - **Quote Signature Verification**: Cryptographic verification of all quotes @@ -133,6 +134,23 @@ Then run: docker compose up -d ``` +## Authentication + +If you have a Mayan API key, pass it on every request as the `x-api-key` HTTP header. This applies to all endpoints that talk to Mayan's backend — `GET /quote`, `POST /quote`, and `POST /build`. The header is optional; requests without an API key are still served at the standard public-RPC rate limits. + +```bash +curl -H "x-api-key: YOUR_API_KEY" \ + "http://localhost:3000/quote?fromToken=...&fromChain=base&..." +``` + +```typescript +fetch('http://localhost:3000/quote?' + params, { + headers: { 'x-api-key': process.env.MAYAN_API_KEY }, +}); +``` + +The server forwards the key to `@mayanfinance/swap-sdk` for upstream quoting / swap construction — it is never logged or stored. Do not pass the API key as a query parameter. + ## API Endpoints ### Health Check @@ -178,6 +196,95 @@ See [ERC20 Token Approval](#erc20-token-approval) section for detailed examples. --- +### Fetch Token List (single chain) + +``` +GET /tokens +``` + +Returns the Mayan-supported token list for a single chain. Thin wrapper around the SDK's `fetchTokenList(chain, nonPortal?, tokenStandards?, apiKey?)`. + +**Query Parameters:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `chain` | string | Yes | Chain name (e.g. `solana`, `base`, `sui`, `hyperevm`). | +| `nonPortal` | boolean | No | If `true`, also include tokens that are **not** bridged through Wormhole/Portal. Defaults to the SDK default (`false`). | +| `tokenStandards` | string | No | Comma-separated list (or repeated key) restricting which token standards to return. Allowed values: `native`, `erc20`, `spl`, `spl2022`, `suicoin`, `hypertoken`. | + +Both `?tokenStandards=erc20,native` and `?tokenStandards=erc20&tokenStandards=native` are accepted. + +**Example:** + +```bash +curl "http://localhost:3000/tokens?chain=base&tokenStandards=erc20,native" +``` + +**Response:** + +```json +{ + "success": true, + "tokens": [ + { + "name": "USDC", + "symbol": "USDC", + "mint": "EfqRM8ZGWhDTKJ7BHmFvNagKVu3AxQRDQs8WMMaoBCu6", + "contract": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "chainId": 8453, + "wChainId": 30, + "decimals": 6, + "logoURI": "https://...", + "coingeckoId": "usd-coin", + "supportsPermit": true, + "verified": true, + "standard": "erc20" + } + ] +} +``` + +The server forwards the `x-api-key` header to the SDK when present. + +--- + +### Fetch Token List (all chains) + +``` +GET /tokens/all +``` + +Returns the Mayan-supported token list for every chain, keyed by chain name. Thin wrapper around the SDK's `fetchAllTokenList(tokenStandards?, apiKey?)`. + +**Query Parameters:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `tokenStandards` | string | No | Comma-separated list (or repeated key) restricting which token standards to return. Allowed values: `native`, `erc20`, `spl`, `spl2022`, `suicoin`, `hypertoken`. | + +**Example:** + +```bash +curl "http://localhost:3000/tokens/all?tokenStandards=native" +``` + +**Response:** + +```json +{ + "success": true, + "tokens": { + "solana": [ { "symbol": "SOL", "standard": "native", ... } ], + "base": [ { "symbol": "ETH", "standard": "native", ... } ], + "arbitrum": [ { "symbol": "ETH", "standard": "native", ... } ] + } +} +``` + +The server forwards the `x-api-key` header to the SDK when present. + +--- + ### Prometheus Metrics ``` @@ -256,6 +363,82 @@ GET /quote?fromToken=0x833589fcd6edb6e08f4c7c32d4f71b54bda02913&fromChain=base&t --- +### Fetch Quote (POST — for `extraInstructions` and `solanaBridgeOptions`) + +``` +POST /quote +``` + +`@mayanfinance/swap-sdk` v13+ calls Mayan's quoter via HTTP POST by default. This route mirrors that flow: it accepts the same fields as `GET /quote` plus two structured options that can't be expressed in a query string — `extraInstructions` and `solanaBridgeOptions`. + +If you pass `extraInstructions` or `solanaBridgeOptions` to `GET /quote`, the server returns a `400 INVALID_REQUEST` pointing you here. + +**Request Body:** +```json +{ + "fromToken": "So11111111111111111111111111111111111111112", + "fromChain": "solana", + "toToken": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "toChain": "arbitrum", + "amountIn64": "250000000", + "slippageBps": "auto", + + "extraInstructions": { + "instructions": [ + { + "programId": "YourProgram1111111111111111111111111111111", + "accounts": [ + { "pubkey": "...", "isSigner": false, "isWritable": true } + ], + "data": "BASE64_ENCODED_INSTRUCTION_DATA" + } + ], + "lookupTables": [] + } +} +``` + +All the fields accepted by `GET /quote` are also accepted in the JSON body. The response shape is identical to `GET /quote`. + +#### `extraInstructions` (Solana-only) + +When you need to pack your own Solana instructions (a pre-swap token transfer, a wrap step, any custom on-chain action) into the **same** transaction as the Mayan swap, describe them here so the quoter sizes a swap route that leaves room for them. The backend picks instructions whose combined size fits inside a single v0 transaction (under the UDP/MTU packet limit). + +| Field | Type | Description | +|-------|------|-------------| +| `instructions` | `InstructionInfo[]` | Your extra instructions. See type below. | +| `lookupTables` | `string[]` (optional) | Base58 ALT addresses your instructions rely on, so the quoter can size the transaction correctly. | + +```typescript +type SolanaKeyInfo = { + pubkey: string; // base58 + isSigner: boolean; + isWritable: boolean; +}; + +type InstructionInfo = { + programId: string; // base58 + accounts: SolanaKeyInfo[]; + data: string; // base64-encoded instruction data +}; +``` + +`extraInstructions` is a **sizing hint** only — you are still responsible for appending those same instructions to the on-chain transaction yourself before signing. + +#### `solanaBridgeOptions` (Solana source only) + +Forwarded to the SDK as-is, with one transport detail: `customPayload` is sent over the wire as a **hex string** and converted to bytes server-side. + +| Field | Type | Description | +|-------|------|-------------| +| `forceSkipCctpInstructions` | `boolean` | Bypass CCTP instructions in the bundled tx. | +| `skipProxyMayanInstructions` | `boolean` | Skip Mayan proxy instructions. | +| `customPayload` | `string` (hex) | Custom payload bytes encoded as a hex string. | + +See the [@mayanfinance/swap-sdk README](https://www.npmjs.com/package/@mayanfinance/swap-sdk) for the full semantics of each option. + +--- + ### Build Transaction ``` @@ -315,6 +498,8 @@ Builds an unsigned transaction from a signed quote. } ``` +The serialized `transaction` is already partially signed by any SDK-generated ephemeral signers (e.g. PDA-seed keypairs) — clients only need to sign as the swapper before broadcasting. The `signers` field is preserved for backwards compatibility; re-signing with those keypairs is a harmless no-op (Ed25519 is deterministic, so the signature bytes are identical). + **Sui Response:** ```json { @@ -379,21 +564,19 @@ Gets EIP-2612 permit parameters for gasless token approvals. --- -### Get HyperCore Permit Parameters +### HyperCore (Hyperliquid Core) as a Destination -``` -POST /hypercore/permit-params -``` +Since `@mayanfinance/swap-sdk` v13.3.0, depositing into HyperCore **no longer requires a separate user permit signature**. Fetch a quote with `toChain: "hypercore"` and call `POST /build` exactly like any other destination — the resulting transaction already handles the HyperCore deposit end-to-end from the user's single swap signature. -Gets permit parameters for HyperCore USDC deposits on Arbitrum. +The `params.usdcPermitSignature` field on `POST /build` is preserved for backwards compatibility but is now a no-op for HyperCore destinations. New integrations should leave it unset. -**Request Body:** -```json -{ - "quote": { /* Quote object */ }, - "userArbitrumAddress": "0xYourArbitrumAddress" -} -``` +Pass the user's HyperCore destination address (an EVM-style `0x…` address) as `params.destinationAddress`. The choice between **USDC (spot)** and **USDC (perps)** is encoded automatically by the `toToken` returned in the quote. + +> **Sui → HyperCore is temporarily disabled** in this release; the SDK throws on that combination. A dedicated entry point will ship in the next release. + +See the [@mayanfinance/swap-sdk README](https://www.npmjs.com/package/@mayanfinance/swap-sdk) for the full HyperCore notes. + +> **Note on `POST /hypercore/permit-params`**: This endpoint still exists for backwards compatibility with older clients, but it is no longer needed — see above. New integrations should ignore it. ## Usage Examples @@ -495,21 +678,14 @@ const buildResponse = await fetch('http://localhost:3000/build', { ### Solana Transaction ```typescript -import { Connection, VersionedTransaction, Keypair } from '@solana/web3.js'; -import bs58 from 'bs58'; +import { Connection, VersionedTransaction } from '@solana/web3.js'; // After building the transaction... +// The returned tx is already partially signed by any SDK-generated ephemeral +// signers (PDA-seed keypairs); the client only needs to sign as the swapper. const txBuffer = Buffer.from(transaction.transaction, 'base64'); const tx = VersionedTransaction.deserialize(txBuffer); -// Sign with additional signers if provided -if (transaction.signers?.length > 0) { - const additionalSigners = transaction.signers.map(s => - Keypair.fromSecretKey(bs58.decode(s)) - ); - tx.sign(additionalSigners); -} - // Sign with user's keypair tx.sign([userKeypair]); @@ -738,8 +914,6 @@ src/ │ ├── evm.ts # EVM transaction builder │ ├── svm.ts # Solana transaction builder │ └── sui.ts # Sui transaction builder -├── middleware/ -│ └── apiKey.ts # Request metrics tracking └── utils/ ├── signature.ts # Quote signature verification └── hypercore.ts # HyperCore permit utilities diff --git a/bun.lock b/bun.lock index c3208d9..bac11b9 100644 --- a/bun.lock +++ b/bun.lock @@ -4,7 +4,7 @@ "": { "name": "tx-builder", "dependencies": { - "@mayanfinance/swap-sdk": "^13.3.0", + "@mayanfinance/swap-sdk": "^14.0.0", "@mysten/sui": "^1.17.0", "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.0", @@ -94,7 +94,7 @@ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@mayanfinance/swap-sdk": ["@mayanfinance/swap-sdk@13.3.0", "", { "dependencies": { "@mysten/sui": "^1.34.0", "@noble/hashes": "1.8.0", "@solana/buffer-layout": "^4 || ^3", "@solana/web3.js": "^1.87.6", "bs58": "^6.0.0", "cross-fetch": "^3.1.5", "ethers": "^6", "js-sha3": "^0.8.0" } }, "sha512-wBsyJB4tTsVEdBeebBykK9KUiLziQ4hLnXomqB8/JT8MjWTCpN50gimAXK/TrepcHkZznQlMarqNNmbGCL9TZw=="], + "@mayanfinance/swap-sdk": ["@mayanfinance/swap-sdk@14.0.0", "", { "dependencies": { "@mysten/sui": "^1.34.0", "@noble/hashes": "1.8.0", "@solana/buffer-layout": "^4 || ^3", "@solana/web3.js": "^1.87.6", "bs58": "^6.0.0", "cross-fetch": "^3.1.5", "ethers": "^6", "js-sha3": "^0.8.0" } }, "sha512-MszmX8SIIcESyySqHMGw7ycHvU91tcm4OXvnQkBq/4mCdvh8J8CzLijHKAKZL7xV/runFGLNXz5dJugAwnUrEA=="], "@mysten/bcs": ["@mysten/bcs@1.9.2", "", { "dependencies": { "@mysten/utils": "0.2.0", "@scure/base": "^1.2.6" } }, "sha512-kBk5xrxV9OWR7i+JhL/plQrgQ2/KJhB2pB5gj+w6GXhbMQwS3DPpOvi/zN0Tj84jwPvHMllpEl0QHj6ywN7/eQ=="], diff --git a/package.json b/package.json index 8ce1ea8..21fa98e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "dev": "bun run --watch src/index.ts", - "start": "bun run src/index.ts", + "start": "node dist/index.js", "build": "bun build src/index.ts --outdir dist --target node", "typecheck": "tsc --noEmit", "test": "vitest run", @@ -23,7 +23,7 @@ "typescript": "^5.0.0" }, "dependencies": { - "@mayanfinance/swap-sdk": "^13.3.0", + "@mayanfinance/swap-sdk": "^14.0.0", "@mysten/sui": "^1.17.0", "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.0", diff --git a/src/builders/evm.ts b/src/builders/evm.ts index 803c1d7..2c2f064 100644 --- a/src/builders/evm.ts +++ b/src/builders/evm.ts @@ -15,7 +15,8 @@ import type { BuildEvmTxParams, EvmTransactionResult } from '../types'; */ export async function buildEvmTransaction( quote: Quote, - params: BuildEvmTxParams + params: BuildEvmTxParams, + apiKey?: string ): Promise { const { swapperAddress, @@ -24,7 +25,6 @@ export async function buildEvmTransaction( permit, referrerAddresses, customPayload, - usdcPermitSignature } = params; const payload = customPayload ? hexToBuffer(customPayload) : undefined; @@ -62,8 +62,7 @@ export async function buildEvmTransaction( // Non-gasless: Build transaction calldata const options = { - usdcPermitSignature, - apiKey: process.env.SWAP_SDK_API_KEY, + apiKey, } const txPayload = await getSwapFromEvmTxPayload( quote, diff --git a/src/builders/index.ts b/src/builders/index.ts index 2a86761..93ff1db 100644 --- a/src/builders/index.ts +++ b/src/builders/index.ts @@ -7,7 +7,6 @@ import { type BuildSvmTxParams, type BuildSuiTxParams, type TransactionResult, - type ChainCategory } from '../types'; import { buildEvmTransaction } from './evm'; import { buildSvmTransaction } from './svm'; @@ -25,22 +24,23 @@ export interface BuilderConnections { export async function buildTransaction( quote: Quote, params: BuildEvmTxParams | BuildSvmTxParams | BuildSuiTxParams, - connections: BuilderConnections + connections: BuilderConnections, + apiKey?: string, ): Promise { const chainCategory = getChainCategory(quote.fromChain); switch (chainCategory) { case 'evm': - return buildEvmTransaction(quote, params as BuildEvmTxParams); + return buildEvmTransaction(quote, params as BuildEvmTxParams, apiKey); case 'svm': const svmConnection = quote.fromChain === 'fogo' ? connections.fogo : connections.solana; - return buildSvmTransaction(quote, params as BuildSvmTxParams, svmConnection); + return buildSvmTransaction(quote, params as BuildSvmTxParams, svmConnection, apiKey); case 'sui': - return buildSuiTransaction(quote, params as BuildSuiTxParams, connections.sui); + return buildSuiTransaction(quote, params as BuildSuiTxParams, connections.sui, apiKey); default: throw new Error(`Unsupported chain category: ${chainCategory}`); diff --git a/src/builders/sui.ts b/src/builders/sui.ts index b9d6a69..e12d1d6 100644 --- a/src/builders/sui.ts +++ b/src/builders/sui.ts @@ -30,14 +30,14 @@ function convertCoinInput(coinInput: SuiCoinInput | undefined): ComposableSuiMov export async function buildSuiTransaction( quote: Quote, params: BuildSuiTxParams, - suiClient: SuiClient + suiClient: SuiClient, + apiKey?: string, ): Promise { const { swapperAddress, destinationAddress, referrerAddresses, customPayload, - usdcPermitSignature, inputCoin, whFeeCoin, builtTransaction, @@ -47,13 +47,9 @@ export async function buildSuiTransaction( // Build composable options const options: ComposableSuiMoveCallsOptions = { - apiKey: process.env.SWAP_SDK_API_KEY, + apiKey, }; - if (usdcPermitSignature) { - options.usdcPermitSignature = usdcPermitSignature; - } - if (inputCoin) { options.inputCoin = convertCoinInput(inputCoin); } diff --git a/src/builders/svm.ts b/src/builders/svm.ts index 301d981..2ea0236 100644 --- a/src/builders/svm.ts +++ b/src/builders/svm.ts @@ -10,7 +10,6 @@ import { import { createSwapFromSolanaInstructions, type Quote, - type ReferrerAddresses, } from '@mayanfinance/swap-sdk'; import type { BuildSvmTxParams, SvmTransactionResult, SerializedInstruction } from '../types'; import bs58 from 'bs58'; @@ -21,14 +20,14 @@ import bs58 from 'bs58'; export async function buildSvmTransaction( quote: Quote, params: BuildSvmTxParams, - connection: Connection + connection: Connection, + apiKey?: string, ): Promise { const { swapperAddress, destinationAddress, referrerAddresses, customPayload, - usdcPermitSignature, allowSwapperOffCurve, forceSkipCctpInstructions, separateSwapTx, @@ -45,12 +44,11 @@ export async function buildSvmTransaction( connection, { customPayload: payload, - usdcPermitSignature, allowSwapperOffCurve, forceSkipCctpInstructions, separateSwapTx, skipProxyMayanInstructions, - apiKey: process.env.SWAP_SDK_API_KEY, + apiKey, } ); @@ -76,10 +74,16 @@ export async function buildSvmTransaction( instructions: result.instructions, }).compileToV0Message(lookupTableAccounts); - // Create versioned transaction (unsigned) + // Create versioned transaction and pre-sign with the SDK's ephemeral signers + // (PDA-seed keypairs the SDK generated for this call — never the user). Clients + // only need to sign with the swapper. `signers` is still returned for + // back-compat; re-signing with the same keypair is a no-op (Ed25519 is + // deterministic and `VersionedTransaction.sign` just overwrites the slot). const transaction = new VersionedTransaction(messageV0); + if (result.signers.length > 0) { + transaction.sign(result.signers); + } - // Serialize transaction to base64 const serializedTransaction = Buffer.from(transaction.serialize()).toString('base64'); return { diff --git a/src/server.ts b/src/server.ts index a6baff2..419a07f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,7 +2,7 @@ import express, { type Request, type Response, type NextFunction } from 'express import { Connection } from '@solana/web3.js'; import { SuiClient } from '@mysten/sui/client'; import { JsonRpcProvider } from 'ethers'; -import {fetchQuote, type Quote, type QuoteParams, type QuoteOptions, addresses} from '@mayanfinance/swap-sdk'; +import {fetchQuote, fetchTokenList, fetchAllTokenList, type Quote, type QuoteParams, type QuoteOptions, type ChainName, type TokenStandard, addresses} from '@mayanfinance/swap-sdk'; import { verifyQuoteSignature } from './utils/signature'; import { buildTransaction, type BuilderConnections } from './builders'; import { getPermitParams, getHyperCorePermitParams } from './utils/hypercore'; @@ -22,8 +22,10 @@ import type { HyperCorePermitParamsResponse, FetchQuoteRequest, FetchQuoteResponse, + FetchTokensResponse, + FetchAllTokensResponse, } from './types'; -import { getChainCategory } from './types'; +import { getChainCategory, VALID_TOKEN_STANDARDS } from './types'; // Error codes const ERROR_CODES = { @@ -105,35 +107,142 @@ export function createServer(config: ServerConfig) { }); }); - // Fetch quote endpoint - app.get('/quote', async (req: Request, res: Response) => { + // Fetch token list for a single chain + // Wraps SDK fetchTokenList(chain, nonPortal?, tokenStandards?, apiKey?) + app.get('/tokens', async (req: Request, res: Response) => { try { - // Parse query parameters with type conversions - const query = req.query; - const body: FetchQuoteRequest = { - fromToken: query.fromToken as string, - fromChain: query.fromChain as FetchQuoteRequest['fromChain'], - toToken: query.toToken as string, - toChain: query.toChain as FetchQuoteRequest['toChain'], - slippageBps: query.slippageBps === 'auto' ? 'auto' : Number(query.slippageBps), - amount: query.amount !== undefined ? Number(query.amount) : undefined, - amountIn64: query.amountIn64 as string | undefined, - gasDrop: query.gasDrop !== undefined ? Number(query.gasDrop) : undefined, - referrer: query.referrer as string | undefined, - referrerBps: query.referrerBps !== undefined ? Number(query.referrerBps) : undefined, - wormhole: query.wormhole !== undefined ? query.wormhole === 'true' : undefined, - swift: query.swift !== undefined ? query.swift === 'true' : undefined, - mctp: query.mctp !== undefined ? query.mctp === 'true' : undefined, - shuttle: query.shuttle !== undefined ? query.shuttle === 'true' : undefined, - fastMctp: query.fastMctp !== undefined ? query.fastMctp === 'true' : undefined, - gasless: query.gasless !== undefined ? query.gasless === 'true' : undefined, - onlyDirect: query.onlyDirect !== undefined ? query.onlyDirect === 'true' : undefined, - fullList: query.fullList !== undefined ? query.fullList === 'true' : undefined, - payload: query.payload as string | undefined, - monoChain: query.monoChain !== undefined ? query.monoChain === 'true' : undefined, - memoHex: query.payload as string | undefined, - }; + const chain = req.query.chain as ChainName | undefined; + if (!chain) { + return res.status(400).json({ + success: false, + error: 'Missing required query param: chain', + code: ERROR_CODES.INVALID_REQUEST, + }); + } + + const standardsError = parseTokenStandards(req.query.tokenStandards); + if ('error' in standardsError) { + return res.status(400).json({ + success: false, + error: standardsError.error, + code: ERROR_CODES.INVALID_REQUEST, + }); + } + + const nonPortal = req.query.nonPortal !== undefined + ? req.query.nonPortal === 'true' + : undefined; + + const apiKey = req.headers['x-api-key'] as string | undefined; + const tokens = await fetchTokenList(chain, nonPortal, standardsError.value, apiKey); + + return res.json({ success: true, tokens }); + } catch (error) { + return handleSdkError(error, res, 'Fetch tokens error'); + } + }); + + // Fetch token list across every chain, keyed by chain name + // Wraps SDK fetchAllTokenList(tokenStandards?, apiKey?) + app.get('/tokens/all', async (req: Request, res: Response) => { + try { + const standardsError = parseTokenStandards(req.query.tokenStandards); + if ('error' in standardsError) { + return res.status(400).json({ + success: false, + error: standardsError.error, + code: ERROR_CODES.INVALID_REQUEST, + }); + } + + const apiKey = req.headers['x-api-key'] as string | undefined; + const tokens = await fetchAllTokenList(standardsError.value, apiKey); + + return res.json({ success: true, tokens }); + } catch (error) { + return handleSdkError(error, res, 'Fetch all tokens error'); + } + }); + function handleSdkError( + error: unknown, + res: Response, + logPrefix: string, + ) { + console.error(`${logPrefix}:`, error); + + const sdkError = error as { code?: string | number; message?: string; msg?: string; data?: unknown }; + const sdkMessage = sdkError.message || sdkError.msg || 'Unknown error'; + if (sdkError.code !== undefined) { + return res.status(400).json({ + success: false, + error: sdkMessage, + code: String(sdkError.code), + ...(sdkError.data !== undefined && { data: sdkError.data }), + }); + } + + return res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + code: ERROR_CODES.INTERNAL_ERROR, + }); + } + + // Fetch quote endpoint (GET — query params only, no extraInstructions support) + app.get('/quote', async (req: Request, res: Response) => { + const query = req.query; + + // extraInstructions / solanaBridgeOptions cannot be expressed as query params. + // Reject up-front with a clear pointer to POST /quote, since callers might + // try to pass them as JSON strings. + if (query.extraInstructions !== undefined || query.solanaBridgeOptions !== undefined) { + return res.status(400).json({ + success: false, + error: 'extraInstructions and solanaBridgeOptions are not supported on GET /quote — they require structured JSON. Use POST /quote with a JSON body instead.', + code: ERROR_CODES.INVALID_REQUEST, + }); + } + + const body: FetchQuoteRequest = { + fromToken: query.fromToken as string, + fromChain: query.fromChain as FetchQuoteRequest['fromChain'], + toToken: query.toToken as string, + toChain: query.toChain as FetchQuoteRequest['toChain'], + slippageBps: query.slippageBps === 'auto' ? 'auto' : Number(query.slippageBps), + amount: query.amount !== undefined ? Number(query.amount) : undefined, + amountIn64: query.amountIn64 as string | undefined, + gasDrop: query.gasDrop !== undefined ? Number(query.gasDrop) : undefined, + referrer: query.referrer as string | undefined, + referrerBps: query.referrerBps !== undefined ? Number(query.referrerBps) : undefined, + wormhole: query.wormhole !== undefined ? query.wormhole === 'true' : undefined, + swift: query.swift !== undefined ? query.swift === 'true' : undefined, + mctp: query.mctp !== undefined ? query.mctp === 'true' : undefined, + shuttle: query.shuttle !== undefined ? query.shuttle === 'true' : undefined, + fastMctp: query.fastMctp !== undefined ? query.fastMctp === 'true' : undefined, + gasless: query.gasless !== undefined ? query.gasless === 'true' : undefined, + onlyDirect: query.onlyDirect !== undefined ? query.onlyDirect === 'true' : undefined, + fullList: query.fullList !== undefined ? query.fullList === 'true' : undefined, + payload: query.payload as string | undefined, + monoChain: query.monoChain !== undefined ? query.monoChain === 'true' : undefined, + memoHex: query.memoHex as string | undefined, + }; + + return handleFetchQuote(body, req, res); + }); + + // Fetch quote endpoint (POST — JSON body, supports extraInstructions and solanaBridgeOptions) + app.post('/quote', async (req: Request, res: Response) => { + const body = (req.body ?? {}) as FetchQuoteRequest; + return handleFetchQuote(body, req, res); + }); + + async function handleFetchQuote( + body: FetchQuoteRequest, + req: Request, + res: Response, + ) { + try { // Validate required fields const validationError = validateQuoteRequest(body); if (validationError) { @@ -181,6 +290,19 @@ export function createServer(config: ServerConfig) { if (body.monoChain !== undefined) quoteOptions.monoChain = body.monoChain; if (body.memoHex) quoteOptions.memoHex = body.memoHex; + // POST-only options + if (body.extraInstructions) { + quoteOptions.extraInstructions = body.extraInstructions; + } + if (body.solanaBridgeOptions) { + const { customPayload, ...rest } = body.solanaBridgeOptions; + quoteOptions.solanaBridgeOptions = { + ...rest, + // SDK accepts Buffer | Uint8Array; convert from hex string for wire transport + customPayload: customPayload ? Buffer.from(customPayload, 'hex') : undefined, + }; + } + // Fetch quotes const quotes = await fetchQuote(quoteParams, quoteOptions); @@ -211,7 +333,7 @@ export function createServer(config: ServerConfig) { code: ERROR_CODES.INTERNAL_ERROR, }); } - }); + } // Build transaction endpoint app.post('/build', async (req: Request, res: Response) => { @@ -253,7 +375,8 @@ export function createServer(config: ServerConfig) { const transaction = await buildTransaction( quote as Quote, body.params, - connections + connections, + req.headers['x-api-key'] as string | undefined ); return res.json({ @@ -478,6 +601,34 @@ function validateParamsForChain( return null; } +// Parse the tokenStandards query param. Accepts a comma-separated string +// (?tokenStandards=erc20,native) or a repeated key (?tokenStandards=erc20&tokenStandards=native). +// Returns { value } for the parsed array (or undefined when omitted) or { error } on a bad value. +function parseTokenStandards( + raw: unknown, +): { value: TokenStandard[] | undefined } | { error: string } { + if (raw === undefined) return { value: undefined }; + + let parts: string[]; + if (Array.isArray(raw)) { + parts = raw.flatMap((v) => String(v).split(',')); + } else { + parts = String(raw).split(','); + } + + const standards = parts.map((s) => s.trim()).filter((s) => s.length > 0); + if (standards.length === 0) return { value: undefined }; + + const invalid = standards.filter((s) => !VALID_TOKEN_STANDARDS.includes(s as TokenStandard)); + if (invalid.length > 0) { + return { + error: `Invalid tokenStandards: ${invalid.join(', ')}. Allowed: ${VALID_TOKEN_STANDARDS.join(', ')}.`, + }; + } + + return { value: standards as TokenStandard[] }; +} + function validateQuoteRequest(body: FetchQuoteRequest): string | null { const missingFields: string[] = []; @@ -537,7 +688,9 @@ export function startServer(config: ServerConfig) { const server = app.listen(config.port, () => { console.log(`Mayan TX Builder API running on port ${config.port}`); console.log(` Health check: http://localhost:${config.port}/health`); - console.log(` Quote endpoint: GET http://localhost:${config.port}/quote`); + console.log(` Quote endpoint: GET/POST http://localhost:${config.port}/quote`); + console.log(` Tokens endpoint: GET http://localhost:${config.port}/tokens?chain=…`); + console.log(` All tokens endpoint: GET http://localhost:${config.port}/tokens/all`); console.log(` Build endpoint: POST http://localhost:${config.port}/build`); }); diff --git a/src/types.ts b/src/types.ts index f5fd2a6..c8b18ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import type { Quote, Erc20Permit, ReferrerAddresses, ChainName, TokenStandard, QuoteOptions, QuoteParams } from '@mayanfinance/swap-sdk'; +import type { Quote, Erc20Permit, ReferrerAddresses, ChainName, TokenStandard, QuoteOptions, QuoteParams, Token } from '@mayanfinance/swap-sdk'; // Chain categories for routing export type ChainCategory = 'evm' | 'svm' | 'sui'; @@ -234,7 +234,33 @@ export type HyperCorePermitParamsResponse = PermitParamsResponse; // Fetch Quote Types +// Solana instruction info accepted by POST /quote (mirrors SDK's InstructionInfo) +export interface SolanaKeyInfo { + pubkey: string; + isSigner: boolean; + isWritable: boolean; +} + +export interface InstructionInfo { + programId: string; + accounts: SolanaKeyInfo[]; + data: string; // base64-encoded +} + +export interface ExtraInstructionsOption { + instructions: InstructionInfo[]; + lookupTables?: string[]; +} + +export interface SolanaBridgeOptionsRequest { + forceSkipCctpInstructions?: boolean; + skipProxyMayanInstructions?: boolean; + customPayload?: string; // hex-encoded bytes +} + // Flat request combining QuoteParams and QuoteOptions +// Used for both GET /quote (query params) and POST /quote (JSON body). +// extraInstructions and solanaBridgeOptions are POST-only. export interface FetchQuoteRequest { // Required params fromToken: string; @@ -264,6 +290,10 @@ export interface FetchQuoteRequest { payload?: string; monoChain?: boolean; memoHex?: string; + + // POST-only options (cannot be expressed in a query string) + extraInstructions?: ExtraInstructionsOption; + solanaBridgeOptions?: SolanaBridgeOptionsRequest; } export interface FetchQuoteResponse { @@ -271,5 +301,33 @@ export interface FetchQuoteResponse { quotes: Quote[]; } -export type { Quote, QuoteParams, QuoteOptions }; +// Fetch Tokens Types + +export const VALID_TOKEN_STANDARDS: readonly TokenStandard[] = [ + 'native', 'erc20', 'spl', 'spl2022', 'suicoin', 'hypertoken', +] as const; + +// Filters accepted by GET /tokens (single-chain list) +export interface FetchTokensRequest { + chain: ChainName; + nonPortal?: boolean; + tokenStandards?: TokenStandard[]; +} + +export interface FetchTokensResponse { + success: true; + tokens: Token[]; +} + +// Filters accepted by GET /tokens/all (every chain, grouped by chain) +export interface FetchAllTokensRequest { + tokenStandards?: TokenStandard[]; +} + +export interface FetchAllTokensResponse { + success: true; + tokens: { [chain: string]: Token[] }; +} + +export type { Quote, QuoteParams, QuoteOptions, Token, TokenStandard }; diff --git a/src/utils/hypercore.ts b/src/utils/hypercore.ts index 51effc9..980a15a 100644 --- a/src/utils/hypercore.ts +++ b/src/utils/hypercore.ts @@ -122,10 +122,6 @@ export async function getHyperCorePermitParams( types: typeof PermitTypes; value: PermitTypedDataValue; }> { - // Validate quote has hyperCoreParams - if (!quote.hyperCoreParams) { - throw new Error('Quote does not have hyperCoreParams'); - } // Validate quote is for HyperCore if (quote.toChain !== 'hypercore') { @@ -150,7 +146,7 @@ export async function getHyperCorePermitParams( value: { owner: userArbitrumAddress, spender: HC_ARBITRUM_BRIDGE, - value: String(quote.hyperCoreParams.depositAmountUSDC64), + value: String('1000000'), nonce: String(nonce), deadline: String(quote.deadline64), },