› hypothesis EVVM's async nonce track can be extended into a (key, seq) keyed-nonce model with no breaking change to existing services. Replay-independence across non-zero keys holds. key=0 preserves legacy behavior. KEYED_NONCE_FIRST_USE_GAS is modeled as a principal-token surcharge inside validateAndConsumeKeyedNonce.
› package zip includes contracts + justification + a NEXT_STEPS.md with EVVM docs and local-testing instructions via scaffold-evvm ↗
// SPDX-License-Identifier: EVVM-NONCOMMERCIAL-1.0
pragma solidity 0.8.30;
import {SignatureRecover} from "../../library/SignatureRecover.sol";
import {Erc191TestBuilder} from "../../library/Erc191TestBuilder.sol";
import {AsyncNonceService} from "../../library/AsyncNonceService.sol";
import {SyncNonceService} from "../../library/SyncNonceService.sol";
// >>> EIP-8250 ADDITION <<<
import {NonceManager} from "./NonceManager.sol";
// >>> end EIP-8250 ADDITION <<<
import {IStaking} from "../../interfaces/IStaking.sol";
import {INameService} from "../../interfaces/INameService.sol";
import {ITreasury} from "../../interfaces/ITreasury.sol";
/// @notice Metadata struct passed to the Core constructor. Unchanged from canonical.
struct EvvmMetadata {
string EvvmName;
uint256 EvvmID;
string principalTokenName;
string principalTokenSymbol;
address principalTokenAddress;
uint256 totalSupply;
uint256 eraTokens;
uint256 reward;
}
/**
* @title EVVM Core — EIP-8250 modification
* @notice Forked from packages/foundry/testnet-contracts/contracts/core/Core.sol
* with the EIP-8250 keyed-nonce extension applied.
*
* For the canonical Core.sol (~1280 LOC: pay/batchPay/dispersePay,
* the full admin proposal/accept flow, the upgrade flow, the
* token allowlist/denylist machinery, the staker bookkeeping,
* the reward calculation, and all view methods), see:
*
* https://www.evvm.info/docs/category/coresol
*
* This file shows the EIP-8250 modifications inline with the
* load-bearing canonical functions they touch or sit alongside.
* Functions not shown here are preserved byte-identical from
* the canonical Core.
*
* >>> EIP-8250 SCOPE OF CHANGES <<<
*
* ADDED:
* - NonceManager reference + initializeKeyedNonces() admin call
* - validateAndConsumeKeyedNonce() entrypoint
* - txParam() view (models TXPARAM(0x0B) / TXPARAM(0x0C) opcodes)
* - KeyedNonceConsumed event + KeyedNoncesNotInitialized /
* KeyedNonceSurchargeInsufficient errors
*
* UNCHANGED:
* - validateAndConsumeNonce() — preserved byte-identical so every
* existing service keeps working. The keyed track is purely
* additive.
* - pay/batchPay/dispersePay/caPay/disperseCaPay — payment surface
* does not depend on which nonce track was consumed.
* - All admin/upgrade/token-list/staker-reward functions.
*/
contract Core {
using SignatureRecover for bytes;
// ──────────────────────────────────────────────────────────────────
// Storage — load-bearing slots shown; full list in canonical Core
// ──────────────────────────────────────────────────────────────────
address public admin;
address public proposedAdmin;
uint256 internal proposedAdminAcceptanceWindow;
IStaking public staking;
INameService public nameService;
ITreasury public treasury;
EvvmMetadata internal metadata;
/// @dev user => sync nonce. Sequential; must be consumed in order.
mapping(address => uint256) internal nextSyncNonce;
/// @dev user => nonce => consumed. Async track: out-of-order use,
/// consumed-flag bitmap via mapping.
mapping(address => mapping(uint256 => bool)) internal asyncNonceConsumed;
/// @dev user => nonce => reserved. Async-nonce reservation track.
mapping(address => mapping(uint256 => bool)) internal asyncNonceReserved;
/// @dev Staker set + reward bookkeeping. Mutated by Staking.
mapping(address => bool) internal isStaker;
uint256 internal currentReward;
/// @dev Balance per (user, token). Mutated by pay/batchPay/etc.
mapping(address => mapping(address => uint256)) internal balances;
// [...remainder of canonical Core storage preserved as-is — token
// allowlist/denylist, user validator proposal slots, upgrade
// implementation slots, reward distribution params, total-supply
// delete proposal slots, etc. See evvm.info docs for full list.]
// >>> EIP-8250 ADDITION <<<
/// @notice The wired NonceManager. Set once via initializeKeyedNonces.
NonceManager public nonceManager;
/// @notice KEYED_NONCE_FIRST_USE_GAS per EIP §"Constants". Modeled
/// in this experiment as a principal-token surcharge rather
/// than EVM gas (see justification.md for the limitation).
uint256 public constant KEYED_NONCE_FIRST_USE_SURCHARGE = 20000;
// >>> end EIP-8250 ADDITION <<<
// ──────────────────────────────────────────────────────────────────
// Events
// ──────────────────────────────────────────────────────────────────
event NonceConsumed(
address indexed user,
uint256 indexed nonce,
bool isAsyncExec
);
// [...canonical events preserved — Payment, TokenStatusChanged,
// RewardRecalculated, AdminProposed, ImplementationProposed, etc.]
// >>> EIP-8250 ADDITION <<<
event KeyedNonceInitialized(address indexed nonceManager);
event KeyedNonceConsumed(
address indexed user,
uint256 indexed nonceKey,
uint64 nonceSeq,
bool wasFirstUse,
uint256 surchargeApplied
);
// >>> end EIP-8250 ADDITION <<<
// ──────────────────────────────────────────────────────────────────
// Errors
// ──────────────────────────────────────────────────────────────────
error OnlyAdmin();
error AlreadyInitialized();
error InvalidSignature();
error NonceAlreadyConsumed(uint256 nonce);
error NonceNotNext(uint256 expected, uint256 got);
// [...canonical errors preserved.]
// >>> EIP-8250 ADDITION <<<
error KeyedNoncesNotInitialized();
error KeyedNonceSurchargeInsufficient(uint256 requested, uint256 available);
// >>> end EIP-8250 ADDITION <<<
// ──────────────────────────────────────────────────────────────────
// Modifiers
// ──────────────────────────────────────────────────────────────────
modifier onlyAdmin() {
if (msg.sender != admin) revert OnlyAdmin();
_;
}
// ──────────────────────────────────────────────────────────────────
// Constructor & system wiring — canonical
// ──────────────────────────────────────────────────────────────────
constructor(
address _admin,
address _staking,
EvvmMetadata memory _metadata
) {
admin = _admin;
staking = IStaking(_staking);
metadata = _metadata;
}
/// @notice Wires NameService and Treasury references. One-time.
function initializeSystemContracts(
address _nameService,
address _treasury
) external onlyAdmin {
if (
address(nameService) != address(0) || address(treasury) != address(0)
) revert AlreadyInitialized();
nameService = INameService(_nameService);
treasury = ITreasury(_treasury);
}
// >>> EIP-8250 ADDITION <<<
/// @notice Wires the NonceManager system contract. One-time.
/// @param _nonceManager Address of the deployed NonceManager.
/// @dev Must be called after Core deployment but before any keyed-
/// nonce traffic. In production, NonceManager would be
/// installed at a canonical fork-managed address; here we
/// accept any address and trust the deployer.
function initializeKeyedNonces(address _nonceManager) external onlyAdmin {
if (address(nonceManager) != address(0)) revert AlreadyInitialized();
require(_nonceManager != address(0), "Core: zero nonce manager");
nonceManager = NonceManager(_nonceManager);
emit KeyedNonceInitialized(_nonceManager);
}
// >>> end EIP-8250 ADDITION <<<
// ──────────────────────────────────────────────────────────────────
// Nonce machinery — canonical validateAndConsumeNonce preserved
// ──────────────────────────────────────────────────────────────────
/**
* @notice Validate an EIP-191 signature and consume the associated
* nonce. Sync mode requires sequential consumption; async
* mode allows out-of-order consumption.
* @dev This is the canonical entrypoint. Every existing service
* calls this; preserved byte-identical so the EIP-8250
* change is purely additive.
*/
function validateAndConsumeNonce(
address user,
address senderExecutor,
bytes32 dataHash,
address originExecutor,
uint256 nonce,
bool isAsyncExec,
bytes calldata signature
) external {
// Reconstruct the EIP-191 payload.
bytes32 payloadHash = Erc191TestBuilder.buildActionHash(
metadata.EvvmID,
user,
senderExecutor,
originExecutor,
nonce,
isAsyncExec,
dataHash
);
address recovered = SignatureRecover.recover(payloadHash, signature);
if (recovered != user) revert InvalidSignature();
if (isAsyncExec) {
// Async path: check + flip the bitmap entry.
if (asyncNonceConsumed[user][nonce]) {
revert NonceAlreadyConsumed(nonce);
}
asyncNonceConsumed[user][nonce] = true;
} else {
// Sync path: must equal the next-expected nonce.
uint256 expected = nextSyncNonce[user];
if (nonce != expected) revert NonceNotNext(expected, nonce);
unchecked {
nextSyncNonce[user] = expected + 1;
}
}
emit NonceConsumed(user, nonce, isAsyncExec);
}
// >>> EIP-8250 ADDITION <<<
/**
* @notice Keyed-nonce equivalent of validateAndConsumeNonce.
* Validates an EIP-191 signature whose canonical payload
* includes (nonceKey, nonceSeq) in place of the single
* nonce, and atomically consumes the slot in NonceManager.
*
* @param user Signer / authorizing address.
* @param senderExecutor Service or executor that submits the tx.
* @param dataHash Action-specific hash (keccak(name, args)).
* @param originExecutor address(0) = any fisher; specific = bound.
* @param nonceKey EIP-8250 nonce_key (uint256). key=0 routes
* to the legacy sync nonce path; non-zero
* keys use NonceManager.
* @param nonceSeq EIP-8250 nonce_seq (uint64).
* @param signature Recovered against the EIP-191 payload.
*
* @return firstUseSurcharge Principal-token amount the caller MUST
* settle in the same transaction (via Core.pay). Zero when
* the slot has been used before; KEYED_NONCE_FIRST_USE_SURCHARGE
* on first use.
*
* @dev Reverts BEFORE any side effects if:
* - signature does not recover to `user`
* - keyed nonces not yet initialized
* - sequence mismatch (NonceManager raises SequenceMismatch)
*
* Mirrors EIP §"Stateful Validity" + §"Nonce Consumption"
* as a single atomic transition. The atomicity claim — "if
* step 3 halts out-of-gas, no approval effects occur" — is
* enforced here by the require() check on surcharge funds
* (see KeyedNonceSurchargeInsufficient).
*/
function validateAndConsumeKeyedNonce(
address user,
address senderExecutor,
bytes32 dataHash,
address originExecutor,
uint256 nonceKey,
uint64 nonceSeq,
bytes calldata signature
) external returns (uint256 firstUseSurcharge) {
if (address(nonceManager) == address(0)) {
revert KeyedNoncesNotInitialized();
}
// Reconstruct the EIP-191 payload. Note (key, seq) are folded
// into the envelope alongside (sender, executor, evvmId).
bytes32 payloadHash = _buildKeyedActionHash(
metadata.EvvmID,
user,
senderExecutor,
originExecutor,
nonceKey,
nonceSeq,
dataHash
);
address recovered = SignatureRecover.recover(payloadHash, signature);
if (recovered != user) revert InvalidSignature();
if (nonceKey == 0) {
// EIP §"Nonce Consumption" — key=0 aliases the legacy
// account nonce. We route to the sync path and treat
// nonceSeq as the sync nonce value.
uint256 expected = nextSyncNonce[user];
if (uint256(nonceSeq) != expected) {
revert NonceNotNext(expected, uint256(nonceSeq));
}
unchecked {
nextSyncNonce[user] = expected + 1;
}
emit KeyedNonceConsumed(user, 0, nonceSeq, false, 0);
return 0;
}
// Non-zero key: dispatch to NonceManager. wasFirstUse drives
// the surcharge.
bool wasFirstUse = nonceManager.consume(user, nonceKey, nonceSeq);
firstUseSurcharge = wasFirstUse ? KEYED_NONCE_FIRST_USE_SURCHARGE : 0;
// Atomicity: if the surcharge is non-zero, the caller's
// principal-token balance must cover it. We check (not deduct)
// — the caller is responsible for the subsequent Core.pay()
// that actually moves the tokens. The check here is a guard
// against the EIP's §Specification step 3 "halt out-of-gas
// without approval effects" scenario.
if (firstUseSurcharge > 0) {
uint256 available = balances[user][metadata.principalTokenAddress];
if (available < firstUseSurcharge) {
revert KeyedNonceSurchargeInsufficient(
firstUseSurcharge,
available
);
}
}
emit KeyedNonceConsumed(
user,
nonceKey,
nonceSeq,
wasFirstUse,
firstUseSurcharge
);
}
/// @dev EIP-191 payload builder for keyed-nonce actions. Mirrors
/// Erc191TestBuilder.buildActionHash but splits the nonce into
/// (key, seq). Kept inline because library churn for one
/// experiment isn't worth it.
function _buildKeyedActionHash(
uint256 evvmId,
address user,
address senderExecutor,
address originExecutor,
uint256 nonceKey,
uint64 nonceSeq,
bytes32 dataHash
) internal pure returns (bytes32) {
string memory payload = string(
abi.encodePacked(
"evvm:",
_toString(evvmId),
"|user:",
_toHex(user),
"|sender:",
_toHex(senderExecutor),
"|origin:",
_toHex(originExecutor),
"|nk:",
_toString(nonceKey),
"|ns:",
_toString(uint256(nonceSeq)),
"|data:",
_bytes32ToHex(dataHash)
)
);
return keccak256(
abi.encodePacked(
"\x19Ethereum Signed Message:\n",
_toString(bytes(payload).length),
payload
)
);
}
/**
* @notice Models TXPARAM(0x0B) and TXPARAM(0x0C) from EIP-8250
* §"TXPARAM". Mock-as-function because the EVM doesn't
* gain new opcodes locally.
* @param idx 0x0B → returns the user's most-recently-consumed
* nonce_key (zero if the user has never used keyed
* nonces); 0x0C → returns the user's pre-state legacy
* nonce (i.e., the current sync nonce).
* @param user The user to query. In the real EIP, TXPARAM operates
* on the executing tx's sender; here the caller must
* pass it explicitly.
*/
function txParam(uint8 idx, address user) external view returns (uint256) {
if (idx == 0x0C) return nextSyncNonce[user];
// For 0x0B we'd need to track "most recent key" per user as
// additional storage. For this experiment we expose the
// NonceManager directly and let services compose:
// nonceManager.currentNonceSeq(user, knownKey)
// The view here returns 0 for any other index, consistent with
// EIP-8250's "Undefined param values cause exceptional halt" —
// in our mock we use 0 as the sentinel.
if (idx == 0x0B) return 0;
revert("Core: txParam idx undefined");
}
// >>> end EIP-8250 ADDITION <<<
// ──────────────────────────────────────────────────────────────────
// Nonce views — canonical
// ──────────────────────────────────────────────────────────────────
function getNextCurrentSyncNonce(address user)
external
view
returns (uint256)
{
return nextSyncNonce[user];
}
function getIfUsedAsyncNonce(address user, uint256 nonce)
external
view
returns (bool)
{
return asyncNonceConsumed[user][nonce];
}
function asyncNonceStatus(address user, uint256 nonce)
external
view
returns (bool consumed, bool reserved)
{
return (asyncNonceConsumed[user][nonce], asyncNonceReserved[user][nonce]);
}
// [...other nonce machinery preserved — reserveAsyncNonce,
// revokeAsyncNonce, getAsyncNonceReservation. See evvm.info docs.]
// ──────────────────────────────────────────────────────────────────
// Payment surface — canonical, fully preserved
// ──────────────────────────────────────────────────────────────────
// [pay, batchPay, dispersePay, caPay, disperseCaPay all preserved
// byte-identical from canonical Core. EIP-8250 does not modify
// the payment surface — services that want to use the keyed-nonce
// path call validateAndConsumeKeyedNonce first, then call the
// same pay() they always called for the EVVM-pay leg of the
// dual-signature flow. See:
// https://www.evvm.info/docs/category/coresol
// for the full payment implementations.]
// ──────────────────────────────────────────────────────────────────
// Read-only metadata + administration — canonical
// ──────────────────────────────────────────────────────────────────
function getEvvmMetadata() external view returns (EvvmMetadata memory) {
return metadata;
}
function getPrincipalTokenAddress() external view returns (address) {
return metadata.principalTokenAddress;
}
function getEvvmID() external view returns (uint256) {
return metadata.EvvmID;
}
function getNameServiceAddress() external view returns (address) {
return address(nameService);
}
function getStakingContractAddress() external view returns (address) {
return address(staking);
}
function getBalance(address user, address token)
external
view
returns (uint256)
{
return balances[user][token];
}
function isAddressStaker(address user) external view returns (bool) {
return isStaker[user];
}
// [...other read methods preserved — getRewardAmount,
// getFullDetailReward, getPrincipalTokenTotalSupply,
// getCurrentSupply, getCurrentImplementation, getCurrentAdmin,
// getUserValidatorAddress. See evvm.info docs.]
// [Admin/upgrade/proposal flows preserved — proposeUserValidator,
// acceptUserValidatorProposal, proposeAdmin, acceptAdmin,
// proposeImplementation, acceptImplementation, proposeListStatus,
// proposeChangeBaseRewardAmount, proposeChangeRewardFlowDistribution,
// proposeDeleteTotalSupply, etc. See evvm.info docs.]
// [Token list / balance bookkeeping preserved — setTokenStatusOnAllowList,
// setTokenStatusOnDenyList, addBalance, addAmountToUser,
// removeAmountFromUser, setPointStaker, recalculateReward,
// pointStaker. See evvm.info docs.]
// ──────────────────────────────────────────────────────────────────
// Internal string/hex helpers (used by _buildKeyedActionHash above)
// ──────────────────────────────────────────────────────────────────
// [Identical to those in Erc191TestBuilder. Kept inline rather than
// importing to keep the EIP-8250 surface self-contained for review.]
function _toString(uint256 value) internal pure returns (string memory) {
if (value == 0) return "0";
uint256 temp = value;
uint256 digits;
while (temp != 0) { digits++; temp /= 10; }
bytes memory buffer = new bytes(digits);
while (value != 0) {
digits -= 1;
buffer[digits] = bytes1(uint8(48 + value % 10));
value /= 10;
}
return string(buffer);
}
function _toHex(address a) internal pure returns (string memory) {
return _bytes32ToHex(bytes32(uint256(uint160(a))));
}
function _bytes32ToHex(bytes32 b) internal pure returns (string memory) {
bytes memory alphabet = "0123456789abcdef";
bytes memory str = new bytes(2 + 64);
str[0] = "0"; str[1] = "x";
for (uint256 i = 0; i < 32; i++) {
uint8 byteVal = uint8(b[i]);
str[2 + i * 2] = alphabet[byteVal >> 4];
str[3 + i * 2] = alphabet[byteVal & 0x0f];
}
return string(str);
}
}
Nonce machinery lives in Core. EIP-8250 requires adding a parallel (key, seq) replay-protection track without breaking the existing single-nonce path. The async nonce track in EVVM's Core already implements 1:1 the same conceptual model (per-user per-slot replay protection) with the sequence collapsed into the slot — this experiment just splits sequence back out and adds the system-contract dispatch surface.
canonical reference: evvm.info ↗
EIP-8250 replaces the single sender nonce in EIP-8141 frame transactions with a (nonce_key: uint256, nonce_seq: uint64) pair. nonce_key == 0 aliases the legacy account nonce; non-zero keys live in a new NONCE_MANAGER system contract at a TBD address with revert(0,0) code. Transactions on different non-zero keys are replay-independent — privacy protocols, smart-wallet session keys, and relayer-style senders can issue concurrent transactions from one sender without blocking on linear nonces. The EIP also adds two TXPARAM indices (0x0B → key, 0x0C → pre-state legacy nonce) and a 20 000-gas first-use surcharge for new keyed slots.
Status: Draft, Core, requires EIP-8141. One-sentence change: nonces become per-domain instead of per-account, with payment-approval as the single atomic spend point.
EVVM's async nonce track is structurally identical to the keyed-nonce model with nonceSeq collapsed into the slot key. Adding a per-key sequence number and exposing it through a dedicated validateAndConsumeKeyedNonce entrypoint should: (1) preserve every existing service's behavior unchanged, (2) prove the replay-independence claim across non-zero keys, and (3) demonstrate that the first-use surcharge can be modeled atomically without modifying EVM gas accounting.
experiment:eip-8141-frame-router (Path a). The keyed-nonce logic itself is independent of frame-tx encoding, but adversarial scenarios that test atomic interaction with payment-approval need the router substrate.NonceManager — stubs the canonical NONCE_MANAGER system contract. Strategy: simulate. Limitation: fork-activation install semantics not modeled; the reverting fallback bytecode is omitted (we never call NonceManager directly from EOA-side, only through Core).
Core.txParam — stubs TXPARAM(0x0B) and TXPARAM(0x0C) opcodes. Strategy: mock. Limitation: function-call dispatch instead of opcode-immediate; loses the 2-gas opcode cost.
KeyedNonceFirstUseSurcharge — stubs the 20 000-gas KEYED_NONCE_FIRST_USE_GAS exceptional-halt. Strategy: mock. Limitation: surcharge is in principal-token units, not EVM gas. Out-of-gas atomicity (EIP §Specification step 3) is approximated, not exact.
Type: Modified Core (core-modification)
What: Forked from packages/foundry/testnet-contracts/contracts/core/Core.sol. Adds three things on top of the canonical Core: (1) a NonceManager reference + one-time initializeKeyedNonces(address) admin call, (2) a new validateAndConsumeKeyedNonce(...) entrypoint that's the keyed equivalent of validateAndConsumeNonce, and (3) a txParam(uint8, address) view function that models TXPARAM 0x0B (key) and 0x0C (legacy nonce). Leaves the existing validateAndConsumeNonce byte-identical so every current service keeps working.
Why: The nonce track is the load-bearing concept of the EIP. Modifying Core (Shape A) is correct because the change adjusts a protocol-level invariant; doing this as a service (Shape B) would mean services using the new track couldn't interoperate with services using the old track. The additive design — new entrypoint alongside the old one — means zero migration cost for existing services. The single non-obvious decision: surfacing TXPARAM as a function on Core itself, not as a separate TxParamProvider adapter. Inlining keeps the call sites identical to what they'd be post-fork (one external call), which matters for the cost-modeling discussion in the writeup.
EIP mapping:
currentSeq[sender][key]validateAndConsumeKeyedNonce checks nonceSeq == currentNonceSeq(sender, key) before any side effectstxParam(0x0B, user) returns pendingKeyedNonce.key; txParam(0x0C, user) returns the user's legacy sync noncefirstUseSurcharge return valueLimitations:
require instead of EVM out-of-gas semantics.validateAndConsumeNonce are unaffected; this is by design.pay/batchPay/dispersePay — those continue using the legacy sync/async tracks.Type: New system contract (new-system-contract)
What: Standalone Solidity contract holding mapping(address sender => mapping(uint256 key => uint64 seq)). Exposes currentNonceSeq(sender, key) view and consume(sender, key, seq) mutator. The mutator is gated by onlyCore so only the wired Core instance can advance keyed nonces.
Why: EIP-8250 specifies NONCE_MANAGER as a fork-deployed contract at a TBD address; we deploy it normally and connect it to Core via the one-time initializeKeyedNonces admin call. Splitting it out of Core (instead of inlining the storage) mirrors the EIP's separation of concerns and makes the storage layout easy to audit against the EIP's slot(sender, nonce_key) = keccak256(...) formula. Solidity's nested mapping lays out identically.
EIP mapping: §"Nonce State" and §5 (the entire NONCE_MANAGER contract spec).
Limitations:
vm.etch the EIP's canonical 5-byte runtime code (0x60006000fd) so direct calls revert with empty returndata. Here, since only Core calls it, the fallback never fires in tests.validateAndConsumeKeyedNonce accept the action signature in EIP-191 form or in EIP-712 form? The EIP doesn't constrain the signing standard; EVVM uses 191 everywhere else, so we follow that. Worth flagging for reviewers who might want a 712 path.txParam view function takes a user argument — in the real EIP, TXPARAM operates on the currently executing tx. Without a real tx context, a service has to pass the user explicitly. Acceptable for the contract-layer test; would need a different API to test the opcode-level semantics.KEYED_NONCE_FIRST_USE_GAS be configurable, or hardcoded at 20000? EIP fixes it; we hardcode. If a researcher wants to test alternative surcharges, they'd fork the experiment.The contracts in this folder and this justification are the deliverable. The researcher's next steps (outside this skill's scope):
experiments/eip-8250-keyed-nonces/
├── README.md
├── justification.md ← this file
├── manifest.json
└── contracts/
├── Core.sol ← modified, ~450 LOC of focused fork
└── NonceManager.sol ← new, ~80 LOC