From a2ad0611e3ef3b0a8f3416c313cc61da7a5c05bc Mon Sep 17 00:00:00 2001 From: Max Wickham Date: Mon, 20 Apr 2026 15:43:17 +0300 Subject: [PATCH 1/9] offboarder --- docs/Management.md | 34 ++++ script/Scribe.s.sol | 32 ++++ script/offboard/ScribeOffboarder.sol | 271 +++++++++++++++++++++++++++ test/offboard/ScribeOffboarder.t.sol | 180 ++++++++++++++++++ 4 files changed, 517 insertions(+) create mode 100644 script/offboard/ScribeOffboarder.sol create mode 100644 test/offboard/ScribeOffboarder.t.sol diff --git a/docs/Management.md b/docs/Management.md index a747753..24cafe0 100644 --- a/docs/Management.md +++ b/docs/Management.md @@ -20,6 +20,7 @@ This document describes how to manage deployed `Scribe` and `ScribeOptimistic` i - [`IToll::kiss`](#itollkiss) - [`IToll::diss`](#itolldiss) - [Offboarding](#offboarding) + - [Offboard via `ScribeOffboarder`](#offboard-via-scribeoffboarder) - [Deactivation](#deactivation) - [Fund Rescue](#fund-resuce) - [Killing](#killing) @@ -286,6 +287,39 @@ Offboarding a Scribe(Optimistic) instance simply means _Chronicle Protocol_ is n However, to ensure an offboarded Scribe(Optimistic) instance may not behave unexpectedly it needs to be deactivated. Furthermore, if the contract is a ScribeOptimistic instance ETH held by the contract may need to be rescued. If its certain the contract will never be used again it is recommended to kill it. +A Scribe(Optimistic) instance can be deactivated in two ways: + +- In a single transaction via a deployed [`ScribeOffboarder`](#offboard-via-scribeoffboarder) contract that holds `auth` on the instance. +- Via two distinct `forge script` [deactivation](#deactivation) commands signed by an `auth`'ed EOA. + +### Offboard via `ScribeOffboarder` + +Offboarding through a `ScribeOffboarder` instance drops every currently-lifted feed, sets `bar` to `1`, and pokes value `0` in a single transaction. After the call, `read()` reverts and no feed is lifted, so no future poke can verify. + +Expects: + +- `OFFBOARDER` is a deployed `ScribeOffboarder` instance that holds `auth` on `SCRIBE`. +- The caller holds `auth` on `OFFBOARDER`. + +Set the additional environment variable: + +- `OFFBOARDER`: The `ScribeOffboarder` instance to offboard `SCRIBE` with. + +Run: + +```bash +$ forge script \ + --keystore "$KEYSTORE" \ + --password "$KEYSTORE_PASSWORD" \ + --broadcast \ + --rpc-url "$RPC_URL" \ + --sig $(cast calldata "offboard(address,address)" "$OFFBOARDER" "$SCRIBE") \ + -vvv \ + script/${SCRIBE_FLAVOUR}.s.sol:${SCRIBE_FLAVOUR}Script +``` + +Note that `auth`'ed addresses on `SCRIBE` are untouched by `offboard` — to make the deactivation permanent, follow up with [`kill`](#killing). + ### Deactivation Deactivating a Scribe(Optimistic) instance means its value is set to zero, leading all `read()` calls to revert/fail, no feeds are lifted, and `bar` is set to `255`. diff --git a/script/Scribe.s.sol b/script/Scribe.s.sol index 6592440..d23f574 100644 --- a/script/Scribe.s.sol +++ b/script/Scribe.s.sol @@ -17,6 +17,8 @@ import {LibSecp256k1} from "src/libs/LibSecp256k1.sol"; import {LibRandom} from "./libs/LibRandom.sol"; import {LibFeed} from "./libs/LibFeed.sol"; +import {ScribeOffboarder} from "./offboard/ScribeOffboarder.sol"; + /** * @notice Scribe Management Script */ @@ -269,6 +271,36 @@ contract ScribeScript is Script { // -- Offboarding Functions -- + /// @dev Offboards instance `scribe` via `ScribeOffboarder` instance + /// `offboarder` in a single transaction. + /// + /// @dev Offboarding drops every currently-lifted feed, sets bar to 1, + /// and pokes value 0. After the call `scribe.read()` reverts and + /// no feed is lifted, so no future poke can verify. + /// + /// @dev Expects: + /// - `offboarder` to hold `auth` on `scribe` + /// - `msg.sender` to hold `auth` on `offboarder` + /// + /// @dev Note that `auth`'ed addresses on `scribe` are untouched. To make + /// the deactivation permanent, follow up with `kill(scribe)`. + function offboard(address offboarder, address scribe) public { + ( + uint8[] memory feedIds, + uint32 pokeAge, + bytes32 signature, + address commitment + ) = ScribeOffboarder(offboarder).computeOffboardArgs(scribe); + + vm.startBroadcast(); + ScribeOffboarder(offboarder).offboard( + scribe, feedIds, pokeAge, signature, commitment + ); + vm.stopBroadcast(); + + console2.log("Offboarded", scribe); + } + /// @dev Step 1 of deactivating instance `self`. /// /// @dev Deactivating an instance means: diff --git a/script/offboard/ScribeOffboarder.sol b/script/offboard/ScribeOffboarder.sol new file mode 100644 index 0000000..b55ec6a --- /dev/null +++ b/script/offboard/ScribeOffboarder.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {Auth} from "chronicle-std/auth/Auth.sol"; + +import {IScribe} from "../../src/IScribe.sol"; + +import {LibSecp256k1} from "../../src/libs/LibSecp256k1.sol"; + +/** + * @title ScribeOffboarder + * + * @notice Permanently disables a `Scribe` oracle in a single transaction. + * The contract must be granted `auth` on the target scribe. + * + * `offboard(scribe, feedIds, pokeAge, sig, commitment)` drops every + * currently-lifted feed, sets bar = 1, lifts the hardcoded + * offboarder feed, pokes val = 0, and drops the offboarder feed + * again. The caller supplies the list of lifted feed ids and a + * pre-computed Schnorr signature — pair it with the + * `computeOffboardArgs(scribe)` view, which enumerates the feeds + * and signs off-chain via eth_call. + * + * @dev Security note: the offboarder feed's private key is hardcoded as a + * `constant` in the bytecode and is therefore publicly readable. This + * is intentional; the feed is only lifted inside an offboarder call + * and dropped again before the transaction returns, so any signatures + * craftable from the leaked key require the attacker to already hold + * `auth` on the scribe and re-lift the feed (at which point they could + * poke arbitrary values directly anyway). + */ +contract ScribeOffboarder is Auth { + // ----------------------------------------------------------------------------- + // Hardcoded Offboarder Feed + // + // All four values below are derived from a single string seed: + // + // SEED = "Chronicle.ScribeOffboarder.v1" + // FEED_PRIV_KEY = uint(keccak256(SEED)) mod Q + // pubKey = vm.createWallet(FEED_PRIV_KEY).{publicKeyX, publicKeyY} + // ECDSA(v,r,s) = vm.sign(FEED_PRIV_KEY, FEED_REGISTRATION_MESSAGE) + // + // where Q is the order of secp256k1 and + // + // FEED_REGISTRATION_MESSAGE = + // keccak256( + // "\x19Ethereum Signed Message:\n32" ‖ + // keccak256("Chronicle Feed Registration") + // ) + // + // FEED_REGISTRATION_MESSAGE is a Scribe protocol constant (see + // `Scribe.feedRegistrationMessage`), so the ECDSA signature below is + // valid against every Scribe deployment. + // + // To regenerate / verify these values, run: + // + // forge script script/PrintFeedConstants.s.sol -vvv + + /// @dev `keccak256("Chronicle.ScribeOffboarder.v1") mod Q`. + uint internal constant FEED_PRIV_KEY = + 0xf067ac0350b6ab5e39cf9138ae16508404c05af8b58676e07ae14e9a510a600e; + + uint private constant FEED_PUBKEY_X = + 0xc2f3bfebb984817bbcaaf2b8e185259b09493f6db4b2c30d7b4feca89f7a64b2; + + uint private constant FEED_PUBKEY_Y = + 0x53a0dc54c1d22a62fbb1b3724737486c7e61c26080a44817eef7f5992a521a40; + + /// @notice Feed id is the highest-order byte of the ethereum address + /// derived from the offboarder feed's public key + /// (= 0x03da1Db2b7875106395548682B068a7C7b296902 => id = 0x03). + uint8 public constant feedId = 0x03; + + // ECDSA signature over FEED_REGISTRATION_MESSAGE by FEED_PRIV_KEY. + uint8 private constant LIFT_V = 28; + bytes32 private constant LIFT_R = + 0xc9988947c96dd6899cc097e3979b1e4d9228e09b4bc7cf53a9e0fe2ff395a77c; + bytes32 private constant LIFT_S = + 0x76ec989c8765878f18daceb7893ea2c24b1a87401c847e240a1301c3565f8dd7; + + // ----------------------------------------------------------------------------- + // secp256k1 Constants + + /// @dev x-coordinate of the secp256k1 generator G. + uint private constant G_X = + 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798; + + /// @dev Order of the secp256k1 group. + uint private constant Q = + 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + + /// @dev `v` value corresponding to G (G_y is even, so parity = 0). + uint8 private constant G_V = 27; + + // ----------------------------------------------------------------------------- + // Other Constants + + /// @dev `computeOffboardArgs` defaults `pokeAge` to + /// `block.timestamp - POKE_AGE_BACKDATE`. Picked so the helper's + /// eth_call result remains usable for ~5 minutes of off-chain + /// signing + tx-submission latency, while still being safely + /// older than `block.timestamp` at execution time. + uint32 private constant POKE_AGE_BACKDATE = 5 minutes; + + // ----------------------------------------------------------------------------- + // Events + + event Offboarded(address indexed caller, address indexed scribe); + + // ----------------------------------------------------------------------------- + // Constructor + + constructor(address initialAuthed) Auth(initialAuthed) {} + + // ----------------------------------------------------------------------------- + // Offboard + + /// @notice Offboards `scribe` using pre-computed inputs. Produce the + /// arguments off-chain by eth_calling + /// `computeOffboardArgs(scribe)`. + /// @dev `feedIds` must include every currently-lifted feed on `scribe` + /// (a missing id would leave that feed lifted after the call). + /// Extra / already-dropped ids are harmless — scribe's `drop` is + /// idempotent per id. + function offboard( + address scribe, + uint8[] calldata feedIds, + uint32 pokeAge, + bytes32 signature, + address commitment + ) external auth { + _offboard(scribe, feedIds, pokeAge, signature, commitment); + } + + /// @notice Returns everything `offboard` needs: the list of + /// currently-lifted feed ids on `scribe`, a `pokeAge` + /// (backdated by ~5 minutes for submission latency), and a + /// 1-of-1 Schnorr signature over + /// `constructPokeMessage({val: 0, age: pokeAge})`. + /// + /// Intended to be eth_called — running it on-chain costs the + /// ~1.1M gas that `scribe.feeds()` charges, which is the whole + /// point of splitting the pre-computation off-chain. + function computeOffboardArgs(address scribe) + external + view + returns ( + uint8[] memory feedIds, + uint32 pokeAge, + bytes32 signature, + address commitment + ) + { + feedIds = _enumerateFeedIds(IScribe(scribe)); + pokeAge = uint32(block.timestamp - POKE_AGE_BACKDATE); + (signature, commitment) = _schnorrSigFor(scribe, pokeAge); + } + + // ----------------------------------------------------------------------------- + // Internal — offboard sequence + + function _offboard( + address scribe, + uint8[] calldata feedIds, + uint32 pokeAge, + bytes32 schnorrSignature, + address schnorrCommitment + ) internal { + require(scribe != address(0), "ScribeOffboarder: scribe=zero"); + IScribe target = IScribe(scribe); + + if (feedIds.length != 0) { + target.drop(feedIds); + } + target.setBar(1); + _liftOffboarderFeed(target); + + target.poke( + IScribe.PokeData({val: 0, age: pokeAge}), + IScribe.SchnorrData({ + signature: schnorrSignature, + commitment: schnorrCommitment, + feedIds: abi.encodePacked(feedId) + }) + ); + + target.drop(feedId); + + // Note that we don't re-call `setBar(1)` — scribe rejects + // `setBar(0)` with `require(bar_ != 0)`, and bar is already 1 from + // the earlier call. With no feeds lifted, no future poke can verify + // regardless. + + emit Offboarded(msg.sender, scribe); + } + + /// @dev Reads every feed currently lifted on `target` and returns their + /// ids. `scribe.feeds()` is the expensive part — 256 slot reads. + function _enumerateFeedIds(IScribe target) + internal + view + returns (uint8[] memory) + { + address[] memory currentFeeds = target.feeds(); + uint8[] memory ids = new uint8[](currentFeeds.length); + for (uint i; i < currentFeeds.length; i++) { + ids[i] = uint8(uint(uint160(currentFeeds[i])) >> 152); + } + return ids; + } + + function _liftOffboarderFeed(IScribe target) internal { + target.lift( + LibSecp256k1.Point({x: FEED_PUBKEY_X, y: FEED_PUBKEY_Y}), + IScribe.ECDSAData({v: LIFT_V, r: LIFT_R, s: LIFT_S}) + ); + } + + function _schnorrSigFor(address scribe, uint32 pokeAge) + internal + view + returns (bytes32 signature, address commitment) + { + bytes32 message = IScribe(scribe) + .constructPokeMessage(IScribe.PokeData({val: 0, age: pokeAge})); + return _signSchnorr(message); + } + + // ----------------------------------------------------------------------------- + // Internal — Schnorr signing + + /// @dev Produces a 1-of-1 Schnorr signature over `message` using the + /// hardcoded offboarder feed. + /// + /// The ethereum address `R_e = address(k·G)` is computed via the + /// ecrecover precompile trick rather than a full secp256k1 scalar + /// multiplication: with `h = 0`, `r = G_x`, `v = G_v`, + /// `s = k·G_x mod Q`, ecrecover returns exactly `address(k·G)` — + /// which is all the signing scheme needs as the commitment. + /// + /// See the scribe repo's `docs/Schnorr.md` for the scheme this + /// implements. + function _signSchnorr(bytes32 message) + internal + pure + returns (bytes32 signature, address commitment) + { + // k = H(privKey ‖ m) mod Q + uint nonce = + uint(keccak256(abi.encodePacked(FEED_PRIV_KEY, message))) % Q; + + // R_e = address(k·G) via the ecrecover trick. + commitment = ecrecover( + bytes32(0), G_V, bytes32(G_X), bytes32(mulmod(nonce, G_X, Q)) + ); + require(commitment != address(0), "ScribeOffboarder: bad commitment"); + + // e = H(Pₓ ‖ Pₚ ‖ m ‖ R_e) mod Q + uint challenge = uint( + keccak256( + abi.encodePacked( + FEED_PUBKEY_X, uint8(FEED_PUBKEY_Y & 1), message, commitment + ) + ) + ) % Q; + + // s = k + e·x mod Q + signature = + bytes32(addmod(nonce, mulmod(challenge, FEED_PRIV_KEY, Q), Q)); + } +} diff --git a/test/offboard/ScribeOffboarder.t.sol b/test/offboard/ScribeOffboarder.t.sol new file mode 100644 index 0000000..2a02bd4 --- /dev/null +++ b/test/offboard/ScribeOffboarder.t.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {Test, Vm} from "forge-std/Test.sol"; + +import {IAuth} from "chronicle-std/auth/IAuth.sol"; +import {IToll} from "chronicle-std/toll/IToll.sol"; + +import {IScribe} from "../../src/IScribe.sol"; +import {Scribe} from "../../src/Scribe.sol"; +import {LibSecp256k1} from "../../src/libs/LibSecp256k1.sol"; + +import {ScribeOffboarder} from "../../script/offboard/ScribeOffboarder.sol"; + +contract ScribeOffboarderTest is Test { + bytes32 internal constant WAT = bytes32("ETH/USD"); + + /// @dev Must match `feedId` in `ScribeOffboarder.sol`. + uint8 internal constant OFFBOARDER_FEED_ID = 0x03; + + Scribe internal scribe; + ScribeOffboarder internal offboarder; + + function setUp() public { + scribe = new Scribe({initialAuthed: address(this), wat_: WAT}); + IToll(address(scribe)).kiss(address(this)); + + offboarder = new ScribeOffboarder({initialAuthed: address(this)}); + IAuth(address(scribe)).rely(address(offboarder)); + + vm.warp(1_700_000_000); + } + + // ----------------------------------------------------------------------------- + // Pre-signed offboard (computeSchnorrSig + offboard(scribe, pokeAge, sig, com)) + + function test_offboard() public { + _liftRandomFeeds(3, 0xBEEF); + + ( + uint8[] memory feedIds, + uint32 pokeAge, + bytes32 sig, + address commitment + ) = offboarder.computeOffboardArgs(address(scribe)); + + offboarder.offboard(address(scribe), feedIds, pokeAge, sig, commitment); + + assertEq(scribe.feeds().length, 0); + assertEq(scribe.bar(), 1); + (bool ok,) = scribe.tryRead(); + assertFalse(ok); + } + + function testFuzz_offboard( + uint8 nFeeds, + uint64 timeAdvance, + uint64 seedSalt + ) public { + nFeeds = uint8(bound(nFeeds, 0, 32)); + timeAdvance = uint64(bound(timeAdvance, 1, 365 days)); + + _liftRandomFeeds(nFeeds, uint(seedSalt) | 1); + vm.warp(block.timestamp + timeAdvance); + + ( + uint8[] memory feedIds, + uint32 pokeAge, + bytes32 sig, + address commitment + ) = offboarder.computeOffboardArgs(address(scribe)); + + offboarder.offboard(address(scribe), feedIds, pokeAge, sig, commitment); + + assertEq(scribe.feeds().length, 0); + assertEq(scribe.bar(), 1); + (bool ok,) = scribe.tryRead(); + assertFalse(ok); + + assertTrue(scribe.authed(address(offboarder))); + } + + // ----------------------------------------------------------------------------- + // Auth Protection + + function testFuzz_offboard_presigned_isAuthProtected(address caller) + public + { + vm.assume(!IAuth(address(offboarder)).authed(caller)); + + ( + uint8[] memory feedIds, + uint32 pokeAge, + bytes32 sig, + address commitment + ) = offboarder.computeOffboardArgs(address(scribe)); + + vm.prank(caller); + vm.expectRevert( + abi.encodeWithSelector(IAuth.NotAuthorized.selector, caller) + ); + offboarder.offboard(address(scribe), feedIds, pokeAge, sig, commitment); + } + + // ----------------------------------------------------------------------------- + // Value zeroing + + /// @dev After offboard, the scribe's stored value MUST be zero — this + /// is what makes `read()` revert permanently. The offboarder + /// contract also has a runtime paranoia check for this, so this + /// test doubles as coverage for that check's success path. + function testFuzz_offboard_zeroesValue(uint8 nFeeds, uint64 seedSalt) + public + { + nFeeds = uint8(bound(nFeeds, 0, 32)); + _liftRandomFeeds(nFeeds, uint(seedSalt) | 1); + + (uint8[] memory feedIds, uint32 pokeAge, bytes32 sig, address com) = + offboarder.computeOffboardArgs(address(scribe)); + offboarder.offboard(address(scribe), feedIds, pokeAge, sig, com); + + (bool ok, uint val) = scribe.tryRead(); + assertFalse(ok, "tryRead should report invalid"); + assertEq(val, 0, "scribe stored value must be zero"); + + vm.expectRevert(); + scribe.read(); + } + + // ----------------------------------------------------------------------------- + // View Sanity + + function test_constants() public { + assertEq(offboarder.feedId(), OFFBOARDER_FEED_ID); + } + + // ----------------------------------------------------------------------------- + // Helpers + + function _feedId(address who) internal pure returns (uint8) { + return uint8(uint(uint160(who)) >> 152); + } + + /// @dev Lifts up to `n` feeds derived from sequential private keys + /// starting at `seed`. Skips collisions with already-lifted feed + /// ids and with the offboarder's reserved id. + function _liftRandomFeeds(uint8 n, uint seed) + internal + returns (uint lifted) + { + if (n == 0) { + return 0; + } + + bytes32 reg = scribe.feedRegistrationMessage(); + + uint bloom; + bloom |= 1 << OFFBOARDER_FEED_ID; // reserve + + uint pk = seed; + uint tried; + while (lifted < n && tried < 1024) { + tried++; + Vm.Wallet memory w = vm.createWallet(pk++); + uint8 id = _feedId(w.addr); + + if (bloom & (1 << id) != 0) { + continue; + } + bloom |= 1 << id; + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(w.privateKey, reg); + scribe.lift( + LibSecp256k1.Point({x: w.publicKeyX, y: w.publicKeyY}), + IScribe.ECDSAData({v: v, r: r, s: s}) + ); + lifted++; + } + } +} From 5c82b9578c5ba0b6cf2d00e37ab443d05e63bace Mon Sep 17 00:00:00 2001 From: Max Wickham Date: Mon, 20 Apr 2026 15:55:12 +0300 Subject: [PATCH 2/9] fmt --- script/Scribe.s.sol | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/script/Scribe.s.sol b/script/Scribe.s.sol index d23f574..9f39554 100644 --- a/script/Scribe.s.sol +++ b/script/Scribe.s.sol @@ -293,9 +293,8 @@ contract ScribeScript is Script { ) = ScribeOffboarder(offboarder).computeOffboardArgs(scribe); vm.startBroadcast(); - ScribeOffboarder(offboarder).offboard( - scribe, feedIds, pokeAge, signature, commitment - ); + ScribeOffboarder(offboarder) + .offboard(scribe, feedIds, pokeAge, signature, commitment); vm.stopBroadcast(); console2.log("Offboarded", scribe); From b089bfcfbec785236e252f1dce605af4e763d697 Mon Sep 17 00:00:00 2001 From: Max Wickham Date: Mon, 20 Apr 2026 15:57:01 +0300 Subject: [PATCH 3/9] comments --- test/offboard/ScribeOffboarder.t.sol | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/offboard/ScribeOffboarder.t.sol b/test/offboard/ScribeOffboarder.t.sol index 2a02bd4..f116f0d 100644 --- a/test/offboard/ScribeOffboarder.t.sol +++ b/test/offboard/ScribeOffboarder.t.sol @@ -105,10 +105,6 @@ contract ScribeOffboarderTest is Test { // ----------------------------------------------------------------------------- // Value zeroing - /// @dev After offboard, the scribe's stored value MUST be zero — this - /// is what makes `read()` revert permanently. The offboarder - /// contract also has a runtime paranoia check for this, so this - /// test doubles as coverage for that check's success path. function testFuzz_offboard_zeroesValue(uint8 nFeeds, uint64 seedSalt) public { From fca999d4245ee92e0ee047954fe150c2a1c18069 Mon Sep 17 00:00:00 2001 From: Max Wickham Date: Mon, 20 Apr 2026 16:48:24 +0300 Subject: [PATCH 4/9] clecanup --- script/offboard/ScribeOffboarder.sol | 79 ++++++++++++---------------- test/offboard/ScribeOffboarder.t.sol | 11 +--- 2 files changed, 35 insertions(+), 55 deletions(-) diff --git a/script/offboard/ScribeOffboarder.sol b/script/offboard/ScribeOffboarder.sol index b55ec6a..2b2a1d7 100644 --- a/script/offboard/ScribeOffboarder.sol +++ b/script/offboard/ScribeOffboarder.sol @@ -13,11 +13,11 @@ import {LibSecp256k1} from "../../src/libs/LibSecp256k1.sol"; * @notice Permanently disables a `Scribe` oracle in a single transaction. * The contract must be granted `auth` on the target scribe. * - * `offboard(scribe, feedIds, pokeAge, sig, commitment)` drops every + * `offboard(scribe, FEED_IDs, pokeAge, sig, commitment)` drops every * currently-lifted feed, sets bar = 1, lifts the hardcoded - * offboarder feed, pokes val = 0, and drops the offboarder feed - * again. The caller supplies the list of lifted feed ids and a - * pre-computed Schnorr signature — pair it with the + * offboarder feed, pokes val = 0, drops the offboarder feed, and + * raises bar = 255. The caller supplies the list of lifted feed + * ids and a pre-computed Schnorr signature — pair it with the * `computeOffboardArgs(scribe)` view, which enumerates the feeds * and signs off-chain via eth_call. * @@ -57,7 +57,7 @@ contract ScribeOffboarder is Auth { // forge script script/PrintFeedConstants.s.sol -vvv /// @dev `keccak256("Chronicle.ScribeOffboarder.v1") mod Q`. - uint internal constant FEED_PRIV_KEY = + uint private constant FEED_PRIV_KEY = 0xf067ac0350b6ab5e39cf9138ae16508404c05af8b58676e07ae14e9a510a600e; uint private constant FEED_PUBKEY_X = @@ -69,7 +69,7 @@ contract ScribeOffboarder is Auth { /// @notice Feed id is the highest-order byte of the ethereum address /// derived from the offboarder feed's public key /// (= 0x03da1Db2b7875106395548682B068a7C7b296902 => id = 0x03). - uint8 public constant feedId = 0x03; + uint8 private constant FEED_ID = 0x03; // ECDSA signature over FEED_REGISTRATION_MESSAGE by FEED_PRIV_KEY. uint8 private constant LIFT_V = 28; @@ -118,18 +118,18 @@ contract ScribeOffboarder is Auth { /// @notice Offboards `scribe` using pre-computed inputs. Produce the /// arguments off-chain by eth_calling /// `computeOffboardArgs(scribe)`. - /// @dev `feedIds` must include every currently-lifted feed on `scribe` + /// @dev `FEED_IDs` must include every currently-lifted feed on `scribe` /// (a missing id would leave that feed lifted after the call). /// Extra / already-dropped ids are harmless — scribe's `drop` is /// idempotent per id. function offboard( address scribe, - uint8[] calldata feedIds, + uint8[] calldata FEED_IDs, uint32 pokeAge, bytes32 signature, address commitment ) external auth { - _offboard(scribe, feedIds, pokeAge, signature, commitment); + _offboard(scribe, FEED_IDs, pokeAge, signature, commitment); } /// @notice Returns everything `offboard` needs: the list of @@ -145,15 +145,20 @@ contract ScribeOffboarder is Auth { external view returns ( - uint8[] memory feedIds, - uint32 pokeAge, - bytes32 signature, - address commitment + uint8[] memory, + uint32, + bytes32, + address ) { - feedIds = _enumerateFeedIds(IScribe(scribe)); - pokeAge = uint32(block.timestamp - POKE_AGE_BACKDATE); - (signature, commitment) = _schnorrSigFor(scribe, pokeAge); + address[] memory currentFeeds = IScribe(scribe).feeds(); + uint8[] memory ids = new uint8[](currentFeeds.length); + for (uint i; i < currentFeeds.length; i++) { + ids[i] = uint8(uint(uint160(currentFeeds[i])) >> 152); + } + uint32 pokeAge = uint32(block.timestamp - POKE_AGE_BACKDATE); + (bytes32 signature, address commitment) = _schnorrSigFor(scribe, pokeAge); + return (ids, pokeAge, signature, commitment); } // ----------------------------------------------------------------------------- @@ -169,53 +174,35 @@ contract ScribeOffboarder is Auth { require(scribe != address(0), "ScribeOffboarder: scribe=zero"); IScribe target = IScribe(scribe); + // Drop all existing scribes (assume caller was honest). if (feedIds.length != 0) { target.drop(feedIds); } + + // Set bar to one and lift the offboarder feed. target.setBar(1); - _liftOffboarderFeed(target); + target.lift( + LibSecp256k1.Point({x: FEED_PUBKEY_X, y: FEED_PUBKEY_Y}), + IScribe.ECDSAData({v: LIFT_V, r: LIFT_R, s: LIFT_S}) + ); target.poke( IScribe.PokeData({val: 0, age: pokeAge}), IScribe.SchnorrData({ signature: schnorrSignature, commitment: schnorrCommitment, - feedIds: abi.encodePacked(feedId) + feedIds: abi.encodePacked(FEED_ID) }) ); - target.drop(feedId); - - // Note that we don't re-call `setBar(1)` — scribe rejects - // `setBar(0)` with `require(bar_ != 0)`, and bar is already 1 from - // the earlier call. With no feeds lifted, no future poke can verify - // regardless. + target.drop(FEED_ID); + + // Set bar to max. + target.setBar(type(uint8).max); emit Offboarded(msg.sender, scribe); } - /// @dev Reads every feed currently lifted on `target` and returns their - /// ids. `scribe.feeds()` is the expensive part — 256 slot reads. - function _enumerateFeedIds(IScribe target) - internal - view - returns (uint8[] memory) - { - address[] memory currentFeeds = target.feeds(); - uint8[] memory ids = new uint8[](currentFeeds.length); - for (uint i; i < currentFeeds.length; i++) { - ids[i] = uint8(uint(uint160(currentFeeds[i])) >> 152); - } - return ids; - } - - function _liftOffboarderFeed(IScribe target) internal { - target.lift( - LibSecp256k1.Point({x: FEED_PUBKEY_X, y: FEED_PUBKEY_Y}), - IScribe.ECDSAData({v: LIFT_V, r: LIFT_R, s: LIFT_S}) - ); - } - function _schnorrSigFor(address scribe, uint32 pokeAge) internal view diff --git a/test/offboard/ScribeOffboarder.t.sol b/test/offboard/ScribeOffboarder.t.sol index f116f0d..a1ae14a 100644 --- a/test/offboard/ScribeOffboarder.t.sol +++ b/test/offboard/ScribeOffboarder.t.sol @@ -47,7 +47,7 @@ contract ScribeOffboarderTest is Test { offboarder.offboard(address(scribe), feedIds, pokeAge, sig, commitment); assertEq(scribe.feeds().length, 0); - assertEq(scribe.bar(), 1); + assertEq(scribe.bar(), type(uint8).max); (bool ok,) = scribe.tryRead(); assertFalse(ok); } @@ -73,7 +73,7 @@ contract ScribeOffboarderTest is Test { offboarder.offboard(address(scribe), feedIds, pokeAge, sig, commitment); assertEq(scribe.feeds().length, 0); - assertEq(scribe.bar(), 1); + assertEq(scribe.bar(), type(uint8).max); (bool ok,) = scribe.tryRead(); assertFalse(ok); @@ -123,13 +123,6 @@ contract ScribeOffboarderTest is Test { scribe.read(); } - // ----------------------------------------------------------------------------- - // View Sanity - - function test_constants() public { - assertEq(offboarder.feedId(), OFFBOARDER_FEED_ID); - } - // ----------------------------------------------------------------------------- // Helpers From fb2c9acf81d174f4bbb089e60d55d8f27b7b3728 Mon Sep 17 00:00:00 2001 From: Max Wickham Date: Mon, 20 Apr 2026 18:33:38 +0300 Subject: [PATCH 5/9] offboarder --- script/Scribe.s.sol | 10 +-- script/offboard/ScribeOffboarder.sol | 118 +++++++-------------------- test/offboard/ScribeOffboarder.t.sol | 37 ++------- 3 files changed, 35 insertions(+), 130 deletions(-) diff --git a/script/Scribe.s.sol b/script/Scribe.s.sol index 9f39554..e328cfb 100644 --- a/script/Scribe.s.sol +++ b/script/Scribe.s.sol @@ -285,16 +285,8 @@ contract ScribeScript is Script { /// @dev Note that `auth`'ed addresses on `scribe` are untouched. To make /// the deactivation permanent, follow up with `kill(scribe)`. function offboard(address offboarder, address scribe) public { - ( - uint8[] memory feedIds, - uint32 pokeAge, - bytes32 signature, - address commitment - ) = ScribeOffboarder(offboarder).computeOffboardArgs(scribe); - vm.startBroadcast(); - ScribeOffboarder(offboarder) - .offboard(scribe, feedIds, pokeAge, signature, commitment); + ScribeOffboarder(offboarder).offboard(scribe); vm.stopBroadcast(); console2.log("Offboarded", scribe); diff --git a/script/offboard/ScribeOffboarder.sol b/script/offboard/ScribeOffboarder.sol index 2b2a1d7..cce5b3f 100644 --- a/script/offboard/ScribeOffboarder.sol +++ b/script/offboard/ScribeOffboarder.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.16; import {Auth} from "chronicle-std/auth/Auth.sol"; +import {IAuth} from "chronicle-std/auth/IAuth.sol"; import {IScribe} from "../../src/IScribe.sol"; @@ -13,13 +14,9 @@ import {LibSecp256k1} from "../../src/libs/LibSecp256k1.sol"; * @notice Permanently disables a `Scribe` oracle in a single transaction. * The contract must be granted `auth` on the target scribe. * - * `offboard(scribe, FEED_IDs, pokeAge, sig, commitment)` drops every - * currently-lifted feed, sets bar = 1, lifts the hardcoded - * offboarder feed, pokes val = 0, drops the offboarder feed, and - * raises bar = 255. The caller supplies the list of lifted feed - * ids and a pre-computed Schnorr signature — pair it with the - * `computeOffboardArgs(scribe)` view, which enumerates the feeds - * and signs off-chain via eth_call. + * `offboard(scribe)` enumerates every currently-lifted feed, drops + * them, sets bar = 1, lifts the hardcoded offboarder feed, pokes + * val = 0, drops the offboarder feed, and raises bar = 255. * * @dev Security note: the offboarder feed's private key is hardcoded as a * `constant` in the bytecode and is therefore publicly readable. This @@ -92,16 +89,6 @@ contract ScribeOffboarder is Auth { /// @dev `v` value corresponding to G (G_y is even, so parity = 0). uint8 private constant G_V = 27; - // ----------------------------------------------------------------------------- - // Other Constants - - /// @dev `computeOffboardArgs` defaults `pokeAge` to - /// `block.timestamp - POKE_AGE_BACKDATE`. Picked so the helper's - /// eth_call result remains usable for ~5 minutes of off-chain - /// signing + tx-submission latency, while still being safely - /// older than `block.timestamp` at execution time. - uint32 private constant POKE_AGE_BACKDATE = 5 minutes; - // ----------------------------------------------------------------------------- // Events @@ -115,68 +102,23 @@ contract ScribeOffboarder is Auth { // ----------------------------------------------------------------------------- // Offboard - /// @notice Offboards `scribe` using pre-computed inputs. Produce the - /// arguments off-chain by eth_calling - /// `computeOffboardArgs(scribe)`. - /// @dev `FEED_IDs` must include every currently-lifted feed on `scribe` - /// (a missing id would leave that feed lifted after the call). - /// Extra / already-dropped ids are harmless — scribe's `drop` is - /// idempotent per id. - function offboard( - address scribe, - uint8[] calldata FEED_IDs, - uint32 pokeAge, - bytes32 signature, - address commitment - ) external auth { - _offboard(scribe, FEED_IDs, pokeAge, signature, commitment); - } - - /// @notice Returns everything `offboard` needs: the list of - /// currently-lifted feed ids on `scribe`, a `pokeAge` - /// (backdated by ~5 minutes for submission latency), and a - /// 1-of-1 Schnorr signature over - /// `constructPokeMessage({val: 0, age: pokeAge})`. - /// - /// Intended to be eth_called — running it on-chain costs the - /// ~1.1M gas that `scribe.feeds()` charges, which is the whole - /// point of splitting the pre-computation off-chain. - function computeOffboardArgs(address scribe) - external - view - returns ( - uint8[] memory, - uint32, - bytes32, - address - ) - { - address[] memory currentFeeds = IScribe(scribe).feeds(); - uint8[] memory ids = new uint8[](currentFeeds.length); - for (uint i; i < currentFeeds.length; i++) { - ids[i] = uint8(uint(uint160(currentFeeds[i])) >> 152); - } - uint32 pokeAge = uint32(block.timestamp - POKE_AGE_BACKDATE); - (bytes32 signature, address commitment) = _schnorrSigFor(scribe, pokeAge); - return (ids, pokeAge, signature, commitment); - } - - // ----------------------------------------------------------------------------- - // Internal — offboard sequence - - function _offboard( - address scribe, - uint8[] calldata feedIds, - uint32 pokeAge, - bytes32 schnorrSignature, - address schnorrCommitment - ) internal { - require(scribe != address(0), "ScribeOffboarder: scribe=zero"); + /// @notice Offboards `scribe` in a single transaction: enumerates the + /// currently-lifted feeds, drops them, sets bar = 1, lifts the + /// hardcoded offboarder feed, pokes val = 0, drops the + /// offboarder feed, and raises bar = 255. + function offboard(address scribe) external auth { + require(IAuth(scribe).authed(address(this))); + IScribe target = IScribe(scribe); - // Drop all existing scribes (assume caller was honest). - if (feedIds.length != 0) { - target.drop(feedIds); + // Drop every currently-lifted feed. + address[] memory currentFeeds = target.feeds(); + if (currentFeeds.length != 0) { + uint8[] memory ids = new uint8[](currentFeeds.length); + for (uint i; i < currentFeeds.length; i++) { + ids[i] = uint8(uint(uint160(currentFeeds[i])) >> 152); + } + target.drop(ids); } // Set bar to one and lift the offboarder feed. @@ -186,33 +128,29 @@ contract ScribeOffboarder is Auth { IScribe.ECDSAData({v: LIFT_V, r: LIFT_R, s: LIFT_S}) ); + // Poke val = 0 via the offboarder feed's 1-of-1 Schnorr signature. + uint32 pokeAge = uint32(block.timestamp); + bytes32 message = target.constructPokeMessage( + IScribe.PokeData({val: 0, age: pokeAge}) + ); + (bytes32 signature, address commitment) = _signSchnorr(message); target.poke( IScribe.PokeData({val: 0, age: pokeAge}), IScribe.SchnorrData({ - signature: schnorrSignature, - commitment: schnorrCommitment, + signature: signature, + commitment: commitment, feedIds: abi.encodePacked(FEED_ID) }) ); target.drop(FEED_ID); - + // Set bar to max. target.setBar(type(uint8).max); emit Offboarded(msg.sender, scribe); } - function _schnorrSigFor(address scribe, uint32 pokeAge) - internal - view - returns (bytes32 signature, address commitment) - { - bytes32 message = IScribe(scribe) - .constructPokeMessage(IScribe.PokeData({val: 0, age: pokeAge})); - return _signSchnorr(message); - } - // ----------------------------------------------------------------------------- // Internal — Schnorr signing diff --git a/test/offboard/ScribeOffboarder.t.sol b/test/offboard/ScribeOffboarder.t.sol index a1ae14a..dd054c7 100644 --- a/test/offboard/ScribeOffboarder.t.sol +++ b/test/offboard/ScribeOffboarder.t.sol @@ -32,19 +32,12 @@ contract ScribeOffboarderTest is Test { } // ----------------------------------------------------------------------------- - // Pre-signed offboard (computeSchnorrSig + offboard(scribe, pokeAge, sig, com)) + // Offboard function test_offboard() public { _liftRandomFeeds(3, 0xBEEF); - ( - uint8[] memory feedIds, - uint32 pokeAge, - bytes32 sig, - address commitment - ) = offboarder.computeOffboardArgs(address(scribe)); - - offboarder.offboard(address(scribe), feedIds, pokeAge, sig, commitment); + offboarder.offboard(address(scribe)); assertEq(scribe.feeds().length, 0); assertEq(scribe.bar(), type(uint8).max); @@ -63,14 +56,7 @@ contract ScribeOffboarderTest is Test { _liftRandomFeeds(nFeeds, uint(seedSalt) | 1); vm.warp(block.timestamp + timeAdvance); - ( - uint8[] memory feedIds, - uint32 pokeAge, - bytes32 sig, - address commitment - ) = offboarder.computeOffboardArgs(address(scribe)); - - offboarder.offboard(address(scribe), feedIds, pokeAge, sig, commitment); + offboarder.offboard(address(scribe)); assertEq(scribe.feeds().length, 0); assertEq(scribe.bar(), type(uint8).max); @@ -83,23 +69,14 @@ contract ScribeOffboarderTest is Test { // ----------------------------------------------------------------------------- // Auth Protection - function testFuzz_offboard_presigned_isAuthProtected(address caller) - public - { + function testFuzz_offboard_isAuthProtected(address caller) public { vm.assume(!IAuth(address(offboarder)).authed(caller)); - ( - uint8[] memory feedIds, - uint32 pokeAge, - bytes32 sig, - address commitment - ) = offboarder.computeOffboardArgs(address(scribe)); - vm.prank(caller); vm.expectRevert( abi.encodeWithSelector(IAuth.NotAuthorized.selector, caller) ); - offboarder.offboard(address(scribe), feedIds, pokeAge, sig, commitment); + offboarder.offboard(address(scribe)); } // ----------------------------------------------------------------------------- @@ -111,9 +88,7 @@ contract ScribeOffboarderTest is Test { nFeeds = uint8(bound(nFeeds, 0, 32)); _liftRandomFeeds(nFeeds, uint(seedSalt) | 1); - (uint8[] memory feedIds, uint32 pokeAge, bytes32 sig, address com) = - offboarder.computeOffboardArgs(address(scribe)); - offboarder.offboard(address(scribe), feedIds, pokeAge, sig, com); + offboarder.offboard(address(scribe)); (bool ok, uint val) = scribe.tryRead(); assertFalse(ok, "tryRead should report invalid"); From e36ee21f108d9b7c070543538d2caf34fb3e92e0 Mon Sep 17 00:00:00 2001 From: Max Wickham Date: Mon, 20 Apr 2026 18:33:50 +0300 Subject: [PATCH 6/9] fmt --- script/offboard/ScribeOffboarder.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/offboard/ScribeOffboarder.sol b/script/offboard/ScribeOffboarder.sol index cce5b3f..c7ea144 100644 --- a/script/offboard/ScribeOffboarder.sol +++ b/script/offboard/ScribeOffboarder.sol @@ -108,7 +108,7 @@ contract ScribeOffboarder is Auth { /// offboarder feed, and raises bar = 255. function offboard(address scribe) external auth { require(IAuth(scribe).authed(address(this))); - + IScribe target = IScribe(scribe); // Drop every currently-lifted feed. From 3a0ba9b23d0a3a5cdb2f2428605ee83ccef6d45e Mon Sep 17 00:00:00 2001 From: Max Wickham Date: Mon, 20 Apr 2026 18:36:32 +0300 Subject: [PATCH 7/9] rescuer deny --- script/offboard/ScribeOffboarder.sol | 3 +++ script/rescue/Rescuer.sol | 3 +++ test/offboard/ScribeOffboarder.t.sol | 2 +- test/rescue/RescuerTest.sol | 3 +++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/script/offboard/ScribeOffboarder.sol b/script/offboard/ScribeOffboarder.sol index c7ea144..e234282 100644 --- a/script/offboard/ScribeOffboarder.sol +++ b/script/offboard/ScribeOffboarder.sol @@ -148,6 +148,9 @@ contract ScribeOffboarder is Auth { // Set bar to max. target.setBar(type(uint8).max); + // Deny offboarder. + IAuth(scribe).deny(address(this)); + emit Offboarded(msg.sender, scribe); } diff --git a/script/rescue/Rescuer.sol b/script/rescue/Rescuer.sol index b867bde..d8d242c 100644 --- a/script/rescue/Rescuer.sol +++ b/script/rescue/Rescuer.sol @@ -105,6 +105,9 @@ contract Rescuer is Auth { // Compute amount of ETH received as challenge reward. uint amount = address(this).balance - balanceBefore; + // Deny rescuer on opScribe. + IAuth(opScribe).deny(address(this)); + // Emit event. emit Recovered(msg.sender, opScribe, amount); } diff --git a/test/offboard/ScribeOffboarder.t.sol b/test/offboard/ScribeOffboarder.t.sol index dd054c7..d055bd2 100644 --- a/test/offboard/ScribeOffboarder.t.sol +++ b/test/offboard/ScribeOffboarder.t.sol @@ -63,7 +63,7 @@ contract ScribeOffboarderTest is Test { (bool ok,) = scribe.tryRead(); assertFalse(ok); - assertTrue(scribe.authed(address(offboarder))); + assertFalse(IAuth(address(scribe)).authed(address(offboarder))); } // ----------------------------------------------------------------------------- diff --git a/test/rescue/RescuerTest.sol b/test/rescue/RescuerTest.sol index 326e8e9..0c4592c 100644 --- a/test/rescue/RescuerTest.sol +++ b/test/rescue/RescuerTest.sol @@ -77,6 +77,9 @@ contract RescuerTest is Test { // Verify feed got kicked. assertFalse(opScribe.feeds(feed.pubKey.toAddress())); + + // Verify rescuer renounced auth on opScribe. + assertFalse(IAuth(address(opScribe)).authed(address(rescuer))); } function testFuzz_suck_FailsIf_RescuerNotAuthedOnOpScribe(uint privKeySeed) From 97038061b0e1e46840e6b745cfa47dbe4276e53f Mon Sep 17 00:00:00 2001 From: Max Wickham Date: Tue, 21 Apr 2026 11:18:18 +0300 Subject: [PATCH 8/9] pr comments --- script/offboard/PrintFeedConstants.s.sol | 67 ++++++++++++++++++++++++ script/offboard/ScribeOffboarder.sol | 2 +- test/offboard/ScribeOffboarder.t.sol | 2 - 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 script/offboard/PrintFeedConstants.s.sol diff --git a/script/offboard/PrintFeedConstants.s.sol b/script/offboard/PrintFeedConstants.s.sol new file mode 100644 index 0000000..7fdce31 --- /dev/null +++ b/script/offboard/PrintFeedConstants.s.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +// forgefmt: disable-start + +import {Script, console2 as console} from "forge-std/Script.sol"; +import {Vm} from "forge-std/Vm.sol"; + +/** + * @notice One-shot helper: derives the offboarder feed's deterministic + * private key from a string seed, prints the public key coordinates + * and the ECDSA signature over Scribe's `feedRegistrationMessage`. + * + * The output is what gets hardcoded into ScribeOffboarder.sol as + * constants. Re-run any time to verify the values. + * + * Usage: + * forge script script/offboarder/PrintFeedConstants.s.sol -vvv + */ +contract PrintFeedConstantsScript is Script { + // Derivation: privKey = uint(keccak256(SEED)) mod Q. + string constant SEED = "Chronicle.ScribeOffboarder.v1"; + + // Order of secp256k1. + uint constant Q = + 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + + // Scribe's feed-registration message (constant across all scribes): + // keccak256("\x19Ethereum Signed Message:\n32" ‖ + // keccak256("Chronicle Feed Registration")) + bytes32 constant FEED_REGISTRATION_MESSAGE = keccak256( + abi.encodePacked( + "\x19Ethereum Signed Message:\n32", + keccak256("Chronicle Feed Registration") + ) + ); + + function run() public { + uint privKey = uint(keccak256(bytes(SEED))) % Q; + require(privKey != 0, "zero privKey"); + + Vm.Wallet memory w = vm.createWallet(privKey); + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(privKey, FEED_REGISTRATION_MESSAGE); + + uint8 feedId = uint8(uint(uint160(w.addr)) >> 152); + + console.log("------ Offboarder Feed Constants ------"); + console.log("seed: ", SEED); + console.log("feedRegistrationMessage:"); + console.logBytes32(FEED_REGISTRATION_MESSAGE); + console.log("FEED_PRIV_KEY:"); + console.logBytes32(bytes32(privKey)); + console.log("FEED_PUBKEY_X:"); + console.logBytes32(bytes32(w.publicKeyX)); + console.log("FEED_PUBKEY_Y:"); + console.logBytes32(bytes32(w.publicKeyY)); + console.log("LIFT_ECDSA_V: ", v); + console.log("LIFT_ECDSA_R:"); + console.logBytes32(r); + console.log("LIFT_ECDSA_S:"); + console.logBytes32(s); + console.log("derived feed address: ", w.addr); + console.log("derived feedId: ", feedId); + } +} +// forgefmt: disable-end diff --git a/script/offboard/ScribeOffboarder.sol b/script/offboard/ScribeOffboarder.sol index e234282..48e04a3 100644 --- a/script/offboard/ScribeOffboarder.sol +++ b/script/offboard/ScribeOffboarder.sol @@ -51,7 +51,7 @@ contract ScribeOffboarder is Auth { // // To regenerate / verify these values, run: // - // forge script script/PrintFeedConstants.s.sol -vvv + // forge script script/offboard/PrintFeedConstants.s.sol -vvv /// @dev `keccak256("Chronicle.ScribeOffboarder.v1") mod Q`. uint private constant FEED_PRIV_KEY = diff --git a/test/offboard/ScribeOffboarder.t.sol b/test/offboard/ScribeOffboarder.t.sol index d055bd2..851d6d1 100644 --- a/test/offboard/ScribeOffboarder.t.sol +++ b/test/offboard/ScribeOffboarder.t.sol @@ -119,8 +119,6 @@ contract ScribeOffboarderTest is Test { bytes32 reg = scribe.feedRegistrationMessage(); uint bloom; - bloom |= 1 << OFFBOARDER_FEED_ID; // reserve - uint pk = seed; uint tried; while (lifted < n && tried < 1024) { From b5da32869f9759abe45db268fd5c8ec4357164de Mon Sep 17 00:00:00 2001 From: Max Wickham Date: Tue, 21 Apr 2026 11:24:00 +0300 Subject: [PATCH 9/9] offboarrd --- script/offboard/PrintFeedConstants.s.sol | 67 ------------------------ script/offboard/ScribeOffboarder.sol | 4 -- 2 files changed, 71 deletions(-) delete mode 100644 script/offboard/PrintFeedConstants.s.sol diff --git a/script/offboard/PrintFeedConstants.s.sol b/script/offboard/PrintFeedConstants.s.sol deleted file mode 100644 index 7fdce31..0000000 --- a/script/offboard/PrintFeedConstants.s.sol +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.16; - -// forgefmt: disable-start - -import {Script, console2 as console} from "forge-std/Script.sol"; -import {Vm} from "forge-std/Vm.sol"; - -/** - * @notice One-shot helper: derives the offboarder feed's deterministic - * private key from a string seed, prints the public key coordinates - * and the ECDSA signature over Scribe's `feedRegistrationMessage`. - * - * The output is what gets hardcoded into ScribeOffboarder.sol as - * constants. Re-run any time to verify the values. - * - * Usage: - * forge script script/offboarder/PrintFeedConstants.s.sol -vvv - */ -contract PrintFeedConstantsScript is Script { - // Derivation: privKey = uint(keccak256(SEED)) mod Q. - string constant SEED = "Chronicle.ScribeOffboarder.v1"; - - // Order of secp256k1. - uint constant Q = - 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; - - // Scribe's feed-registration message (constant across all scribes): - // keccak256("\x19Ethereum Signed Message:\n32" ‖ - // keccak256("Chronicle Feed Registration")) - bytes32 constant FEED_REGISTRATION_MESSAGE = keccak256( - abi.encodePacked( - "\x19Ethereum Signed Message:\n32", - keccak256("Chronicle Feed Registration") - ) - ); - - function run() public { - uint privKey = uint(keccak256(bytes(SEED))) % Q; - require(privKey != 0, "zero privKey"); - - Vm.Wallet memory w = vm.createWallet(privKey); - (uint8 v, bytes32 r, bytes32 s) = - vm.sign(privKey, FEED_REGISTRATION_MESSAGE); - - uint8 feedId = uint8(uint(uint160(w.addr)) >> 152); - - console.log("------ Offboarder Feed Constants ------"); - console.log("seed: ", SEED); - console.log("feedRegistrationMessage:"); - console.logBytes32(FEED_REGISTRATION_MESSAGE); - console.log("FEED_PRIV_KEY:"); - console.logBytes32(bytes32(privKey)); - console.log("FEED_PUBKEY_X:"); - console.logBytes32(bytes32(w.publicKeyX)); - console.log("FEED_PUBKEY_Y:"); - console.logBytes32(bytes32(w.publicKeyY)); - console.log("LIFT_ECDSA_V: ", v); - console.log("LIFT_ECDSA_R:"); - console.logBytes32(r); - console.log("LIFT_ECDSA_S:"); - console.logBytes32(s); - console.log("derived feed address: ", w.addr); - console.log("derived feedId: ", feedId); - } -} -// forgefmt: disable-end diff --git a/script/offboard/ScribeOffboarder.sol b/script/offboard/ScribeOffboarder.sol index 48e04a3..48eced5 100644 --- a/script/offboard/ScribeOffboarder.sol +++ b/script/offboard/ScribeOffboarder.sol @@ -48,10 +48,6 @@ contract ScribeOffboarder is Auth { // FEED_REGISTRATION_MESSAGE is a Scribe protocol constant (see // `Scribe.feedRegistrationMessage`), so the ECDSA signature below is // valid against every Scribe deployment. - // - // To regenerate / verify these values, run: - // - // forge script script/offboard/PrintFeedConstants.s.sol -vvv /// @dev `keccak256("Chronicle.ScribeOffboarder.v1") mod Q`. uint private constant FEED_PRIV_KEY =