Skip to main content

Swaps and gas-free transfers

Move value between two Varnir identities without an on-chain transaction, gas, a liquidity pool, or a bridge. A transfer between identities is a ledger operation — it re-books a pooled balance from one identity to another — so it is instant, costs no gas, and never touches the underlying chain until someone decides to withdraw.

A swap trades two of these at once, and Varnir settles it atomically — one transaction both parties co-sign, moving both legs or neither (buildSwap/signSwap/submitSwap, below). Both legs are rows on the same ledger, which is why a cross-chain swap looks identical to a same-chain one.

See it live

The playground runs this end to end against the public testnet. "Run the exchange" settles a real cross-chain atomic swap — Tron TRX for Sepolia ETH — in one co-signed Varnir.Swap transaction, with a scanner link to the settlement. Nothing there is mocked.

Two balances, one ledger​

Every asset you hold on Varnir is a pooled L2 balance booked against your identity — summed across whatever L1 wallets back it. Deposits credit it; withdrawals debit it; and a transfer to another identity moves it between two rows on the same ledger.

Because both sides of a swap are rows on that same ledger, a swap needs no third party to hold both legs, no pool to stake into, and no bridge to cross. Each party moves what it already holds to the other. If one leg fails to sign or the sender is short, that leg does not happen — there is no half-settled state with an intermediary holding the other side.

transferOnLedger​

import { VarnirClient } from '@varnir/chain-client';

const client = new VarnirClient({ network: 'testnet', identity, privateKeyHex });

const { umid } = await client.transferOnLedger({
to: recipientIdentity, // the recipient IDENTITY, not an L1 address
chain: 'niles', // which chain's pooled balance to move
amount: '12.5', // human decimal string
// token: '<L1 contract address>', // omit for the chain's native asset
});

Three things decide whether this is correct:

  • to is an identity, not an address. It is what onboard / computeIdentity returns for the other party. The recipient is a ledger stream the contract credits directly — a stream it can credit, not a string it would have to trust — so passing an L1 address here is wrong.
  • token is the L1 contract address, not a ticker. The L2 partition is keyed on the normalised contract address, because "USDT" names a different contract on every chain. A token the on-ledger registry does not describe is refused, not scaled by a guess — call listTokens(chain) to see what a network credits. Omit token entirely for the native asset (TRX, ETH).
  • amount is a human decimal string, converted to raw smallest units by exact string arithmetic — see Backends for the convention shared across the SDK.

Unlike send, there is no L1 broadcast, no gas reserve, and no wallet selection — nothing leaves L2. umid is a real ledger transaction id you can open in the scanner or poll with getTransfer(umid).

Atomic swaps — buildSwap / signSwap / submitSwap​

A swap settles as one ledger transaction that both parties sign. The maker gives one asset, the taker gives another, and the Varnir.Swap contract moves both legs or neither — neither the taker nor the contract can rewrite the amounts either side signed.

// Maker gives 1 TRX (niles); taker gives 0.00001 ETH (sep). Cross-chain is no
// different from same-chain — the legs just name a different chain/token.
const tx = await maker.buildSwap({
maker: { identity: maker.identity, chain: 'niles', amount: '1' },
taker: { identity: taker.identity, chain: 'sep', amount: '0.00001' },
});
maker.signSwap(tx); // each party signs the SAME tx
taker.signSwap(tx);
const { umid } = await taker.submitSwap(tx); // submit once both have signed
  • Both parties are inputs, so the ledger requires both signatures. A swap cannot settle on one side's signature, and there is no half-settled state.
  • The contract enforces the trade. It checks each side holds what it gives, rejects a self-trade, refuses a read-only or treasury/AFX-protected key, and commits both partition moves in a single transaction — atomic by construction.
  • umid is the one settlement id for both legs. Open it in the scanner console (/console?stream=<umid>).
  • token is an L1 contract address and amount a human decimal string — the same rules as transferOnLedger above; omit token for the native asset.

The manual, non-atomic alternative​

You can still do a swap as two independent transferOnLedger calls — reasonable when one side is trusted or pre-funded — but then atomicity and ordering are yours:

await maker.transferOnLedger({ to: taker.identity, chain: 'niles', amount: '10' });
await taker.transferOnLedger({ to: maker.identity, chain: 'niles', amount: '1.05',
token: '<nile-usdt-contract>' });

Each leg is independent here: if the second never happens, the first still did. Prefer buildSwap whenever you need "both or neither".

Resting orders — post an order, then go offline​

The atomic swap above needs both parties online to co-sign one transaction. A resting order removes that: the maker signs the terms off-ledger, hands the signed order to a matcher (or anyone), and goes offline. A taker fills it later — in one transaction that the taker alone signs — and the contract verifies the maker's signature on the order before settling. Both legs still move atomically, or neither does.

A resting order is a limit order: give/want isn't just "what for what", it's a signed price. The maker's implied rate is want.amount / give.amount (how much of want it charges per unit of give), and — as the next section covers — the maker always settles at exactly that rate; it's the taker on the other side of a crossing trade who can end up paying less than it was willing to.

// Maker: sign an order, then you can go offline. Nothing has moved yet.
// This is a LIMIT order priced at 0.00001 ETH per TRX (0.00001 / 1) — the
// maker always settles at exactly this rate; see "Crossing the spread" below
// for what happens when a taker's own order is priced better than this.
const order = await maker.createOrder({
give: { chain: 'niles', amount: '1' }, // give 1 TRX
want: { chain: 'sep', amount: '0.00001' }, // for 0.00001 ETH
expireMs: Date.now() + 60 * 60 * 1000, // valid for an hour
// token: '<contract address>' on give/want for a token instead of the native asset
});
// `order` is { order, sig, orderHash } — publish it to a matcher / order book.

// Taker, any time before it expires: fill it. One tx, only the taker signs.
const { umid } = await taker.fillOrder(order);

CreateOrderOptions is exactly { give: OrderLeg; want: OrderLeg; expireMs: number }, where OrderLeg is { chain, token?, amount } — the same leg shape used everywhere else in this doc (human decimal amount, token an L1 contract address, omitted for the native asset).

What the contract guarantees when a taker fills:

  • The maker authorised exactly these terms. The maker's signature is over the whole order, so a taker cannot change the amounts, assets, or expiry — any edit breaks the signature and the fill is refused. The contract verifies that signature against the maker's on-ledger keys, the same way the ledger verifies any transaction signature.
  • Both legs or neither. In one atomic transaction the maker is debited its give and credited its want (or the pro-rata amount for a partial fill — see below), and the taker the reverse.
  • Single use per unit filled. Each fill is marked against the maker's own stream, cumulatively, so the same order's remaining balance can only ever be spent once — a fully-filled or cancelled order can never be filled again.
  • Full or partial fill. A taker may fill the whole order or, by passing fillOrder's fillAmount, a portion of it; the remainder stays resting for the next taker. See fillOrder below.

Crossing the spread — two orders don't need to name the same rate​

A resting order (the maker, already on the book) and an incoming order on the opposite side (the taker, arriving now) don't have to quote the exact same rate to match. They match whenever their prices cross — the taker is willing to pay at least what the maker is asking — and settlement always happens at the maker's own rate, so a taker whose bid crosses generously keeps the difference as price improvement. This is the standard best-execution convention, and it's exactly what Varnir.Swap's voteMatch checks:

// Maker (order A) gives X, wants Y. Taker (order B) gives Y, wants X.
// They cross when the taker's bid meets or beats the maker's ask:
wantA.amount * wantB.amount <= giveA.amount * giveB.amount // exact BigInt, no float

Equality is the old same-rate case (still matches, unchanged); strict crossing is new. A worked example with round numbers, to make the exact-integer check concrete:

  • Maker A rests: give 100 X, want 10 Y (asking 0.1 Y per X).
  • Taker B arrives: give 12 Y, want 100 X (bidding 0.12 Y per X — better for the maker than A's ask, so it crosses).
  • Check: wantA * wantB <= giveA * giveB → 10 * 100 <= 100 * 12 → 1000 <= 1200 → crosses.
  • Settlement is at A's (the maker's) rate: fillY = fillX * wantA / giveA = 100 * 10 / 100 = 10. B only pays 10 Y, not the 12 Y it was willing to give — B keeps the other 2 Y as price improvement.

The off-ledger matcher (apps/minigate/src/matcher.ts) mirrors this exact relation as isCrossableCounterpart and runs a continuous double auction over the resting book: orders are bucketed by asset pair, and each arriving order is paired against the best-priced crossing counterpart already resting — best price first, earliest receivedAt breaking ties (price-time priority, the standard exchange rule). Matching is event-driven — it fires the instant a qualifying order arrives — so a crossing pair typically settles within tens of milliseconds, backed by a periodic sweep as a backstop.

Partial matches are also possible on the matcher path: it fills whichever side has less room left and rests the remainder, exactly as fillOrder's partial fill does below — so a large resting order may be worked down by several smaller crossing takers over time.

fillOrder — full, partial, and rate-limited fills​

async fillOrder(
signed: SignedOrder,
opts?: { fillAmount?: string; minReceive?: string },
): Promise<{ umid: string }>
  • fillAmount (optional, smallest units of the order's want) takes only part of the order; omit it to fill the whole remaining order (unchanged default behavior). The maker's give is paid pro-rata at the order's own signed rate (give * fillAmount / want), and that division must be exact — a fillAmount that would leave fractional-unit dust is rejected, both here (fail-fast) and by the contract (authoritative). The unfilled remainder stays resting for the next taker.
  • minReceive (optional, smallest units of the order's give) is the taker's own floor: the least give-asset it will accept for this fill. A fill always settles at the maker's signed rate — never worse for the taker — so minReceive only ever rejects a fill that would pay out less than that floor; it can't change the settled rate itself. The contract (voteFill) enforces it. Omit it and the transaction is byte-for-byte identical to before this option existed.
// fillAmount is denominated in the order's WANT asset (ETH/wei here), not its
// give asset. Take half of a 1 TRX-for-0.00001 ETH order: 0.00001 ETH is
// 10000000000000 wei (18 decimals), so half is 5000000000000. At the order's
// own rate that pays 500000 sun (0.5 TRX, 6 decimals) — refuse to settle for
// less than 480000 sun, a 4% floor under that exact pro-rata payout.
const { umid } = await taker.fillOrder(order, {
fillAmount: '5000000000000', // half of 0.00001 ETH, in wei
minReceive: '480000', // 0.48 TRX in smallest units (6 decimals)
});

Cancelling a resting order​

expireMs caps how long an order can rest, but you can revoke one instantly:

await maker.cancelOrder(order.orderHash);

This writes an on-ledger cancel marker signed by the maker — authoritative even if the matcher holding the order forgets it. A cancelled order can never be filled.

Publishing and browsing the book​

createOrder signs the terms; it doesn't publish them. Two more calls round out the resting-order flow:

// Publish a signed order to every configured gateway, so a matcher/taker
// can find it. Succeeds if at least one gateway accepts it.
await maker.postOrder(order);

// Browse what's resting right now.
const book = await maker.getOrderBook(); // OrderBookEntry[]

OrderBookEntry is { order, orderHash, receivedAt, sig? } — the same order shape SignedOrder carries, plus the maker's signature. sig is typed optional (for backward compatibility with a gateway that hasn't updated), but minigate's /orders now includes it: an order is a public signed artifact regardless of who's browsing, and it's Varnir.Swap — not the gateway — that is the sole authority on whether a signature is valid, so exposing it here isn't a new trust boundary. That's what makes a direct fill possible: with sig in hand, a taker can call fillOrder (or marketOrder, below) straight against a book row.

There are still two ways a taker settles against the book, and both are live:

  • Mirror-and-match (Take). Post the exact mirrored counterpart (swap give/want, keep everything else) as your own resting order and let the matcher pair the two. No signature needed up front — this works even against a gateway that omits sig. This is how the scanner and mobile exchange UIs implement Take; the exchange guide walks through it end to end.
  • Direct fill. Call fillOrder with a book row's own { order, sig, orderHash } and settle in one transaction, no resting order of your own needed. This is what marketOrder (below) does automatically.

createOrder, fillOrder, cancelOrder, postOrder, getOrderBook, and marketOrder are the whole set behind the exchange UI: Make signs and posts, Take posts the mirrored counterpart, Cancel calls cancelOrder, and Market calls marketOrder for an immediate direct fill.

For AI agents​

A resting order is a natural unit for an autonomous agent: it decides terms, signs them, and posts them without staying online to complete the trade — another agent, or a matcher, fills it. Because the signed order is a standalone artifact, it is also where the treasury propose/verify rules extend to trading: an agent's proposer key signs the order, a verifier key reviews and co-signs it, and only a fully-approved order can be filled. (That order-level M-of-N is the next increment; until it lands, a treasury-protected identity is refused a fill rather than allowed to move funds on one key.)

Market orders — marketOrder​

A resting order waits to be filled. marketOrder does the opposite: it fills immediately against whatever crosses right now, at the best price on the book, and rests nothing of its own. It's immediate-or-cancel (IOC): it fills against existing liquidity on the spot or throws. It never posts a resting order.

async marketOrder(opts: {
give: OrderLeg;
wantAsset: { network: string; token?: string };
maxSlippageBps?: number;
}): Promise<MarketOrderResult>
import { VarnirClient } from '@varnir/chain-client';

const client = new VarnirClient({ network: 'testnet', identity, privateKeyHex });

// Spend up to 100 TRX (niles) for whatever ETH the book currently offers,
// accepting no worse than 0.5% under the best crossing order's own rate.
const result = await client.marketOrder({
give: { chain: 'niles', amount: '100' }, // up to 100 TRX
wantAsset: { network: 'sep' }, // alias or ledger code both work — see note
maxSlippageBps: 50, // 50 bps = 0.5% (the default)
});
// result: { fills: [{ umid, orderHash }], spentGive, receivedWant, unfilledGive }
  • give is an OrderLeg ({ chain, token?, amount }) exactly like createOrder's — chain accepts either the chain alias ('niles', 'sep', 'tbnb', or the VarnirChain enum) or the ledger code ('SEP', 'TRX-NILE', 'tBNB') — and a human decimal amount.
  • wantAsset is not an OrderLeg — it takes a network plus an optional token (an L1 contract address; omit for the native asset). The network accepts the same either-form as give.chain — alias or ledger code; both are normalised internally, so neither traps you on the distinction. There's no amount to give here — marketOrder spends up to the whole of give.amount and reports back exactly how much of wantAsset it got.
  • maxSlippageBps (optional, default 50 = 0.5%) is the taker's own floor below the chosen order's own rate, in basis points (10000 = 100%). It's converted to an exact-BigInt minReceive and carried into the underlying fillOrder call, so the contract — not the SDK — is what actually enforces it.

MarketOrderResult (all raw smallest units, unlike the human-decimal amount inputs elsewhere in this doc):

interface MarketOrderResult {
fills: { umid: string; orderHash: string }[]; // 0 or 1 entries in v1 — see below
spentGive: string; // raw smallest units of `give` actually spent
receivedWant: string; // raw smallest units of `wantAsset` received
unfilledGive: string; // raw smallest units of `give.amount` left over; "0" if fully filled
}

Selection and ranking. marketOrder reads the current book (getOrderBook()), keeps only orders that cross this taker (which, since a market order names no minimum rate of its own, is every resting order on the matching asset pair), and ranks candidates by the maker's own signed rate — best price first, tied-broken by earliest receivedAt (the same price-time priority the matcher uses).

One order per call (v1). marketOrder fills at most one resting order per call — the best-ranked one that can accept a non-trivial, dust-free fill — rather than sweeping across several makers. If that order's own remaining want is smaller than the give.amount you offered, unfilledGive reports what's left over instead of walking to the next-best order automatically:

if (result.unfilledGive !== '0') {
// Call marketOrder again — against the now-refreshed book — to keep
// spending the remainder. Each call is its own IOC decision.
}

No liquidity. If nothing on the book crosses this taker at all, marketOrder throws a clear error naming the asset pair it looked for, rather than silently returning an empty result — so a caller can't mistake "the book was empty" for "the fill succeeded with nothing to do".

Cross-chain is the same operation​

The cross-chain case is not a special mode. The legs differ only by which chain and token they name:

// Maker gives Tron USDT, taker gives Sepolia ETH.
await maker.transferOnLedger({ to: taker.identity, chain: 'niles', amount: '0.037',
token: '<nile-usdt-contract>' });
await taker.transferOnLedger({ to: maker.identity, chain: 'sep', amount: '0.00001' });

Nothing is wrapped, nothing is minted, and neither chain is aware the swap happened. The USDT never leaves Tron's pool and the ETH never leaves Sepolia's pool as far as the chains are concerned — only the L2 book changed. The assets touch L1 again only when whoever now holds them decides to withdraw, which is a separate decision made later. That withdrawal is the send path (a real L1 broadcast that pays its own gas), not this one.

When to use which​

You want to…Use
Move a balance to another Varnir identitytransferOnLedger — instant, gas-free
Swap atomically with a counterparty (both online)buildSwap → both signSwap → submitSwap
Post a limit order and let someone fill it latercreateOrder → publish → a taker's fillOrder
Fill now at the best available price (IOC)marketOrder
Swap where one side is trusted / pre-fundedtwo transferOnLedger calls, one per side
Pay an external L1 address / withdraw off Varnirsend — real L1 broadcast, pays gas
Let an agent propose spends a human approvesAI agent payments

Current limits​

  • Both parties must be onboarded Varnir identities. transferOnLedger credits a ledger stream; there is no L2 balance for an address that has never onboarded. To pay an external L1 address, use send.
  • Token sends off Varnir are limited. transferOnLedger moves any registered token freely on L2, but the L1 send path currently builds only native-asset withdrawals — see the note under Send. So a token you hold on L2 can be swapped now and withdrawn once that path lands.
  • Atomic swaps are built in. buildSwap/signSwap/submitSwap settle both legs in one co-signed transaction; createOrder/fillOrder do the same for a maker who signed and went offline. Atomicity is only yours to coordinate if you deliberately use two separate transferOnLedger calls.
  • Resting orders support full and partial fills, and crossing-spread matching. fillOrder's fillAmount takes part of an order; the off-ledger matcher pairs any two orders whose prices cross, not just same-rate pairs, and always settles at the resting maker's price. The order-book matcher and the exchange UI (scanner and mobile) are built — see the exchange guide. Order-level M-of-N approval for treasury-protected identities is still the next increment — such an identity is refused a fill on one key today.
  • Market orders fill at most one resting order per call (v1). marketOrder is IOC and best-price-time ranked, but doesn't sweep multiple makers in one call — see unfilledGive above for continuing a partial spend.
  • Unknown-decimal tokens are refused. A token missing from the registry cannot be scaled safely, so the call throws rather than risk a wrong amount. Register or discover it via listTokens.