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:
- The on-chain registry walk verifier (under
Amaru.Treasury.Registry.Verify) reduces an untrustedmetadata.jsonhint to aVerifiedRegistryprojection, refusing every claim that disagrees with chain or build-time derivation. - The swap wizard (under
Amaru.Treasury.Tx.SwapWizard) composes verified registry data with operator-supplied wizard answers and a curatedNetworkConstantstable to produceintent.json. - The final transaction preflight (under
Amaru.Treasury.Build.Common) runs the finished unsigned Conway transaction throughCardano.Tx.Validate.validatePhase1against the sampledChainContextbefore 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¶
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¶
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:
- Build-time pinned constants in
Amaru.Treasury.Registry.Constants: - the two seed
OutputReferences (scopesSeedTxIdHex,registrySeedTxIdHex), - the four compiled Plutus blobs (
scopes,treasury_registry,permissions,treasury) embedded fromassets/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. - The on-chain ledger observed through a local
cardano-nodesocket. 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_atTxIn that points at a spent UTxO fails the chain check. - Tampered hashes: any of
treasury_script.hash,registry_script.hash,permissions_script.hash,address,ownerdisagreeing with the on-chain anchor (NFT datum) or the build-time derivation aborts. - Substituted UTxOs: a
*.deployed_atTxIn 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_atUTxO 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:
- Build-time
NetworkConstants—swapOrderAddress,usdmPolicy,usdmToken,sundaeProtocolFeeLovelace,extraPerChunkLovelace,poolId. Hard-coded per network (mainnet/preprod/preview), reviewed in the PR that advances the table. NOT verified against chain. - Operator-supplied answers:
--wallet-addr,--scope,--usdm,--chunk-usdm/--split,--min-rate,--validity-hours, rationale text, optional--extra-signerwitnesses. Structurally validated but the wizard cannot judge intent. - The local
cardano-nodesocket 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
--scopeanswer: the wizard verifies the named scope but cannot detect that the operator typed the wrong scope name. Mitigation: every value-affecting step emits aWizardEventline on stderr (or--log PATH) so the operator can read the trace before signing. - A
--min-ratethat 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
NetworkConstantstable — 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-signerkey 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:
- 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. - Layer 2 build-time — is the
NetworkConstantsrow for this network up to date? Same PR-review answer. - 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.