@butternetwork/wdk-protocol-swidge-butter
v0.1.0
Published
Butter Network Swidge provider for WDK.
Readme
@butternetwork/wdk-protocol-swidge-butter
Butter Network Swidge provider for WDK.
This package adapts WDK's Swidge interface to Butter Smart Router's /route,
/swap, /supportedChainInfo, token-discovery, and Butter swap-data APIs.
See CHANGELOG.md for release notes, breaking changes, and known upstream issues, and Known limitations for what this provider does not do.
Install
npm install @butternetwork/wdk-protocol-swidge-butter @tetherto/wdk-walletUsage
import ButterSwidgeProtocol, {
toEvmWalletClient,
toEvmPublicClient
} from '@butternetwork/wdk-protocol-swidge-butter'
// EVM execution needs BOTH a full WDK account and an EVM-capable sender.
const protocol = new ButterSwidgeProtocol(account, {
sourceChainId: 56,
entrance: 'wdk',
requestTimeoutMs: 10_000, // complete Butter HTTP request, including body parsing
apiKeyId: process.env.BUTTER_API_KEY_ID,
apiSecret: process.env.BUTTER_API_SECRET,
maxNativeFee: 20000000000000000n, // required for cross-chain (see Safety Defaults)
evm: {
walletClient: toEvmWalletClient(viemWalletClient),
publicClient: toEvmPublicClient(viemPublicClient), // enables allowance checks + status
approvalTimeoutMs: 10_000
}
})EVM execution requires both:
- a full (send-capable) WDK account — the WDK
swidge()contract throws without one, so a read-only or absent account is rejected; and - an EVM-capable sender —
evm.walletClient— to carry the swap/approval calldata (data/chainId). The WDK account alone cannot, because the WDKTransactiontype only guarantees{ to, value }, so calldata could be silently dropped. The wallet client carries a boundaccount.addressthat is validated against the WDK account, so the signer, calldata initiator, and allowance owner can never split. (This dual requirement collapses to one if WDK extendsTransactionwithdata.)
The account resolves the sender address and (optionally) confirms approval
receipts via getTransactionReceipt; it is never used to submit EVM calldata.
Wrap a viem wallet client with the exported toEvmWalletClient adapter (a raw
viem client is not structurally assignable). An optional evm.publicClient
enables ERC20 allowance checks (without one, an approval is always submitted and
confirmed through a receipt lookup). When every send reports the gas fee it
paid, the executed SwidgeResult reports that measured source gas; otherwise it
keeps the route estimate.
Only exact-in quotes are supported. Pass fromTokenAmount as a positive
bigint in base units. This module deliberately rejects exact-out
(toTokenAmount) before any network request: Butter's exact-out routing is not
uniformly available across chains, so the option fails fast with
ButterExactOutUnsupportedError. This also applies to the WDK base-class
swap() delegation when it forwards a tokenOutAmount.
apiSecret must not be bundled into browser or mobile clients. For public
clients, use a backend proxy. Authentication defaults to optional; anonymous
requests are subject to Butter's unauthenticated rate limits. Set
authMode: 'required' for production integrations that must never fall back to
anonymous requests.
Affiliate and referrer
Two optional construction-time settings are forwarded to Butter /route:
| Config | Format | Notes |
| --- | --- | --- |
| affiliate | <nickname> or <nickname>:<rate> | The affiliate collecting the integrator's share. Validated at construction. |
| referrer | free-form string | Mandatory for Solana same-chain routes; optional on EVM. |
Leaving affiliate unset does not make the swap cheaper. Butter substitutes
its own default affiliate wallet whenever the parameter is absent, so the share
is charged to the user either way — omitting it only forgoes your cut of a fee
the user already pays. Set it to collect that share, and know that it is being
collected regardless.
It is validated at construction rather than on the first request for the same reason: because Butter silently falls back to its own wallet, a malformed value would otherwise produce a perfectly successful swap with the share quietly going elsewhere, and nothing to notice.
A Solana same-chain route without referrer throws ButterConfigurationError
before any request is sent — Butter documents the parameter as mandatory there, so
the request could never be valid.
Both participate in the route cache key, so a route quoted under one affiliate is never reused after it changes. When unset, neither appears in the outgoing query nor in the cache key.
Exact-in only
Pass fromTokenAmount. Exact-out (toTokenAmount) is rejected before any network
request with ButterExactOutUnsupportedError, on both quoteSwidge/swidge and the
legacy quoteSwap/swap delegation path.
Butter's /route documents type: exactOut as a valid value, but two things stop
this package from offering it:
- The default production endpoint has been observed rejecting
type=exactOutwitherrno 2000("Parameter error") while the identicalexactInrequest succeeds. - The
/routedocumentation describesamountonly as "amount of source token", with no variant for exactOut — so even against a working endpoint, which side the amount denominates is unspecified, and guessing would misprice the trade.
npm run example:probe-exact-out re-checks both against the live API (read-only, no
funded account, exactIn used as a control). If it reports exactOut accepted and
settles the denomination, re-enabling is a small change: the execution-side
machinery — the source-amount upper bound, and the min(cap, route-reported input)
fee denominator that keeps an inflated route from understating a fee ratio — is
retained and unit-tested.
Behavior
quoteSwidge(options)calls Butter/route, stores a non-binding quote as an optional execution cache, and returns it with arouteHashyou can pin.swidge(options, config?)can be called directly. By default it reuses a matching fresh cached route or obtains a new one, enforces fee limits, calls/swap, validates the returned transaction intent, performs EVM approval when required, then sends the source transaction.- Route freshness is stricter on execution than on quoting. A cached route is
reused for a quote while ≥15s of its 5-minute lifetime remain, but
execution requires ≥
routeExecutionMarginSeconds(default 45s): it still has to complete the/swapround-trip, an optional ERC20 approval, and the swap send before the quoted price has to hold on-chain. Inside the margin, unpinned execution transparently re-quotes. The default deliberately does not assume an approval — when approvals are expected, raiserouteExecutionMarginSecondsaboveevm.approvalTimeoutMs / 1000(which defaults to 10s). These two values are coupled; the margin is configurable rather than hardcoded so the coupling stays explicit. - Butter HTTP calls have a complete-request deadline:
requestTimeoutMsdefaults to 10,000ms and covers the fetch plus error-body or JSON-body parsing. Timed-out requests abort and throwButterApiError; they are not retried automatically. ERC20 approval confirmation independently defaults to 10,000ms viaevm.approvalTimeoutMs(0means immediate timeout). - Pinning a quote: pass
options.routeHash(from a priorquoteSwidgeresult) toswidgeto execute that exact quoted route.swidgeacceptsButterSwidgeOptions(SwidgeOptions & { routeHash? }), so the field is part of the public typed API. If the route has expired, expires within the execution margin, or no longer matches the options,swidgethrowsButterActionRequiredErrorinstead of silently re-quoting at a different price — a pin is the price you approved, so it is never re-fetched the way an unpinned execution is. Pins are held in the instance's in-memory route cache, so quote and execution must use the same protocol instance. WithoutrouteHash, execution auto-re-quotes as before. - Exact-in only; see Exact-in only for why exact-out is rejected.
getSwidgeStatus(id)calls/api/queryBridgeInfoBySourceHash;{ byOrderId: true }calls/api/queryCrossInfoByOrderId. The options type is exported asButterSwidgeStatusOptions. Note this package never produces an order ID —SwidgeResult.idis always the source-chain hash — sobyOrderIdis for callers who obtained one from Butter separately. Same-chain swaps produce no cross-chain record, so their status is derived from the transaction receipt — but only after the source transaction is attributed to a Butter Router. If this instance executed the id (recorded atswidgetime) it is trusted; otherwise the source tx is fetched viaevm.publicClient.getTransactionand must target an allowlisted Router and beswapAndCall(swapAndBridge⇒ cross-chain). This attribution holds even with explicit{ fromChain, toChain }hints — hints never bypass it — so an unrelated transaction is never reported as a completed swidge (an unverifiable same-chain id throws). It also works across process restarts / new instances. Without a resolvable attribution it defaults to the cross-chain API (which never falsely reports completion). Transaction and receipt lookups treat only viem'sTransactionNotFoundError/TransactionReceiptNotFoundErroras absence; infrastructure faults (RPC timeout, auth, rate-limit) propagate to the caller rather than being masked as "not found" (which would force a falsependingor a silent cross-API fallback). Receipt-derived status requires anevm.publicClientwithgetTransactionReceiptor an account that exposesgetTransactionReceipt, and is fail-closed (only an explicit success iscompleted; an unknown receipt status stayspending).getSwidgeStatusmaps Butter cross states0 → pending(crossing),1 → completed, and6 → refunded. There is no numericfailedstate. Any undocumented or intermediate code (e.g. a relaying state) maps conservatively topendingrather than a terminal status, so an in-flight transfer is never misreported as failed. A response with no swidge info or no state still throws (the id is invalid/unknown).getSupportedChains()merges Router-supported chains with token API metadata. Each entry carries an extraexecutionfield describing how this instance would execute on that chain:native(built-in EVM),adapter(configuredtransactionAdapters), orquote-only. A chain whose merged metadata is missing anid,type, ornativeTokensymbol is dropped rather than listed with a placeholder — the same fail-closed rulegetSupportedTokensapplies to a token with unusable decimals. Quoting and execution are unaffected: they take chain ids from the caller, not from this listing, and a dropped chain keeps any strict slippage floor it qualifies for. Runnpm run example:discoverto see which chains this costs you on live data (the output reports the dropped ids and their missing fields).getSupportedTokens(options)calls Router/supportedTokenList?chainId=<id>. Chain selection usesfromChain, thentoChain, then the instance's source chain; route-scopedfromTokenfiltering is not available from Butter Router's per-chain listing. The result is Butter's advertised, non-exhaustive catalog, not a route allowlist: source- and destination-chain swaps can make additional tokens routeable.quoteSwidgeandswidgetherefore never require catalog membership;/routedetermines whether the requested token pair is currently routeable.- Token decimals resolve from
tokenDecimalsconfig first, then automatically through Butter's/findTokenAPI (cached per chain and canonical token). An explicitgetSupportedTokens()call also seeds this cache from its validated catalog. ConfiguretokenDecimalsonly for tokens Butter cannot resolve. Tron Base58Check and Butter hex forms share one cache key; Solana Base58 mints remain case-sensitive. - Native aliases are resolved per chain before calling Butter:
solmaps to Solana'sSo11111111111111111111111111111111111111112,trxmaps to Tron'sT9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb, andbtcmaps to the zero-address token identifier. The canonical addresses and the genericnativesentinel are also accepted.
Status & fee mapping
getSwidgeStatus maps Butter's state to WDK's SwidgeStatus:
| Source | Value | SwidgeStatus |
| --- | --- | --- |
| Cross-chain state | 0 crossing | pending |
| Cross-chain state | 1 completed | completed |
| Cross-chain state | 6 refund | refunded |
| Cross-chain state | any other / intermediate | pending (never a false terminal) |
| Same-chain receipt | explicit success | completed |
| Same-chain receipt | explicit revert | failed |
| Same-chain receipt | missing / unknown | pending |
quoteSwidge/swidge map Butter route fees into WDK SwidgeFee[]. The last column
is where each entry lands in the legacy swap()/bridge() scalars:
| Butter field | SwidgeFee.type | Legacy field | Notes |
| --- | --- | --- | --- |
| bridgeFee.in | protocol | bridgeFee | inbound leg of the bridge fee, in its own token |
| bridgeFee.out | protocol | bridgeFee | outbound leg of the bridge fee, in its own token |
| bridgeFee.affiliate | affiliate | (not visible) | integrator/affiliate share — counted against maxProtocolFeeBps |
| bridgeFee.amount | — | — | never priced; used only to detect that a fee exists which no component describes |
| gasFee | network | fee | source-chain gas; estimate, replaced by measured gas when the sender reports every send's fee |
| swapFee.nativeFee | protocol | bridgeFee | native-denominated actual swap fee, including any charge configured by feeConfig |
| swapFee.tokenFee | protocol | bridgeFee | input-token-denominated actual swap fee, including any charge configured by feeConfig |
| feeConfig | — | — | referrer fee configuration used to validate /swap calldata; never added as a separate fee |
bridgeFee is reported per component, and the top-level bridgeFee.amount summary is
never priced. It is a single figure in a single token describing a fee that can
span three tokens, so it is not attributable — and amounts in different tokens cannot
be added, which rules out reconstructing a component from it or even checking it
against the components' sum. When a route reports a summary but no in, out or
affiliate, the fee is omitted from fees[] with a bridge-fee-components-missing
warning and a configured protocol cap refuses outright, rather than measuring a number
it cannot attribute. npm run example:probe-fee-model shows how a live route
decomposes.
The affiliate share counts against maxProtocolFeeBps, even though fees[]
keeps WDK's affiliate type for it. This is a deliberate deviation: WDK has no
affiliate cap, and leaving the share unbounded bites hardest when you do not set
affiliate — Butter then substitutes its own wallet, so your users pay a cut you
never chose. maxProtocolFeeBps is the only knob available to bound it.
swapFee is Butter's authoritative actual fee result and already includes the fee
configured by feeConfig. Fee mapping and maxProtocolFeeBps therefore read only
swapFee; feeConfig is never added, used as a fallback, or compared with it. The
configuration remains security-sensitive during execution: /swap calldata must
encode the same (feeType, referrer, rateOrNativeFee) tuple returned by /route.
When a protocol cap is configured, missing swapFee amounts fail closed while
explicit zero amounts are accepted.
fees[] is always populated: if Butter reports no fees at all, it carries a single
zero-amount network entry rather than being empty (an empty array reads as "free").
Read fees[], not the legacy scalars. The protocol group can hold three
different denominations at once — bridge token, native, and input token — so the
legacy bridgeFee total adds unlike currencies together. bridge()/quoteBridge()
at least group by type (fee ← network, bridgeFee ← protocol);
swap()/quoteSwap() do not group at all and sum every entry regardless of
type or currency. Both behaviours live in the WDK base class, which providers must
not override, so this needs a WDK-side fix. Set onWarning to be told when it
applies to a given route:
const protocol = new ButterSwidgeProtocol(account, {
sourceChainId: 56,
entrance: 'wdk',
onWarning: ({ code, message, details }) => console.warn(code, message, details)
})
// -> 'mixed-currency-protocol-fees' when the protocol group spans several tokens
// -> 'no-fees-reported' when Butter reported none and fees[] is a placeholder
// -> 'bridge-fee-components-missing' when a bridge fee summary cannot be split
// from its affiliate shareSafety Defaults
sourceChainIdandentranceare required.Exact-out, zero inputs, unsafe JavaScript numbers, and amount conversions that would discard decimal precision are rejected.
Explicit cross-chain slippage below Butter's documented floor is rejected. Defaults use the applicable minimum. BTC routes use the stricter 300 bps floor; additional IDs can be configured with
strictSlippageChainIds.minAmountOutis compared locally with the minimum returned by/routebecause Butter's documented API does not expose a separate request parameter. For cross-chain execution this remains a quote check, not calldata enforcement: the destination minimum is inside the nested bridge payload trusted to Butter.refundAddressis optional, and when you name one it is verified rather than assumed. Omit it to accept Butter's own default refund destination, trusted like the rest of the destination routing. Naming one asks for a guarantee, so it is checked against the address the calldata actually encodes: the nested bridge payload'srefundAddresscross-chain, orswapAndCall's leftover receiver same-chain. If that payload cannot be decoded, the guarantee cannot be checked, so execution is rejected instead of proceeding as if it held — droprefundAddressto continue with Butter's default. It no longer has to equal the source sender: on a cross-VM route the source address is not even spendable on the destination chain.The built-in EVM path executes exactly one Router transaction. A
/swapresponse with more than one transaction is rejected, so repeated individually-valid Router calls cannot multiply native/ERC20 spend.EVM Router V3 calldata is validated at a deliberate middle tier. Always enforced: the target must be an allowlisted router (and match the route's
contract); the top-level intent — initiator, source token, source amount, and empty permit data — must match the request; the referrerfeeDatamust match the route's quotedfeeConfigas a full(feeType, referrer, rateOrNativeFee)tuple — emptyfeeDatais rejected when the route quoted a non-zero fee, and a non-emptyfeeDatarequires the quoted tuple to be complete (fail closed on any missing field) and to match exactly, so/swapcannot inject an uncheckedfeeType/referrerby under-specifying the quote; and the transaction value must satisfy the native-spend bounds below. Same-chainswapAndCalladditionally verifies the destination token, recipient, leftover receiver, and minimum output.Native-spend bounds:
tx.valueis checked as two one-sided bounds rather than an exactinput + routerFee + bridgeFeeequality./routeformats the router fee as a decimal string while/swapreturnstx.valueas a hex integer, so exact equality would reject a perfectly good transaction over a sub-wei artifact in that round-trip.- The native input half is a hard lower bound: a value below the quoted native input is rejected as under-funded.
- The remaining fee half is bounded only from above. Paying less than quoted cannot harm you — the router reverts if the fee is genuinely insufficient — so there is no lower bound and no two-sided tolerance.
- The fee half's upper bounds are
maxNativeFee(the security boundary) and the quotedrouterFee + bridgeFeeplus a 0.5 % formatting-drift tolerance (a consistency sanity check that catches a/swapcharging materially more native than/routeadvertised). The bridge messaging fee insidetx.valuecomes from the/swapcalldata and is trusted — it is not bounded by the quote — somaxNativeFee(an absolute cap on the whole fee half, in native base units) is the actual native-drain guard. When set, it is enforced on any chain (same-chain carries only the router fee). Cross-chain execution fails closed without it whenever the destination chain differs from the source chain — including when the calldata reports a zero bridge fee, so a route cannot opt out of the cap by under-reporting whattx.valuespends. Same-chain swaps do not require it.
maxNativeFeecan also be passed per call onswidge(options), where it takes precedence over the configured value (in both directions — a per-call cap may loosen or tighten it, and0nmeans "no native fee at all"). Prefer the per-call form when one long-lived instance serves a wide range of trade sizes: a single absolute cap is either too tight for small routes or nominal for large ones, and the caller knows the size at call time. Setting it per call also satisfies the cross-chain fail-closed requirement.Cross-VM destinations require an explicit
recipientonswidge. WDK defaults the recipient to the account address, which is only meaningful while the destination chain uses the same address format; bridging EVM→Solana/BTC without one would otherwise forward a0xaddress as the destination receiver. Address families are resolved from a best-effort table of Butter's non-EVM chain ids (constants.ts: NON_EVM_CHAIN_FAMILIES) — unlisted chains are treated as EVM, so the table must be extended when Butter adds a non-EVM chain. The requirement applies only toswidge:quoteSwidgestill prices a cross-VM route without a recipient, since asking a price before choosing a destination address is the normal flow.Cross-chain destination routing — the destination recipient, output token, and minimum output encoded in the nested bridge payload — is trusted to Butter's
/swapresponse and is NOT verified. This is an accepted middle-tier trust boundary, not full calldata intent validation: a compromised or buggy/swapcould route the destination output elsewhere. Only the bridge target (destination chain) is checked. Source-token exposure remains bounded because the module approves only the exact input amount to the router. SettingminAmountOutrejects an inadequate route, but does not upgrade this cross-chain destination guarantee beyondquoted-only.ERC20 approval only occurs after calldata validation and only targets a configured Butter router for the source chain. The approval is always for the exact input amount — there is no unbounded/
maxapproval option — so a compromised router can never move more than this swap's input.maxNetworkFeeBpsandmaxProtocolFeeBpsare enforced only inswidge, before/swap, approvals, or transaction submission.quoteSwidgenever throws on a cap — a quote is a non-binding estimate and always returns the full fee breakdown for inspection. Per-call values override constructor defaults. Cross-token fees require route-provided USD or same-stage valuation metadata when a cap is enabled; unvaluable fees fail closed withButterFeeValuationError.Quotes and discovery do not require a signer or local transaction adapter. Execution without a send-capable account or configured signer fails before a route request.
Tron, Solana, and BTC require explicit
transactionAdapters; Tron is not treated as viem-compatible EVM execution. Adapter execution bypasses the Router V3 calldata validation performed on the built-in EVM path — only chain ID and required transaction fields are checked, so adapters carry their own trust responsibility for the provider-supplied transaction data. Adapter output is still fully classified before anything is broadcast: each declaredtypemust be a legalSwidgeTransactionrole, a multi-transaction result must classify every entry ({ transaction, type }), and the set must resolve to exactly onesource— any violation throws with nothing sent, so a failed classification cannot leave a partially-broadcast operation a retry could double-execute.EVM transaction submission requires both a full (send-capable) WDK account (per the WDK
swidge()contract) andevm.walletClient(which carries the swap calldata, with a boundaccount.addressvalidated against the WDK account); the WDK account cannot submit EVM calldata because itsTransactiontype is only{ to, value }. The account is used for the sender address and approval receipts. ERC20 approval is always the exact input amount — an oversized existing allowance is reduced (approve(0)thenapprove(amount)), and an approval that cannot be confirmed (no receipt source) is refused rather than sent fire-and-forget.SwidgeResult.feesreports the measured source gas only when every send returns a fee, otherwise the route estimate; bridge/protocol fees remain route-derived estimates.Partial execution is reported, never silently discarded. Execution can broadcast more than one transaction (
approve(0),approve(amount), the swap; or several adapter legs). If execution fails after at least one transaction has already gone out,swidge()throws aButterPartialExecutionErrorwhosetransactionslists every broadcast hash in submission order and whosecauseis the original failure. Do not blindly retry — those transactions are already on-chain and re-sending would double-execute them; inspect them first. This includes an approval that cannot be confirmed (reverted, unknown receipt status, or a confirmation timeout): the approval is already on the wire, so you get its hash and the underlying error ascause— the swap itself is still never sent against an unconfirmed approval. It also includes a send that succeeded but reported an unusable gas fee: a transaction is recorded the moment its send returns, before the fee is validated, so a malformed fee never erases the hash. Fees are checked at runtime as non-negative bigints and hashes as non-empty strings on both the built-in EVM path and the adapter path, because a host-supplied sender makes the declared types hints rather than guarantees. The hash is the one value checked before recording — it is the record, so a send that returns no usable hash is unidentifiable and throws unwrapped rather than being reported. A failure before anything is broadcast propagates unwrapped. When the broadcast set includes thesourcetransaction, it is registered before the throw, sogetSwidgeStatus(hash)still resolves the in-flight swidge.Transaction/receipt lookups through
toEvmPublicClienttreat only a genuine viem not-found as absent; every other fault (RPC timeout, auth, rate-limit) propagates. The check is copy-independent — it matches viem's errornameplus itsBaseErrorshape rather than relying oninstanceof, which fails when the host application resolves a different copy of viem than this package.Legacy
swap()/quoteSwap()/bridge()/quoteBridge()from the WDK base class sumfees[].amountacross denominations (ignoringfee.token), so their scalarfee/bridgeFeeare only meaningful when every fee shares one currency. Butter fees can span native, input, and bridge tokens — read the itemisedfees[]on theSwidgeQuote/SwidgeResultfor correct per-currency costs. This is a WDK base-class contract issue a provider cannot fix without overriding legacy methods (which is disallowed); a WDK-side change is needed.
Example fee policy:
const protocol = new ButterSwidgeProtocol(account, {
sourceChainId: 56,
entrance: 'wdk',
maxNetworkFeeBps: 100,
maxProtocolFeeBps: 200
})
await protocol.swidge(options, { maxNetworkFeeBps: 50 })Supported chains and tokens
Execution capability comes in three tiers (discovery.ts: executionFor), reported
per chain as execution by getSupportedChains():
| Tier | Meaning |
| --- | --- |
| native | built-in EVM Router execution: this package validates the /swap calldata itself and submits it through evm.walletClient |
| adapter | execution goes through a transactionAdapters entry you supply. Router calldata validation does not apply — only chain ID and required fields are checked |
| quote-only | quoting, discovery, and status work; execution is unavailable until you pin a Router via routerContracts or supply an adapter |
Chains with a pinned Router in the built-in registry (constants.ts:
DEFAULT_ROUTER_CONTRACTS), i.e. native out of the box:
| Chain | ID |
| --- | --- |
| Ethereum | 1 |
| OP Mainnet | 10 |
| BNB Smart Chain | 56 |
| Unichain | 130 |
| Polygon | 137 |
| X Layer | 196 |
| Base | 8453 |
| Arbitrum One | 42161 |
| Avalanche C-Chain | 43114 |
| Linea | 59144 |
This is a curated subset of Butter's deployments, not the full set — see
Router Registry for adding others. Non-EVM chains Butter
supports (Solana, Bitcoin, Tron) are quote-only until you supply an adapter;
Tron is always adapter-or-quote-only and never uses the built-in EVM path.
Butter's advertised token catalog is discovered at runtime, not listed here —
call getSupportedTokens({ fromChain }). It is useful for recommended-token UIs
and cache prewarming, but it is intentionally non-exhaustive and must not be used
to reject a quote. Both discovery listings are fail-closed on missing required
metadata: a catalog token without usable decimals, and a chain without an id,
type, or nativeToken symbol, are dropped rather than returned with a
placeholder. A chain you expect to see but don't is usually this, not an outage.
Known limitations
- No automated testnet integration tests. The WDK integration guide asks for
them; this package does not have them yet. The env-gated flows in
examples/are the live-check mechanism in the meantime, including a read-onlyexample:decode-swap-datafor inspecting real Router calldata. - Cross-chain
toTokenAmountMinis quoted, not enforced. Checkquote.destinationGuarantees:'enforced'(same-chain, the minimum is verified against the Router calldata) or'quoted-only'(cross-chain, the destination minimum sits in the nested bridge payload that this package trusts to Butter by design). WDK's field description calls it a guaranteed minimum, so the difference is worth knowing. - Exact-out is not supported — see Exact-in only. Butter
documents the mode, but the default production endpoint rejects it and the
denomination of
amountfor it is unspecified. - A destination chain this package does not recognize requires an explicit
recipient. The address-family table is best-effort and Butter adds chains between releases, so an unrecognized chain is treated as "cannot default the recipient" rather than assumed EVM. Add such a chain toevmChainIdsonce you have confirmed it is EVM. priceImpactis not reported. Butter only exposes it per route leg, with no documented unit or whole-operation aggregation, so picking one leg would misrepresent a multi-leg operation. The field is leftundefinedrather than guessed.- Legacy fee scalars can be meaningless. See the fee mapping table above.
Router Registry
The package includes a versioned registry of known Router V3 deployments.
Addresses are pinned because /route and /swap are remote, untrusted inputs;
an API response cannot authorize a new transaction target by itself. The
built-in set is a curated subset of Butter's deployments; a chain without a
pinned entry is quote-only for built-in EVM execution until its Router is
supplied via routerContracts (verify the address independently first).
Per-chain configuration replaces the built-in entries for that chain:
const protocol = new ButterSwidgeProtocol(account, {
sourceChainId: 56,
entrance: 'wdk',
apiKeyId,
apiSecret,
routerContracts: {
56: [{ address: '0x1111111111111111111111111111111111111111', version: 'v3' }],
137: [{ address: '0x2222222222222222222222222222222222222222', version: 'v3' }]
}
})Use an empty array to disable built-in EVM execution for a chain. A configured address must use a validator version supported by this package; an address with a new ABI version requires a package update.
When Butter changes a Router address, existing installations reject calldata to
the new address before approval or transaction submission. This is a deliberate
fail-closed outage, not an automatic migration. Integrators can restore service
without waiting for a package release by verifying the deployment independently
and replacing that chain's routerContracts entry. In an emergency involving a
vulnerable old Router, operators must remove it (or temporarily configure [])
and notify integrators; the static defaults cannot dynamically revoke a formerly
trusted deployment.
Development
npm test
npm run typecheck
npm run build
npm pack --dry-runExamples
Runnable Node.js examples for discovery, exact-in quotes, read-only Router
calldata inspection, status lookup, and a confirmation-gated same-chain EVM swap
are available in examples/.
npm run example:discover
npm run example:quote
npm run example:decode-swap-data
npm run example:probe-exact-out
npm run example:status
npm run example:swapOnly example:swap sends a transaction, and it refuses to run without an
explicit confirmation value.
