SPB Git

Repository Public

debridge.ts 15.0 KB 195 lines typescript

1// deBridge Liquidity Network (DLN) — intent-based cross-chain swaps: the user locks the input in DlnSource, a solver2// ("taker") delivers a fixed amount of the output token on the destination chain, then unlocks the input for itself.3// Price discovery only. The DLN API has a single endpoint for estimating and for creating an order4// (GET /v1.0/dln/order/create-tx): its answer carries the estimate AND the calldata of the order. This adapter reads the5// estimate, uses the calldata only for two sanity checks (target contract, recipient bytes) and never signs, relays or6// submits anything. Execution dossier: docs/swap/providers/debridge.md.7//8// Verified live on 2026-11-10 (GET /v1.0/supported-chains-info): the chain ids below. DLN has no same-chain swaps9// (error 25 SAME_SOURCE_AND_DESTINATION_CHAINS) and does not serve Bitcoin, Sui or the wallet's other non-EVM networks.10import { z } from 'zod';11import {12  ProviderError, resolveAsset, sameAsset,13  type AssetRef, type FeeItem, type ProviderContext, type RouteStep, type SwapProvider, type SwapQuote, type SwapQuoteRequest,14} from '../types.ts';15import type { ProviderEnv } from './index.ts';1617const ID = 'debridge';18/** pinned API base and version */19export const DLN_API = 'https://dln.debridge.finance/v1.0';20export const DLN_DOCS = 'https://docs.debridge.com/dln-details/integration-guidelines/order-creation/creating-order/creating-order';21/** deBridge: "sign and submit within 30 seconds" of the create-tx answer */22const VALIDITY_MS = 30_000;2324type Kind = 'evm' | 'sol' | 'tron';25interface DlnChain { chainId: number; kind: Kind; native: string; source: string; destination: string; router?: string }26const EVM = { kind: 'evm' as const, native: '0x0000000000000000000000000000000000000000', source: '0xeF4fB24aD0916217251F553c0596F8Edc630EB66', destination: '0xE7351Fd770A37282b91D153Ee690B63579D6dd7f', router: '0x663DC15D3C1aC63ff12E45Ab68FeA3F0a883C251' };27/**28 * wallet network id -> DLN chain id (deBridge's own ids for Solana, HyperEVM and TRON), the id DLN uses for the chain coin,29 * and the DLN contracts: DlnSource (holds the input), DlnDestination (fulfilment), DeBridgeRouter (pre-order swap + order).30 * Source: https://docs.debridge.com/dln-details/overview/deployed-contracts (read 2026-11-10).31 */32export const DLN_CHAINS: Record<string, DlnChain> = {33  ethereum: { chainId: 1, ...EVM }, optimism: { chainId: 10, ...EVM }, bsc: { chainId: 56, ...EVM }, polygon: { chainId: 137, ...EVM },34  base: { chainId: 8453, ...EVM }, arbitrum: { chainId: 42161, ...EVM }, avalanche: { chainId: 43114, ...EVM }, hyperevm: { chainId: 100000022, ...EVM },35  solana: { chainId: 7565164, kind: 'sol', native: '11111111111111111111111111111111', source: 'src5qyZHqTqecJV4aY6Cb6zDZLMDzrDKKezs22MPHr4', destination: 'dst5MGcFPoBeREFAA5E3tU5ij8m5uVYwkzkSAbsLbNo' },36  tron: { chainId: 100000026, kind: 'tron', native: 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb', source: 'TX2Ut1reF59i2WPzsYVoMfA25EkUkavnd5', destination: 'TXCbCdoHjg28g36X5jnWTP88mRzx54RqXp', router: 'TRsqznGXF7mSYhRxU49L3HY13QW1v9yBZa' },37};38const NETWORK_OF = new Map(Object.entries(DLN_CHAINS).map(([net, c]) => [c.chainId, net]));39/** "nothing can be quoted for this request" answers (HTTP 400): reported as no route, not as a provider failure */40const NO_ROUTE = new Set(['ERROR_LOW_GIVE_AMOUNT', 'IMPOSSIBLE_ROUTE', 'UNSUPPORTED_TOKEN_IN', 'UNSUPPORTED_TOKEN_OUT']);4142// ---- response schema (unknown keys are stripped; `raw` is the validated payload) ----43const Amount = z.string().regex(/^\d{1,78}$/);44const Hex = z.string().regex(/^0x[0-9a-fA-F]*$/).max(200_000);45const Addr = z.string().min(1).max(128);46const Estimate = z.object({47  estimation: z.object({48    srcChainTokenIn: z.object({ chainId: z.number().int().positive(), address: Addr, decimals: z.number().int().min(0).max(36), amount: Amount, approximateOperatingExpense: Amount.optional(), mutatedWithOperatingExpense: z.boolean() }),49    /** present when the input is first swapped into a reserve asset (pre-order swap) */50    srcChainTokenOut: z.object({ chainId: z.number().int().positive(), address: Addr, decimals: z.number().int().min(0).max(36), amount: Amount, maxRefundAmount: Amount.optional() }).optional(),51    dstChainTokenOut: z.object({ chainId: z.number().int().positive(), address: Addr, decimals: z.number().int().min(0).max(36), amount: Amount, recommendedAmount: Amount, maxTheoreticalAmount: Amount.optional() }),52    costsDetails: z.array(z.object({53      chain: z.string().regex(/^\d{1,12}$/), tokenIn: Addr, tokenOut: Addr, amountIn: Amount, amountOut: Amount, type: z.string().max(64),54      payload: z.object({ feeAmount: Amount.optional(), feeBps: z.string().max(16).optional() }).optional(),55    })).max(16),56    recommendedSlippage: z.number().finite().min(0).max(100).optional(),57  }),58  tx: z.object({ data: Hex, to: z.string().regex(/^0x[0-9a-fA-F]{40}$/).optional(), value: Amount.optional() }),59  order: z.object({ approximateFulfillmentDelay: z.number().finite().min(0).max(7 * 86400) }),60  orderId: z.string().regex(/^0x[0-9a-fA-F]{64}$/),61  fixFee: Amount,62  protocolFee: Amount.optional(),63  estimatedTransactionFee: z.object({ total: Amount }).optional(),64  usdPriceImpact: z.number().finite().nullable().optional(),65});66const ErrorBody = z.object({ errorId: z.string().max(80), errorCode: z.number().optional(), errorMessage: z.string().optional() });6768const bad = (msg: string): never => { throw new ProviderError(ID, 'schema', msg); };69const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';70function base58Decode(s: string): Uint8Array {71  let n = 0n;72  for (const c of s) { const i = B58.indexOf(c); if (i < 0) throw new Error('not base58'); n = n * 58n + BigInt(i); }73  const body: number[] = [];74  for (; n > 0n; n >>= 8n) body.unshift(Number(n & 0xffn));75  let zeros = 0; while (zeros < s.length && s[zeros] === '1') zeros++;76  return Uint8Array.from([...new Array<number>(zeros).fill(0), ...body]);77}78const hex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');79/** the bytes of an account as they appear inside a DLN order: 20 (EVM, TRON without its 0x41 prefix) or 32 (Solana) */80function accountHex(kind: Kind, addr: string): string {81  if (kind === 'evm') { if (!/^0x[0-9a-fA-F]{40}$/.test(addr)) throw new Error('not an EVM address'); return addr.slice(2).toLowerCase(); }82  const raw = base58Decode(addr);83  if (kind === 'sol') { if (raw.length !== 32) throw new Error('not a Solana address'); return hex(raw); }84  if (raw.length !== 25 || raw[0] !== 0x41) throw new Error('not a TRON address');85  return hex(raw.slice(1, 21));86}87const dlnToken = (a: AssetRef) => (a.asset === 'native' ? DLN_CHAINS[a.network]!.native : a.asset);88const sameAddr = (kind: Kind, a: string, b: string) => (kind === 'evm' ? a.toLowerCase() === b.toLowerCase() : a === b);89/** a (chain, token) pair named by DLN -> registry asset, or null when it is outside the wallet's registry */90function refOf(chainId: number, address: string): AssetRef | null {91  const network = NETWORK_OF.get(chainId);92  if (!network) return null;93  const c = DLN_CHAINS[network]!;94  if (sameAddr(c.kind, address, c.native)) return { network, asset: 'native' };95  try { return { network, asset: resolveAsset({ network, asset: address }).def.id }; } catch { return null; }96}9798function toQuote(p: z.infer<typeof Estimate>, req: SwapQuoteRequest, now: number): SwapQuote {99  const src = DLN_CHAINS[req.from.network]!, dst = DLN_CHAINS[req.to.network]!;100  const { srcChainTokenIn: tin, dstChainTokenOut: tout, costsDetails } = p.estimation;101  const from = refOf(tin.chainId, tin.address), to = refOf(tout.chainId, tout.address);102  if (!from || !to || !sameAsset(from, req.from) || !sameAsset(to, req.to)) bad('estimate is for another pair');103  if (tin.decimals !== resolveAsset(req.from).def.decimals || tout.decimals !== resolveAsset(req.to).def.decimals) bad('decimals differ from the registry');104  // prependOperatingExpenses=false: the order takes exactly the requested input105  if (tin.mutatedWithOperatingExpense || BigInt(tin.amount) !== req.amountIn) bad('estimate is for another input amount');106  // dstChainTokenOutAmount=auto: the order's take amount is the recommended amount. It is fixed inside the order, so the107  // recipient gets exactly this amount or the order is not filled: expected and guaranteed minimum are the same figure.108  const amountOut = BigInt(tout.amount);109  if (amountOut <= 0n || amountOut !== BigInt(tout.recommendedAmount)) bad('output amount is not the recommended amount');110  const fixFee = BigInt(p.fixFee), native: AssetRef = { network: req.from.network, asset: 'native' };111112  // ---- the calldata is not used for anything but these checks. They are necessary conditions, not a verification of the113  // order: the recipient bytes must appear in it (they also appear as the destination authority), the target must be pinned ----114  const data = p.tx.data.toLowerCase();115  let recipient: string;116  try { recipient = accountHex(dst.kind, req.recipient); } catch { throw new ProviderError(ID, 'bad_response', 'recipient is not an address of the destination network'); }117  if (!data.includes(recipient)) bad('order does not pay the requested recipient');118  let target: string | null = null;119  if (src.kind === 'sol') { if (p.tx.to !== undefined || p.tx.value !== undefined) bad('unexpected EVM fields for a Solana order'); } else {120    // EVM and TRON (TRON contracts are returned in 0x form): the order goes to DlnSource, or to DeBridgeRouter when a pre-order swap is needed121    const allowed = [src.source, src.router!].map((a) => [a, src.kind === 'tron' ? accountHex('tron', a) : a.slice(2).toLowerCase()] as const);122    target = allowed.find(([, h]) => h === p.tx.to?.slice(2).toLowerCase())?.[0] ?? bad('transaction target is not a pinned DLN contract');123    // value = flat fee, plus the input itself when the input is the chain coin124    if (p.tx.value === undefined || BigInt(p.tx.value) !== fixFee + (req.from.asset === 'native' ? req.amountIn : 0n)) bad('transaction value is not the flat fee (plus a native input)');125  }126127  // ---- fees ----128  const fees: FeeItem[] = [{ kind: 'protocol', asset: native, amount: fixFee, includedInOutput: false, estimated: false }]; // flat fee, chain coin of the source, paid on top129  if (p.estimatedTransactionFee) fees.push({ kind: 'network', asset: native, amount: BigInt(p.estimatedTransactionFee.total), includedInOutput: false, estimated: true });130  const via: string[] = [];131  for (const c of costsDetails) {132    if (c.type === 'PreSwap') via.unshift('pre-order swap');133    if (c.type === 'AfterSwap') via.push('destination swap');134    const kind = c.type === 'DlnProtocolFee' ? 'protocol' as const : c.type === 'TakerMargin' ? 'provider' as const : c.type === 'EstimatedOperatingExpenses' ? 'bridge' as const : null;135    if (!kind) continue; // PreSwap/AfterSwap legs and their slippage reserves are not fees; they are already inside the fixed output136    if (c.payload?.feeAmount === undefined) return bad(`${c.type} without an amount`);137    // charged in the reserve asset of that chain; outside the registry (e.g. USDC on BNB Chain) it cannot be expressed as a FeeItem — it stays in `raw`138    const asset = refOf(Number(c.chain), c.tokenIn);139    if (asset) fees.push({ kind, asset, amount: BigInt(c.payload.feeAmount), includedInOutput: true, estimated: c.type === 'EstimatedOperatingExpenses' });140  }141  via.splice(via.includes('pre-order swap') ? 1 : 0, 0, 'DLN');142143  const contracts: RouteStep['contracts'] = [];144  if (target) { contracts.push({ network: req.from.network, address: target, role: 'router' }); if (req.from.asset !== 'native') contracts.push({ network: req.from.network, address: target, role: 'spender' }); }145  contracts.push({ network: req.from.network, address: src.source, role: src.kind === 'sol' ? 'program' : 'vault' });146  contracts.push({ network: req.to.network, address: dst.destination, role: dst.kind === 'sol' ? 'program' : 'bridge' });147148  return {149    id: `${ID}:${p.orderId}`, provider: ID, request: req,150    steps: [{ kind: 'bridge', provider: ID, from: req.from, to: req.to, via, contracts }],151    amountOut, minAmountOut: amountOut, fees,152    estimatedSeconds: Math.ceil(p.order.approximateFulfillmentDelay),153    // deBridge reports the USD value lost between input and output (all fees included), in percent, negative = loss154    priceImpactBps: p.usdPriceImpact == null ? null : Math.round(-p.usdPriceImpact * 100),155    expiresAt: now + VALIDITY_MS, firmness: 'firm',156    executability: { class: 'opaque', reason: 'the DLN API builds the order transaction (calldata for DlnSource or DeBridgeRouter, a serialized transaction on Solana); the signer can pin the target contract, the value and the allowance but does not rebuild the order struct (recipient, take amount, authorities) by itself yet' },157    // an unfilled order can be cancelled from the destination chain by the order authority; the input and both fees return on the source chain158    refundable: true, atomic: false,159    raw: p,160  };161}162163export function createDebridgeProvider(_env: ProviderEnv = {}): SwapProvider {164  const networks = Object.keys(DLN_CHAINS);165  const known = (a: AssetRef) => { if (!DLN_CHAINS[a.network]) return false; try { resolveAsset(a); return true; } catch { return false; } };166  return {167    id: ID, name: 'deBridge',168    // public API, 50 requests per minute without a key169    capabilities: () => ({ networks, crossChain: true, needsKey: false, configured: true, kinds: ['bridge'], docs: DLN_DOCS }),170    supportsPair: (from, to) => known(from) && known(to) && from.network !== to.network,171    async quote(req: SwapQuoteRequest, ctx: ProviderContext): Promise<SwapQuote[]> {172      if (!this.supportsPair(req.from, req.to)) return [];173      if (req.amountIn <= 0n) throw new ProviderError(ID, 'bad_response', 'invalid amount');174      const q = new URLSearchParams({175        srcChainId: String(DLN_CHAINS[req.from.network]!.chainId), srcChainTokenIn: dlnToken(req.from), srcChainTokenInAmount: req.amountIn.toString(),176        dstChainId: String(DLN_CHAINS[req.to.network]!.chainId), dstChainTokenOut: dlnToken(req.to),177        // market order: DLN sets the output so that a solver fills it. There is no slippage parameter: the output is fixed in the order.178        dstChainTokenOutAmount: 'auto', prependOperatingExpenses: 'false',179        dstChainTokenOutRecipient: req.recipient, senderAddress: req.sender,180        // both authorities are the wallet's own addresses: only they can cancel the order and receive the refund181        srcChainOrderAuthorityAddress: req.sender, dstChainOrderAuthorityAddress: req.recipient,182      });183      let payload: unknown;184      try { payload = await ctx.http.getJson(`${DLN_API}/dln/order/create-tx?${q}`, { signal: ctx.signal }); }185      catch (e) {186        if (e instanceof ProviderError && e.code === 'http') { const err = ErrorBody.safeParse((e as { body?: unknown }).body); if (err.success && NO_ROUTE.has(err.data.errorId)) return []; }187        throw e;188      }189      const parsed = Estimate.safeParse(payload);190      if (!parsed.success) throw new ProviderError(ID, 'schema', parsed.error.issues.slice(0, 3).map((i) => `${i.path.join('.')}: ${i.message}`).join('; '));191      return [toQuote(parsed.data, req, ctx.now())];192    },193  };194}195
Files main
Menu

Theme