Skip to content

Feat/seismic#116

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

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

Conversation

@Khrafts
Copy link
Copy Markdown
Member

@Khrafts Khrafts commented May 18, 2026

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

Scope

Changes are confined to src/projects/yieldToOne/MYieldToOne.sol (plus its interface). The rest of the M0 stack (MExtension, SwapFacility, Portal, M Token, …) is unchanged — only this one extension is rebuilt with the Seismic ssolc toolchain against the mercury EVM revision so it can hold shielded suint256 / sbytes32 state.

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.

Function surface (current)

Entry point Behavior
transfer(address, suint256) Shielded path. Currently emits the inherited Transfer(address,address,uint256) with cleartext amount — being closed in the encrypted-events phase below.
transferFrom(address, address, suint256) Shielded path. Decrements shieldedAllowance.
approve(address, suint256) Writes shieldedAllowance.
transferFrom(address, address, uint256) Reverts UseShieldedTransfer unless _isInfra(msg.sender). Used by Portal on outflow.
approve(address, uint256) Reverts UseShieldedApprove unless _isInfra(spender). Used by Portal's forceApprove(SwapFacility).
transfer(address, uint256) Always reverts.
permit(...) (both overloads) Always reverts.
balanceOf(address account) Returns cleartext when msg.sender == account OR _isInfra(msg.sender), 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.
totalSupply / yield / wrap / unwrap Unchanged.

In progress: encrypted Transfer events

Per the Seismic SRC-20 encrypted-events tutorial. Design:

  • Add a second Transfer(address,address,bytes) overload alongside the inherited Transfer(address,address,uint256). Distinct topic0; coexists cleanly with standard ERC-20 indexers.
  • Encrypt only the user-to-user _shieldedTransfer path (the suint256 overloads). Mint, burn, infra transferFrom(uint256), and forced transfers stay on the plaintext Transfer(uint256) event — their amounts are already public via bridge calldata or operator privilege.
  • Contract holds its own secp256k1 keypair: shielded sbytes32 contractPrivateKey set once via an admin call sent as TxSeismic 0x4A; plain bytes contractPublicKey exposed for off-chain decryption clients.
  • Recipients call registerPublicKey(bytes) once (33-byte compressed secp256k1). Unregistered recipients still receive transfers but get an empty-ciphertext event.
  • Per-emit nonce derived from keccak256(from, to, ++encryptedEventNonce) (monotonic counter; avoids the tutorial's same-block nonce-reuse risk on repeat transfers).

Open questions for the Seismic team: UX of empty-ciphertext fallback, nonce strategy, and sbytes32 zero-sentinel semantics.

Build & test

FOUNDRY_PROFILE=seismic ./build.sh -p seismic
FOUNDRY_PROFILE=seismic sforge test --force --match-path test/unit/projects/yieldToOne/

Integration tests are skipped under the Seismic profile — they fork mainnet, which doesn't implement eth_getFlaggedStorageAt. See [profile.seismic].no_match_path in foundry.toml.

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
Copy link
Copy Markdown

@MalteHerrmann MalteHerrmann left a comment

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
Comment on lines +279 to +283
* outbound bridging. Returns the raw `suint256` so comparisons stay shielded.
*/
function _balanceOf(address account) internal view returns (suint256) {
return _getMYieldToOneStorageLocation().balanceOf[account];
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We could double-check with Seismic, not sure if this needs to return the shielded value since it's only used internal to the contract execution and that should not leak any insights into returned values.

Khrafts added 9 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.
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