From 6f81a3f65c6e1e2137980dbe896f8ee6c67cb5c8 Mon Sep 17 00:00:00 2001 From: merkleplant Date: Fri, 17 Apr 2026 13:34:35 +0200 Subject: [PATCH 1/3] src: Add router and factory contracts --- src/IScribeFactory.sol | 69 +++++++ src/IScribeRouter.sol | 79 ++++++++ src/ScribeFactory.sol | 128 +++++++++++++ src/ScribeRouter.sol | 157 ++++++++++++++++ test/IScribeFactoryTest.sol | 291 +++++++++++++++++++++++++++++ test/IScribeRouterTest.sol | 361 ++++++++++++++++++++++++++++++++++++ test/Runner.t.sol | 25 +++ 7 files changed, 1110 insertions(+) create mode 100644 src/IScribeFactory.sol create mode 100644 src/IScribeRouter.sol create mode 100644 src/ScribeFactory.sol create mode 100644 src/ScribeRouter.sol create mode 100644 test/IScribeFactoryTest.sol create mode 100644 test/IScribeRouterTest.sol diff --git a/src/IScribeFactory.sol b/src/IScribeFactory.sol new file mode 100644 index 0000000..97acaf3 --- /dev/null +++ b/src/IScribeFactory.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {IScribe} from "./IScribe.sol"; + +import {LibSecp256k1} from "./libs/LibSecp256k1.sol"; + +interface IScribeFactory { + /// @dev ScribeConfig encapsulates a Scribe instance's deployment + /// configuration. + struct ScribeConfig { + string name; + bytes32 wat; + uint8 bar; + address[] authed; + address[] tolled; + LibSecp256k1.Point[] validators; + IScribe.ECDSAData[] registrationSigs; + } + + /// @dev ScribeRouterConfig encapsulates a ScribeRouter instance's + /// deployment configuration. + struct ScribeRouterConfig { + string name; + address[] authed; + address[] tolled; + } + + /// @notice Emitted when new Scribe instance deployed. + /// @param caller The caller's address. + /// @param scribe The deployed Scribe instance's address. + /// @param name The Scribe instance's name. + event ScribeDeployed( + address indexed caller, address indexed scribe, string name + ); + + /// @notice Emitted when new ScribeRouter instance deployed. + /// @param caller The caller's address. + /// @param router The deployed ScribeRouter instance's address. + /// @param name The ScribeRouter instance's name. + event ScribeRouterDeployed( + address indexed caller, address indexed router, string name + ); + + /// @notice Deploys a new Scribe and ScribeRouter instance. + /// @dev Only callable by toll'ed address. + /// @param scribeCfg The Scribe instance's deployment configuration. + /// @param routerCfg The ScribeRouter instance's deployment configuration. + /// @return The deployed Scribe instance's address. + /// @return The deployed ScribeRouter instance's address. + function plantScribeWithRouter( + ScribeConfig calldata scribeCfg, + ScribeRouterConfig calldata routerCfg + ) external returns (address, address); + + /// @notice Deploys a new Scribe instance. + /// @dev Only callable by toll'ed address. + /// @param cfg The Scribe instance's deployment configuration. + /// @return The deployed Scribe instance's address. + function plantScribe(ScribeConfig calldata cfg) external returns (address); + + /// @notice Deploys a new ScribeRouter instance. + /// @dev Only callable by toll'ed address. + /// @param cfg The ScribeRouter instance's deployment configuration. + /// @return The deployed ScribeRouter instance's address. + function plantRouter(ScribeRouterConfig calldata cfg) + external + returns (address); +} diff --git a/src/IScribeRouter.sol b/src/IScribeRouter.sol new file mode 100644 index 0000000..8e099d3 --- /dev/null +++ b/src/IScribeRouter.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {IChronicle} from "chronicle-std/IChronicle.sol"; + +interface IScribeRouter is IChronicle { + /// @notice Emitted when the scribe address is updated. + /// @param caller The caller's address. + /// @param oldScribe The old scribe address. + /// @param newScribe The new scribe address. + event ScribeUpdated( + address indexed caller, address oldScribe, address newScribe + ); + + /// @notice Returns the name identifier. + /// @return name The name of the oracle. + function name() external view returns (string memory name); + + /// @notice Returns the wat identifier. + /// @return wat The wat of the oracle. + function wat() external view returns (bytes32 wat); + + /// @notice Returns the scribe address. + /// @return scribe The scribe address. + function scribe() external view returns (address scribe); + + /// @notice Updates the scribe contract. + /// @dev Only callable by auth'ed address. + /// @param scribe The scribe address. + function setScribe(address scribe) external; + + // -- MakerDAO Compatibility -- + + /// @notice Returns the oracle's current value. + /// @custom:deprecated Use `tryRead()(bool,uint)` instead. + /// @return value The oracle's current value if it exists, zero otherwise. + /// @return isValid True if value exists, false otherwise. + function peek() external view returns (uint value, bool isValid); + + /// @notice Returns the oracle's current value. + /// @custom:deprecated Use `tryRead()(bool,uint)` instead. + /// @return value The oracle's current value if it exists, zero otherwise. + /// @return isValid True if value exists, false otherwise. + function peep() external view returns (uint value, bool isValid); + + // -- Chainlink Compatibility -- + + /// @notice Returns the number of decimals of the oracle's value. + /// @dev Provides partial compatibility with Chainlink's + /// IAggregatorV3Interface. + /// @return decimals The oracle value's number of decimals. + function decimals() external view returns (uint8 decimals); + + /// @notice Returns the oracle's latest value. + /// @dev Provides partial compatibility with Chainlink's + /// IAggregatorV3Interface. + /// @return roundId 1. + /// @return answer The oracle's latest value. + /// @return startedAt 0. + /// @return updatedAt The timestamp of oracle's latest update. + /// @return answeredInRound 1. + function latestRoundData() + external + view + returns ( + uint80 roundId, + int answer, + uint startedAt, + uint updatedAt, + uint80 answeredInRound + ); + + /// @notice Returns the oracle's latest value. + /// @dev Provides partial compatibility with Chainlink's + /// IAggregatorV3Interface. + /// @custom:deprecated See https://docs.chain.link/data-feeds/api-reference/#latestanswer. + /// @return answer The oracle's latest value. + function latestAnswer() external view returns (int answer); +} diff --git a/src/ScribeFactory.sol b/src/ScribeFactory.sol new file mode 100644 index 0000000..50071e9 --- /dev/null +++ b/src/ScribeFactory.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.16; + +import {Auth} from "chronicle-std/auth/Auth.sol"; +import {Toll} from "chronicle-std/toll/Toll.sol"; + +import {IScribeFactory} from "./IScribeFactory.sol"; + +import {Scribe} from "./Scribe.sol"; +import {ScribeRouter} from "./ScribeRouter.sol"; + +import {LibSecp256k1} from "./libs/LibSecp256k1.sol"; + +/** + * @title ScribeFactory + * @custom:version 2.0.1 + * + * @notice Factory contract to deploy Scribe and ScribeRouter oracle contracts + * + * @author Chronicle Labs, Inc + * @custom:security-contact security@chroniclelabs.org + */ +contract ScribeFactory is IScribeFactory, Auth, Toll { + constructor(address initialAuthed) payable Auth(initialAuthed) {} + + /// @inheritdoc IScribeFactory + function plantScribeWithRouter( + ScribeConfig calldata scribeCfg, + ScribeRouterConfig calldata routerCfg + ) public toll returns (address, address) { + Scribe scribe = _plantScribe(scribeCfg); + ScribeRouter router = _plantRouter(routerCfg); + + scribe.kiss(address(router)); + router.setScribe(address(scribe)); + + scribe.deny(address(this)); + router.deny(address(this)); + + return (address(scribe), address(router)); + } + + /// @inheritdoc IScribeFactory + function plantScribe(ScribeConfig calldata cfg) + public + toll + returns (address) + { + Scribe scribe = _plantScribe(cfg); + + scribe.deny(address(this)); + + return address(scribe); + } + + /// @inheritdoc IScribeFactory + function plantRouter(ScribeRouterConfig calldata cfg) + public + toll + returns (address) + { + ScribeRouter router = _plantRouter(cfg); + + router.deny(address(this)); + + return address(router); + } + + // -- Internal Helpers -- + + function _plantScribe(ScribeConfig calldata cfg) internal returns (Scribe) { + // Deploy scribe. + Scribe scribe = new Scribe(address(this), cfg.wat); + emit ScribeDeployed(msg.sender, address(scribe), cfg.name); + + // Lift validators and set bar. + scribe.lift(cfg.validators, cfg.registrationSigs); + scribe.setBar(cfg.bar); + + // Rely authed and kiss tolled. + for (uint i; i < cfg.authed.length; i++) { + scribe.rely(cfg.authed[i]); + } + for (uint i; i < cfg.tolled.length; i++) { + scribe.kiss(cfg.tolled[i]); + } + + // Kiss zero address. + scribe.kiss(address(0)); + + return scribe; + } + + function _plantRouter(ScribeRouterConfig calldata cfg) + internal + returns (ScribeRouter) + { + // Deploy router. + ScribeRouter router = new ScribeRouter(address(this), cfg.name); + emit ScribeRouterDeployed(msg.sender, address(router), cfg.name); + + // Rely authed and kiss tolled. + for (uint i; i < cfg.authed.length; i++) { + router.rely(cfg.authed[i]); + } + for (uint i; i < cfg.tolled.length; i++) { + router.kiss(cfg.tolled[i]); + } + + // Kiss zero address. + router.kiss(address(0)); + + return router; + } + + // -- Overridden Toll Functions -- + + /// @dev Defines authorization for IToll's authenticated functions. + function toll_auth() internal override(Toll) auth {} +} + +/** + * @dev Contract overwrite to deploy contract instances with specific naming. + */ +contract ScribeFactory_COUNTER is ScribeFactory { + // @todo ^^^^^^^ Adjust counter of Factory instance. + constructor(address initialAuthed) ScribeFactory(initialAuthed) {} +} diff --git a/src/ScribeRouter.sol b/src/ScribeRouter.sol new file mode 100644 index 0000000..92a4914 --- /dev/null +++ b/src/ScribeRouter.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.16; + +import {IChronicle} from "chronicle-std/IChronicle.sol"; +import {Auth} from "chronicle-std/auth/Auth.sol"; +import {Toll} from "chronicle-std/toll/Toll.sol"; + +import {IScribeRouter} from "./IScribeRouter.sol"; +import {IScribe} from "./IScribe.sol"; + +/** + * @title ScribeRouter + * @custom:version 2.0.1 + * + * @notice A router contract for Scribe oracles + * + * @author Chronicle Labs, Inc + * @custom:security-contact security@chroniclelabs.org + */ +contract ScribeRouter is IScribeRouter, Auth, Toll { + /// @inheritdoc IScribeRouter + bytes32 public immutable wat; + /// @inheritdoc IScribeRouter + string public name; + + /// @inheritdoc IScribeRouter + address public scribe; + + constructor(address initialAuthed, string memory name_) + payable + Auth(initialAuthed) + { + name = name_; + wat = keccak256(bytes(name_)); + } + + /// @inheritdoc IScribeRouter + function setScribe(address scribe_) external auth { + if (scribe != scribe_) { + emit ScribeUpdated(msg.sender, scribe, scribe_); + scribe = scribe_; + } + } + + function _tryReadWithAge() internal view returns (bool, uint, uint) { + return IScribe(scribe).tryReadWithAge(); + } + + // -- IChronicle -- + + /// @inheritdoc IChronicle + function read() external view toll returns (uint) { + bool ok; + uint val; + (ok, val,) = _tryReadWithAge(); + require(ok); + return val; + } + + /// @inheritdoc IChronicle + function tryRead() external view toll returns (bool, uint) { + bool ok; + uint val; + (ok, val,) = _tryReadWithAge(); + return (ok, val); + } + + /// @inheritdoc IChronicle + function readWithAge() external view toll returns (uint, uint) { + bool ok; + uint val; + uint age; + (ok, val, age) = _tryReadWithAge(); + require(ok); + return (val, age); + } + + /// @inheritdoc IChronicle + function tryReadWithAge() external view toll returns (bool, uint, uint) { + bool ok; + uint val; + uint age; + (ok, val, age) = _tryReadWithAge(); + return (ok, val, age); + } + + // -- MakerDAO Compatibility -- + + /// @inheritdoc IScribeRouter + function peek() external view toll returns (uint, bool) { + uint val; + (, val,) = _tryReadWithAge(); + return (val, val != 0); + } + + /// @inheritdoc IScribeRouter + function peep() external view toll returns (uint, bool) { + uint val; + (, val,) = _tryReadWithAge(); + return (val, val != 0); + } + + // -- Chainlink Compatibility -- + + /// @inheritdoc IScribeRouter + function decimals() external pure returns (uint8) { + return 18; + } + + /// @inheritdoc IScribeRouter + function latestRoundData() + external + view + toll + returns ( + uint80 roundId, + int answer, + uint startedAt, + uint updatedAt, + uint80 answeredInRound + ) + { + bool ok; + uint val; + uint age; + (ok, val, age) = _tryReadWithAge(); + + roundId = 1; + answer = int(val); + // assert(uint(answer) == uint(val)); + startedAt = 0; + updatedAt = age; + answeredInRound = roundId; + } + + /// @inheritdoc IScribeRouter + function latestAnswer() external view toll returns (int) { + uint val; + (, val,) = _tryReadWithAge(); + return int(val); + } + + // -- Overridden Toll Functions -- + + /// @dev Defines authorization for IToll's authenticated functions. + function toll_auth() internal override(Toll) auth {} +} + +/** + * @dev Contract overwrite to deploy contract instances with specific naming. + */ +contract ChronicleRouter_BASE_QUOTE_COUNTER is ScribeRouter { + // @todo ^^^^ ^^^^^ ^^^^^^^ Adjust name of Router instance. + constructor(address initialAuthed, string memory name_) + ScribeRouter(initialAuthed, name_) + {} +} diff --git a/test/IScribeFactoryTest.sol b/test/IScribeFactoryTest.sol new file mode 100644 index 0000000..78f17aa --- /dev/null +++ b/test/IScribeFactoryTest.sol @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {Test} 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 {IScribeFactory} from "src/IScribeFactory.sol"; +import {IScribeRouter} from "src/IScribeRouter.sol"; +import {ScribeFactory} from "src/ScribeFactory.sol"; + +import {LibSecp256k1} from "src/libs/LibSecp256k1.sol"; + +import {LibFeed} from "script/libs/LibFeed.sol"; + +abstract contract IScribeFactoryTest is Test { + using LibSecp256k1 for LibSecp256k1.Point; + using LibFeed for LibFeed.Feed; + + ScribeFactory private factory; + + // Events copied from IScribeFactory. + event ScribeDeployed( + address indexed caller, address indexed scribe, string name + ); + event ScribeRouterDeployed( + address indexed caller, address indexed router, string name + ); + + bytes32 constant FEED_REGISTRATION_MESSAGE = keccak256( + abi.encodePacked( + "\x19Ethereum Signed Message:\n32", + keccak256("Chronicle Feed Registration") + ) + ); + + function setUp(address factory_) internal virtual { + factory = ScribeFactory(factory_); + IToll(factory_).kiss(address(this)); + } + + // -- Helpers -- + + function _makeFeeds(uint8 numberFeeds) + internal + returns (LibSecp256k1.Point[] memory, IScribe.ECDSAData[] memory) + { + LibSecp256k1.Point[] memory pubKeys = + new LibSecp256k1.Point[](numberFeeds); + IScribe.ECDSAData[] memory sigs = new IScribe.ECDSAData[](numberFeeds); + + // Note to not start with privKey=1. This is because the sum of public + // keys would evaluate to: + // pubKeyOf(1) + pubKeyOf(2) + pubKeyOf(3) + ... + // = pubKeyOf(3) + pubKeyOf(3) + ... + // Note that pubKeyOf(3) would be doubled. Doubling is not supported by + // LibSecp256k1 as this would indicate a double-signing attack. + uint privKey = 2; + uint bloom; + uint ctr; + while (ctr != numberFeeds) { + LibFeed.Feed memory feed = LibFeed.newFeed({privKey: privKey}); + + // Check whether feed with id already created, if not create and + // lift. + if (bloom & (1 << feed.id) == 0) { + bloom |= 1 << feed.id; + + pubKeys[ctr] = feed.pubKey; + sigs[ctr] = feed.signECDSA(FEED_REGISTRATION_MESSAGE); + ctr++; + } + + privKey++; + } + + return (pubKeys, sigs); + } + + function _makeScribeConfig() + internal + returns (IScribeFactory.ScribeConfig memory cfg) + { + ( + LibSecp256k1.Point[] memory validators, + IScribe.ECDSAData[] memory registrationSigs + ) = _makeFeeds(2); + + address[] memory authed = new address[](1); + authed[0] = address(this); + + address[] memory tolled = new address[](1); + tolled[0] = address(this); + + cfg = IScribeFactory.ScribeConfig({ + name: "ETH/USD", + wat: "ETH/USD", + bar: 2, + authed: authed, + tolled: tolled, + validators: validators, + registrationSigs: registrationSigs + }); + } + + function _makeRouterConfig() + internal + pure + returns (IScribeFactory.ScribeRouterConfig memory cfg) + { + address[] memory authed = new address[](1); + authed[0] = address(0xbeef); + + address[] memory tolled = new address[](1); + tolled[0] = address(0xcafe); + + cfg = IScribeFactory.ScribeRouterConfig({ + name: "ETH/USD", authed: authed, tolled: tolled + }); + } + + // -- Test: Deployment -- + + function test_Deployment() public { + assertTrue(IAuth(address(factory)).authed(address(this))); + } + + // -- Test: plantScribe -- + + function test_plantScribe() public { + IScribeFactory.ScribeConfig memory cfg = _makeScribeConfig(); + + vm.expectEmit(true, false, false, true); + emit ScribeDeployed(address(this), address(0), cfg.name); + + address scribe = factory.plantScribe(cfg); + assertNotEq(scribe.code.length, 0); + + // Wat set. + assertEq(IScribe(scribe).wat(), cfg.wat); + + // Feeds lifted and bar set. + for (uint i; i < cfg.validators.length; i++) { + assertTrue(IScribe(scribe).feeds(cfg.validators[i].toAddress())); + } + assertEq(IScribe(scribe).bar(), cfg.bar); + + // Expected addresses authed and tolled. + for (uint i; i < cfg.authed.length; i++) { + assertTrue(IAuth(scribe).authed(cfg.authed[i])); + } + for (uint i; i < cfg.tolled.length; i++) { + assertTrue(IToll(scribe).tolled(cfg.tolled[i])); + } + + // Zero address tolled. + assertTrue(IToll(scribe).tolled(address(0))); + + // Factory denied. + assertFalse(IAuth(scribe).authed(address(factory))); + } + + // -- Test: plantRouter -- + + function test_plantRouter() public { + IScribeFactory.ScribeRouterConfig memory cfg = _makeRouterConfig(); + + vm.expectEmit(true, false, false, true); + emit ScribeRouterDeployed(address(this), address(0), cfg.name); + + address router = factory.plantRouter(cfg); + assertNotEq(router.code.length, 0); + + // Name set. + assertEq(IScribeRouter(router).name(), cfg.name); + + // Expected addresses authed and tolled. + for (uint i; i < cfg.authed.length; i++) { + assertTrue(IAuth(router).authed(cfg.authed[i])); + } + for (uint i; i < cfg.tolled.length; i++) { + assertTrue(IToll(router).tolled(cfg.tolled[i])); + } + + // Zero address tolled. + assertTrue(IToll(router).tolled(address(0))); + + // Factory denied. + assertFalse(IAuth(router).authed(address(factory))); + + // Scribe not set. + assertEq(IScribeRouter(router).scribe(), address(0)); + } + + // -- Test: plantScribeWithRouter -- + + function test_plantScribeWithRouter() public { + IScribeFactory.ScribeConfig memory scribeCfg = _makeScribeConfig(); + IScribeFactory.ScribeRouterConfig memory routerCfg = _makeRouterConfig(); + + (address scribe, address router) = + factory.plantScribeWithRouter(scribeCfg, routerCfg); + assertNotEq(scribe.code.length, 0); + assertNotEq(router.code.length, 0); + + // Scribe set on router and router tolled on scribe. + assertEq(IScribeRouter(router).scribe(), scribe); + assertTrue(IToll(scribe).tolled(router)); + + // -- Scribe Checks + + // Wat set. + assertEq(IScribe(scribe).wat(), scribeCfg.wat); + + // Feeds lifted and bar set. + for (uint i; i < scribeCfg.validators.length; i++) { + assertTrue( + IScribe(scribe).feeds(scribeCfg.validators[i].toAddress()) + ); + } + assertEq(IScribe(scribe).bar(), scribeCfg.bar); + + // Expected addresses authed and tolled. + for (uint i; i < scribeCfg.authed.length; i++) { + assertTrue(IAuth(scribe).authed(scribeCfg.authed[i])); + } + for (uint i; i < scribeCfg.tolled.length; i++) { + assertTrue(IToll(scribe).tolled(scribeCfg.tolled[i])); + } + + // Zero address tolled. + assertTrue(IToll(scribe).tolled(address(0))); + + // Factory denied. + assertFalse(IAuth(scribe).authed(address(factory))); + + // -- ScribeRouter Checks + + // Name set. + assertEq(IScribeRouter(router).name(), routerCfg.name); + + // Expected addresses authed and tolled. + for (uint i; i < routerCfg.authed.length; i++) { + assertTrue(IAuth(router).authed(routerCfg.authed[i])); + } + for (uint i; i < routerCfg.tolled.length; i++) { + assertTrue(IToll(router).tolled(routerCfg.tolled[i])); + } + + // Zero address tolled. + assertTrue(IToll(router).tolled(address(0))); + + // Factory denied. + assertFalse(IAuth(router).authed(address(factory))); + } + + // -- Test: Toll Protection -- + + function test_plantScribe_isTollProtected() public { + IScribeFactory.ScribeConfig memory cfg = _makeScribeConfig(); + + vm.prank(address(0xdead)); + vm.expectRevert( + abi.encodeWithSelector(IToll.NotTolled.selector, address(0xdead)) + ); + factory.plantScribe(cfg); + } + + function test_plantRouter_isTollProtected() public { + IScribeFactory.ScribeRouterConfig memory cfg = _makeRouterConfig(); + + vm.prank(address(0xdead)); + vm.expectRevert( + abi.encodeWithSelector(IToll.NotTolled.selector, address(0xdead)) + ); + factory.plantRouter(cfg); + } + + function test_plantScribeWithRouter_isTollProtected() public { + IScribeFactory.ScribeConfig memory scribeCfg = _makeScribeConfig(); + IScribeFactory.ScribeRouterConfig memory routerCfg = _makeRouterConfig(); + + vm.prank(address(0xdead)); + vm.expectRevert( + abi.encodeWithSelector(IToll.NotTolled.selector, address(0xdead)) + ); + factory.plantScribeWithRouter(scribeCfg, routerCfg); + } +} diff --git a/test/IScribeRouterTest.sol b/test/IScribeRouterTest.sol new file mode 100644 index 0000000..011b613 --- /dev/null +++ b/test/IScribeRouterTest.sol @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {Test} from "forge-std/Test.sol"; + +import {IAuth} from "chronicle-std/auth/IAuth.sol"; +import {IToll} from "chronicle-std/toll/IToll.sol"; + +import {Scribe} from "src/Scribe.sol"; +import {IScribe} from "src/IScribe.sol"; + +import {IScribeRouter} from "src/IScribeRouter.sol"; + +import {LibFeed} from "script/libs/LibFeed.sol"; + +abstract contract IScribeRouterTest is Test { + using LibFeed for LibFeed.Feed; + using LibFeed for LibFeed.Feed[]; + + IScribeRouter private router; + IScribe private scribe; + + // Events copied from IScribeRouter. + event ScribeUpdated( + address indexed caller, address oldScribe, address newScribe + ); + + function setUp(address router_) internal virtual { + // Set router and toll address(this). + router = IScribeRouter(router_); + IToll(address(router)).kiss(address(this)); + + // Deploy scribe and toll router and address(this). + scribe = new Scribe(address(this), "ETH/USD"); + IToll(address(scribe)).kiss(address(router)); + IToll(address(scribe)).kiss(address(this)); + + // Set scribe on router. + router.setScribe(address(scribe)); + } + + function _liftFeeds(uint8 numberFeeds) + internal + returns (LibFeed.Feed[] memory) + { + LibFeed.Feed[] memory feeds = new LibFeed.Feed[](uint(numberFeeds)); + + // Note to not start with privKey=1. This is because the sum of public + // keys would evaluate to: + // pubKeyOf(1) + pubKeyOf(2) + pubKeyOf(3) + ... + // = pubKeyOf(3) + pubKeyOf(3) + ... + // Note that pubKeyOf(3) would be doubled. Doubling is not supported by + // LibSecp256k1 as this would indicate a double-signing attack. + uint privKey = 2; + uint bloom; + uint ctr; + while (ctr != numberFeeds) { + LibFeed.Feed memory feed = LibFeed.newFeed({privKey: privKey}); + + // Check whether feed with id already created, if not create and + // lift. + if (bloom & (1 << feed.id) == 0) { + bloom |= 1 << feed.id; + + feeds[ctr++] = feed; + scribe.lift( + feed.pubKey, + feed.signECDSA(scribe.feedRegistrationMessage()) + ); + } + + privKey++; + } + + return feeds; + } + + function _poke(uint128 val) internal { + LibFeed.Feed[] memory feeds = _liftFeeds(scribe.bar()); + + IScribe.PokeData memory pokeData; + pokeData.val = val; + pokeData.age = uint32(block.timestamp); + + IScribe.SchnorrData memory schnorrData; + schnorrData = feeds.signSchnorr(scribe.constructPokeMessage(pokeData)); + + scribe.poke(pokeData, schnorrData); + } + + function _checkReadFunctions(uint wantVal, uint wantAge) internal { + bool ok; + uint gotVal; + uint gotAge; + + assertEq(router.read(), wantVal); + assertEq(scribe.read(), wantVal); + + (ok, gotVal) = router.tryRead(); + assertEq(gotVal, wantVal); + assertTrue(ok); + (ok, gotVal) = scribe.tryRead(); + assertEq(gotVal, wantVal); + assertTrue(ok); + + (gotVal, gotAge) = router.readWithAge(); + assertEq(gotVal, wantVal); + assertEq(gotAge, wantAge); + (gotVal, gotAge) = scribe.readWithAge(); + assertEq(gotVal, wantVal); + assertEq(gotAge, wantAge); + + (ok, gotVal, gotAge) = router.tryReadWithAge(); + assertTrue(ok); + assertEq(gotVal, wantVal); + assertEq(gotAge, wantAge); + (ok, gotVal, gotAge) = scribe.tryReadWithAge(); + assertTrue(ok); + assertEq(gotVal, wantVal); + assertEq(gotAge, wantAge); + + (gotVal, ok) = router.peek(); + assertEq(gotVal, wantVal); + assertTrue(ok); + (gotVal, ok) = scribe.peek(); + assertEq(gotVal, wantVal); + assertTrue(ok); + + (gotVal, ok) = router.peep(); + assertEq(gotVal, wantVal); + assertTrue(ok); + (gotVal, ok) = scribe.peep(); + assertEq(gotVal, wantVal); + assertTrue(ok); + + uint80 roundId; + int answer; + uint startedAt; + uint updatedAt; + uint80 answeredInRound; + (roundId, answer, startedAt, updatedAt, answeredInRound) = + router.latestRoundData(); + assertEq(uint(roundId), 1); + assertEq(uint(answer), wantVal); + assertEq(startedAt, 0); + assertEq(updatedAt, wantAge); + assertEq(uint(answeredInRound), 1); + (roundId, answer, startedAt, updatedAt, answeredInRound) = + scribe.latestRoundData(); + assertEq(uint(roundId), 1); + assertEq(uint(answer), wantVal); + assertEq(startedAt, 0); + assertEq(updatedAt, wantAge); + assertEq(uint(answeredInRound), 1); + + answer = router.latestAnswer(); + assertEq(uint(answer), wantVal); + answer = scribe.latestAnswer(); + assertEq(uint(answer), wantVal); + } + + // -- Test: Deployment -- + + function test_Deployment() public { + // Address given as constructor argument is auth'ed. + assertTrue(IAuth(address(router)).authed(address(this))); + + // Name and wat are set. + assertEq(router.name(), "ETH/USD"); + assertEq(router.wat(), keccak256(bytes("ETH/USD"))); + } + + // -- Test: setScribe -- + + function testFuzz_setScribe(address newScribe) public { + address current = router.scribe(); + if (current != newScribe) { + vm.expectEmit(); + emit ScribeUpdated(address(this), current, newScribe); + } + + router.setScribe(newScribe); + assertEq(router.scribe(), newScribe); + } + + function test_setScribe_isAuthProtected() public { + vm.prank(address(0xbeef)); + vm.expectRevert( + abi.encodeWithSelector( + IAuth.NotAuthorized.selector, address(0xbeef) + ) + ); + router.setScribe(address(0)); + } + + // -- Test: Read Functions -- + + function testFuzz_readFunctions(uint128 val) public { + vm.assume(val != 0); + _poke(val); + _checkReadFunctions(val, block.timestamp); + } + + function test_readFunctions_ValZero() public { + bool ok; + uint gotVal; + uint gotAge; + + // read() reverts. + vm.expectRevert(); + router.read(); + vm.expectRevert(); + scribe.read(); + + // tryRead() returns (false, 0). + (ok, gotVal) = router.tryRead(); + assertFalse(ok); + assertEq(gotVal, 0); + (ok, gotVal) = scribe.tryRead(); + assertFalse(ok); + assertEq(gotVal, 0); + + // readWithAge() reverts. + vm.expectRevert(); + router.readWithAge(); + vm.expectRevert(); + scribe.readWithAge(); + + // tryReadWithAge() returns (false, 0, 0). + (ok, gotVal, gotAge) = router.tryReadWithAge(); + assertFalse(ok); + assertEq(gotVal, 0); + assertEq(gotAge, 0); + (ok, gotVal, gotAge) = scribe.tryReadWithAge(); + assertFalse(ok); + assertEq(gotVal, 0); + assertEq(gotAge, 0); + + // peek() returns (0, false). + (gotVal, ok) = router.peek(); + assertEq(gotVal, 0); + assertFalse(ok); + (gotVal, ok) = scribe.peek(); + assertEq(gotVal, 0); + assertFalse(ok); + + // peep() returns (0, false). + (gotVal, ok) = router.peep(); + assertEq(gotVal, 0); + assertFalse(ok); + (gotVal, ok) = scribe.peep(); + assertEq(gotVal, 0); + assertFalse(ok); + + // latestRoundData() returns (1, 0, 0, 0, 1). + uint80 roundId; + int answer; + uint startedAt; + uint updatedAt; + uint80 answeredInRound; + (roundId, answer, startedAt, updatedAt, answeredInRound) = + router.latestRoundData(); + assertEq(uint(roundId), 1); + assertEq(uint(answer), 0); + assertEq(startedAt, 0); + assertEq(updatedAt, 0); + assertEq(uint(answeredInRound), 1); + (roundId, answer, startedAt, updatedAt, answeredInRound) = + scribe.latestRoundData(); + assertEq(uint(roundId), 1); + assertEq(uint(answer), 0); + assertEq(startedAt, 0); + assertEq(updatedAt, 0); + assertEq(uint(answeredInRound), 1); + + // latestAnswer() returns 0. + answer = router.latestAnswer(); + assertEq(uint(answer), 0); + answer = scribe.latestAnswer(); + assertEq(uint(answer), 0); + } + + // -- Test: decimals -- + + function test_decimals() public { + assertEq(router.decimals(), 18); + } + + // -- Test: Toll Protected Functions -- + + // - IChronicle Functions + + function test_read_isTollProtected() public { + vm.prank(address(0xbeef)); + vm.expectRevert( + abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) + ); + router.read(); + } + + function test_tryRead_isTollProtected() public { + vm.prank(address(0xbeef)); + vm.expectRevert( + abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) + ); + router.tryRead(); + } + + function test_readWithAge_isTollProtected() public { + vm.prank(address(0xbeef)); + vm.expectRevert( + abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) + ); + router.readWithAge(); + } + + function test_tryReadWithAge_isTollProtected() public { + vm.prank(address(0xbeef)); + vm.expectRevert( + abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) + ); + router.tryReadWithAge(); + } + + // - MakerDAO Compatibility + + function test_peek_isTollProtected() public { + vm.prank(address(0xbeef)); + vm.expectRevert( + abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) + ); + router.peek(); + } + + function test_peep_isTollProtected() public { + vm.prank(address(0xbeef)); + vm.expectRevert( + abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) + ); + router.peep(); + } + + // - Chainlink Compatibility + + function test_latestRoundData_isTollProtected() public { + vm.prank(address(0xbeef)); + vm.expectRevert( + abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) + ); + router.latestRoundData(); + } + + function test_latestAnswer_isTollProtected() public { + vm.prank(address(0xbeef)); + vm.expectRevert( + abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) + ); + router.latestAnswer(); + } +} + diff --git a/test/Runner.t.sol b/test/Runner.t.sol index 32397eb..9bd285e 100644 --- a/test/Runner.t.sol +++ b/test/Runner.t.sol @@ -26,6 +26,31 @@ contract ScribeInvariantTest is IScribeInvariantTest { } } +// -- Test: ScribeRouter -- + +import {ScribeRouter} from "src/ScribeRouter.sol"; +import {IScribeRouter} from "src/IScribeRouter.sol"; + +import {IScribeRouterTest} from "./IScribeRouterTest.sol"; + +contract ScribeRouterTest is IScribeRouterTest { + function setUp() public { + setUp(address(new ScribeRouter(address(this), "ETH/USD"))); + } +} + +// -- Test: ScribeFactory -- + +import {ScribeFactory} from "src/ScribeFactory.sol"; + +import {IScribeFactoryTest} from "./IScribeFactoryTest.sol"; + +contract ScribeFactoryTest is IScribeFactoryTest { + function setUp() public { + setUp(address(new ScribeFactory(address(this)))); + } +} + // -- Extensions import {ScribeLST} from "src/extensions/ScribeLST.sol"; From 1381ff5aeea23126f35d921c46d7260b103a5e7b Mon Sep 17 00:00:00 2001 From: merkleplant Date: Mon, 20 Apr 2026 14:41:27 +0200 Subject: [PATCH 2/3] script: Adds ScribeZeroValue contract for offboarding --- script/offboarder/ScribeZeroValue.sol | 73 +++++++++++++++++ test/offboarder/ScribeZeroValueTest.t.sol | 82 +++++++++++++++++++ .../{RescuerTest.sol => RescuerTest.t.sol} | 0 3 files changed, 155 insertions(+) create mode 100644 script/offboarder/ScribeZeroValue.sol create mode 100644 test/offboarder/ScribeZeroValueTest.t.sol rename test/rescue/{RescuerTest.sol => RescuerTest.t.sol} (100%) diff --git a/script/offboarder/ScribeZeroValue.sol b/script/offboarder/ScribeZeroValue.sol new file mode 100644 index 0000000..996a6ca --- /dev/null +++ b/script/offboarder/ScribeZeroValue.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +/** + * @title ScribeZeroValue + * + * @notice Static scribe oracle returning zero or reverting on read functions + * + * @dev Deployment: + * ```bash + * $ forge create script/offboarder/ScribeZeroValue.sol:ScribeZeroValue \ + * --keystore $KEYSTORE \ + * --password $KEYSTORE_PASSWORD \ + * --rpc-url $RPC_URL \ + * --verifier-url $ETHERSCAN_API_URL \ + * --etherscan-api-key $ETHERSCAN_API_KEY + * ``` + * + * @author Chronicle Labs, Inc + * @custom:security-contact security@chroniclelabs.org + */ +contract ScribeZeroValue { + function read() external pure returns (uint) { + revert(); + } + + function tryRead() external pure returns (bool, uint) { + return (false, 0); + } + + function readWithAge() external pure returns (uint, uint) { + revert(); + } + + function tryReadWithAge() external pure returns (bool, uint, uint) { + return (false, 0, 0); + } + + // - MakerDAO Compatibility + + function peek() external pure returns (uint, bool) { + return (0, false); + } + + function peep() external pure returns (uint, bool) { + return (0, false); + } + + // - Chainlink Compatibility + + function latestRoundData() + external + pure + returns ( + uint80 roundId, + int answer, + uint startedAt, + uint updatedAt, + uint80 answeredInRound + ) + { + roundId = 1; + answer = 0; + startedAt = 0; + updatedAt = 0; + answeredInRound = roundId; + } + + function latestAnswer() external pure returns (int) { + int answer = 0; + return answer; + } +} diff --git a/test/offboarder/ScribeZeroValueTest.t.sol b/test/offboarder/ScribeZeroValueTest.t.sol new file mode 100644 index 0000000..0e7c358 --- /dev/null +++ b/test/offboarder/ScribeZeroValueTest.t.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {Test} from "forge-std/Test.sol"; + +import {ScribeZeroValue} from "script/offboarder/ScribeZeroValue.sol"; + +contract ScribeZeroValueTest is Test { + ScribeZeroValue private scribe; + + function setUp() public { + scribe = new ScribeZeroValue(); + } + + function test_read() public { + vm.expectRevert(); + scribe.read(); + } + + function test_tryRead() public { + bool ok; + uint val; + (ok, val) = scribe.tryRead(); + assertFalse(ok); + assertEq(val, 0); + } + + function test_readWithAge() public { + vm.expectRevert(); + scribe.readWithAge(); + } + + function test_tryReadWithAge() public { + bool ok; + uint val; + uint age; + (ok, val, age) = scribe.tryReadWithAge(); + assertFalse(ok); + assertEq(val, 0); + assertEq(age, 0); + } + + // - MakerDAO Compatibility + + function test_peek() public { + uint val; + bool ok; + (val, ok) = scribe.peek(); + assertEq(val, 0); + assertFalse(ok); + } + + function test_peep() public { + uint val; + bool ok; + (val, ok) = scribe.peep(); + assertEq(val, 0); + assertFalse(ok); + } + + // - Chainlink Compatibility + + function test_lastRoundData() public { + ( + uint80 roundId, + int answer, + uint startedAt, + uint updatedAt, + uint80 answeredInRound + ) = scribe.latestRoundData(); + assertEq(roundId, 1); + assertEq(answer, 0); + assertEq(startedAt, 0); + assertEq(updatedAt, 0); + assertEq(answeredInRound, 1); + } + + function test_latestAnswer() public { + int answer = scribe.latestAnswer(); + assertEq(answer, 0); + } +} diff --git a/test/rescue/RescuerTest.sol b/test/rescue/RescuerTest.t.sol similarity index 100% rename from test/rescue/RescuerTest.sol rename to test/rescue/RescuerTest.t.sol From 0d8987de2d35bcb471244b9fbac9196ba470174f Mon Sep 17 00:00:00 2001 From: merkleplant Date: Mon, 20 Apr 2026 15:16:37 +0200 Subject: [PATCH 3/3] script: Fix offboard directory naming --- script/{offboarder => offboard}/ScribeZeroValue.sol | 0 test/{offboarder => offboard}/ScribeZeroValueTest.t.sol | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename script/{offboarder => offboard}/ScribeZeroValue.sol (100%) rename test/{offboarder => offboard}/ScribeZeroValueTest.t.sol (96%) diff --git a/script/offboarder/ScribeZeroValue.sol b/script/offboard/ScribeZeroValue.sol similarity index 100% rename from script/offboarder/ScribeZeroValue.sol rename to script/offboard/ScribeZeroValue.sol diff --git a/test/offboarder/ScribeZeroValueTest.t.sol b/test/offboard/ScribeZeroValueTest.t.sol similarity index 96% rename from test/offboarder/ScribeZeroValueTest.t.sol rename to test/offboard/ScribeZeroValueTest.t.sol index 0e7c358..f3d6e41 100644 --- a/test/offboarder/ScribeZeroValueTest.t.sol +++ b/test/offboard/ScribeZeroValueTest.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.16; import {Test} from "forge-std/Test.sol"; -import {ScribeZeroValue} from "script/offboarder/ScribeZeroValue.sol"; +import {ScribeZeroValue} from "script/offboard/ScribeZeroValue.sol"; contract ScribeZeroValueTest is Test { ScribeZeroValue private scribe;