Feat/seismic#116
Draft
Khrafts wants to merge 16 commits into
Draft
Conversation
sforge's pragma resolver rejects strict-eq =0.8.26 against ssolc 0.8.31-develop (the only available Seismic build). Widen all 79 strict pragmas across src/, test/, script/ to ^0.8.26 — admits both the existing local solc 0.8.26 (so stock forge keeps working) and ssolc 0.8.31 (so sforge can build the Seismic MYieldToOne implementation). lib/common/src/* and OZ Upgradeable already use compatible ranges and need no touch. Audit-material subset (compiled into MYieldToOne deployment bytecode): src/MExtension.sol src/projects/yieldToOne/MYieldToOne.sol src/projects/yieldToOne/interfaces/IMYieldToOne.sol src/interfaces/IMExtension.sol src/interfaces/IMTokenLike.sol src/swap/interfaces/ISwapFacility.sol The other 73 files are test/script/sibling-project files outside MYieldToOne's compilation closure — no audit impact, widened only to satisfy sforge's whole-repo pragma scan. Reversible when Seismic ships ssolc-0.8.26: same sed in reverse.
…file - Convert MYieldToOne.balanceOf storage to suint256; gate public reader on msg.sender == account (Seismic signed read required externally) and override _revertIfInsufficientBalance to compare in shielded space with a zeroed revert payload. - Add Unauthorized error to IMYieldToOne; update harness setters/getters for the new shielded type. - Mark MExtension._revertIfInsufficientBalance virtual so extensions can override the balance-leak path. - Add foundry "seismic" profile (mercury EVM, ssolc-compatible) with no_match_path for out-of-scope sibling integration tests that fork mainnet (no eth_getFlaggedStorageAt). - Add scripts/seismic-env.sh repo-local toolchain bootstrap and ignore .seismic-toolchain/, .claude/, .planning/.
- .husky/pre-commit: when .seismic-toolchain/bin/sforge is present, prepend it to PATH and export FOUNDRY_PROFILE=seismic. Falls back to stock forge when the toolchain isn't installed (e.g. on non-Seismic branches), so this is a no-op for contributors who haven't bootstrapped sforge yet. - Makefile: have the `tests` (and siblings) target inherit FOUNDRY_PROFILE from the environment when set, so the hook's export reaches sforge. - test.sh: swap forge → sforge when FOUNDRY_PROFILE=seismic (stock solc can't parse shielded types like suint256). The hook now compiles shielded code via ssolc + mercury EVM instead of failing at parse time, which surfaces real unit-test fallout from the shielded-balance change (separate fix to follow).
MalteHerrmann
left a comment
There was a problem hiding this comment.
just some preliminary comments
Tracks the lib/common branch that marks the public IERC20 / IERC20Extended entry points (approve, transfer, transferFrom, allowance, permit) as `virtual`. Lets MYieldToOne override the inherited ERC20 API directly for the Seismic shielded variant. Pointer intentionally lives on a non-main branch in m0-foundation/common — scoped to this seismic work, not an org-wide ERC20Extended change.
Extends the balanceOf shielding from ba90d43 to cover transfer, transferFrom, approve, and permit. Adds a shielded allowance mapping and shielded-typed entry points alongside the inherited IERC20 ones (which now revert). - transfer/approve/transferFrom(address,suint256) — new shielded entries - transfer/approve/transferFrom(uint256) — kept in ABI, revert with UseShieldedTransfer / UseShieldedApprove - permit (both overloads) — revert with UseShieldedApprove - allowance(owner,spender) — gated like balanceOf; reads shielded mapping - InsufficientAllowance / InsufficientBalance revert payloads zero the shielded field (no value leak via revert) Storage appends shieldedAllowance to MYieldToOneStorageStruct (upgrade-safe). Inherited ERC20ExtendedStorageStruct.allowance slot is orphaned — never written, never read. Consequences: - m-portal-v2 Portal.transferFrom outbound bridging stops working (uint256 calldata); bridge via unwrap/wrap of M instead - No permit / EIP-3009 signed transfers (both encode uint256 in EIP-712) - Inheritors (MYieldToOneForcedTransfer, JMIExtension) pick up the new ABI for free; their existing IERC20-path tests are follow-up work Built + tested under FOUNDRY_PROFILE=seismic — 42/42 unit tests pass (40 unit + 2 fuzz @ 5000 runs).
Allows `swapFacility` as a second exempted caller of the gated `balanceOf(address)` view alongside the account itself. The SwapFacility immutable is shared M0 infra trusted to observe extension balances along its operational paths without forcing a Seismic signed read. The gate continues to revert for arbitrary callers — only the holder (via signed read) and the SwapFacility are allowed. Adds three regression tests: holder-can-read, unauthorized-third-party- reverts, swap-facility-can-read.
Add an admin-gated infra allowlist that conditionally re-enables the native uint256 approve/transferFrom/balanceOf paths for trusted M0 infrastructure (Portal, LimitOrderProtocol), while non-infra callers keep the shielded-only behavior. A central _isInfra helper (swapFacility immutable + allowlist) gates approve (on spender), transferFrom (on caller), and balanceOf (on caller). Native and shielded paths share the single shieldedAllowance slot; transfer and permit remain shielded-only. setAllowlisted (single + batch) is DEFAULT_ADMIN_ROLE gated.
User-to-user shielded transfers now emit a second Transfer overload —
Transfer(address indexed, address indexed, bytes) — carrying the
amount as an AES-GCM ciphertext derived from ECDH between the
contract's keypair and the recipient's registered pubkey, plus HKDF.
Adds storage slots 5–8 (publicKeys, _contractPublicKey,
contractPrivateKey, encryptedEventNonce) and four externals:
setContractKey (admin-only, one-shot, TxSeismic 0x4A operational
requirement), registerPublicKey, publicKeyOf, contractPublicKey.
Threads encryptEmit through _spendAllowanceAndTransfer and
_shieldedTransfer so the suint256 entry points emit the encrypted
overload while the infra-gated transferFrom(uint256) keeps the
inherited plaintext Transfer(uint256). Mint, burn, forced transfers,
and approvals are untouched.
Nonce uses a contract-wide monotonic counter
(bytes12(keccak256(from, to, ++counter))) instead of the tutorial's
block-number formulation, avoiding AES-GCM nonce reuse when the same
(from, to) pair transfers twice in a block.
Unregistered-recipient fallback emits Transfer(from, to, bytes(""))
— transfer still succeeds, amount recoverable only via the
recipient's gated balanceOf read.
Open questions filed in docs/seismic-question-encrypted-events-ux.md
re sbytes32 zero-sentinel reads, nonce strategy, and precompile
input layouts. Cited from setContractKey and the three precompile
wrappers' NatSpec for the auditor.
…sion Adds 17 new unit tests for the encrypted-events phase (commit 9ba313e): setContractKey — admin-only, one-shot, length validation, emit registerPublicKey — write, idempotent overwrite, length validation, emit shielded transfer/transferFrom — bytes-overload emit + nonce increment shielded transfer with unregistered recipient — empty-bytes fallback shielded transfer with unset contract key — ContractKeyNotSet infra transferFrom(uint256) — plaintext Transfer(uint256) overload only mint/burn — plaintext Transfer(uint256) overload only Precompiles 0x65 / 0x66 / 0x68 are mocked via vm.mockCall — they are not available in the local sforge environment; on-chain semantics will be validated on Seismic devnet (and during Seismic's review of the open questions in docs/seismic-question-encrypted-events-ux.md). Updates the two pre-existing tests that asserted the plaintext Transfer emit on the shielded user-to-user path: test_transfer test_transferFrom_finiteAllowanceDecrements Both now assert the bytes-variant emit (empty payload, since the test recipient is not registered — the fallback branch fires before the contract-key check). Extends MYieldToOneHarness with getEncryptedEventNonce() to read slot 8 without recomputing the ERC-7201 offset (mirrors the existing getBalanceOf / getShieldedAllowance pattern). Extends [profile.seismic].no_match_path in foundry.toml to skip test/unit/projects/JMIExtension.t.sol and the entire test/integration/ directory. JMIExtension is EIP-170 oversize under ssolc+mercury (25 853 bytes) and is not a Seismic deployment target; its unit suite was written against the unshielded balanceOf surface and trips Unauthorized on the new gated read. Integration suites all fork ETH_MAINNET via BaseIntegrationTest and abort with HTTP 400 on eth_getFlaggedStorageAt; integration coverage for this profile lives on Seismic devnet. Carries through the prettier-only formatting diff that was staged on MYieldToOne.sol (line-wrap collapse on the precompile helpers and _spendAllowanceAndTransfer signature). Suite under FOUNDRY_PROFILE=seismic: 369 passed, 0 failed.
The docs/ directory is gitignored and intentionally kept local-only. Removes pointers that would 404 for any reader outside the local working tree. Substantive NatSpec content (operational TxSeismic 0x4A requirement, sbytes32 zero-sentinel open question, unregistered- recipient fallback semantics, foundry profile rationale) is preserved inline; only the see-also links are dropped. Also drops two stale @dev notes pointing at the precompile-addresses open question on _ecdh, _hkdf, _aesGcmEncrypt — that question was retired in favor of treating the Seismic tutorial as the source of truth for addresses and input layouts.
…gitmodules The gitlink already pins lib/common at a1fbf37 (m0-foundation/common's feat/erc20-virtual). Declaring the branch makes the tracked ref explicit so the intent is visible and `git submodule update --remote` follows the right branch. No build/bytecode change.
JMIExtension inherits the shielded MYieldToOne, whose suint256 closure inflates its runtime bytecode to 25,853 B under ssolc+mercury at the default profile's 19,999 optimizer runs -- 1,277 B over the EIP-170 24,576 B limit, so `sforge build` failed the size check. Setting optimizer_runs = 800 in [profile.seismic] (the knee of the size/gas curve) brings JMIExtension to 23,240 B (+1,336 B headroom) while keeping as much runtime-gas optimization as the limit allows. Only the seismic profile is affected; the default profile stays at 19,999 so the rest of the suite and audit-reproducible sibling extensions are unchanged. Also refreshes the [profile.seismic] comment that referenced the old 25,853 B size as a test-skip rationale.
Inline `sforge --verify` can't verify mercury/ssolc builds: the socialscan explorer rejects `evmVersion: mercury` and the `-develop` compiler tag, and forge has no flag to strip them. Add script/verify-seismic.py, which rebuilds the exact ssolc standard-json from out/build-info, drops evmVersion, relabels the compiler to v0.8.31+commit.cd9163d8, and submits every contract in a deploy broadcast (incl. CreateX additionalContracts) to the explorer. Contracts are identified by bytecode-matching against build-info, optimizer runs are read from metadata, and 5xx responses are retried with backoff. - Makefile: seismic-testnet/-mainnet deploys broadcast-only, then auto-verify the whole broadcast; add standalone re-verify targets; separate testnet and mainnet verifier URLs + chain ids. - Config.sol: register Seismic testnet (chain 5124) and set the deployer. - .gitignore: ignore CLAUDE.local.md.
* fix(MYieldToOne): validate key material in setContractKey and registerPublicKey A zero private key bypassed the one-shot guard (which compares the stored key to zero), emitting ContractKeySet while leaving the contract key-less and a second call able to succeed. Reject it with ZeroPrivateKey, and reject any 33-byte public key whose first byte is not the compressed-point prefix 0x02/0x03 with InvalidPublicKeyPrefix in both entry points. * feat(MYieldToOne): extend the balanceOf read gate to compliance roles Compliance operators previously had no sanctioned way to learn how much to seize: forceTransfer takes an explicit amount but neither manager role was in the balanceOf gate. Allow FREEZE_MANAGER_ROLE holders to read any balance, and FORCED_TRANSFER_MANAGER_ROLE holders on the ForcedTransfer subclass via a balanceOf override. * feat(MYieldToOne): encrypted Approval events on the shielded approve path The shielded approve stored the allowance in shielded space but emitted the standard Approval with the exact amount, making every allowance public. Mirror the Transfer treatment: refactor the encrypted-emit crypto core into a shared _encryptAmount helper, emit an encrypted-bytes Approval(address,address,bytes) overload to the spender's registered key on the suint256 approve path (empty-ciphertext fallback if unregistered), and keep the plaintext Approval on the native infra path where the amount is already public in plain calldata. The nonce counter is shared across both event kinds. * fix(MYieldToOne): check ContractKeyNotSet before the unregistered-key fallback Before setContractKey, shielded transfers to unregistered recipients succeeded (empty-ciphertext fallback) while transfers to registered ones reverted -- inconsistent partial availability that leaked who is registered via reverts. Check the contract key first so ALL user-path shielded transfers/approves revert ContractKeyNotSet uniformly pre-key; wrap/unwrap/mint/burn/native-infra paths are unaffected (plaintext emits, no key needed). The key is deliberately not folded into initialize(): initializer calldata travels in plaintext inside the proxy deployment, which would leak the private key. * docs(MYieldToOne): annotate accepted shielded-branching side channels Add NOTE comments at the three ssolc 10311 sites (allowance check, sender-balance check, _revertIfInsufficientBalance) documenting the accepted 1-bit revert-vs-success leak inherent to ERC20 insufficient-balance/allowance semantics, and the missing unchecked justification in yield(). * style(yieldToOne): match comment density and NatSpec shape to main Comment-only sweep: one-line @dev + full @param lists on internal functions (restores _update's params), terse interface error/event docs, bespoke section dividers folded into the standard sections. Verified zero behavioral change (diff is comments-only; 151/151 yieldToOne unit tests unchanged). * test(MYieldToOne): close shielded coverage gaps Precompile failure paths (0x65/0x68/0x66), ciphertext fidelity and expectCall input pinning, nonce monotonicity across Transfer/Approval emits, zero-amount both branches, self-transfer and zero-recipient, native-path freeze matrix, de-listing re-blocks, residual-allowance pin, shielded transferFrom fuzz, unwrap insufficient-balance payload. * test(MYieldToOneForcedTransfer): cover compliance and dual-emit paths forceTransfer to a registered recipient stays plaintext-only with the encrypted-event nonce untouched; shielded-overload smoke tests; balanceOf gate for both compliance roles; seizure flow via production-visible interfaces; forceTransfer works while paused. * test(JMIExtension): migrate suite to shielded semantics Gated balanceOf asserts moved to a harness getter, user transfers to the suint256 overloads, pause coverage via the shielded path, inherited gate/allowlist/native-revert tests, and a wrap dual-emit regression. 59/59 green under the seismic profile (suite was previously excluded with zero runnable tests). * test(MYieldToOne): add seed-based shielded-accounting simulation Randomized op sequences over wrap/unwrap/transfers/claims/forceTransfer/ freeze/pause asserting sum-of-balances == totalSupply, M-backing solvency, and encrypted-event nonce monotonicity after every step. * test(integration): fix latent post-shielding API usage suint256 overloads for user-path calls, gated balanceOf reads via the harness, contract-key install in setUp for the pre-key revert semantics, and infra-allowlisting of the swap adapter where it spends natively. Compile-verified; the suite still requires a Seismic-aware RPC to run. * chore(build): make seismic the default profile and finish the sforge migration profile ?= seismic with a repo-local toolchain PATH prepend and fail-loud install guard; shared FORGE_BIN selector for build/sizes/coverage/ gas-report; integration fails loudly without a Seismic devnet RPC; slither replaced with a documented limitation; dead non-Seismic deploy/ upgrade/propose targets removed; force=true in the seismic profile kills the OZ upgrades-core partial-build-info flake; JMIExtension.t.sol re-admitted to the seismic test set; pre-commit fails fast with an install hint; package.json scripts pruned to match. * ci: run the unit suite under the Seismic toolchain New test-seismic.yml installs the pinned toolchain and runs the seismic profile suite plus a sizes check; the three stock-forge workflows are disabled on this branch (they cannot parse suint256 and were permanently red). * feat(script): post-deploy ops tooling for the Seismic stack set-contract-key.sh wraps scast send --seismic (TxSeismic 0x4A) so the contract encryption key never appears in plaintext calldata — a Foundry script cannot do a shielded broadcast and would leak it permanently. ConfigureSeismicExtension.s.sol approves the extension on SwapFacility and allowlists the infra addresses. .env.example gains the Seismic block. * chore(deployments): record the chain-5124 Seismic testnet stack deployments/5124.json with the live SwapFacility and USDS extension addresses, plus the recovered broadcast record so the re-verify target and ScriptBase address resolution work from a fresh clone. * chore(deps): align foundry.lock with the lib/common gitlink The lock still pinned lib/common at v1.5.0 while the submodule points at a1fbf37 (feat/erc20-virtual, v1.5.1 + the 12-line virtual-marker delta); a forge-driven dependency materialization could have silently rolled it back. * docs: assemble the audit package for the Seismic branch README rewritten for this standalone branch (shielded MYieldToOne description, Seismic build instructions, 5124 deployment table); AUDIT-SCOPE.md with the in-scope list, system context, trust-model table, ERC-20 deviations, and accepted risks; TOOLCHAIN.md pins the Seismic toolchain the deployed bytecode was built with; RUNBOOK.md orders deploy -> verify -> configure -> set-contract-key; audits/README maps prior reports to their actual coverage; the Seismic design docs are un-ignored and tracked. * fix(script): make LimitOrderProtocol optional in ConfigureSeismicExtension It is not yet deployed on Seismic testnet; the allowlist starts with Portal only and LOP is added via the same target once live. * chore(deployments): record the 5124 extension configuration broadcast SwapFacility.setAdminApprovedExtension(USDS, true) and setAllowlisted([Portal], true) executed on Seismic testnet; verified on-chain (isApprovedExtension and isAllowlisted both true). * test(integration): in-process Seismic suite, sanvil E2E, and live-testnet checker sforge's EVM implements the real Seismic precompiles, so the 11-test suite pins the 0x65/0x66/0x67/0x68 vectors and proves real-key encrypt/decrypt round-trips with no mocks. run-sanvil-e2e.sh drives the full stack over RPC: setContractKey as TxSeismic 0x4A (private key absent from on-chain calldata), four-way signed-read gating, and off-chain decryption of a captured Transfer(bytes) event. check-live-testnet.sh is a read-only chain-5124 checklist. New make targets integration-seismic / e2e-sanvil / check-live-seismic-testnet; CI runs the in-process suite. * feat(script): off-chain decryptor for encrypted Transfer/Approval events Pure-stdlib secp256k1 ECDH + double HKDF-SHA256 (libsecp256k1 shared secret with info 'aes-gcm key', then 'seismic_hkdf_105') + keccak-derived nonce + AES-256-GCM, matching the pinned precompile semantics; fast-paths through the cryptography package when available. --self-test asserts the cross-validated vectors. * docs(reports): coverage, gas, and size evidence for the audit package Generated with the pinned toolchain: yieldToOne contracts at 100% line/ branch/function unit coverage (src total 94.13% lines); EIP-170 margins (JMIExtension tightest at 1,040 B); shielded-path gas medians. * docs: pin 5124 system context and update execution status Portal provenance + remaining suite-deployment addresses footnoted in AUDIT-SCOPE.md; RUNBOOK/AUDIT-READINESS reflect the executed configure step (extension approved, Portal allowlisted) with set-contract-key as the one remaining on-chain action. * docs: untrack internal audit docs and inline auditor-facing scope The audit-readiness plan, scope, runbook, toolchain pins, and the docs/audit/ design notes were internal/generated material that should not be git-tracked. Remove them from tracking (kept locally) and ignore all of docs/. Fold the auditor-facing essentials those files were referenced for into the tracked tree so a fresh clone has no dead links: - README: add the pinned-toolchain table and an "Audit scope" section (in-scope files + ERC-20 deviations) - repoint audits/README, reports/README, the slither Makefile target, ConfigureSeismicExtension, and the seismic integration breadcrumbs to in-repo content / the real make targets * test(seismic): harden SRC-20 unit coverage and add live in-process integration Unit (test/unit/projects/yieldToOne): +15 tests — encrypted-event fuzz (exactly-one-nonce-per-emit, ciphertext == precompile output, bytes-overload topic0 distinct from the uint256 overload), zero-amount shielded transferFrom to a registered recipient, allowance-boundary fuzz, and type(uint256).max boundaries; strengthened the simulation invariants (infra + unregistered holders, per-holder no-underflow, live balanceOf-gate check at each checkpoint). Integration: new test/integration/seismic/MExtensionSystemSeismic.t.sol — a non-forking, in-process system suite on the seismic EVM (real 0x65/0x66/0x68 precompiles, no mocks) covering wrap, cross-extension swap via SwapFacility, shielded transfer/transferFrom with real ciphertexts, yield accrue/claim, freeze + forceTransfer, and permissioned-extension gating. `make integration-seismic` now runs 22 tests (was 11). The mainnet-fork integration suites stay unrunnable under mercury (no usable eth_getFlaggedStorageAt fork source), so this restores live multi-contract SRC-20 coverage without forking. * ci(seismic): co-locate ssolc in cached toolchain dir so cache-hit runs find it sfoundryup installs `ssolc` to /usr/local/bin (install_ssolc ignores FOUNDRY_BIN_DIR), which is outside the cached .seismic-toolchain dir. The first (cache-miss) run compiles fine because /usr/local/bin is on PATH, but the cache only stores .seismic-toolchain — so every cache-hit run starts on a fresh runner with no ssolc and fails at compile time with "`ssolc` not found in PATH" (the Makefile only prepends .seismic-toolchain/bin to PATH). Fix: in the install step, export FOUNDRY_BIN_DIR and copy the installed ssolc into .seismic-toolchain/bin so it's cached and restored alongside sforge. Roll the cache key via TOOLCHAIN_CACHE_VERSION to evict the existing ssolc-less caches, and assert ssolc presence up front so a regression fails fast instead of deep in compilation. * docs: internal doc cleanups
MalteHerrmann
left a comment
There was a problem hiding this comment.
just some comments here and there, as we're talking about it on the call atm
MalteHerrmann
left a comment
There was a problem hiding this comment.
some more comments, will dig into the shell scripts later, but contracts are fully reviewed now 🙌
…ce scan (#119) * feat(seismic): add indexed encryptKeyHash topic to shielded Transfer/Approval events Align the SRC20-facing shielded events with the target shape: Transfer(address indexed from, address indexed to, bytes32 indexed encryptKeyHash, bytes encryptedAmount) Approval(address indexed owner, address indexed spender, bytes32 indexed encryptKeyHash, bytes encryptedAmount) encryptKeyHash is keccak256 of the recipient/spender's registered public key (the key encryptedAmount is encrypted to), or bytes32(0) on the no-registered-key fallback. Being indexed, it lets a client identify which registered key a ciphertext is bound to (keys are overwritable via registerPublicKey) and pick the matching private key to decrypt. _encryptAmount now returns (bytes32 encryptKeyHash, bytes ciphertext); the two shielded emit sites pass it through. JMIExtension and MYieldToOneForcedTransfer inherit the change. Updated unit + in-process seismic integration tests, the e2e topic filter, and NatSpec. * feat(MYieldToOne): add ALLOWLIST_MANAGER_ROLE + ALLOWLIST_ADMIN_ROLE for the infra allowlist Gate setAllowlisted behind a dedicated ALLOWLIST_MANAGER_ROLE (operator) instead of DEFAULT_ADMIN_ROLE, administered by a new ALLOWLIST_ADMIN_ROLE whose holder (allowlistAdmin) is passed into initialize. _setRoleAdmin wires ALLOWLIST_ADMIN_ROLE as the role admin of ALLOWLIST_MANAGER_ROLE, so allowlist control is delegated independently of the default admin; initialize reverts ZeroAllowlistAdmin on a zero address. The new initializer param is appended last (robust to the harness param reordering) and threaded through MYieldToOneForcedTransfer, JMIExtension, the three harnesses, the Config structs + DeployBase (init-data extracted to helpers to avoid stack-too-deep), the deploy entry scripts (ALLOWLIST_ADMIN env), and every test setUp. make tests: 486 passed. make integration-seismic: 22 passed. * fix(MYieldToOne): disable the inherited ERC-3009 plaintext transfer path The six ERC-3009 transferWithAuthorization/receiveWithAuthorization overloads inherited from ERC3009Upgradeable reach MExtension._transfer directly, emitting a plaintext Transfer(address,address,uint256) with the cleartext amount and bypassing the encrypted-event machinery — an ungated, fund-moving path that leaks the amount the shielded SRC-20 otherwise hides. Inherited verbatim by MYieldToOneForcedTransfer (deployed) and JMIExtension. Override all six to revert UseShieldedTransfer, mirroring the existing permit reverts. ERC-3009 hard-codes a plaintext uint256 value, so the path cannot be shielded — reverting is the only privacy-preserving option. Needs the matching virtual on the base entry points: lib/common feat/erc20-virtual bumped a1fbf37..703ba8a. Tests: revert coverage for all six overloads incl. valid-signature cases proving even a valid authorization reverts, plus an inherited-revert regression on MYieldToOneForcedTransfer. JMIExtension 23,067 B (< 24,576 EIP-170). * fix(MYieldToOne): emit EncryptedAmountNonce so decryptors read the nonce, not scan it The encrypted Transfer/Approval payload's AES-GCM nonce is bytes12(keccak256(from, to, ++encryptedEventNonce)), but the counter was never emitted, forcing the off-chain decryptor to brute-force scan 1..N per event. Emit a dedicated EncryptedAmountNonce(from, to, nonce) from _encryptAmount whenever a counter is consumed (registered-key path only, never the no-key fallback or the plaintext infra paths), leaving the existing Transfer/Approval topic0 untouched. The decryptor reads the exact counter from the event (--nonce-counter); --scan stays as a legacy fallback for pre-event logs. Zero-amount shielded transfers early-return as a no-op (no emit, no counter, no balance change); the zero-amount tests assert this and the nonce/ciphertext fuzz tests are bounded to amount >= 1. ConfigureSeismicExtension now asserts each infra address is allowlisted after configuration. make tests: 494 passed. make integration-seismic: 22 passed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Scope
The shielding lives in
src/projects/yieldToOne/MYieldToOne.sol(plus itsIMYieldToOneinterface); the deployed subclassMYieldToOneForcedTransferandJMIExtensioninherit it with no extra shielding logic of their own. The rest of the M0 stack (MExtension,SwapFacility, Portal, M Token, …) keeps its logic unchanged — only these extensions are rebuilt with the Seismicssolctoolchain against themercuryEVM revision so they can hold shieldedsuint256/sbytes32state. The branch also carries Seismic deploy/verify tooling (script/verify-seismic.py,make deploy-*-seismic-*targets,Config.solchain-5124 registration) and a repo-wide pragma widening to admitssolc.What landed (in commit order)
cb754110.8.26 → ^0.8.26ssolc 0.8.31compile MYieldToOne.ba90d43balanceOfwithsuint256+ Seismic build profilebalanceOfbecomesmapping(address => suint256). Adds[profile.seismic]tofoundry.toml(mercury EVM,no_match_pathfor incompatible integration suites).b84bfb5sforge.c3490f3feat/erc20-virtual_updatehook.325a57dtransfer/approvepipelinesuint256overloads oftransfer/approve/transferFrom, theshieldedAllowanceslot, and_shieldedTransfer/_shieldedApproveinternals. Reverts the inheritedTransfer(uint256)and bothpermitoverloads. ShieldedInsufficientBalance/InsufficientAllowancerevert payloads zeroed so they don't leak the shielded value.7350fc5balanceOfgatebalanceOfbecomes readable by the holder OR theswapFacilityimmutable.172cb30_isInfra(account)predicate (account == swapFacility || allowlist[account]). Nativeuint256transferFromrequires_isInfra(msg.sender); nativeuint256approverequires_isInfra(spender);balanceOfinfra read-exemption extended to the allowlist. Admin-gatedsetAllowlisted(single + batch) +AllowlistSetevent.9ba313eTransferoverload carrying the amount as an AES-GCM ciphertext, derived via ECDH between the contract's keypair and the recipient's registered pubkey + HKDF. Adds storage slots for the keypair/nonce and four externals:setContractKey(admin, one-shot,TxSeismic 0x4A),registerPublicKey,publicKeyOf,contractPublicKey. Monotonic per-emit nonce (keccak256(from, to, ++counter)); unregistered recipient → empty-ciphertext fallback. Mint/burn/forced-transfer/infra paths keep the plaintextTransfer(uint256).c14ec9c0x65/0x66/0x68mocked locally viavm.mockCall. Migrates the two pre-existing tests that asserted the plaintext emit on the shielded path. Extends[profile.seismic].no_match_path.8f8faeddocs/is gitignored; removes see-also pointers that would 404 for external readers. Substantive NatSpec preserved inline.4da2e30feat/erc20-virtualin.gitmodules389a9aeoptimizer_runsat 800 so JMIExtension fits EIP-170suint256closure inflates JMIExtension to 25,853 B at 19,999 runs (1,277 B over the 24,576 B limit);optimizer_runs = 800brings it to 23,240 B. Seismic profile only — the default profile stays at 19,999 for audit-reproducible sibling extensions.2cb7a6c0456c6escript/verify-seismic.py, which rebuilds the exactssolcstandard-json fromout/build-info, dropsevmVersion, relabels the compiler tov0.8.31+commit.cd9163d8, and submits every contract in a deploy broadcast (incl. CreateXadditionalContracts) to the explorer. Makefile deploys broadcast-only then auto-verify;Config.solregisters Seismic testnet (chain 5124).d1a1565(#118)setContractKey/registerPublicKey(ZeroPrivateKey,InvalidPublicKeyPrefix— a zero key previously bypassed the one-shot guard);balanceOfgate extended toFREEZE_MANAGER_ROLE(+FORCED_TRANSFER_MANAGER_ROLEon the ForcedTransfer subclass) so compliance can size seizures; encryptedApprovalevents on the shieldedapprovepath (the allowance was stored shielded but emitted in cleartext); uniformContractKeyNotSetrevert before the unregistered-key fallback (so pre-key availability no longer leaks who is registered); shielded-branching side-channel NatSpec; comment/NatSpec parity withmain; large test expansion + a non-forking in-process seismic integration suite (make integration-seismic, 22 tests, real0x65/0x66/0x68precompiles); CI ssolc-cache fix.735e3fe(#119)bytes32 indexed encryptKeyHashtopic to the shieldedTransfer/Approvalevents so clients can pick the matching key when registrations are overwritten; dedicatedALLOWLIST_MANAGER_ROLE+ALLOWLIST_ADMIN_ROLEforsetAllowlisted(moved offDEFAULT_ADMIN_ROLE; newallowlistAdmininitialize param); disables all six inherited ERC-3009transferWithAuthorization/receiveWithAuthorizationoverloads (revertUseShieldedTransfer) — they hard-code a plaintextuint256 valueand reached_transferdirectly, leaking the amount the shielded SRC-20 hides; emitsEncryptedAmountNonce(from, to, nonce)so off-chain decryptors read the exact AES-GCM counter instead of brute-force scanning. Zero-amount shielded transfers early-return as a no-op.Function surface (current)
transfer(address, suint256)Transfer(from, to, encryptKeyHash, encryptedAmount)+EncryptedAmountNonceto the recipient's registered key. RevertsContractKeyNotSetbefore the contract key is installed; empty-ciphertext fallback if the recipient hasn't registered a key.transferFrom(address, address, suint256)shieldedAllowance; same encryptedTransferemit.approve(address, suint256)shieldedAllowance; emits the encryptedApproval(owner, spender, encryptKeyHash, encryptedAmount)+EncryptedAmountNonceto the spender's registered key.transferFrom(address, address, uint256)UseShieldedTransferunless_isInfra(msg.sender). PlaintextTransfer(uint256)on the infra path. Used by Portal on outflow.approve(address, uint256)UseShieldedApproveunless_isInfra(spender). PlaintextApproval(uint256)on the infra path. Used by Portal'sforceApprove(SwapFacility).transfer(address, uint256)UseShieldedTransfer.permit(...)(both overloads)UseShieldedApprove.transferWithAuthorization/receiveWithAuthorization(ERC-3009, all six overloads)UseShieldedTransfer— ERC-3009 hard-codes a plaintextuint256 valuethat can't be shielded, so reverting is the only privacy-preserving option.balanceOf(address account)msg.sender == account,_isInfra(msg.sender), orFREEZE_MANAGER_ROLE(alsoFORCED_TRANSFER_MANAGER_ROLEon the ForcedTransfer subclass), else revertsUnauthorized. External callers use a Seismic signed read (TxSeismic 0x4A).allowance(address owner, address spender)owner/spender.setContractKey(sbytes32, bytes)TxSeismic 0x4A). RevertsZeroPrivateKey/InvalidPublicKeyPrefix/ContractKeyAlreadySet. Deliberately not folded intoinitialize(initializer calldata is plaintext).registerPublicKey(bytes)publicKeyOf(address)/contractPublicKey()setAllowlisted(address, bool)(single + batch)ALLOWLIST_MANAGER_ROLE(administered byALLOWLIST_ADMIN_ROLE, notDEFAULT_ADMIN_ROLE). EmitsAllowlistSet.totalSupply/yield/wrap/unwrapEncrypted Transfer & Approval events (landed —
9ba313e, #118, #119)Per the Seismic SRC-20 encrypted-events tutorial.
suint256user paths emit encrypted overloads with a distincttopic0, coexisting cleanly with standard ERC-20 indexers (which MUST track both topics):Transfer(address indexed from, address indexed to, bytes32 indexed encryptKeyHash, bytes encryptedAmount)Approval(address indexed owner, address indexed spender, bytes32 indexed encryptKeyHash, bytes encryptedAmount)encryptKeyHashiskeccak256of the recipient/spender's registered public key — the key the amount is encrypted to — orbytes32(0)on the no-registered-key fallback. Being indexed, it lets a client tell which registered key a ciphertext is bound to (keys are overwritable viaregisterPublicKey) and pick the matching private key to decrypt.transferFrom(uint256), and forced transfers stay on the plaintextTransfer(uint256)— their amounts are already public via bridge calldata or operator privilege.sbytes32private key installed once viasetContractKeysent asTxSeismic 0x4A; the plain public key is exposed viacontractPublicKey(). Key material is validated (ZeroPrivateKey,InvalidPublicKeyPrefix), so the one-shot guard can't be bypassed with a zero key.registerPublicKey(bytes)once (33-byte compressed secp256k1). Before the contract key is installed, all user-path shielded transfers/approves revertContractKeyNotSetuniformly — so pre-key availability never leaks who is registered via differential reverts.bytes12(keccak256(from, to, ++encryptedEventNonce))(contract-wide monotonic counter; avoids same-block nonce reuse on repeat transfers). The counter is published viaEncryptedAmountNonce(from, to, nonce)so off-chain decryptors read it directly rather than brute-force scanning the counter space.Remaining UX/semantics questions for the Seismic team (empty-ciphertext fallback,
sbytes32zero-sentinel reads, precompile input layouts) are tracked out-of-repo; on-chain precompile semantics are validated on the Seismic devnet/sanvil rather than locally.Build & test
profiledefaults toseismic(forge→sforgeautomatically). Mainnet-fork integration tests are excluded under the Seismic profile ([profile.seismic].no_match_path = "test/integration/**") — fork RPCs don't implementeth_getFlaggedStorageAt.make integrationruns them against a Seismic devnet whenSEISMIC_DEVNET_RPC_URLis set.