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:
parent
ef38ff0e57
commit
7daa62b5e5
8 changed files with 192 additions and 65 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue