Zerochord
On chain

The settlement validator

The Aiken validator that turns a route whose legs settle separately into one that fills or gives everything back.

A route is mixed when some legs settle atomically inside the user's own transaction and some legs are handed to a permissioned batcher that fills an order UTxO later.

Settled leg by leg, such a route has no aggregate guarantee. The Kernel legs land, a Commitment leg is never filled, and the user ends up below the direct route they were quoted.

This validator holds the route proceeds and releases them only when the user is paid the whole committed aggregate. That turns "some legs fill and some do not" into "the route fills, or the user takes everything back after expiry".

The build

ItemValue
Sourceonchain/validators/settlement.ak
Aikenv1.1.23
Plutus versionV3
Standard libraryaiken-lang/stdlib v3.1.0
Property testingaiken-lang/fuzz v2.2.0
Compiled size1384 bytes
Script hash12edd011492ecc8953bb5b559b1c25509d5a59477b67d146bef82bf2

The blueprint is onchain/plutus.json. cardano-swap deploy show --network <network> builds it and prints the hash, the size and the address it would deploy to.

Where it is deployed

On Cardano preprod:

ItemValue
Addressaddr_test1wqfwm5q3fyhvez2nhdd4txcuy4gf6kjegaak052xhmuzhus9c702t
Reference script8e066c7dde25ef4b7158e1995e841fbc2a4ee8ee5061f79a55bef23396eb75c0#0
Deployment transaction8e066c7dde25ef4b7158e1995e841fbc2a4ee8ee5061f79a55bef23396eb75c0
Expiry3600 slots

cardano-swap deploy settlement --network <network> --submit publishes it and prints the values to write into that network's configuration. Where a network has not been deployed to, the configuration holds null and reading it throws naming the key and the network, rather than borrowing another network's value.

The datum

pub type SettlementDatum {
  /// Payment key hash of the user. Only this key can be paid on a claim
  /// and only this key can be refunded after expiry.
  owner: VerificationKeyHash,
  /// The asset the user must be paid in.
  output_asset: AssetClass,
  /// The aggregate the user must receive across the whole transaction.
  /// This is the only amount the validator enforces.
  minimum_output: Int,
  /// The direct-route quote recorded when the route was built.
  /// Deliberately not enforced.
  baseline_output: Int,
  /// POSIX time in milliseconds. The refund path opens at this point.
  expiry_slot: Int,
  /// Leg count of the route, recorded for the same receipt reason.
  expected_legs: Int,
}

baseline_output and expected_legs are carried so the saving claim can be recomputed from chain data alone, without trusting the quoting service that made the claim in the first place. The validator does not enforce either.

expiry_slot holds POSIX time in milliseconds despite its name. The validator compares it against the script context's lower bound, which the ledger reports in milliseconds. The off-chain type names the field expiryMs so the unit cannot be misread off the type.

The redeemer is a bare enum, so Claim is constructor 0 and Refund is constructor 1, both with no fields.

The spend path

validator settlement {
  spend(datum, redeemer, own_ref, self) {
    expect Some(config) = datum
    let own_input = resolve_input(self.inputs, own_ref)

    // exactly one input at this script's payment credential
    expect [_] = list.filter(self.inputs, fn(input) {
      input.output.address.payment_credential == own_input.address.payment_credential
    })

    when redeemer is {
      Claim ->
        paid_to_owner(self.outputs, config.owner, config.output_asset)
          >= config.minimum_output
      Refund -> and {
        starts_at_or_after(self.validity_range, config.expiry_slot),
        fully_returned(own_input.value, returned_to_owner(self.outputs, config.owner)),
      }
    }
  }

  else(_) { fail }
}

Claim

The user must be paid at least minimum_output of output_asset across the transaction.

The commitment is on the aggregate, not on any single output. A batcher and a wallet both split payment over several outputs for reasons that have nothing to do with the route, so paid_to_owner sums the asset across every output whose payment credential is the owner's key hash. Summing is what makes the guarantee survive that.

Refund

Two conditions, both required.

The transaction cannot be valid before the expiry. starts_at_or_after reads the validity range's lower bound and requires it to be at or after expiry_slot. A transaction with an unbounded lower bound proves nothing about the current time and is rejected outright.

Everything the settlement UTxO held comes back, ADA included. fully_returned walks every asset in the held value and requires at least that quantity in the merged value paid to the owner.

Because ADA is included, the refunding transaction has to bring its own fee input. That is the user's own transaction, so the cost lands where the choice is made, and it keeps the check free of a fee tolerance that would otherwise be a slow leak.

The double satisfaction guard

Both redeemers measure value by reading the transaction outputs that pay the owner. Two settlement UTxOs spent together would each read the same outputs and each conclude, correctly on its own terms, that it was satisfied. Two UTxOs each owed 100 would both be released by a single output of 100, and the spender keeps 100.

The fix taken here is a restriction rather than tagging: a transaction may spend exactly one input carrying this script's payment credential.

With a single claimant there is no shared measurement left to double count, and no per-output bookkeeping is needed, which keeps the reference script small.

The cost is that a user holding two settlement UTxOs claims them in two transactions. Settlement UTxOs are one per route and short lived, so that cost is rare.

The credential is read from the resolved own input rather than from a parameter, so the validator stays unparameterised and has one hash.

What it covers, and what already has a guarantee

A pure Kernel route is already atomic and needs no validator from this project. A pure single venue Commitment route is already guarded by that venue's own minimum. The gap is the mixed route, and that is what this validator is for.

Its guarantee, stated exactly: never worse than the quoted minimum, or recover the pieces.

If one leg fills and another does not, the refund after expiry returns a mix of input and output assets rather than the original input, because the filled leg cannot be reversed. That is why the router prefers a single class route whenever its net output is within routing.singleClassPreferenceBps of a mixed one. A slightly worse price with a clean guarantee is the better product.

Tests

The validator's own tests are in the same file, built by hand from transaction.placeholder so each test states only the one fact it is about. packages/tx/src/__tests__/settlement.test.ts exercises the off-chain builders against it.

Both paths are confirmed on preprod, each against a twin differing in exactly one variable:

  • A claim paying the committed minimum is accepted, and its twin paying one base unit less is refused.
  • A claim on an under funded commitment is refused.
  • A refund one slot before expiry is refused.
  • A double satisfaction attempt is refused.

On this page