Skip to content

Feat/seismic#116

Draft
Khrafts wants to merge 16 commits into
mainfrom
feat/seismic
Draft

Feat/seismic#116
Khrafts wants to merge 16 commits into
mainfrom
feat/seismic

Conversation

@Khrafts

@Khrafts Khrafts commented May 18, 2026

Copy link
Copy Markdown
Member

Not merging. feat/seismic is a long-lived diff-tracking branch carrying the M0 stack onto Seismic. It will never target main.

Scope

The shielding lives in src/projects/yieldToOne/MYieldToOne.sol (plus its IMYieldToOne interface); the deployed subclass MYieldToOneForcedTransfer and JMIExtension inherit 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 Seismic ssolc toolchain against the mercury EVM revision so they can hold shielded suint256 / sbytes32 state. The branch also carries Seismic deploy/verify tooling (script/verify-seismic.py, make deploy-*-seismic-* targets, Config.sol chain-5124 registration) and a repo-wide pragma widening to admit ssolc.

What landed (in commit order)

Commit Subject What it does
cb75411 chore: widen pragma 0.8.26 → ^0.8.26 Lets ssolc 0.8.31 compile MYieldToOne.
ba90d43 feat(MYieldToOne): shield balanceOf with suint256 + Seismic build profile Storage migration: balanceOf becomes mapping(address => suint256). Adds [profile.seismic] to foundry.toml (mercury EVM, no_match_path for incompatible integration suites).
b84bfb5 chore(hooks): teach pre-commit to use the Seismic toolchain Hook plumbing for sforge.
c3490f3 chore(lib): bump common to feat/erc20-virtual Common-lib pin for the shielded _update hook.
325a57d feat(MYieldToOne): SRC-20-style shielding for transfer/approve pipeline Adds the suint256 overloads of transfer / approve / transferFrom, the shieldedAllowance slot, and _shieldedTransfer / _shieldedApprove internals. Reverts the inherited Transfer(uint256) and both permit overloads. Shielded InsufficientBalance / InsufficientAllowance revert payloads zeroed so they don't leak the shielded value.
7350fc5 feat(MYieldToOne): exempt SwapFacility from balanceOf gate balanceOf becomes readable by the holder OR the swapFacility immutable.
172cb30 feat(MYieldToOne): allowlist-gated native ERC-20 surface for M0 infra Adds the _isInfra(account) predicate (account == swapFacility || allowlist[account]). Native uint256 transferFrom requires _isInfra(msg.sender); native uint256 approve requires _isInfra(spender); balanceOf infra read-exemption extended to the allowlist. Admin-gated setAllowlisted (single + batch) + AllowlistSet event.
9ba313e feat(MYieldToOne): encrypted Transfer events via per-recipient ECDH User-to-user shielded transfers emit a second Transfer overload 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 plaintext Transfer(uint256).
c14ec9c test(MYieldToOne): cover encrypted Transfer events + dual-emit regression 17 new unit tests; precompiles 0x65/0x66/0x68 mocked locally via vm.mockCall. Migrates the two pre-existing tests that asserted the plaintext emit on the shielded path. Extends [profile.seismic].no_match_path.
8f8faed chore(MYieldToOne): drop docs/ references from NatSpec and foundry.toml docs/ is gitignored; removes see-also pointers that would 404 for external readers. Substantive NatSpec preserved inline.
4da2e30 chore(submodule): declare lib/common branch = feat/erc20-virtual in .gitmodules Makes the tracked submodule branch explicit. No build/bytecode change.
389a9ae chore(seismic): cap optimizer_runs at 800 so JMIExtension fits EIP-170 The suint256 closure inflates JMIExtension to 25,853 B at 19,999 runs (1,277 B over the 24,576 B limit); optimizer_runs = 800 brings it to 23,240 B. Seismic profile only — the default profile stays at 19,999 for audit-reproducible sibling extensions.
2cb7a6c docs(MYieldToOne): trim verbose comments and fix stale shielded-transfer NatSpec Comment-only.
0456c6e feat(seismic): auto-verify deployed contracts on the socialscan explorer Adds 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. Makefile deploys broadcast-only then auto-verify; Config.sol registers Seismic testnet (chain 5124).
d1a1565 (#118) Audit readiness for the Seismic shielded SRC-20 branch Key-material validation in setContractKey/registerPublicKey (ZeroPrivateKey, InvalidPublicKeyPrefix — a zero key previously bypassed the one-shot guard); balanceOf gate extended to FREEZE_MANAGER_ROLE (+ FORCED_TRANSFER_MANAGER_ROLE on the ForcedTransfer subclass) so compliance can size seizures; encrypted Approval events on the shielded approve path (the allowance was stored shielded but emitted in cleartext); uniform ContractKeyNotSet revert before the unregistered-key fallback (so pre-key availability no longer leaks who is registered); shielded-branching side-channel NatSpec; comment/NatSpec parity with main; large test expansion + a non-forking in-process seismic integration suite (make integration-seismic, 22 tests, real 0x65/0x66/0x68 precompiles); CI ssolc-cache fix.
735e3fe (#119) Seismic remediations: ERC-3009 plaintext bypass + encrypted-amount nonce scan Adds a bytes32 indexed encryptKeyHash topic to the shielded Transfer/Approval events so clients can pick the matching key when registrations are overwritten; dedicated ALLOWLIST_MANAGER_ROLE + ALLOWLIST_ADMIN_ROLE for setAllowlisted (moved off DEFAULT_ADMIN_ROLE; new allowlistAdmin initialize param); disables all six inherited ERC-3009 transferWithAuthorization/receiveWithAuthorization overloads (revert UseShieldedTransfer) — they hard-code a plaintext uint256 value and reached _transfer directly, leaking the amount the shielded SRC-20 hides; emits EncryptedAmountNonce(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)

Entry point Behavior
transfer(address, suint256) Shielded path. Emits the encrypted Transfer(from, to, encryptKeyHash, encryptedAmount) + EncryptedAmountNonce to the recipient's registered key. Reverts ContractKeyNotSet before the contract key is installed; empty-ciphertext fallback if the recipient hasn't registered a key.
transferFrom(address, address, suint256) Shielded path. Decrements shieldedAllowance; same encrypted Transfer emit.
approve(address, suint256) Writes shieldedAllowance; emits the encrypted Approval(owner, spender, encryptKeyHash, encryptedAmount) + EncryptedAmountNonce to the spender's registered key.
transferFrom(address, address, uint256) Reverts UseShieldedTransfer unless _isInfra(msg.sender). Plaintext Transfer(uint256) on the infra path. Used by Portal on outflow.
approve(address, uint256) Reverts UseShieldedApprove unless _isInfra(spender). Plaintext Approval(uint256) on the infra path. Used by Portal's forceApprove(SwapFacility).
transfer(address, uint256) Always reverts UseShieldedTransfer.
permit(...) (both overloads) Always revert UseShieldedApprove.
transferWithAuthorization / receiveWithAuthorization (ERC-3009, all six overloads) Always revert UseShieldedTransfer — ERC-3009 hard-codes a plaintext uint256 value that can't be shielded, so reverting is the only privacy-preserving option.
balanceOf(address account) Returns cleartext when msg.sender == account, _isInfra(msg.sender), or FREEZE_MANAGER_ROLE (also FORCED_TRANSFER_MANAGER_ROLE on the ForcedTransfer subclass), else reverts Unauthorized. External callers use a Seismic signed read (TxSeismic 0x4A).
allowance(address owner, address spender) Same gate but no infra exemption — only owner / spender.
setContractKey(sbytes32, bytes) Admin-only, one-shot; installs the contract's encrypted-event keypair (sent as TxSeismic 0x4A). Reverts ZeroPrivateKey / InvalidPublicKeyPrefix / ContractKeyAlreadySet. Deliberately not folded into initialize (initializer calldata is plaintext).
registerPublicKey(bytes) Anyone; registers/overwrites the caller's 33-byte compressed secp256k1 key. Reverts on bad length/prefix.
publicKeyOf(address) / contractPublicKey() Plaintext reads of registered / contract public keys for off-chain decryption clients.
setAllowlisted(address, bool) (single + batch) ALLOWLIST_MANAGER_ROLE (administered by ALLOWLIST_ADMIN_ROLE, not DEFAULT_ADMIN_ROLE). Emits AllowlistSet.
totalSupply / yield / wrap / unwrap Unchanged.

Encrypted Transfer & Approval events (landed — 9ba313e, #118, #119)

Per the Seismic SRC-20 encrypted-events tutorial.

  • The suint256 user paths emit encrypted overloads with a distinct topic0, 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)
    • encryptKeyHash is keccak256 of the recipient/spender's registered public key — the key the amount is encrypted to — or bytes32(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 via registerPublicKey) and pick the matching private key to decrypt.
  • Only the user-to-user shielded path is encrypted. Mint, burn, infra transferFrom(uint256), and forced transfers stay on the plaintext Transfer(uint256) — their amounts are already public via bridge calldata or operator privilege.
  • The contract holds its own secp256k1 keypair: a shielded sbytes32 private key installed once via setContractKey sent as TxSeismic 0x4A; the plain public key is exposed via contractPublicKey(). Key material is validated (ZeroPrivateKey, InvalidPublicKeyPrefix), so the one-shot guard can't be bypassed with a zero key.
  • Recipients/spenders call registerPublicKey(bytes) once (33-byte compressed secp256k1). Before the contract key is installed, all user-path shielded transfers/approves revert ContractKeyNotSet uniformly — so pre-key availability never leaks who is registered via differential reverts.
  • Per-emit AES-GCM nonce = bytes12(keccak256(from, to, ++encryptedEventNonce)) (contract-wide monotonic counter; avoids same-block nonce reuse on repeat transfers). The counter is published via EncryptedAmountNonce(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, sbytes32 zero-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

make build profile=seismic         # ssolc + mercury build
make tests profile=seismic         # full unit suite (494 passing)
make integration-seismic           # in-process multi-contract seismic suite (22 passing, real precompiles, no fork)
make e2e-sanvil                    # full sanvil E2E: TxSeismic key install, signed-read gating, off-chain decryption

profile defaults to seismic (forgesforge automatically). Mainnet-fork integration tests are excluded under the Seismic profile ([profile.seismic].no_match_path = "test/integration/**") — fork RPCs don't implement eth_getFlaggedStorageAt. make integration runs them against a Seismic devnet when SEISMIC_DEVNET_RPC_URL is set.

Khrafts added 3 commits May 16, 2026 16:23
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).
@Khrafts Khrafts requested review from JPMora89 and MalteHerrmann May 18, 2026 13:04

@MalteHerrmann MalteHerrmann left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just some preliminary comments

Comment thread src/projects/yieldToOne/MYieldToOne.sol
Comment thread src/projects/yieldToOne/MYieldToOne.sol Outdated
Khrafts added 12 commits May 18, 2026 16:31
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 MalteHerrmann left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just some comments here and there, as we're talking about it on the call atm

Comment thread script/set-contract-key.sh
Comment thread script/set-contract-key.sh
Comment thread script/verify-seismic.py
Comment thread src/projects/yieldToOne/interfaces/IMYieldToOne.sol
Comment thread src/projects/yieldToOne/MYieldToOne.sol
Comment thread src/projects/yieldToOne/MYieldToOne.sol Outdated
Comment thread src/projects/yieldToOne/MYieldToOne.sol
Comment thread .env.example Outdated

@MalteHerrmann MalteHerrmann left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some more comments, will dig into the shell scripts later, but contracts are fully reviewed now 🙌

Comment thread script/ConfigureSeismicExtension.s.sol
Comment thread src/projects/yieldToOne/MYieldToOne.sol
Comment thread src/projects/yieldToOne/MYieldToOne.sol Outdated
Comment thread src/projects/yieldToOne/MYieldToOne.sol
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants