Zerochord
How it works

Building transactions

The builders in @cardano-swap/tx, the withdraw-zero pattern, the validity window, and why building and submitting are separate calls.

@cardano-swap/tx turns a quote into a signed transaction. It builds on @lucid-evolution/lucid, with packages/tx/src/lucid.ts implementing Lucid's Provider interface over this project's own ChainProvider.

The builders

BuilderWhat it produces
buildDanogoSwapA Kernel class swap: one transaction spending the pool UTxOs directly.
buildMinswapSwapOrderA Commitment class order UTxO carrying a binding minimum.
buildMinswapCancelOrderThe cancellation that spends an order back to its owner.
buildLockIntoSettlementPays route proceeds into the settlement script.
buildClaimSettlementReleases them when the committed aggregate reaches the user.
buildRefundSettlementReturns everything after expiry.

Each settlement builder has a plan* counterpart that produces the plan without building, so a caller can inspect what would happen before anything is constructed.

@cardano-swap/tx also carries SundaeSwap order construction, the Pyth zero withdrawal, and a simulation helper.

The quote is the only source of the minimum

Every builder takes the quote object itself, not a copy of its numbers.

const { tx, plan } = await buildMinswapSwapOrder(
  { lucid, provider, network, tagLines },
  { quote, ownerAddress },
);

The minimum written on chain is quote.minimumOut, and there is no parameter that can loosen it. The command line's --min-out can only tighten the bound, and the builder refuses anything looser than the quote.

The Kernel swap: withdraw zero

buildDanogoSwap produces the shape that makes a Kernel route atomic:

  • The protocol config and the pool script are reference inputs.
  • The pool script runs once, as a zero withdrawal from its own reward account, carrying the swap redeemer.
  • Each pool UTxO is spent and paid back with its new value and datum.
  • One signature, one submission, no order UTxO and no batcher.

The script running once for the whole transaction rather than once per pool is what lets several pools fit inside one execution unit budget. Several pools in one transaction is what makes the hops chain atomically: either the whole route settles or the transaction does not make it onto the chain.

Every Dano Finance pool sits at a base address whose payment part is the pool script and whose staking part is a script unique to that pool. Spending the pool requires that staking script to run, so its hash is read out of the address rather than out of configuration.

Legs carry the venue's own sign convention unchanged, so the redeemer bytes match what the validator expects:

interface DanogoSwapLeg {
  /** `txHash#index` of the pool UTxO to spend. */
  poolOutRef: string;
  /** Positive pays tokenX in and takes tokenY out; negative is the other way. */
  deltaAmount: bigint;
  /** The minimum this leg must produce. The builder refuses to build below it. */
  minOutChangeAmount?: bigint;
}

minOutChangeAmount is an off-chain guard. The on-chain guarantee comes from the pool output the validator checks, which the builder computes exactly.

The Commitment order

buildMinswapSwapOrder reads pools but spends none of them, so the route's direction and the order's lp_asset are derived from what the validator itself reads.

Two shapes are built:

  • SwapExactIn for a route that touches one pool.
  • SwapMultiRouting for a route that chains up to three pools under one end to end minimum_receive. MINSWAP_MAX_ROUTING_POOLS is 3, which is what the order validator accepts.

The single end to end bound is why the multi-hop form is worth building. The intermediate legs cannot slip away from the user, because only the final amount is checked.

Every order carries an expiry

MINSWAP_DEFAULT_ORDER_TTL_MS is 24 hours. An order with no expiry rests forever, so an expiry is always written. A day is long enough that a temporary batcher outage does not strand the order, and short enough that stranded capital is bounded.

After the expiry, anyone may cancel the order and keep up to the configured cancellation tip, with the rest returning to the owner. The plan the builder returns carries both values, so the interface can show them.

The validity window

Both builders set a validity interval. The lower bound is 120 seconds behind the clock, to absorb skew. The upper bound defaults to 240 000 ms ahead.

The ledger refuses a transaction whose upper validity bound has passed, whatever the validator would have said, so the failure arrives before any script runs and looks nothing like a script failure. A window sized for the builder's own machine is too small the moment the transaction is handed to a person, to another process, or to a queue. One build that took 5 seconds took 210 seconds an hour later, spending most of its own window being built.

submitWindowMs therefore widens the upper bound on request:

await buildDanogoSwap({ lucid, provider, network, adapter, tagLines, submitWindowMs: 600_000 }, legs);

The default is the builder's previous fixed value in every case, so widening is opt in and no existing caller changes behaviour. A non positive or non integral value throws SubmitWindowError rather than being coerced.

The swap screen leaves it unset, because it submits within seconds of signing.

Building and submitting are separate

The transaction is signed and complete before anything is sent, so the same path can be exercised without spending anything, and the thing inspected is the thing that would go to the chain.

const prepared = await prepareSwap({ source, quote, wallet, network });
const txHash = await submitPrepared(source, prepared);

submitPrepared checks the window before sending. A wallet dialog waits for a person, and the deployed Dano Finance validator refuses any interval longer than about 360 slots, so a user who takes four minutes to approve has a transaction the ledger will not take. Rather than let the node answer with OutsideValidityIntervalUTxO, the application raises ValidityWindowClosedError naming the cause, sends nothing, and refetches the quote.

Every builder tags its transaction

Builders never attach raw metadata of their own. They pass through attachProductTag, which validates the CIP-20 message and refuses one that does not name the project.

See Transaction tags.

Execution units are never invented

The Lucid Provider adapter deliberately does not implement evaluateTx. Lucid falls back to its own local UPLC evaluator.

A provider returning invented execution units would be worse than one that declines: an underestimated budget makes the transaction fail at submission, after the user has signed.

On this page