fix(escrow_wip): apply 2026-05-09 internal audit findings

Two HIGH validator-side bugs + several MED/LOW off-chain issues found
in the subagent-driven audit on this branch. New validator hash:
a8081acef26935d9b5f44b92052178e17301b6d6e6808c91c5b56f5d.

## HIGH-1: Deposit redeemer let depositors drain tokens

aiken-escrow/validators/escrow.ak Deposit branch now requires
`value_geq_value(new_value, in_value)` before computing net_added.
Previously net_added could carry negative quantities (when new_value
< in_value component-wise), letting a depositor write a matching
new_d.deposits with reduced values and pocket the difference as
wallet change. Latent under v1 ADA-only MCP usage but the validator
must hold against all callers.

## HIGH-2: Empty/partial deposits enabled funds drain via Veto/Refund

Veto and Refund branches now require
`value_eq(deposits_to_value(d.deposits), in_value)` — the tracked
deposits must account for the full locked value. Previously
`refund_outputs_satisfy(_, [])` was vacuously true on empty deposits,
so a driver could fire Veto/Refund on an escrow opened with
`initial_contributor=None` (deposits=[], in_value>0) and pocket the
input's lovelace as change.

Defense in depth: escrow_open builder now refuses
`initial_contributor=None`. New helper `deposits_to_value` folds
deposit FlatValues into a Value via `assets.add` for the equality
check.

## MED: off-chain fixes

- escrow_open min-utxo bumped 1M → 2M (Conway-era inline-datum
  + script-address outputs need ~1.4-1.7 ADA, NOT the 1 ADA default).
- escrow_settle_unsigned + escrow_refund_timeout_unsigned now derive
  `validity_lower_ms` via slot_to_posix_ms(network, slot) instead of
  Koios's `block_time*1000` — the chain reconstructs `lower` from the
  slot, so Koios's ~1s drift could pass off-chain preflight while the
  chain rejects at the strict-`>` boundary.
- escrow_open_unsigned MCP tool no longer accepts (and silently
  discards) `fee_lovelace` — the unsigned-tx builder auto-estimates.

## LOW: defensive depth

- escrow_veto + escrow_refund_timeout: `qty as u64` → `u64::try_from`
  so a corrupt or adversarial datum with negative i128 qty can't slip
  through with a wraparound.

## Tests

- 36 escrow builder tests pass (added rejects_no_initial_contributor)
- 132 dao tests pass under --features escrow_wip
- aldabra-mcp release build clean

## Infra

- Validator artifact files (plutus.json, validator.cbor.hex)
  regenerated. Dockerfile already wired to bake them at
  /etc/aldabra/escrow/ for MCP tools' validator_script_path arg.
- Internal audit findings written up at
  aiken-escrow/README.md including the v2-deferred
  items (multi-asset spend-input, lovelace-not-cross-checked, etc.)

Third-party audit still required before any mainnet deployment.
This commit is contained in:
Sulkta 2026-05-09 14:06:17 -07:00
parent ef38ff0e57
commit 7daa62b5e5
8 changed files with 192 additions and 65 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,7 +23,7 @@ use aiken/crypto.{VerificationKeyHash}
use aiken/interval.{Finite}
use aiken/cbor
use cardano/address.{Address, VerificationKey}
use cardano/assets.{Value, flatten, merge, negate, quantity_of, zero}
use cardano/assets.{Value, add, flatten, merge, negate, quantity_of, zero}
use cardano/transaction.{
Transaction, Output, OutputReference, InlineDatum, find_input,
}
@ -166,6 +166,44 @@ fn value_geq_flat(paid: Value, flat: FlatValue) -> Bool {
)
}
/// Sum every entry across all deposits into a single on-chain `Value`.
/// Used by Veto / Refund-timeout to enforce the invariant
/// `sum(deposits.value) == in_value` — i.e., the escrow's tracked
/// deposits must account for every lovelace + token actually locked
/// at the script. Without this, an escrow opened with empty deposits
/// + non-zero locked value (or one griefed via a token send) lets the
/// driver pocket untracked funds via vacuous-true `refund_outputs_satisfy`.
/// HIGH-2 fix from 2026-05-09 internal audit.
fn deposits_to_value(deposits: List<DepositEntry>) -> Value {
list.foldr(
deposits,
zero,
fn(d, acc) {
list.foldr(
d.value,
acc,
fn(policy_entry, acc2) {
let Pair(policy, assets_) = policy_entry
list.foldr(
assets_,
acc2,
fn(asset_entry, acc3) {
let Pair(name, qty) = asset_entry
add(acc3, policy, name, qty)
},
)
},
)
},
)
}
/// Component-wise equality on opaque `Value`. Cheaper than two-direction
/// `value_geq_value` because we can short-circuit on the first mismatch.
fn value_eq(a: Value, b: Value) -> Bool {
value_geq_value(a, b) && value_geq_value(b, a)
}
/// For each Deposit entry, an output to that contributor's base address
/// must pay at least the entry's value.
fn refund_outputs_satisfy(
@ -299,6 +337,13 @@ validator escrow {
expect signed_by(self, contributor)
expect Some((new_d, new_value)) =
find_continuing_output(self.outputs, script_addr)
// HIGH-1 fix (2026-05-09 audit): without this, `net_added` from
// `flatten(merge(new_value, negate(in_value)))` could carry
// negative quantities and the depositor could DRAIN tokens
// while writing a matching new_d.deposits with reduced values.
// Forcing new_value ≥ in_value component-wise ensures every
// net_added entry is non-negative.
expect value_geq_value(new_value, in_value)
// Datum unchanged except `deposits`
expect new_d.party_a == d.party_a
expect new_d.party_b == d.party_b
@ -334,6 +379,14 @@ validator escrow {
Veto -> {
expect Agreed { .. } = d.state
expect signed_by(self, d.party_a) || signed_by(self, d.party_b)
// HIGH-2 fix (2026-05-09 audit): without this, an escrow whose
// deposits don't account for every lovelace at the script
// (e.g. opened with `initial_contributor=None`, or griefed via
// a token-send) lets the driver pocket untracked funds as
// change because `refund_outputs_satisfy` is vacuously true on
// empty / partial deposits. Enforcing equality forces the
// tracked deposits to be the FULL accounting of locked value.
expect value_eq(deposits_to_value(d.deposits), in_value)
refund_outputs_satisfy(self.outputs, d.deposits)
}
@ -361,6 +414,10 @@ validator escrow {
expect d.state == Open
expect Some(lower) = tx_lower_ms(self)
expect lower > d.open_deadline_ms
// HIGH-2 fix (2026-05-09 audit): same invariant as Veto —
// deposits must account for full in_value, else driver
// pockets untracked funds via vacuous refund_outputs_satisfy.
expect value_eq(deposits_to_value(d.deposits), in_value)
refund_outputs_satisfy(self.outputs, d.deposits)
}
}