A Cardano wallet key owning an EVM Safe: on-chain verification of CIP-8/COSE
signData signatures from Cardano Ed25519 keys, and a set-once registry that
binds such a key to a Safe as an ERC-1271 owner.
Self-contained Foundry project: no network access needed — all dependencies
are vendored in lib/ (see PINS below). From the repo root:
forge test -vv # 128 tests; GAS lines match gas-output.txt (up to test-scheduling order)
# (the differential campaign replay skips unless CAMPAIGN_DIR is set —
# docs/REVIEW.md, The differential campaign)Unaudited as a whole. The vendored curve core (SCL) carries upstream audit
provenance — see the PINS table, including its caveats. The composed system
built on it — the registry, the CIP-8 envelope verification, the ERC-1271
surface, and the deployment machinery — is original to this repository and has
not yet received an independent external audit. One is planned, and its report
will be published in this repository regardless of outcome. Until then, the
deployments recorded in docs/DEPLOYMENT.md are test deployments: review
them, attack them, reproduce them — but do not gate a high-value account on
this code.
docs/ARCHITECTURE.md— what the system does and how the pieces relatedocs/CRYPTO_SPEC.md— the exact bytes and acceptance rulesdocs/THREAT_MODEL.md— attacker model and the numbered invariants (INV-NN)docs/REVIEW.md— how changes are tested, reviewed, and adjudicateddocs/DEPLOYMENT.md— deterministic deployment procedure and the record of live deploymentsdocs/WALLET_SURVEY.md— per-wallet provenance behind the accepted header shapesAGENTS.md— house rules for every contributor, human or agentSECURITY.md— scope of adversarial work and vulnerability reporting
src/CardanoOwnerRegistry.sol— the candidate production contract: per-Safe set-once registration running the sequence ofdocs/CRYPTO_SPEC.md, Registration (credential, key acceptance, header template, proof of possession over a payload naming the Safe and chain), plus the ERC-1271isValidSignature(bytes32,bytes)interface with the bounded decode ofdocs/CRYPTO_SPEC.md, ERC-1271 wire format, which never reverts on adversarial input.src/Ed25519.sol— expansion and the verification equation, first-party over the vendored SCL curve primitives, with libsodium's acceptance rules. Its header is the single owner of the deviation list (reject-only, fail-closed).src/Blake2b224.sol— BLAKE2b-224 of 32 bytes via the EIP-152 precompile.bench/comparators/— third-party verifiers (mel-project/ed25519-sol, Near-One/rainbow-bridge) kept verbatim for benchmarking; their divergence from libsodium is pinned in their tests as EXPECTED assertions, not logs.test/Core.t.sol— the acceptance-set suite: all 205 libsodium-valid vectors must verify, all 16 libsodium-rejected vectors and the 16 forgeries against the verification equation must refuse (assertFalse; a revert counts as refusal, acceptance fails the build).test/EdgeSuite.t.sol— the fixed ed25519-speccheck differential corpus: 12 hash-pinned upstream cases plus seven generated torsion-bearing cases must match their recorded PyNaCl/libsodium verdict; none of them carries a deviation.test/WeakKeys.t.sol— deviation 3: the twenty-key table recomputed from the curve, and two oracle-accepted signatures per key refused at expansion beside accepted adjacent controls.test/Registry.t.sol— registration invariants + ERC-1271 behavior.test/EnvelopeOracle.t.sol— the CIP-30 envelope differential corpus replayed: recorded verdicts from an independent COSE implementation (the Cardano Foundation CIP-30 DataSignature parser, pinned by jar sha256) are re-asserted through the registry's own entry points, the oracle's independently serialized Sig_structure bytes must equalCardanoCip8.sigStructure's wherever it parsed an envelope, and every vector it accepts that this repository rejects carries a named narrowness class (docs/REVIEW.md, The envelope oracle).test/SafeIntegration.t.sol— the registry as owner of real Safes: the canonical Safe v1.5.0 singleton and proxy factory (and, as comparative evidence that pre-v1.5.0 Safes cannot use it, the v1.4.1 singleton) etched from the hash-pinned vendored runtimes infixtures/safe_artifacts.json; deterministic factory provisioning, registration and owner actions through realexecTransaction, selector-trace evidence that v1.5.0 calls onlyisValidSignature(bytes32,bytes)while v1.4.1 demands the unimplemented legacy interface and reverts, and the owner-threshold firing matrix (docs/THREAT_MODEL.md, Deployment assumptions).fixtures/— the committed vector corpus;scripts/— its generators.tools/lint.py— the mechanical half ofAGENTS.md, over every file the repository owns, tracked or unignored (vendored trees, fixtures and captures excepted; ignored products such ascampaign/are never read): every cited test and path exists, every Foundry test is named by the convention and asserts, no revert expectation is bare, no hex literal is retyped in a suite or generator, every reason string insrc/is named by a test, every gas figure in prose is labeled, no box-drawing character or curly quote anywhere. CI runs its self-test (a synthetic tree planting one instance of every violation class beside ignored paths that must stay silent) and then the lint, and fails on any finding.
Condensed from docs/WALLET_SURVEY.md, which holds the evidence tier and
provenance behind every cell. Registration payloads are 73 bytes and
owner-action payloads 86: a wallet path that hashes payloads at or below those
sizes emits signatures this registry cannot accept.
| Wallet | Evidence | Software path | Hardware paths |
|---|---|---|---|
| Eternl | live capture | registered + validated on the live test deployment | not surveyed |
| Lace | live capture | registered + validated on the live test deployment | not surveyed |
| Typhon | shipping bundle | compatible, byte-verified | Ledger compatible (never hashes); Trezor not surveyed |
| GameChanger | shipping bundle | compatible, byte-reproduced | Ledger + Trezor compatible (one self-verifying builder, never hashes) |
| GeroWallet | source | compatible, byte-verified | Ledger compatible (hashes only at ≥ 198 B); Trezor undetermined — COSE built in firmware |
| Vespr | source | compatible, golden-vector verified | Ledger + Keystone incompatible — they hash at ≥ 50 B, below this protocol's payload sizes |
Capture rows are the strongest tier: exercised end to end against a live deployment. Bundle and source rows are byte-verified against the pinned shipping artifact but not yet exercised live.
Needs uv. Each script pins its own dependencies
inline (PEP 723), so uv run scripts/<name>.py is the entire invocation — no
venv to prepare, and the pinned versions apply regardless of any active
environment. Generation is deterministic (fixed seeds, fixed keys): rerunning
the scripts must leave fixtures/ byte-identical. Scripts read and write
fixtures/:
scripts/parse_rfc.py→fixtures/rfc_vectors.jsonfromfixtures/rfc8032.txt(vectors are extracted from the RFC text mechanically, never transcribed).scripts/gen_vectors.py→fixtures/vectors.json(205 valid) +fixtures/adversarial.json(16, each asserted libsodium-REJECTED before being written) + its section offixtures/edge_report.txt(the oracle's characterization; every generator below that extends it owns one section, so the file is byte-identical whatever order they run in).scripts/gen_blake2b.py→fixtures/blake2b_vectors.json+ theH_INITconstant.scripts/gen_poc.py→fixtures/poc.json(registry fixtures; addresses derived from blake2b224(pk); the legacy-interface hash is keccak256 of the fixture's ownlegacyData, computed with pycryptodome, and the suite hashes the same bytes on-chain before accepting the signature over it).scripts/gen_wallet_fixtures.py→fixtures/wallet_e2e.json(real shipping-wallet signatures, extracted from the CIP-30 captures undere2e/captures/and libsodium-verified before emission; exercised bytest/WalletE2E.t.solunder the chain id and account each payload commits to).scripts/gen_boundary.py→fixtures/boundary_points.json(small-order, negative-zero, non-canonical and non-square point encodings, each classification proven by the script's own Edwards arithmetic) +fixtures/small_order_forgeries.json(one equation-holding, nonzero-scalar forgery for every decompressible small-order encoding, each required to be rejected by the pinned libsodium oracle before emission).scripts/gen_edge_suite.py→fixtures/edge_suite.jsonfrom the verbatim, hash-pinnedfixtures/ed25519_speccheck_cases.json; it records and rechecks PyNaCl/libsodium's verdict and computes the deviation flag for every tuple from its key bytes, generates an accepted mixed-order family spanning all seven nonidentity torsion components, and extendsfixtures/edge_report.txtwith the oracle verdict and expected verifier result per edge condition.scripts/gen_equation_forgeries.py→fixtures/equation_forgeries.json(forgeries against the verification equation, every one asserted libsodium-REJECTED and checked against the equation it targets before writing: neutral recomputations, mixed-order tuples satisfying the scalar-subtraction equation, and small-orderRwith the equation holding).scripts/gen_weak_keys.py→fixtures/weak_keys.json(deviation 3: the twenty-key table with its labels and the simulated precomputation failures, plus two libsodium-accepted signatures per table key and per adjacent control; the deviation column is table membership computed from each vector's key bytes).--solidityprints the table as the body ofEd25519.isWeakKey.scripts/gen_campaign.py→fixtures/campaign_report.txt+fixtures/campaign_manifest.json(chunk count, per-chunk sha256, vector count — what the replay verifies) + the gitignored 100,000-vector differential corpus they identify (docs/REVIEW.md, The differential campaign).scripts/gen_campaign_check.py→fixtures/campaign_check/(the small committed corpus pinning the manifest verification's sensitivity: a good replay plus its four mechanically derived tampered variants, generated with the campaign generator's own oracle-asserting machinery).scripts/gen_safe_fixtures.py→fixtures/safe_suite.json(CIP-8 signatures binding the deterministic Safe addresses the integration suite provisions, plus the Safe EIP-712 transaction hashes they authorize; every signature libsodium-verified before emission, every address and hash re-derived in-suite from the etched canonical bytecode). It readsfixtures/safe_artifacts.json, which is not a generator output but a vendored retrieval pin — canonical Safe runtime bytecode with its provenance recorded inside the file and in the PINS table — so regeneration stays fully offline.scripts/gen_envelope_oracle.py→fixtures/envelope_oracle.json(the CIP-30 envelope differential corpus: the real captures, a synthesized acceptance matrix over address types, header templates, payload domains and COSE tagging, and adversarial envelope mutations — every vector judged both by the off-chain pipeline,e2e/parse_signdata.parse, and by the independent CIP-30 oracle before emission, with the P1/P2/P3 gates ofdocs/REVIEW.mdfailing generation on any unexplained disagreement). The one generator needing more than uv: a Java 17+ runtime on PATH and, on the first run only, network access to fetch the three pinned jars from Maven Central into~/.cache/cip8-verifier/; every use, cached or fresh, re-verifies their sha256 against the constants in the script, and the committedscripts/EnvelopeOracle.javadriver is compiled by the pinned ecj at generation time. Build and test never touch Java.scripts/cip8.pyandscripts/oracle.pyare not generators but what the generators import: the CIP-8 envelope (both header templates, the Sig_structure, both payload grammars) built byte for byte asCardanoCip8builds it and asserted equal to cbor2's serialization on every call, and the libsodium verdict convention. The envelope oracle's runner and the off-chain gatee2e/parse_signdata.pydeliberately import neither (docs/REVIEW.md, The envelope oracle); the gate's own refusals are exercised byuv run e2e/parse_signdata.py --selftest, which CI runs beside the regeneration.
| Component | Version / commit | Provenance |
|---|---|---|
| forge | 1.7.1 | foundryup |
| solc | 0.8.36 (pinned in foundry.toml) | foundry-managed |
| evm_version | cancun (pinned) | — |
| optimizer | on, runs = 1000000 | — |
| lib/crypto-lib | get-smooth/crypto-lib @ d714e9824e2e2e44be3c8fd498e0de651ddc9425 (2024-12-14), subset: src/ minus libMPC/, libSCL_BIP327.sol, Ed25519DelegationEIP7702.sol; plus external/. One deliberate diff against upstream: the seven public functions of libSCL_EIP6565.sol are flattened to internal, inlining the EdDSA core into its callers — no separately deployed library, no delegatecall, single-artifact CREATE2 deployment. Guarded by test_registryArtifactSelfContained, which fails without the flattening |
The EdDSA-relevant tree at this commit is byte-identical to the post-remediation audit state 248f2ba (2024-09-16, "Veridise last report"); the VERIDISE-002 s-range fix is commit c7552d0. The report's named review base c559657 resolves to c55965773c9fbb21bd3d037c2d027907456a6149 (2024-07-22): reachable from no branch or tag, but fetchable by SHA (git fetch origin c55965773c9fbb21bd3d037c2d027907456a6149), so the exact reviewed tree is obtainable and verifiable. Its direct child on that same unmerged line, 0af3c3cae84b29a14fa374a29824dc3abbb3d586 ("VERIDISE 002: check s value range", 2024-08-21), carries tree c87081cb947f4937e831597bbac923771c6ff78d — byte-identical to mainline c7552d0, an ancestor of 248f2ba and of the pinned commit. The reviewed line never merged as commits; its remediated tree is exactly the mainline state this pin descends from (verified 2026-08-29 against a fresh bare clone: fetch-by-SHA, branch/tag reachability, tree hashes, ancestry). The Veridise reports are silent on cofactor, torsion and subgroup handling and record that the reviewed source suite included EdDSA integration tests but Wycheproof vectors only for ECDSA; no audit conclusion is claimed for cofactor or torsion handling, which this repository pins against libsodium itself. The verification equation is composed first-party in src/Ed25519.sol from the vendored HashInternal, ecGenMulmuladdB4W, WeierStrass2Edwards and edCompress; the vendored Verify_LE is no longer called (docs/CRYPTO_SPEC.md, Signature acceptance) |
| lib/forge-std | foundry-rs/forge-std master @ 37a36ca389095b2f677abb07642634573ba7e265 (2026-08-07): tag v1.16.2 (bf647bd6046f2f7da30d0c2bf435e5c76a780c1b, 2026-06-30) plus six later master commits — four workflow-only dependency bumps and two source changes, b96a46a954876eea1c0ffb48c63e1dfbac593781 (Vm interface update, 2026-07-20) and 37a36ca389095b2f677abb07642634573ba7e265 (StdStorage short-slot fix); six paths differ from the tag: lib/forge-std/src/Vm.sol, lib/forge-std/src/StdStorage.sol, their two tests, and two workflow files under the upstream .github/ |
The vendored tree is byte-identical to that commit's tree, .git excluded (verified 2026-09-03 against a fresh clone with a recursive diff: zero differences). Test and script tooling only: nothing in src/ imports it |
| bench/comparators/mel | mel-project/ed25519-sol @ 3b36ec8c361c8fe29e6eff10019521454f888ff5 (2022-07-14, the tip of main at verification); Ed25519.sol sha256 42445e313d0a3684207ab455f04cc9e7a7075532d8956e0683274c26ee8efe67, Sha512.sol sha256 96a74497a31f38c57bbf989e66575ae72338bbce950cbbf9bc202cb52cd91eb8 |
Both files byte-identical to the upstream files of the same names under its src/ directory at that commit (git blob hashes equal, verified 2026-09-03). Benchmark material only, never imported by src/; its divergence from libsodium is pinned as expected in test/Mel.t.sol |
| bench/comparators/near | Near-One/rainbow-bridge @ 63fb73ba3c3ced07b57c3040a5c6cdaff5d0f255 (2021-11-11), path contracts/eth/nearbridge/contracts/Ed25519.sol; Ed25519.sol sha256 e778b62f584609811cf18c78ec3a1271d087f391f88fbbaab23533900bb21efe; the same file blob is present at 0c6b0cfcb200e69a8d062c259fd3138d9bbfbde6 (2026-04-09) |
Byte-identical to the upstream file at the pinned commit (blob hash equal, verified 2026-09-03). Benchmark material only; GPL-3.0-or-later governs only itself (THIRD_PARTY_LICENSES.md); its divergence from libsodium is pinned as expected in test/Near.t.sol |
| PyNaCl | 1.5.0 (bundles libsodium 1.0.18 — same lineage cardano-node pins) | PyPI via uv, pinned in each script's PEP 723 header |
| Taming the many EdDSAs vectors | novifinancial/ed25519-speccheck @ 65519336fda78a3d016e947df6d82848aca0c9da; cases.json sha256 08e47a36d9aead288664930505584f353fff113ab854f2800db1e4f5b3540450 |
The upstream file is vendored byte-identically as fixtures/ed25519_speccheck_cases.json; the generator verifies the digest before deriving the fixture and obtains every expectation independently from the pinned PyNaCl/libsodium oracle |
| cbor2 | 5.9.0 (the serialization every template and Sig_structure built by scripts/cip8.py is asserted equal to; the framing implementation of e2e/parse_signdata.py and scripts/gen_envelope_oracle.py, which import nothing from that builder; decoder in gen_wallet_fixtures.py and for the Byron fixture in gen_poc.py) |
PyPI via uv, pinned inline |
| pycryptodome | 3.23.0 (keccak256 in gen_safe_fixtures.py for EIP-712 and CREATE2, and in gen_poc.py for the legacy-interface fixture hash; the suite re-derives every hash on the etched bytecode or on the fixture bytes) | PyPI via uv, pinned inline |
| ecdsa | 0.19.1 (secp256k1 address derivation for the suite's operator EOA in gen_safe_fixtures.py; the suite asserts vm.addr agrees) |
PyPI via uv, pinned inline |
| Safe v1.5.0 singleton runtime | safe-smart-account tag v1.5.0 = commit dc437e8fba8b4805d76bcbd1c668c9fd3d1e83be; canonical address 0xFf51A5898e281Db6DfC7855790607438dF2ca44b; runtime keccak256 0xdda019cbd7c867a533a2a86e5c53434fdc50b13122b5a5ddb4a8df61b31c20f2, sha256 0xe4bb8e7d6bd13577e196f5c1d718add9fb2e007f09bff25faf06c1100b1409c7 (both also inside fixtures/safe_artifacts.json) |
eth_getCode 2026-08-28 from Etherscan API v2 (chain 1), Blockscout (chain 1) and the Base public RPC (chain 8453), all byte-identical; keccak256 equals the codeHash pinned in safe-global/safe-deployments @ 0974182c16c57ca6fe2b9bba8cffb8a7e55fb83c; VERSION() and both digests re-checked by test/SafeIntegration.t.sol on every run |
| Safe proxy factory v1.5.0 runtime | same tag/commit; canonical address 0x14F2982D601c9458F93bd70B218933A6f8165e7b; runtime keccak256 0x967dae4cda22b0c9ef7f31b010bdc1ceb0af9904b0c3dc060b5302e4c18a4529, sha256 0xd7a2beaef456f3e3ff69c98f258b99b8453e16e9df58d153c0b2b4f366bd34ca; includes proxyCreationCode() output (fetched via eth_call from two of the same sources, byte-identical) |
same retrieval and safe-deployments cross-check; the suite asserts the etched factory returns the committed proxy creation code |
| Safe v1.4.1 singleton runtime (pre-v1.5.0 incompatibility evidence) | safe-smart-account tag v1.4.1 = commit bf943f80fec5ac647159d26161446ac5d716a294; canonical address 0x41675C099F32341bf84BFc5382aF534df5C7461a; runtime keccak256 0x1fe2df852ba3299d6534ef416eefa406e56ced995bca886ab7a553e6d0c5e1c4, sha256 0x79756c276443953adde61bbc2bfa134c03b35cf45181dab9d4f9503df22f45c8 |
same retrieval and safe-deployments cross-check; kept solely as evidence that pre-v1.5.0 Safes validate contract owners through isValidSignature(bytes,bytes), which this registry does not implement |
| CIP-30 envelope oracle | org.cardanofoundation:cip30-data-signature-parser 0.0.12 (release tag v0.0.12 = commit 33d6c9558575d42adc838c16eb9a51a6960bd5f1); shaded jar sha256 68ee9016b6bc27a57326007872eb7e27c72ecdef583649fd53f6f0134d5942b0 |
MIT, Cardano Foundation; fetched from Maven Central and digest-verified on every use by scripts/gen_envelope_oracle.py; generation-time only, never in the build, test or deployment path. Its COSE parsing (co.nstant.in.cbor) and Ed25519 backend (net.i2p.crypto eddsa), both shaded into the jar, share no code with libsodium, SCL or cbor2 — the independence the corpus exists to exercise. Companions pinned the same way: slf4j-api 2.0.5 (jar sha256 f4a2974509291acc49fda4a79b0d59e15e2b524095d6421c66391b92387af4c9, the parser's declared logging API) and ecj 3.46.0 (jar sha256 d0d43f8e2d7003e5efed612e2cbb5f01870043397d8f1bbe536fd9128f4fcbf7, compiles the committed runner on any Java 17+ runtime) |
| Slither | 0.11.6 | PyPI via uvx; CI static analysis, with vendored-path triage in slither.config.json |
Slither runs all 102 detectors over the production compilation and fails CI on
any unsuppressed first-party result: the CI command
(uvx --from slither-analyzer==0.11.6 slither . --warn-unused-ignores, with
slither.config.json in force) reports 0 results, and the job log is the
record. slither.config.json excludes results whose source lies in the pinned
vendored tree; it is not a static-analysis claim over that code. As a dated
characterization, not a committed measurement and not a gate: an unfiltered
run (--filter-paths '^$') under the same pins on 2026-09-03 reported 51
results. Fifty are wholly vendored: 46 informational and four medium — three
false uninitialized-local reports for Solidity-zeroed fixed-size memory arrays
and one intentional (len / 128) * 128 block-flooring operation in
external/sha512/Sha2Ext.sol. The fifty-first is an
incorrect-versions-of-solidity result spanning the vendored tree and all four
first-party sources; the path filter drops that one too, so the gate makes no
pragma-range claim — the range is pinned by solc in foundry.toml instead.
The three first-party suppressions are source-local: the required blake2f
assembly call, its exact EIP-152 input words, and the exact Ed25519 group
order. --warn-unused-ignores keeps obsolete suppressions visible.
gas-output.txt is the committed benchmark output under exactly this
configuration, its header naming the forge, solc, EVM version and optimizer
pins it was produced under; every figure labeled measured comes from it.
MIT (LICENSE) for everything original to this repository. Vendored components
and their licenses are enumerated in THIRD_PARTY_LICENSES.md.