Commit graph

13 commits

Author SHA1 Message Date
Kayos
d1c9e7a732 security: enforce max_send_lovelace + sandbox *_path args
Two CRIT findings from the 2026-05-12 Opus audit. Both are
mainnet-blocking against the aldabra-mainnet container.

CRIT-1 — cap-bypass via unsigned-build → sign_partial → submit chain.
Previously `wallet_send` / `wallet_mint` / `wallet_mint_cip68_nft` /
`wallet_script_spend` enforced `max_send_lovelace`, but the unsigned-
build tools + `wallet_sign_partial` + `wallet_submit_signed_tx` did
not. A prompt-injection that walked the cold-signer chain could drain
the wallet past the cap with zero policy enforcement.

Fix:
- `wallet_send_unsigned` / `wallet_mint_unsigned` /
  `wallet_plutus_mint_unsigned` now enforce the cap on the user-
  supplied destination lovelace, mirroring their signed equivalents.
  All three gain a `force: bool` arg with `#[serde(default)]`.
- `wallet_sign_partial` and `wallet_submit_signed_tx` decode the
  Conway tx CBOR, sum lovelace across every output whose address is
  NOT this wallet's own primary address, and enforce the cap on that
  total. Both gain `force: bool`. The chokepoint covers cold-signed
  multi-sig flows and any hand-built CBOR the daemon would otherwise
  blindly sign or submit.
- New free fn `sum_non_self_lovelace` is the unit-testable core of
  the chokepoint logic; `enforce_cap_on_cbor` wraps it.
- The sum uses `try_fold` + `checked_add` (NOT `.sum::<u64>()`) so a
  crafted CBOR that overflows `u64::MAX` fails the check instead of
  wrapping silently in release builds.

CRIT-2 — path traversal via `reference_script_path` and
`policy_cbor_path`. Previously the tools called `std::fs::read_to_
string(p)` on any path the LLM passed. The MCP daemon runs as the
same user that owns `$ALDABRA_DATA/mnemonic.age` /
`$ALDABRA_DATA/root-xprv.age`. Decode-error messages included the
hex_decode position offset — a small but real information leak about
non-hex file structure.

Fix:
- New `Config::safe_reads_root` field (default `$ALDABRA_DATA/scripts/`,
  override via `ALDABRA_SAFE_READS_ROOT` env or TOML).
- New `assert_inside_sandbox` helper canonicalize()s both the root and
  the user-supplied path, then enforces `starts_with`. Rejects
  outside-root paths, `..`-traversal, and nonexistent paths with
  generic messages.
- Hardlink-rejection: post-canonicalize, stat the file and refuse if
  `nlink > 1`. `canonicalize` resolves symlinks but NOT hardlinks (a
  hardlink IS the file — same inode, different directory entry), so
  without this check an attacker with daemon-uid write access could
  plant a hardlink to the encrypted key blob inside the sandbox and
  exfiltrate bytes through the read path.
- `resolve_ref_script_bytes` + `resolve_policy_cbor_bytes` + the
  `resolve_validator_required` wrapper used by all 5 escrow spend
  tools take `&Path` and route through the sandbox.
- Error messages on hex_decode failures no longer carry the path
  string or byte-offset position — return a constant "contents are
  not valid hex" instead.
- `main.rs` creates the sandbox root with 0o700 perms at startup if
  missing. chmod errors are surfaced (not swallowed) so a broken
  filesystem doesn't silently fall back to umask 0o755.
- README documents the new `ALDABRA_SAFE_READS_ROOT` env var alongside
  `ALDABRA_MAX_SEND_LOVELACE` (also previously undocumented).

Tests (243 → 253, +10):
- 5 sandbox tests: accept-inside, reject-outside, reject-dotdot,
  reject-nonexistent, reject-hardlink.
- 1 non-hex regression: constant message (no byte-offset leak).
- 3 cap tests: self-send → 0 non-self total, outbound counts,
  overflow → Err (regression for the prompt-injection `u64::MAX`
  wraparound attempt).
- 1 garbage-CBOR test: clean error.

No new clippy warnings, no new fmt drift, `cargo audit` unchanged
(0 CVEs, 2 transitive unmaintained warnings).

Adversarial review of the first draft (3 Opus reviewers) caught the
u64 overflow, the hardlink bypass, and the swallowed chmod error.
2026-05-12 12:45:03 -07:00
bdbb7e0539 chore: replace LAN-IP refs with public mirror URLs
Cargo.toml + Cargo.lock now point at https://github.com/Sulkta-Coop/pallas
for the pallas-fork patch entries. External clones from either public
mirror (github.com/Sulkta-Coop or gitlab.com/sulkta) build out of the box
— no LAN access needed.

Sulkta-internal builds short-circuit to LAN gitea via a `git config
url.X.insteadOf` rewrite on each host. Symmetric: covers both github and
gitlab → gitea. Same locked SHA either way; routing is environment-level,
not source-of-truth. See docs/internal-build-rewrites.md.

Dockerfile build-time rewrite also flipped to take public-URL inputs;
uses `gitea.sulkta.lan` instead of the bare LAN IP.
2026-05-10 14:46:05 -07:00
c7f7dcb102 audit: cargo fmt + clippy --fix across workspace + retract_votes cooldown bug fix
Surfaced by Track #38 code audit (2026-05-09):

1. cargo fmt --all: 217 formatting diffs across 35 files. Pure
   whitespace; no semantic changes.

2. cargo clippy --fix: 30 warnings -> 10. Auto-applied:
   - useless format!() (3 sites in builder/proposal_*.rs)
   - needless_borrow_for_generic_args (4 sites)
   - cloned_ref_to_slice_refs (1 site, builder/proposal_cosign.rs)
   - derivable_impls (1 site, dao/config.rs)
   - unused imports/variables (3 sites)

   Remaining 10 warnings are non-trivial (too_many_arguments on a
   constructor at 8 args, FromStr trait shadow, doc_lazy_continuation
   on a few comment blocks). Filed as tech-debt; no action this pass.

3. cargo audit: 0 vulnerabilities. 2 unmaintained advisories on
   transitive deps:
   - paste 1.0.15 (RUSTSEC-2024-0436) via rmcp + pallas-traverse
   - proc-macro-error 1.0.4 (RUSTSEC-2024-0370) via age->i18n-embed-fl
   Both upstream; tracked but no action needed locally.

4. Test failure surfaced: builder::proposal_retract_votes::tests::
   voting_ready_in_window_subtracts_vote_weight failed — cooldown
   check was applied unconditionally for RemoveVoterLockOnly mode,
   blocking the legitimate 'retract during voting window' path
   where the proposal datum mutates (vote weight subtraction). Per
   Agora's premoveLocks rule, cooldown only applies when retracting
   AFTER voting closed but BEFORE Finished — not during the active
   voting window. Fixed by gating cooldown on
   '!proposal_datum_will_change' so the in-window retract path
   bypasses cooldown the same way RemoveAllLocks does.

   Test: 87/87 aldabra-dao lib tests pass post-fix (was 86/87).
2026-05-09 10:27:48 -07:00
a0daadf38e fix(dao): audit punch list — H-1 to H-4 + M-2 + pallas bump
Lands the high-priority fixes from the 2026-05-06 audit before any
mainnet submit of the new vote/cosign/advance/destroy txs.

## H-1: Locked→Finished gate (MCP tool)

`dao_proposal_advance_unsigned` now refuses LockedToFinished unless
`tx_lower_ms > executing_end`. During the executing period the
validator demands gstMoved=true (governor input present); builder
doesn't include the governor input, so a tx in that window would
fee-burn. The proper Locked→Finished + GAT-mint flow is Phase 4c-bis;
this gate keeps us out of the broken middle.

## H-2 + H-4: strict-boundary + tx-upper-inside-period (MCP tool)

Validator's pgetRelation is strict on PAfter (`period_end < lb`)
and demands `ub <= period_end` on PWithin. Tool now picks PWithin
only when `tx_lower_ms >= period_start && tx_upper_ms <= period_end`,
PAfter only when `tx_lower_ms > period_end` strictly, and explicit-
errors on the boundary-straddling case (when tx validity range
crosses out of the target period). Same logic mirrored for the
VotingReady→Locked + VotingReady→Finished branches.

## H-3: vote builder lower-bound preflight (MCP tool)

`dao_proposal_vote_unsigned` previously checked only validity_upper
vs voting_end_ms. Validator demands BOTH `voting_start <= lb` AND
`ub <= voting_end`. Vote-too-early would hit "too early or invalid"
script error. New preflight on tx_lower_ms vs voting_start.

## M-2: DRep deposit pulled from ProtocolParams

Hardcoded constant (500 ADA) was wrong if the protocol changes
drep_deposit OR if the DRep was originally registered at a different
deposit amount (deregistration must match registration). Added
`drep_deposit_lovelace: u64` to ProtocolParams (default 500 ADA),
governance.rs build_signed_drep_registration / deregistration now
read from params instead of the constant. Constant kept for
backward compat with a doc note pointing at the params field.

## Pallas fork bump 507fd9da → 8091abd1

M-4 from the audit landed on the fork: voting_procedures builder
debug_assert_ne!s against empty CBOR map (0xa0) and docs the
upstream NonEmptyKeyValuePairs::decode footgun.

L-1 from the audit was a false finding — the audit subagent
misread the constants. PROPOSAL_CREATE_*_EX_UNITS are already at
the post-2026-05-05-H-2 values (5M mem / 2G steps per spend, 2M / 1G
per mint). The new builders alias these correctly. No change needed.
2026-05-06 08:06:44 -07:00
b93bda75c9 build: switch aldabra-pallas patch URLs to SSH
Per rescope 2026-05-06: real code repos get SSH for git auth, no
embedded credentials in URLs at all. Companion to commit a3a8421
which dropped the embedded token; this drops the HTTP transport
in favor of pure SSH key auth.

- Cargo.toml [patch.crates-io] URLs now ssh://git@192.168.0.5:23/...
- Cargo.lock source URLs match.
- .cargo/config.toml [net] git-fetch-with-cli = true unchanged —
  still required so cargo defers to system git, which uses the
  configured SSH identity.

Hosts that build aldabra need:
- /root/.ssh/config alias 'gitea' → 192.168.0.5:23, IdentityFile
  pointing at a key registered to the kayos Gitea account
- The corresponding private key

The ed25519 key generated for this is at
/root/.openclaw/keys/id_ed25519_kayos_gitea on the dev box. Pubkey
registered on kayos's Gitea account 2026-05-06. The crafting-table
runner on Lucy still uses an HTTP credential helper for now — that's
operational state in a kayos-controlled container, allowed under
the rescope. Will migrate to SSH later if Cobb wants full parity.
2026-05-06 07:55:32 -07:00
a3a842138c build: strip Gitea token from pallas patch URLs + add cargo config
Hard rule from Cobb 2026-05-06: zero secrets hardcoded in committed
source. The [patch.crates-io] block had the kayos Gitea PAT embedded
in the URL, which cargo then duplicated into Cargo.lock's source URLs.

Fix:
- Cargo.toml [patch.crates-io] URLs are now tokenless
  (http://192.168.0.5:3001/...)
- Cargo.lock source URLs scrubbed to match
- .cargo/config.toml adds [net] git-fetch-with-cli = true so cargo
  defers to system git for fetches; system git authenticates via
  the user's git credential helper (~/.git-credentials chmod 600).

Operators (devs + crafting-table runner) need a working git credential
helper for the LAN Gitea, configured out-of-band (NOT in this repo).
Pattern: `git config --global credential.helper store` +
`echo http://USER:TOKEN@192.168.0.5:3001 > ~/.git-credentials &&
chmod 600 ~/.git-credentials`. After Cobb rotates the kayos PAT,
update that file on every host that builds aldabra.
2026-05-06 07:45:37 -07:00
6443dcd858 feat(governance): wallet_drep_vote_cast + pallas voting_procedures patch
Phase 6, key-credentialed slice (script-DRep bridge for the DAO is the
remaining sub-arc).

## pallas-fork patch (Sulkta-Coop/pallas feat-aux-data HEAD 507fd9da)

Threads voting_procedures through StagingTransaction → conway::
build_conway_raw, mirroring the auxiliary_data + certificates patches.

- pallas-txbuilder/src/transaction/model.rs: voting_procedures field +
  builder methods .voting_procedures() / .clear_voting_procedures()
- pallas-txbuilder/src/conway.rs: VotingProcedures::decode_fragment on
  the way out, assigned to TransactionBody.voting_procedures
- BRANCH-NOTES.md: section 3 added documenting the new patch
- 2 new tests (round-trip + negative path) on the txbuilder side

aldabra Cargo.lock SHAs bumped to the new HEAD.

## aldabra-core/src/governance.rs

- VoteChoice enum (Yes/No/Abstain) with into_pallas() conversion
- build_signed_drep_vote_cast — assembles VotingProcedures CBOR
  (NonEmptyKeyValuePairs<Voter, NonEmptyKeyValuePairs<GovActionId,
  VotingProcedure>>) with this wallet's stake credential as a
  Voter::DRepKey, attaches via the new pallas API, dual-witness signs.
- Optional CIP-100 anchor on the vote.

## aldabra-mcp/src/tools.rs

- wallet_drep_vote_cast tool: gov_action_tx_hash + gov_action_index +
  vote (yes/no/abstain) + optional anchor.

What's still scope-of-Phase-6:
- Script-credentialed DRep voting (the DAO governor as DRep, with
  redeemer-driven authorization). Needs a different signing path
  since the voter is a script credential, not a key credential.
  Separate builder; defer until Sulkta wants to actually bridge.
2026-05-06 07:14:17 -07:00
f17479ab92 audit fixes: all 9 findings resolved + wallet generation tooling
HIGH:
- HIGH-1 enforce_value_cap helper applied to wallet.send,
  wallet.mint, wallet.mint.cip68_nft, wallet.script.spend. each
  gained a `force` arg; cap also covers the user_lovelace+ref_lovelace
  sum on cip68_nft. wallet.stake.delegate skipped (2 ada deposit is
  protocol-fixed, not a transfer to a non-wallet destination).
- HIGH-2 wallet.tx_summary mcp tool — read-only decode of a conway
  tx cbor → typed TxSummary (inputs, outputs+assets, fee, certs,
  mint, witness count, aux-data presence). new aldabra-core::inspect
  module. callers MUST run this before wallet.sign_partial /
  wallet.submit_signed_tx on any cbor they didn't build themselves.

MEDIUM:
- M-1 zeroize stack-resident extended_bytes after SecretKeyExtended
  consumes them. tx.rs::payment_key_to_private + sign.rs::add_witness.
- M-2 atomic 0o600 mnemonic file create via OpenOptions+
  OpenOptionsExt. removes the prior toctou window between fs::write
  (default umask) and chmod 600.
- M-3 prompt_or_env_passphrase + unlock_passphrase helpers wrap the
  passphrase in Zeroizing<String>. ALDABRA_PASSPHRASE env still
  unzeroizable in the env block itself (documented headless tradeoff).
- M-4 is_hex_64 validator on submit_tx response — koios error wrapped
  in quotes can no longer round-trip as a fake tx_hash.

LOW + cleanup:
- L-1 checked_add for inner sums of checked_sub patterns in tx.rs.
  remaining sites (mint.rs, stake.rs, plutus.rs) deferred — same
  pattern, can't overflow with realistic cardano amounts but
  defensive. picked up next.
- L-2 root key scoped to a block in main.rs — XPrv drops + wipes
  after deriving payment_key + stake_key + address. saves ~96 bytes
  of secret material lifetime.
- L-3 TxStatus gained a Pending variant for the mempool-but-not-yet-
  confirmed case. previously rendered as Confirmed{block_height: None}
  which was misleading.
- L-4 .expect("we built this key") → typed ? propagation in
  tx.rs::prepare_payment.
- L-5 removed dead fns (build_and_sign, decode_hex) + unused imports.

WALLET GENERATION (audit prompted gap-find):
aldabra had only an import path. no "generate fresh wallet" tool.
- Mnemonic::generate() — bip39::Mnemonic::generate_in(English, 24)
  with the rand feature. returns (Mnemonic, Zeroizing<String>) so
  the caller can display the phrase once for cold backup.
- aldabra --generate-mnemonic — print fresh phrase, exit. no disk.
- aldabra --bootstrap-new — generate + display + encrypt one-shot.
- bip39 dep gains the rand feature for OsRng-backed generation.
- standard 24-word BIP-39, recoverable from any cardano wallet.

mcp tools: 16 → 17 (added wallet.tx_summary).
unit tests: 88 → 93. cargo audit clean (0 cves), cargo build clean
(0 warnings). all four cli flags smoke-tested:
--generate-mnemonic prints + exits; --bootstrap-new generates +
encrypts + derives a real preprod address; mnemonic.age has 0o600
perms confirmed atomic.

audit doc memory/spec-aldabra-audit-2026-05-04.md updated with
status markers.
2026-05-04 14:52:08 -07:00
0ba95c1709 phase 4.5, 4.6, 3.6 close-out: stake delegation + multisig mint primitive
stake key + reward address (4.5):
- StakeKey::stake_address(network) — bech32 (`stake1...` mainnet,
  `stake_test1...` testnet) via pallas_addresses::StakeAddress::new
  (added to the fork in the same commit since the upstream tuple
  struct had no public constructor).
- StakeKey::xprv() — crate-internal accessor for signing.
- WalletInner now holds the stake_key alongside the payment_key.
- mcp tool wallet.stake.address surfaces the bech32.

stake delegation (4.6):
- new aldabra-core::stake module:
  - parse_pool_id(bech32) → Hash<28>
  - build_signed_stake_delegation(payment, stake, network, utxos,
    change_addr, pool_bech32, register_first, params) → signed cbor.
  - if register_first: prepends a StakeRegistration cert (consumes
    a 2 ADA deposit from inputs). otherwise just delegates.
  - signs with both payment_key (body witness) and stake_key (cert
    witness). reuses sign::add_witness for both — same body-hash
    ed25519 signing path regardless of CIP-1852 chain index.
- mcp tool wallet.stake.delegate: pool_id, register_first (defaults
  true). signs + submits.

3.6 close-out — wallet.mint.unsigned mcp tool:
- exposes the existing build_unsigned_mint with caller-supplied
  PolicySpec (json), so multi-sig / treasury flows can build through
  this wallet without it auto-signing. round-trip with
  wallet.sign_partial chain → wallet.submit_signed_tx.

depends on Sulkta-Coop/pallas@feat-aux-data which gained two more
patches in the same branch:
- StakeAddress::new public constructor.
- StagingTransaction::add_certificate / clear_certificates +
  Conway::build_conway_raw decode-and-plumb for certs (filling in the
  `certificates: None, // TODO` upstream).

mcp tools: 12 → 15 (wallet.stake.address, wallet.stake.delegate,
wallet.mint.unsigned).

79 → 84 unit tests. new coverage: stake address bech32 round-trip,
pool_id bech32 parse + reject-wrong-hrp, delegation tx with + without
registration (asserts cert count, witness count, cert variants).
fork tests grew: certificates_plumb_through_to_tx_body and
no_certificates_means_none.
2026-05-04 12:41:10 -07:00
a93a2b7cfa phase 3.2: cip-25 metadata via the pallas fork
unblocks named mints. wallet.mint now accepts an optional `metadata`
arg (json object); explorers + wallets render the asset with name/image
instead of <asset1xyz...>.

new aldabra-core::metadata module:
- json_to_metadatum: serde_json::Value → Metadatum (recursive). numbers
  must fit i64 (cardano metadata Int width). strings >64 bytes split
  into Array<Text> chunks at utf-8 char boundaries (CIP-25 v2
  long-string convention). null is rejected.
- build_cip25_aux_data(policy_id_hex, asset_name_hex, json_value):
  builds the label-721 wrapper (Map { 721: Map { policy_bytes:
  Map { name_bytes: attrs }, "version": "2.0" } }), wraps in
  AuxiliaryData::PostAlonzo, returns cbor bytes.

mint module:
- new build_signed_mint_with_metadata + build_unsigned_mint now take
  optional cip25_metadata. backward-compat: build_signed_mint is a
  thin no-metadata wrapper.
- prepare_mint + build_mint_staging plumb aux_data_cbor through.
  staging.auxiliary_data(bytes) is the new fork API surface — when
  set, conway::build_conway_raw decodes + computes
  auxiliary_data_hash automatically.
- regression test build_signed_mint_with_metadata_produces_aux_hash:
  decodes the resulting signed cbor, asserts both
  body.auxiliary_data_hash is Some and tx.auxiliary_data is present.
  catches the failure mode where metadata is silently dropped.

mcp wallet.mint gains a `metadata` arg field surfaced via schemars
JsonSchema. tools/list shape correctly carries the optional json
object.

depends on Sulkta-Coop/pallas@feat-aux-data — vendored via
[patch.crates-io] in the workspace Cargo.toml. PR upstream pending.

56 → 65 unit tests. 8 → 8 mcp tools (count unchanged, wallet.mint
gained an arg).
2026-05-04 12:11:11 -07:00
2f3d975c0f phase 3.1, 3.4, 3.5: native policy + mint path (no metadata yet)
new aldabra-core::mint module:
- PolicySpec enum: SingleSig, SingleSigTimelock, NofK
  - SingleSig{pkh}: ScriptPubkey native script
  - SingleSigTimelock{pkh, slot}: ScriptAll[ScriptPubkey, InvalidHereafter(slot)]
  - NofK{n, [pkhs]}: ScriptNOfK
- PolicySpec::single_sig(payment) + single_sig_timelock(payment, slot)
  convenience constructors that derive the pkh from a PaymentKey.
- policy_id() = pallas_traverse::ComputeHash<28>::compute_hash, which
  is blake2b-224 of (0x00 || cbor) — the canonical native-script hash.
- to_cbor() for callers that want the script bytes raw.

build_signed_mint / build_unsigned_mint:
- two-pass fee like the send path, plus a few extras specific to mint:
  staging.mint_asset(policy, name, qty), .script(Native, cbor),
  .disclosed_signer(payment_pkh) — the disclosed_signer surfaces the
  required signature in the tx body so the chain knows which witness
  to verify against the script.
- positive qty mints (asset goes into dest output), negative qty burns
  (asset comes out of input holdings, change preserves leftover).
- token-bearing change must hold ≥ min_utxo lovelace — same guard as
  the send path.

mcp tools:
- wallet.policy.create — args: invalid_after_slot? — returns
  {policy_id_hex, script_cbor_hex, type}.
- wallet.mint — args: dest_address, dest_lovelace (≥ 1 ADA),
  asset_name_hex, quantity (i64), invalid_after_slot? — auto-generates
  a single-sig policy bound to the wallet's payment key, builds, signs,
  submits.

8 → 10 mcp tools. 48 → 56 unit tests.

3.2 (CIP-25 metadata) is BLOCKED on pallas-txbuilder 0.32/0.35 — both
hardcode `auxiliary_data: None` in the conway builder. options for next
session: (a) post-build CBOR injection, (b) assemble tx via
pallas-primitives directly, (c) wait for upstream. flagged in the
spec doc.

3.3 (CIP-68) depends on 3.2. 3.6 (MAP 2-of-2) needs the multi-key
signing flow on the build side; PolicySpec::NofK variant is ready but
build_signed_mint only sign with one key today.
2026-05-04 11:44:16 -07:00
dd84303885 phase 2.1-2.4: send path — submit + status, txbuilder, wallet.send, wallet.tx_status
chain backend grew submit_tx (POST /submittx, raw cbor body) and
tx_status (POST /tx_info → Confirmed{block,epoch}|NotFound). serde
tag-based status enum so the mcp tool returns clean json.

new core::tx module: ProtocolParams + InputUtxo + build_signed_payment.
two-pass fee refinement — build unsigned, measure size, add witness
overhead constant (128 bytes for vkey+sig+cbor framing), recompute
real fee, build with final fee, sign once (PrivateKey doesn't impl
Clone in pallas-wallet, so we don't double-sign). change below
min-utxo merges into fee instead of emitting dust.

added pallas-txbuilder + pallas-wallet 0.32 deps. PaymentKey gains
crate-private xprv() accessor; payment_key_to_private converts
ed25519-bip32 XPrv → pallas-wallet PrivateKey::Extended via the
64-byte extended secret bytes.

mcp tools.rs: 4 → 6 tools.
- wallet.send (to_address, lovelace, force) with hard-cap guard
- wallet.tx_status (tx_hash) → status json
SendArgs/TxStatusArgs use schemars derive so rmcp generates proper
input schemas. config.rs adds max_send_lovelace (default 100 ADA,
ALDABRA_MAX_SEND_LOVELACE env override).

37 unit tests. mcp tools/list smoke confirms all 6 tools register
with correct schemas (force defaults false, lovelace required uint64,
to_address required string).

phase 2.5 (native-asset send), 2.6 (cold-sign offline mode), and
2.7 (real preprod smoke against a funded wallet) still open.
2026-05-04 11:18:33 -07:00
bc39148b63 phase 1: full read path — bip39 + cip-3 + cip-1852 + koios + age-mnemonic + rmcp
end-to-end working wallet: paste 24-word mnemonic, age-encrypt at rest,
on unlock derive root + payment + stake keys, build cip-19 base address,
serve four tools over mcp stdio (wallet.address, wallet.network,
wallet.balance, wallet.utxos).

deps added: ed25519-bip32 0.4 (pallas only ships raw ed25519, not the
cardano variant of bip32 hd derivation), cryptoxide 0.4 for pbkdf2-hmac-sha512,
age 0.10 for at-rest mnemonic encryption, rpassword 7 for tty-only passphrase
prompts, toml 0.9 for config.toml.

new modules:
- crates/aldabra-core/src/derive.rs — payment + stake key derivation, hash
- crates/aldabra-chain/src/koios.rs — real reqwest impl, asset aggregation
- crates/aldabra-mcp/src/{bootstrap,config,tools}.rs

caught one bug pre-flight: get_balance was clobbering same-asset
quantities across utxos instead of summing. fixed + regression test.

headless support via ALDABRA_PASSPHRASE env (mcp clients own stdin so
the rpassword prompt path can't run). docker secret / systemd
EnvironmentFile sources it in production.

dockerfile: multi-stage rust:1.95-bookworm → debian:bookworm-slim, tini
as pid1, non-root aldabra user, /var/lib/aldabra owned 700.

29 unit tests + 1 ignored live-koios test. preprod smoke test exercised
initialize → tools/list → tools/call wallet.address end-to-end via
piped json-rpc; correct preprod address came back from canonical
abandon-art mnemonic.

phase 2 (send) is next.
2026-05-04 11:09:00 -07:00