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
| Item | Value |
|---|---|
| Source | onchain/validators/settlement.ak |
| Aiken | v1.1.23 |
| Plutus version | V3 |
| Standard library | aiken-lang/stdlib v3.1.0 |
| Property testing | aiken-lang/fuzz v2.2.0 |
| Compiled size | 1384 bytes |
| Script hash | 12edd011492ecc8953bb5b559b1c25509d5a59477b67d146bef82bf2 |
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:
| Item | Value |
|---|---|
| Address | addr_test1wqfwm5q3fyhvez2nhdd4txcuy4gf6kjegaak052xhmuzhus9c702t |
| Reference script | 8e066c7dde25ef4b7158e1995e841fbc2a4ee8ee5061f79a55bef23396eb75c0#0 |
| Deployment transaction | 8e066c7dde25ef4b7158e1995e841fbc2a4ee8ee5061f79a55bef23396eb75c0 |
| Expiry | 3600 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.