Skip to content

Trust model

This page is the canonical reference for what amaru-treasury-tx trusts when it produces an unsigned transaction. It synthesises the upstream Amaru treasury system into a dependency graph and names the trust roots and threat surfaces this CLI cannot eliminate.

The CLI's safety story has three layers:

  1. The on-chain registry walk verifier (under Amaru.Treasury.Registry.Verify) reduces an untrusted metadata.json hint to a VerifiedRegistry projection, refusing every claim that disagrees with chain or build-time derivation.
  2. The swap wizard (under Amaru.Treasury.Tx.SwapWizard) composes verified registry data with operator-supplied wizard answers and a curated NetworkConstants table to produce intent.json.
  3. The final transaction preflight (under Amaru.Treasury.Build.Common) runs the finished unsigned Conway transaction through Cardano.Tx.Validate.validatePhase1 against the sampled ChainContext before CBOR is written.

The verifier's trust roots are described below; the wizard adds two more layers that are explicitly NOT verified by this codebase.

Final transaction preflight

Every build action produces a final unsigned transaction after balancing, script evaluation, and local fee alignment. Before the CLI writes CBOR, it runs the tx-tools Conway phase-1 validator with:

  • the network family selected by the CLI/N2C magic;
  • the protocol parameters sampled from the local node;
  • the resolved spend and reference UTxOs sampled from the same node view;
  • the tip slot sampled from that same view.

Unsigned transactions are expected to miss vkey witnesses. That witness completeness noise is filtered out because signing happens later. Any remaining phase-1 ledger failure is treated as a build failure and the CLI writes no unsigned transaction.

The current upstream validator does not seed reward-account state. For transactions that include withdrawals, the builder keeps the existing script-evaluation checks but skips this phase-1 preflight to avoid false WithdrawalsNotInRewardsCERTS failures until tx-tools grows reward state support. swap-cancel, which has no withdrawals, exercises the full phase-1 preflight.

System overview

The upstream treasury ( pragma-org/amaru-treasury) has three NFT-pinned UTxO families — Scopes, Registry, Treasury — plus a permissions script that holds no UTxO and is invoked through the withdraw-zero pattern.

The treasury holds the money but knows nothing about owners; it asks permissions; permissions asks the scopes UTxO; the registry tells everyone where the treasury and vendor scripts live — all three lookups are by NFT policy id, so addresses can rotate without re-parameterising scripts.

Bake-time parameter graph

flowchart TD seedS[seed UTxO for scopes] seedR[seed UTxO for registry] Scope{{Scope tag}} S["scopes validator
policy = SCOPES_NFT"] P["permissions(scopes_nft, scope)"] R["treasury_registry(seed, scope)
policy = REG_NFT[scope]"] T["Sundae treasury(cfg)
cfg.registry_token = REG_NFT[scope]
cfg.permissions.* = hash(P)"] seedS --> S seedR --> R Scope --> P Scope --> R S -- SCOPES_NFT --> P P -- "permissions script hash" --> T R -- "REG_NFT[scope]" --> T

Each script's hash is fixed at deploy time by:

  • the seed UTxO that anchors the NFT policy (one-shot — once the seed is spent, the policy id can never be re-minted);
  • the scope tag parameter for per-scope variants (permissions, registry, treasury);
  • the scopes NFT policy which permissions reads as a reference input to discover owner credentials.

Run-time UTxO graph

flowchart TD SU["Scopes UTxO (NFT-pinned)
Datum: Scopes {
core_development,
ops_and_use_cases,
network_compliance,
middleware : MultisigScript
}"] RU["Registry UTxO per scope (NFT-pinned)
Datum: ScriptHashRegistry {
treasury : Script h_t,
vendor : Script h_v
}
Immutable: old == new"] TU["Treasury UTxO per scope (Sundae)
Datum: TreasuryConfiguration {
registry_token,
permissions { reorganize, sweep, fund=AnyOf[], disburse },
expiration,
payout_upperbound = 0
}
Holds: withdrawn ADA"] P["permissions script (no UTxO)
withdraw-zero gate
Reorganize → owner
Sweep / Disburse → owner + 1 other
Fund → False"] VU["Vendor UTxO(s)
per disbursement"] GA[(PRAGMA General Assembly multisig)] TU -- "withdraw 0 (delegates auth)" --> P P -- "reference input" --> SU TU -- "reference input" --> RU TU -- "disburse / sweep" --> VU VU -- "reference input on cancel" --> RU GA -- "spend (mutate)" --> SU GA -- "spend (immutable)" --> RU

Why each arrow exists

Arrow Direction Reason
Treasury → Permissions runtime, withdraw-zero Sundae's treasury delegates who can sweep / disburse / reorganize to the permissions script. TreasuryConfiguration.permissions.* literally stores the permissions script hash.
Permissions → Scopes runtime, reference input expect_scopes(self.reference_inputs, scopes_nft) — owner credentials are dynamic, so permissions reads them from the Scopes UTxO each time. Decouples ownership rotation from script hashes.
Treasury → Registry runtime, reference input Sundae's vendor logic needs the treasury & vendor script hashes (e.g. for cancel-to-treasury). It locates them via the registry_token policy baked into TreasuryConfiguration.
Scopes UTxO ← admin spend Mutable but only by PRAGMA General Assembly multisig (must_be_approved_by_general_assembly). New datum must still match the Scopes shape.
Registry UTxO ← admin spend Spendable only by GA, and with_state forces old_datum == new_datum — effectively immutable until the NFT is burned.
Both NFT mints ← seed UTxO one-shot The two trap validators are parameterised by a seed OutputReference so the policy id is unique and minting can only happen once.
Vendor → Registry runtime, reference input Vendor outputs use the registry to find the treasury hash to return to on cancel.

Verifier trust roots (layer 1)

Two and only two:

  1. Build-time pinned constants in Amaru.Treasury.Registry.Constants:
  2. the two seed OutputReferences (scopesSeedTxIdHex, registrySeedTxIdHex),
  3. the four compiled Plutus blobs (scopes, treasury_registry, permissions, treasury) embedded from assets/plutus/*.cbor. These are reviewed in the PR that advances the upstream pin. The seeds are load-bearing: they make every NFT policy one-shot, so a forged Scopes or Registry NFT under our policy id is impossible.
  4. The on-chain ledger observed through a local cardano-node socket. The verifier never trusts a remote indexer, an HTTP endpoint, or the operator's filesystem outside of the metadata hint.

metadata.json is not a trust root. Every consumed field is cross-checked against an anchor (chain or build-time derivation), and an unverifiable field aborts the run.

What the verifier protects against

  • Stale references: a *.deployed_at TxIn that points at a spent UTxO fails the chain check.
  • Tampered hashes: any of treasury_script.hash, registry_script.hash, permissions_script.hash, address, owner disagreeing with the on-chain anchor (NFT datum) or the build-time derivation aborts.
  • Substituted UTxOs: a *.deployed_at TxIn that points at a UTxO not carrying the expected NFT (registry case) or not carrying the expected reference script (treasury / permissions case) aborts.
  • Wrong-scope swap: per-scope verification is keyed on ScopeId; a metadata file describing the wrong scope produces a typed error.
  • Mutable-owner attack: the Scopes datum is the only mutable field across the system; the verifier reads it through the on-chain Scopes NFT (one-shot policy + GA-multisig-gated spend) at the moment of the run, so a stale local copy of owners cannot survive.

What the verifier does NOT protect against

  • Compromised build-time pin: a malicious blob committed under assets/plutus/ would be accepted. Mitigation: the pin advances by PR; the diff is small and reviewable.
  • Compromised local cardano-node: a node serving forged ledger state to LSQ. Mitigation: run a node you control, verify it is in sync.
  • Race vs. mempool: a *.deployed_at UTxO consumed in flight after our LSQ acquire and before submission. The next run will fail; partial output is never written. Acceptable.
  • Off-chain wizard inputs — see layer 2 below.

The verifier is fail-closed: any anchor mismatch, spent UTxO, ambiguous match, parse failure, or chain query error returns a typed RegistryWalkError and the caller writes no output.

Wizard trust roots (layer 2)

In addition to layer 1, the wizard depends on:

  1. Build-time NetworkConstantsswapOrderAddress, usdmPolicy, usdmToken, sundaeProtocolFeeLovelace, extraPerChunkLovelace, poolId. Hard-coded per network (mainnet/preprod/preview), reviewed in the PR that advances the table. NOT verified against chain.
  2. Operator-supplied answers: --wallet-addr, --scope, --usdm, --chunk-usdm/--split, --min-rate, --validity-hours, rationale text, optional --extra-signer witnesses. Structurally validated but the wizard cannot judge intent.
  3. The local cardano-node socket for tip + UTxO queries (same as layer 1).

How intent.json fields map to the trust graph

Field Source Trust
scopesDeployedAt verifier (scope_owners TxIn) layer 1
permissionsDeployedAt verifier (permissions_script.deployed_at, ref-script UTxO) layer 1
treasuryDeployedAt verifier (treasury_script.deployed_at, ref-script UTxO) layer 1
registryDeployedAt verifier (registry_script.deployed_at, the registry NFT UTxO with inline ScriptHashRegistry datum) layer 1
registryPolicyId derived from build-time seed layer 1
treasuryAddress derived from verified treasury script hash + upstream addr1x<treasury_hash><treasury_hash> convention layer 1
treasuryScriptHash verifier (matches metadata, registry NFT datum, AND build-time derivation) layer 1
permissionsRewardAccount per-scope permissions script hash (the withdraw-zero target — see below) layer 1
coreOwner / opsOwner / networkComplianceOwner / middlewareOwner verifier (parsed from on-chain Scopes NFT datum) layer 1
swapOrderAddress, usdmPolicy, usdmToken, sundaeProtocolFeeLovelace, extraPerChunkLovelace, poolId NetworkConstants table layer 2 build-time
walletTxIn, walletAddress operator's --wallet-addr + largest pure-ADA UTxO selection layer 2 operator
chunkSizeLovelace, amountLovelace, rateNumerator, rateDenominator derived from --usdm, --chunk-usdm/--split, --min-rate layer 2 operator
validityUpperBoundSlot tip + --validity-hours layer 2 operator + node
signers selected scope owner inferred from --scope, plus optional extra witnesses from --extra-signer/--signer layer 1 default and scope-name extras; layer 2 raw key-hash extras
rationale.* operator-supplied free text layer 2 operator

Why permissionsRewardAccount is the permissions script hash

The Sundae treasury delegates authority to the permissions script via the withdraw-zero pattern: a transaction that wants to disburse / sweep / reorganize must include a 0-lovelace withdrawal from a reward account whose stake credential IS the permissions script. That gives the permissions validator a chance to run, where it reads the on-chain Scopes UTxO as a reference input and enforces the per-action approval rules (owner alone for reorganize; owner + one witness for sweep / disburse).

So permissionsRewardAccount MUST be the per-scope permissions script hash — NOT the stake credential of the treasury address. The two coincide only on networks where upstream's deployment sets addr1x<treasury_hash><treasury_hash>; relying on the coincidence would silently substitute the treasury hash on testnets that diverge.

What the wizard does NOT protect against

  • A wrong --scope answer: the wizard verifies the named scope but cannot detect that the operator typed the wrong scope name. Mitigation: every value-affecting step emits a WizardEvent line on stderr (or --log PATH) so the operator can read the trace before signing.
  • A --min-rate that disagrees with intent: the rate becomes a Sundae limit-price, not a sanity check on the swap's economic correctness. The wizard echoes the resolved numerator/denominator on stderr.
  • A wrong --wallet-addr: the wallet TxIn supplies fuel + collateral; if the operator signs with a key not matching the address, the tx fails at submission, not at wizard time.
  • A compromised NetworkConstants table — see layer 2 (1).
  • Operator-supplied raw signer key hashes: scope-name extra signers are resolved from the verified owner set, but a raw --extra-signer key hash is accepted as operator input. A wrong raw key hash produces a tx that needs a wrong signature; submission fails. Prefer scope-name extra signers for normal operation.
  • A compromised cardano-node — see layer 1.

The wizard is fail-closed: any verifier failure, missing network constant, empty wallet/treasury UTxO set, or out-of-range answer aborts with a non-zero exit and a typed message. Partial intent.json is never written.

Trace as audit trail

The wizard runs non-interactively (no confirmation prompt). Every value-affecting step — verifier acceptance, on-chain owner parsing, NetworkConstants lookup, UTxO selection, validity slot, chunk shape, output destination — emits a single structured WizardEvent line through Tracer IO WizardEvent. By default these lines go to stderr; --log PATH redirects them to a file. Operators are expected to read the trace before signing the produced intent.json; that is the audit gate.

What this means in practice

A reviewer of a wizard-produced intent.json should ask, in order:

  1. Layer 1 — does the build-time pin match what was reviewed in the most recent assets/plutus/ PR? Was the verifier run against a node the operator controls? Both can be answered from the PR history and the operator's machine state.
  2. Layer 2 build-time — is the NetworkConstants row for this network up to date? Same PR-review answer.
  3. Layer 2 operator — do --scope, --wallet-addr, --usdm, --min-rate, and rationale match the human intent for this swap? This is irreducibly the operator's responsibility.

Layer 1 is binary: either the verifier accepted or it refused. Layer 2 is what the audit trail (rationale fields + the trace log) is for.