Reference apps: the Sepolia state oracle (tools/reference-apps/oracle: IgneumStateOracle with keyed BLAKE2b through the EIP-152 precompile, the body merkle path, the IGNS record parse and an MPT verifier; StubCertificateVerifier as the stand-in; deploy, demo and the negative cases through eth_call, 34 passed on Sepolia), its addresses on /oracle

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 10:41:57 +00:00
parent ce33c57aa3
commit 107b79cea7
14 changed files with 2240 additions and 3 deletions

View file

@ -236,8 +236,8 @@
<section class="card" aria-labelledby="deployed">
<h2 id="deployed">On Sepolia</h2>
<div class="kv" data-oracle-kv>
<div class="k">Oracle</div><div class="mono" data-oracle-address>deploying today; the address lands here with its first stored checkpoint</div>
<div class="k">Verifier</div><div class="mono" data-verifier-address>the shared IgneumCertificateVerifier (DEX lane), or the stand-in until it lands</div>
<div class="k">Oracle</div><div class="mono" data-oracle-address>0xefe9879de29c401eeff195d5bf71c86b9caaa1b2 <small>IgneumStateOracle, deployed 8 October 2026 (tx 0xb8650f5b…d644, block 11869608)</small></div>
<div class="k">Verifier</div><div class="mono" data-verifier-address>0xa197ef31d5d5613125779179668e2482ac69a6f6 <small>StubCertificateVerifier, the stand-in: it records any well-formed certificate and does not check the BLS signature; the shared IgneumCertificateVerifier replaces it through setVerifier the day it lands</small></div>
<div class="k">Chain</div><div>Sepolia, chain id 11155111; Devnet 3 (igneum-devnet-3), no value</div>
</div>
<p class="note">How to read a balance from another contract: <code>IIgneumStateOracle(oracle).provenBalance(number, account, accountProof)</code>, where <code>number</code> is a Devnet 3 chain block whose state root the oracle holds and <code>accountProof</code> is the <code>eth_getProof</code> account proof at that block. A storage slot: <code>provenStorage(number, account, slot, accountProof, storageProof)</code>. Both revert on any mismatch.</p>
@ -250,7 +250,7 @@
<li>The coinbase transaction under the carrier's <code>hash_merkle_root</code>, and the segment record parsed out of its extra data: the record's <code>post_root</code> for its block is stored under the certificate's index.</li>
<li>Every read: a keccak-keyed Merkle Patricia proof against the stored root, verified on chain.</li>
</ol>
<p class="note">Said plainly, on the contract too (<code>trust()</code>): the aggregator's BLS signature on the record is not checked on chain in this slice, and the voter table the verifier holds is installed by its deployer. The negative cases in <code>test.mjs</code> are a flipped proof node, a wrong block number, a header removed from the path, a merkle sibling altered, a record with its <code>post_root</code> altered, a header with its nonce altered: each reverts.</p>
<p class="note">Said plainly, on the contract too (<code>trust()</code>): the aggregator's BLS signature on the record is not checked on chain in this slice, and while the stand-in verifier is installed no certificate signature is checked on chain either; the voter table the shared verifier holds is installed by its deployer. Gas on Sepolia, measured 8 October 2026: a 62-header path 13.3 million (about 215,000 per header, 15 precompile blocks each), a 10-header path about 2.2 million, the state-root store 177,000, a balance read 153,000. The negative cases in <code>test.mjs</code> are a flipped proof node, a wrong block number, a header removed from the path, a merkle sibling altered, a record with its <code>post_root</code> altered, a header with its nonce altered: each reverts.</p>
<p class="asof">Devnet 3, no value. Sepolia, no value. A demonstration of the verification path, not a product.</p>
</main>
<!-- footer:start -->

View file

@ -0,0 +1 @@
node_modules/

View file

@ -0,0 +1,52 @@
# Igneum Devnet 3 state oracle on Sepolia
A contract on Ethereum Sepolia that stores proven Devnet 3 state roots and lets other contracts read a proven
Devnet 3 balance or storage slot. Certificates are read from the shared verifier (`IIgneumCertificateVerifier`);
a `StubCertificateVerifier` stands in until the real one lands (`setVerifier`, deployer only, swaps it).
Addresses, transaction hashes, blocks and gas are in `deployment.json` (every write is appended under `writes`).
## What the oracle checks on chain
1. Every header in the path is hashed with keyed BLAKE2b-256 ("BlockHash") through the EIP-152 precompile and
parsed out of the same bytes. Each next header must list the previous hash among its level-0 parents. The last
hash must be the checkpoint the verifier holds for the given certificate index.
2. The carrier's coinbase transaction is hashed ("TransactionHash") and the merkle path ("MerkleBranchHash", a
missing right child is 32 zero bytes) must reach the carrier's hash_merkle_root, parsed from the header bytes.
3. The coinbase payload is walked from the transaction bytes (so it is bound to the hash); the nested sections
`IGNS || IGNP || IGNF` are taken from the end; the chosen 586-byte segment record gives the statement's
number, block hash and post_root (number must equal the record's `last`, block hash must equal the record's
block, chain id must be Devnet 3's 0x116f). The root is stored under the number with the certificate index.
4. `provenBalance`, `provenAccount` and `provenStorage` verify eth_getProof account and storage proofs against
the stored post_root with the contract's own RLP and keccak-keyed Merkle Patricia trie walk, and revert with a
reason on any mismatch.
Not checked on chain in this slice (also stated by `trust()`): the aggregator's BLS signature over the segment
record and the ZK proof behind it. Also not checked: the segment record's version field (the layout is fixed
either way).
## Files
- `contracts/Blake2b.sol`: keyed BLAKE2b-256 over the precompile at 0x09.
- `contracts/Mpt.sol`: RLP reading, MPT proofs, account and storage decoding.
- `contracts/IIgneumCertificateVerifier.sol`, `contracts/StubCertificateVerifier.sol`, `contracts/IgneumStateOracle.sol`.
- `compile.mjs`: solc-js (via IR, optimizer 200, cancun) to `artifacts/*.json`.
- `deploy.mjs`: EIP-1559 deploy of the stub and the oracle, writes `deployment.json` (`--force`, `--oracle-only`).
- `demo.mjs`: submits the certificate and the state root for `fixtures/dn3-balance.json` (or the synthetic vector
when the fixture is absent) and prints the Sepolia-read balance beside the fixture's.
- `test.mjs`: the vectors and negatives through eth_call. Part A is the receipt fixture (real headers, a real merkle
path); part B is a seeded synthetic vector (segment record, coinbase, three-header path, account and storage
proofs); part C is the balance fixture when it exists.
- `lib.mjs`: the byte layouts, a JS mirror of the BLAKE2b driver (checked against @noble/hashes), the trie builder.
## Run
```
node compile.mjs
node deploy.mjs
node test.mjs
node demo.mjs
```
The deployer key is read from `~/.config/igneum/sepolia-deployer`. Sepolia's gas schedule is repriced (a plain
transfer estimates 12,000 gas), so the numbers in `deployment.json` are what this network charges.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,35 @@
// Compiles the oracle contracts with solc-js and writes artifacts/<Name>.json ({abi, bytecode, deployedBytecode}).
import solc from 'solc';
import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const here = path.dirname(fileURLToPath(import.meta.url));
const dir = path.join(here, 'contracts');
const sources = {};
for (const f of readdirSync(dir)) if (f.endsWith('.sol')) sources[f] = { content: readFileSync(path.join(dir, f), 'utf8') };
const input = {
language: 'Solidity',
sources,
settings: {
optimizer: { enabled: true, runs: 200 },
evmVersion: 'cancun',
viaIR: true,
outputSelection: { '*': { '*': ['abi', 'evm.bytecode.object', 'evm.deployedBytecode.object'] } },
},
};
const out = JSON.parse(solc.compile(JSON.stringify(input)));
let failed = false;
for (const e of out.errors ?? []) { console.error(e.formattedMessage); if (e.severity === 'error') failed = true; }
if (failed) process.exit(1);
mkdirSync(path.join(here, 'artifacts'), { recursive: true });
const want = ['IgneumStateOracle', 'StubCertificateVerifier'];
for (const file of Object.keys(out.contracts)) {
for (const [name, c] of Object.entries(out.contracts[file])) {
if (!want.includes(name)) continue;
const art = { name, solc: solc.version(), abi: c.abi, bytecode: '0x' + c.evm.bytecode.object, deployedBytecode: '0x' + c.evm.deployedBytecode.object };
writeFileSync(path.join(here, 'artifacts', name + '.json'), JSON.stringify(art, null, 1) + '\n');
console.log(name, 'creation bytes', c.evm.bytecode.object.length / 2, 'runtime bytes', c.evm.deployedBytecode.object.length / 2);
}
}
console.log('solc', solc.version());

View file

@ -0,0 +1,292 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {IIgneumCertificateVerifier} from "./IIgneumCertificateVerifier.sol";
import {Blake2b} from "./Blake2b.sol";
import {Mpt} from "./Mpt.sol";
// Stores proven Igneum Devnet 3 state roots on Sepolia and answers balance and storage reads under them.
//
// A state root enters through submitStateRoot with:
// headers the serialized headers from the carrier block up to a certified checkpoint, carrier first
// coinbaseTx the carrier's serialized coinbase transaction
// leafIndex, siblings the merkle path from the coinbase hash to the carrier's hash_merkle_root
// recordIndex which segment record inside the coinbase payload's IGNS section to take
// Every header is hashed with keyed BLAKE2b-256 ("BlockHash") on chain and parsed out of the same bytes.
// Each next header must list the previous hash among its level-0 parents. The last hash must be the
// checkpoint the verifier holds for the given certificate index. The coinbase hash ("TransactionHash")
// must reach the carrier's hash_merkle_root through the path ("MerkleBranchHash"). The record's
// statement then gives (number, block hash, post state root), which is stored under the number.
contract IgneumStateOracle {
struct Root {
bytes32 postRoot;
bytes32 blockHash;
bytes32 carrier;
uint64 certIndex;
}
IIgneumCertificateVerifier public verifier;
address public owner;
uint64 public immutable evmChainId; // the statement's chain id, 0 to skip the check
mapping(uint64 => Root) private _roots;
event StateRoot(uint64 indexed number, bytes32 postRoot, bytes32 blockHash, uint64 certIndex, bytes32 carrier);
event VerifierSet(address verifier);
uint256 private constant RECORD_LEN = 586;
constructor(address verifier_, uint64 evmChainId_) {
verifier = IIgneumCertificateVerifier(verifier_);
owner = msg.sender;
evmChainId = evmChainId_;
}
function setVerifier(address v) external {
require(msg.sender == owner, "oracle: owner only");
verifier = IIgneumCertificateVerifier(v);
emit VerifierSet(v);
}
function trust() external pure returns (string memory) {
return "Checked on chain: header hashes and parent links to a certified checkpoint, the coinbase merkle path, the segment record layout, and the account and storage proofs under post_root. Not checked on chain in this slice: the aggregator's BLS signature over the segment record and the ZK proof behind it.";
}
// ---- hashes -------------------------------------------------------------------------------------
function headerHash(bytes memory header) public view returns (bytes32) {
return Blake2b.hash256("BlockHash", header);
}
function transactionHash(bytes memory tx_) public view returns (bytes32) {
return Blake2b.hash256("TransactionHash", tx_);
}
function merkleRoot(bytes32 leaf, uint256 index, bytes32[] calldata siblings) public view returns (bytes32 h) {
h = leaf;
for (uint256 i = 0; i < siblings.length; i++) {
h = (index & 1) == 0
? Blake2b.hash256("MerkleBranchHash", abi.encodePacked(h, siblings[i]))
: Blake2b.hash256("MerkleBranchHash", abi.encodePacked(siblings[i], h));
index >>= 1;
}
require(index == 0, "merkle: leaf index beyond the path");
}
// ---- headers ------------------------------------------------------------------------------------
function le64(bytes memory b, uint256 off) private pure returns (uint64) {
require(off + 8 <= b.length, "header: short");
uint256 w;
assembly { w := mload(add(add(b, 32), off)) }
return Blake2b.swap64(uint64(w >> 192));
}
function word(bytes memory b, uint256 off) private pure returns (bytes32 w) {
require(off + 32 <= b.length, "header: short");
assembly { w := mload(add(add(b, 32), off)) }
}
// Parses the level-0 parents and hash_merkle_root out of a serialized header.
function parseHeader(bytes memory h) public pure returns (uint256 parentsOff, uint256 parentCount, bytes32 merkle) {
uint64 levels = le64(h, 2);
require(levels >= 1, "header: no parent levels");
uint256 off = 10;
parentCount = le64(h, off);
parentsOff = off + 8;
off = parentsOff + 32 * parentCount;
for (uint256 l = 1; l < levels; l++) {
uint256 cnt = le64(h, off);
off += 8 + 32 * cnt;
}
merkle = word(h, off);
}
function listsParent(bytes memory h, uint256 parentsOff, uint256 parentCount, bytes32 x) private pure returns (bool) {
for (uint256 i = 0; i < parentCount; i++) {
if (word(h, parentsOff + 32 * i) == x) return true;
}
return false;
}
// Hashes every header, checks each parent link, and that the last hash is `checkpoint`.
// Returns the carrier's hash and hash_merkle_root.
function checkHeaderPath(bytes[] calldata headers, bytes32 checkpoint) public view returns (bytes32 carrier, bytes32 merkle) {
require(headers.length > 0, "headers: none");
bytes32 prev;
for (uint256 i = 0; i < headers.length; i++) {
bytes memory h = headers[i];
(uint256 pOff, uint256 pCount, bytes32 m) = parseHeader(h);
if (i == 0) {
merkle = m;
} else {
require(listsParent(h, pOff, pCount, prev), "headers: a header does not name the previous one as a parent");
}
prev = headerHash(h);
if (i == 0) carrier = prev;
}
require(prev == checkpoint, "headers: the last header is not the certified checkpoint");
}
// ---- the coinbase transaction and its segment record ---------------------------------------------
// Walks the serialized transaction to the payload. Amounts are 8 bytes on the wire.
function payloadOf(bytes memory t) public pure returns (uint256 off, uint256 len) {
uint16 version = uint16(le64(t, 0) & 0xffff);
uint256 p = 2;
uint256 nIn = le64(t, p);
p += 8;
for (uint256 i = 0; i < nIn; i++) {
p += 36;
uint256 sigLen = le64(t, p);
p += 8 + sigLen;
if (version < 1) p += 1;
p += 8;
if (version >= 1) p += 2;
}
uint256 nOut = le64(t, p);
p += 8;
for (uint256 i = 0; i < nOut; i++) {
p += 8 + 2;
uint256 spkLen = le64(t, p);
p += 8 + spkLen;
if (version >= 1) {
require(p < t.length, "coinbase: short");
if (uint8(t[p]) != 0) p += 34;
p += 1;
}
}
p += 8 + 20 + 8;
len = le64(t, p);
off = p + 8;
require(off + len <= t.length, "coinbase: payload overruns");
}
// The nested sections at the end of the extra data: items || len_le32 || TAG. Returns the IGNS items.
function segmentSection(bytes memory t, uint256 payloadOff, uint256 payloadLen) public pure returns (uint256 off, uint256 len) {
require(payloadLen >= 19, "coinbase: payload too short");
uint256 scriptLen = uint8(t[payloadOff + 18]);
require(19 + scriptLen <= payloadLen, "coinbase: script overruns");
uint256 start = payloadOff + 19 + scriptLen;
uint256 end = payloadOff + payloadLen;
end = takeSection(t, start, end, "IGNF");
end = takeSection(t, start, end, "IGNP");
uint256 before = takeSection(t, start, end, "IGNS");
require(before != end, "coinbase: no IGNS section");
off = before;
len = end - 8 - before;
}
// If the bytes in [start, end) end with `tag`, returns the start of that section; else returns `end`.
function takeSection(bytes memory t, uint256 start, uint256 end, bytes4 tag) private pure returns (uint256) {
if (end < start + 8) return end;
bytes4 have;
assembly { have := mload(add(add(t, 32), sub(end, 4))) }
if (have != tag) return end;
uint256 len = le32(t, end - 8);
if (len + 8 > end - start) return end;
return end - 8 - len;
}
function le32(bytes memory b, uint256 off) private pure returns (uint32) {
uint256 w;
assembly { w := mload(add(add(b, 32), off)) }
uint32 be = uint32(w >> 224);
return ((be & 0xff) << 24) | ((be & 0xff00) << 8) | ((be & 0xff0000) >> 8) | (be >> 24);
}
// Reads record `recordIndex` of the IGNS section: (last block number, block hash, post state root, chain id).
function readRecord(bytes memory t, uint256 recordIndex)
public
pure
returns (uint64 number, bytes32 blockHash, bytes32 postRoot, uint64 chainId)
{
(uint256 pOff, uint256 pLen) = payloadOf(t);
(uint256 sOff, uint256 sLen) = segmentSection(t, pOff, pLen);
require(sLen % RECORD_LEN == 0, "record: section length");
require((recordIndex + 1) * RECORD_LEN <= sLen, "record: index beyond the section");
uint256 r = sOff + recordIndex * RECORD_LEN;
uint64 last = le64(t, r + 10);
bytes32 block_ = word(t, r + 18);
uint256 pv = r + 118;
chainId = uint64(uint256(word(t, pv)) >> 192);
number = uint64(uint256(word(t, pv + 8)) >> 192);
blockHash = word(t, pv + 16);
postRoot = word(t, pv + 148);
require(number == last, "record: statement is not for the segment's last block");
require(blockHash == block_, "record: block hash is not the statement's");
require(postRoot != bytes32(0), "record: zero post_root");
}
// ---- the whole check ----------------------------------------------------------------------------
function verifyStateRoot(
uint64 certIndex,
bytes[] calldata headers,
bytes calldata coinbaseTx,
uint256 leafIndex,
bytes32[] calldata siblings,
uint256 recordIndex
) public view returns (uint64 number, bytes32 postRoot, bytes32 blockHash, bytes32 carrier) {
bytes32 checkpoint = verifier.finalCheckpoint(certIndex);
require(checkpoint != bytes32(0), "oracle: no certificate at that index");
bytes32 merkle;
(carrier, merkle) = checkHeaderPath(headers, checkpoint);
bytes memory t = coinbaseTx;
bytes32 leaf = transactionHash(t);
require(merkleRoot(leaf, leafIndex, siblings) == merkle, "merkle: path does not reach the carrier's hash_merkle_root");
uint64 chainId;
(number, blockHash, postRoot, chainId) = readRecord(t, recordIndex);
require(evmChainId == 0 || chainId == evmChainId, "record: statement chain id");
}
function submitStateRoot(
uint64 certIndex,
bytes[] calldata headers,
bytes calldata coinbaseTx,
uint256 leafIndex,
bytes32[] calldata siblings,
uint256 recordIndex
) external returns (uint64 number, bytes32 postRoot) {
bytes32 blockHash;
bytes32 carrier;
(number, postRoot, blockHash, carrier) = verifyStateRoot(certIndex, headers, coinbaseTx, leafIndex, siblings, recordIndex);
Root storage r = _roots[number];
require(r.postRoot == bytes32(0) || r.postRoot == postRoot, "oracle: another root is stored for that block");
_roots[number] = Root(postRoot, blockHash, carrier, certIndex);
emit StateRoot(number, postRoot, blockHash, certIndex, carrier);
}
// ---- reads --------------------------------------------------------------------------------------
function stateRoot(uint64 number) public view returns (bytes32 postRoot, bytes32 blockHash, bytes32 carrier, uint64 certIndex) {
Root storage r = _roots[number];
require(r.postRoot != bytes32(0), "oracle: no proven root for that block");
return (r.postRoot, r.blockHash, r.carrier, r.certIndex);
}
function provenAccount(uint64 number, address a, bytes[] calldata accountProof)
public
view
returns (bool exists, uint256 nonce, uint256 balance, bytes32 storageRoot, bytes32 codeHash)
{
(bytes32 root,,,) = stateRoot(number);
return Mpt.account(root, a, accountProof);
}
function provenBalance(uint64 number, address a, bytes[] calldata accountProof) external view returns (uint256 balance) {
(,, balance,,) = provenAccount(number, a, accountProof);
}
function provenStorage(uint64 number, address a, bytes32 slot, bytes[] calldata accountProof, bytes[] calldata storageProof)
external
view
returns (bytes32)
{
(bool exists,,, bytes32 storageRoot,) = provenAccount(number, a, accountProof);
if (!exists) {
require(storageProof.length == 0, "mpt: storage proof for an absent account");
return bytes32(0);
}
return Mpt.storageSlot(storageRoot, slot, storageProof);
}
}

View file

@ -0,0 +1,40 @@
// Reads a Devnet 3 balance from Sepolia. With fixtures/dn3-balance.json: submits its certificate through the verifier
// (stub or real), submits the state root with the full proof, then calls provenBalance and prints the Sepolia-read
// balance beside the fixture's. Without it: the same flow on the synthetic vector, and says so.
import { readFileSync, existsSync } from 'node:fs';
import * as L from './lib.mjs';
const d = L.deployment();
const pub = L.publicClient();
const wallet = L.walletClient(L.deployerAccount());
const abi = L.artifact('IgneumStateOracle').abi;
const read = (functionName, args) => pub.readContract({ address: d.oracle.address, abi, functionName, args });
const fx = L.fixtures + '/dn3-balance.json';
const real = existsSync(fx);
const V = real ? L.vectorFromBalanceFixture(JSON.parse(readFileSync(fx, 'utf8'))) : L.buildSynthetic();
console.log(L.ukTime(), 'UK oracle', d.oracle.address, 'on Sepolia, verifier', await read('verifier'), real ? '' : '(no dn3-balance.json yet: synthetic vector)');
console.log('vector:', V.kind, 'checkpoint', V.certIndex, V.checkpoint.slice(0, 12), 'headers', V.headers.length, 'chain block', V.number, 'address', L.hex0x(V.address));
const c = await L.ensureCertificate(pub, wallet, d, V.certIndex, L.hex0x(L.hexToBytes(V.checkpoint)), V.bitmap, V.signature);
console.log(L.ukTime(), 'UK certificate', V.certIndex, c.already ? 'already recorded on the verifier' : 'recorded: tx ' + c.tx + ' block ' + c.block + ' gas ' + c.gasUsed);
const r = await L.ensureStateRoot(pub, wallet, d, V);
console.log(L.ukTime(), 'UK state root for block', V.number, r.already ? 'already stored' : 'stored: tx ' + r.tx + ' block ' + r.block + ' gas ' + r.gasUsed + ' (' + r.headers + ' headers, ' + r.calldataBytes + ' bytes of calldata)');
const [root, blockHash, carrier, certIndex] = await read('stateRoot', [V.number]);
console.log('stored post_root', root, 'block hash', blockHash.slice(0, 14), 'carrier', carrier.slice(0, 14), 'certificate', certIndex);
const balance = await read('provenBalance', [V.number, L.hex0x(V.address), V.accountProof.map(L.hex0x)]);
const expected = real ? V.balance : V.account.balance;
const ign = w => (Number(w / 10n ** 12n) / 1e6).toFixed(6);
console.log('');
console.log('Sepolia-read balance :', balance.toString(), 'wei =', ign(balance), 'IGN');
console.log('fixture balance :', expected.toString(), 'wei =', ign(expected), 'IGN');
console.log(balance === expected ? 'MATCH' : 'MISMATCH');
if (real && V.storageProofs.length) {
for (const sp of V.storageProofs) {
const v = await read('provenStorage', [V.number, L.hex0x(V.address), L.hex0x(sp.slot), V.accountProof.map(L.hex0x), sp.proof.map(L.hex0x)]);
console.log('slot', L.hex0x(sp.slot).slice(0, 14), 'Sepolia-read', v, 'fixture', sp.value.toString(), BigInt(v) === sp.value ? 'MATCH' : 'MISMATCH');
}
}
console.log('');
console.log('trust():', await read('trust'));
process.exit(balance === expected ? 0 : 1);

View file

@ -0,0 +1,40 @@
// Deploys StubCertificateVerifier and IgneumStateOracle(stub, Devnet 3 EVM chain id) to Sepolia with EIP-1559
// transactions, waits for each receipt and writes deployment.json. Refuses to redeploy over an existing
// deployment.json unless --force is given; --oracle-only keeps the stub from deployment.json and redeploys the oracle.
import { writeFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { here, artifact, publicClient, walletClient, deployerAccount, EVM_CHAIN_ID, ukTime, deployment } from './lib.mjs';
const out = path.join(here, 'deployment.json');
if (existsSync(out) && !process.argv.includes('--force')) {
const d = deployment();
console.log('deployment.json exists (stub', d.stub.address, 'oracle', d.oracle.address + '); pass --force to redeploy');
process.exit(0);
}
const account = deployerAccount();
const pub = publicClient();
const wallet = walletClient(account);
const bal = await pub.getBalance({ address: account.address });
console.log(ukTime(), 'UK deployer', account.address, 'balance', Number(bal) / 1e18, 'ETH');
async function deploy(name, args) {
const art = artifact(name);
const hash = await wallet.deployContract({ abi: art.abi, bytecode: art.bytecode, args });
console.log(ukTime(), 'UK', name, 'sent', hash);
const r = await pub.waitForTransactionReceipt({ hash, timeout: 180_000 });
if (r.status !== 'success') throw new Error(name + ' deployment reverted in ' + hash);
console.log(ukTime(), 'UK', name, 'at', r.contractAddress, 'block', r.blockNumber, 'gas', r.gasUsed);
return { address: r.contractAddress, tx: hash, block: Number(r.blockNumber), gasUsed: Number(r.gasUsed) };
}
const oracleOnly = process.argv.includes('--oracle-only');
const previous = existsSync(out) ? deployment() : null;
const stub = oracleOnly && previous ? previous.stub : await deploy('StubCertificateVerifier', []);
const oracle = await deploy('IgneumStateOracle', [stub.address, EVM_CHAIN_ID]);
const after = await pub.getBalance({ address: account.address });
const d = {
network: 'sepolia', chainId: 11155111, rpc: 'https://ethereum-sepolia-rpc.publicnode.com', deployer: account.address,
evmChainId: EVM_CHAIN_ID, verifier: 'stub', stub, oracle, deployedAt: new Date().toISOString(), spentWei: (bal - after).toString(),
previous: previous ? [...(previous.previous || []), { stub: previous.stub, oracle: previous.oracle, deployedAt: previous.deployedAt, writes: previous.writes || [] }] : [],
};
writeFileSync(out, JSON.stringify(d, null, 1) + '\n');
console.log(ukTime(), 'UK wrote', out, 'spent', Number(bal - after) / 1e18, 'ETH');

View file

@ -0,0 +1,86 @@
{
"network": "sepolia",
"chainId": 11155111,
"rpc": "https://ethereum-sepolia-rpc.publicnode.com",
"deployer": "0xe1D08384ef4c4c1511c8f05F94C914C4DeDc2787",
"evmChainId": 4463,
"verifier": "stub",
"stub": {
"address": "0xa197ef31d5d5613125779179668e2482ac69a6f6",
"tx": "0xa01091b62dfb4a89b25afb57a3d384677a9ad62370748504d2b3f288c0d3ad38",
"block": 11869583,
"gasUsed": 8434842
},
"oracle": {
"address": "0xefe9879de29c401eeff195d5bf71c86b9caaa1b2",
"tx": "0xb8650f5b2a6965b94fde56a668e9c593b227f5b7e8d78949e32edd7f79d2d644",
"block": 11869608,
"gasUsed": 19585581
},
"deployedAt": "2026-10-08T10:37:13.434Z",
"spentWei": "19585913954877",
"previous": [
{
"stub": {
"address": "0xa197ef31d5d5613125779179668e2482ac69a6f6",
"tx": "0xa01091b62dfb4a89b25afb57a3d384677a9ad62370748504d2b3f288c0d3ad38",
"block": 11869583,
"gasUsed": 8434842
},
"oracle": {
"address": "0xa0754e555933b21703cdc6f9ff62e22ddb0a641e",
"tx": "0x411b44f37c17834fab674a1fb5f6a0d9680cc0d3c485797068961158139edbb7",
"block": 11869584,
"gasUsed": 19585581
},
"deployedAt": "2026-10-08T10:32:27.409Z",
"writes": [
{
"at": "2026-10-08T10:35:00Z",
"what": "submitCertificate",
"vector": "synthetic",
"index": "9001",
"tx": "0x4ee21dc6fc3b5e72e29ae04bb6cace759193ce117246e4417704b8e627d8b0de",
"block": 11869597,
"gasUsed": 239120
},
{
"at": "2026-10-08T10:35:00Z",
"what": "submitStateRoot",
"vector": "synthetic",
"number": "27039",
"certIndex": "9001",
"tx": "0x2d7f282580fdbb245af47abe429d48573d72c06403bf75fdc5407ffdd97d8df0",
"block": 11869598,
"gasUsed": 580161,
"headers": 3
}
]
}
],
"writes": [
{
"at": "2026-10-08T10:37:50.681Z",
"what": "submitCertificate",
"index": "9002",
"checkpoint": "0x6bef4a7e59cae5ef017598418fa0d3be7f1528d8169191014596d9cc232fd036",
"verifier": "0xa197ef31D5D5613125779179668E2482AC69a6F6",
"tx": "0x6c2e8ff6506853aac877712b3a5e51ac57ab3426f71a03422ffd9cfae5c3ef4c",
"block": 11869611,
"gasUsed": 239120
},
{
"at": "2026-10-08T10:38:04.312Z",
"what": "submitStateRoot",
"vector": "synthetic",
"number": "9000027039",
"postRoot": "0xb03ecb60bbd03a652155ee5e4c76bc5ad2e8623fe24ef3810ea4f4628e94dff6",
"certIndex": "9002",
"tx": "0x8907936e01b8e1ce323a68263d574128793b4db55d74738484a4881ff6788219",
"block": 11869612,
"gasUsed": 580281,
"calldataBytes": 1863,
"headers": 3
}
]
}

View file

@ -0,0 +1,345 @@
// Shared helpers for the oracle scripts: byte layouts (header, coinbase transaction, segment record, coinbase
// payload), a JS mirror of the BLAKE2b driver the contract runs through the EIP-152 precompile, a small
// Merkle Patricia trie builder for synthetic vectors, and the RPC plumbing (viem, Sepolia, the deployer key).
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { blake2b } from '@noble/hashes/blake2.js';
import { keccak_256 } from '@noble/hashes/sha3.js';
import { createPublicClient, createWalletClient, http, defineChain } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { headerHash, coinbaseTxHash, merkleRootFromPath, rlpEncode, trimBig, hexToBytes, bytesToHex, segmentRecordsOf } from '../../../site/lc/core.js';
export { blake2b, keccak_256, headerHash, coinbaseTxHash, merkleRootFromPath, rlpEncode, trimBig, hexToBytes, bytesToHex };
export const here = path.dirname(fileURLToPath(import.meta.url));
export const fixtures = path.resolve(here, '../fixtures');
export const RPC = 'https://ethereum-sepolia-rpc.publicnode.com';
export const EVM_CHAIN_ID = 0x116f; // Devnet 3's EVM chain id, from the receipt fixture
export const strip = s => String(s).replace(/^0x/i, '');
export const hex0x = b => '0x' + bytesToHex(b);
const te = new TextEncoder();
export function concat(parts) { const n = parts.reduce((a, p) => a + p.length, 0); const m = new Uint8Array(n); let o = 0; for (const p of parts) { m.set(p, o); o += p.length; } return m; }
export const u16le = v => { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, Number(v), true); return b; };
export const u32le = v => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, Number(v), true); return b; };
export const u64le = v => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(v), true); return b; };
export const u32be = v => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, Number(v), false); return b; };
export const u64be = v => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(v), false); return b; };
export function randomBytes(n) { const b = new Uint8Array(n); for (let i = 0; i < n; i++) b[i] = Math.floor(Math.random() * 256); return b; }
// A seeded byte source (xorshift32) so a synthetic vector is the same on every run.
export function seeded(seed) { let x = seed >>> 0 || 1; return n => { const b = new Uint8Array(n); for (let i = 0; i < n; i++) { x ^= x << 13; x >>>= 0; x ^= x >>> 17; x ^= x << 5; x >>>= 0; b[i] = x & 0xff; } return b; }; }
// ---- the header, byte for byte as site/verify/core.js headerHash hashes it -----------------------------------
export function serializeHeader(h) {
const levels = h.parents_by_level || [];
const parts = [u16le(h.version), u64le(levels.length)];
for (const level of levels) { parts.push(u64le(level.length)); for (const p of level) parts.push(hexToBytes(strip(p))); }
parts.push(hexToBytes(strip(h.hash_merkle_root)), hexToBytes(strip(h.accepted_id_merkle_root)), hexToBytes(strip(h.utxo_commitment)),
u64le(h.timestamp), u32le(h.bits), u64le(h.nonce), u64le(h.daa_score), u64le(h.blue_score));
const bw = String(h.blue_work).replace(/^0+/, '');
const bwBytes = bw.length ? hexToBytes(bw.length % 2 ? '0' + bw : bw) : new Uint8Array(0);
parts.push(u64le(bwBytes.length), bwBytes, hexToBytes(strip(h.pruning_point)), hexToBytes(strip(h.vote_key_hash)));
return concat(parts);
}
// ---- the coinbase transaction, byte for byte as site/light/core.js coinbaseTxHash hashes it ---------------------
export function serializeCoinbase(tx, amountWireLen = 8) {
const version = Number(tx.version || 0);
const parts = [u16le(version), u64le((tx.inputs || []).length)];
for (const inp of tx.inputs || []) {
parts.push(hexToBytes(strip(inp.previousOutpoint.transactionId)), u32le(inp.previousOutpoint.index));
const sig = hexToBytes(strip(inp.signatureScript || ''));
parts.push(u64le(sig.length), sig);
if (version < 1) parts.push(new Uint8Array([Number(inp.sigOpCount || 0)]));
parts.push(u64le(inp.sequence));
if (version >= 1) parts.push(u16le(inp.computeBudget || 0));
}
parts.push(u64le((tx.outputs || []).length));
for (const out of tx.outputs || []) {
const v = new Uint8Array(amountWireLen); let x = BigInt(out.value);
for (let i = 0; i < amountWireLen; i++) { v[i] = Number(x & 0xffn); x >>= 8n; }
const spk = hexToBytes(strip(out.scriptPublicKey));
parts.push(v, spk.subarray(0, 2), u64le(spk.length - 2), spk.subarray(2));
if (version >= 1) parts.push(new Uint8Array([out.covenant ? 1 : 0]));
}
const payload = hexToBytes(strip(tx.payload || ''));
parts.push(u64le(tx.lockTime || 0), hexToBytes(strip(tx.subnetworkId)), u64le(tx.gas || 0), u64le(payload.length), payload);
const mass = BigInt(tx.mass || 0);
if (version < 1) { if (mass > 0n) parts.push(u64le(mass)); } else parts.push(u64le(mass));
return concat(parts);
}
// ---- the segment record (586 bytes) and the coinbase payload sections ----------------------------------------
export function encodeBlockStatement(s) {
return concat([u64be(s.chain_id), u64be(s.number), s.block_hash, s.parent_hash, u32be(s.shard_count), s.tx_commitment, s.pre_root, s.post_root, s.receipts,
u64be(s.gas_used), u64be(s.pgas_used), u32be(s.executed), u32be(s.skipped), s.provers, s.shard_vk, s.agg_vk, u64be(s.chain_len)]);
}
export function encodeSegmentRecord(r) {
const pv = encodeBlockStatement(r.statement);
if (pv.length !== 340) throw new Error('statement is ' + pv.length + ' bytes');
const b = concat([u16le(r.version), u64le(r.first), u64le(r.last), r.block, r.pubkey, r.payout, pv, r.proof_hash, r.signature]);
if (b.length !== 586) throw new Error('record is ' + b.length + ' bytes');
return b;
}
const section = (items, tag) => concat([items, u32le(items.length), te.encode(tag)]);
// blue_score || subsidy || script version || script len || script || IGNS || IGNP || IGNF
export function buildCoinbasePayload({ blueScore = 1n, subsidy = 0n, script = new Uint8Array(0), minerBytes = new Uint8Array(0), records = [], ignp = new Uint8Array(0), ignf = new Uint8Array(0) }) {
return concat([u64le(blueScore), u64le(subsidy), u16le(0), new Uint8Array([script.length]), script, minerBytes,
section(concat(records), 'IGNS'), section(ignp, 'IGNP'), section(ignf, 'IGNF')]);
}
// ---- keyed BLAKE2b-256 the way the contract drives the EIP-152 compression function -------------------------
const MASK = (1n << 64n) - 1n;
const IV = [0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n, 0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n];
const SIGMA = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
[11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4], [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
[9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13], [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
[12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11], [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
[6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5], [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
];
const rotr = (x, n) => ((x >> n) | (x << (64n - n))) & MASK;
// The compression function F(h, m, t, f) with 12 rounds, exactly what the precompile computes.
export function blake2bF(h, m, t0, t1, f) {
const v = new Array(16);
for (let i = 0; i < 8; i++) { v[i] = h[i]; v[i + 8] = IV[i]; }
v[12] ^= t0; v[13] ^= t1; if (f) v[14] ^= MASK;
const G = (a, b, c, d, x, y) => {
v[a] = (v[a] + v[b] + x) & MASK; v[d] = rotr(v[d] ^ v[a], 32n); v[c] = (v[c] + v[d]) & MASK; v[b] = rotr(v[b] ^ v[c], 24n);
v[a] = (v[a] + v[b] + y) & MASK; v[d] = rotr(v[d] ^ v[a], 16n); v[c] = (v[c] + v[d]) & MASK; v[b] = rotr(v[b] ^ v[c], 63n);
};
for (let r = 0; r < 12; r++) {
const s = SIGMA[r % 10];
G(0, 4, 8, 12, m[s[0]], m[s[1]]); G(1, 5, 9, 13, m[s[2]], m[s[3]]); G(2, 6, 10, 14, m[s[4]], m[s[5]]); G(3, 7, 11, 15, m[s[6]], m[s[7]]);
G(0, 5, 10, 15, m[s[8]], m[s[9]]); G(1, 6, 11, 12, m[s[10]], m[s[11]]); G(2, 7, 8, 13, m[s[12]], m[s[13]]); G(3, 4, 9, 14, m[s[14]], m[s[15]]);
}
for (let i = 0; i < 8; i++) h[i] ^= v[i] ^ v[i + 8];
}
const wordsLE = block => { const dv = new DataView(block.buffer, block.byteOffset, 128); const m = []; for (let i = 0; i < 16; i++) m.push(dv.getBigUint64(i * 8, true)); return m; };
// Same driver as Blake2b.sol: parameter block, the key as the first block (t = 128), then the message blocks.
export function keyedBlake2b256(key, data) {
const h = IV.slice();
h[0] ^= 0x01010000n ^ (BigInt(key.length) << 8n) ^ 32n;
const kb = new Uint8Array(128); kb.set(key);
blake2bF(h, wordsLE(kb), 128n, 0n, data.length === 0);
let done = 0;
while (done < data.length) {
const take = Math.min(128, data.length - done);
const mb = new Uint8Array(128); mb.set(data.subarray(done, done + take));
done += take;
blake2bF(h, wordsLE(mb), 128n + BigInt(done), 0n, done === data.length);
}
const out = new Uint8Array(32); const dv = new DataView(out.buffer);
for (let i = 0; i < 4; i++) dv.setBigUint64(i * 8, h[i], true);
return out;
}
export const nobleKeyed = (key, data) => blake2b(data, { dkLen: 32, key });
// ---- a Merkle Patricia trie builder (secure trie, 32-byte keys) for synthetic vectors --------------------------
const nibblesOf = b => { const out = []; for (const x of b) out.push(x >> 4, x & 15); return out; };
function hp(nibs, leaf) {
const odd = nibs.length & 1; const flag = (leaf ? 2 : 0) | odd;
const out = []; let i = 0;
if (odd) { out.push((flag << 4) | nibs[0]); i = 1; } else out.push(flag << 4);
for (; i < nibs.length; i += 2) out.push((nibs[i] << 4) | nibs[i + 1]);
return new Uint8Array(out);
}
const common = (a, b) => { let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++; return i; };
export class Trie {
constructor() { this.root = null; }
put(keyBytes, valueBytes) { this.root = this._put(this.root, nibblesOf(keyBytes), valueBytes); }
_put(n, path, value) {
if (!n) return { kind: 'leaf', path, value };
if (n.kind === 'leaf') {
const cp = common(n.path, path);
if (cp === n.path.length && cp === path.length) return { kind: 'leaf', path, value };
const br = { kind: 'branch', children: new Array(16).fill(null), value: null };
const place = (p, v) => { if (p.length === cp) br.value = v; else br.children[p[cp]] = { kind: 'leaf', path: p.slice(cp + 1), value: v }; };
place(n.path, n.value); place(path, value);
return cp ? { kind: 'ext', path: path.slice(0, cp), child: br } : br;
}
if (n.kind === 'ext') {
const cp = common(n.path, path);
if (cp === n.path.length) return { kind: 'ext', path: n.path, child: this._put(n.child, path.slice(cp), value) };
const br = { kind: 'branch', children: new Array(16).fill(null), value: null };
const rem = n.path.slice(cp);
br.children[rem[0]] = rem.length === 1 ? n.child : { kind: 'ext', path: rem.slice(1), child: n.child };
if (path.length === cp) br.value = value; else br.children[path[cp]] = { kind: 'leaf', path: path.slice(cp + 1), value };
return cp ? { kind: 'ext', path: path.slice(0, cp), child: br } : br;
}
if (path.length === 0) return { ...n, value };
const children = n.children.slice();
children[path[0]] = this._put(children[path[0]], path.slice(1), value);
return { kind: 'branch', children, value: n.value };
}
encode(n) {
if (n.kind === 'leaf') return rlpEncodeRaw([hp(n.path, true), n.value]);
if (n.kind === 'ext') return rlpEncodeRaw([hp(n.path, false), this.ref(n.child)]);
return rlpEncodeRaw([...n.children.map(c => (c ? this.ref(c) : new Uint8Array(0))), n.value || new Uint8Array(0)]);
}
// A child reference: the keccak of its encoding, or the encoding itself (spliced in raw) when shorter than 32 bytes.
ref(n) { const e = this.encode(n); return e.length < 32 ? { __raw: e } : keccak_256(e); }
rootHash() { return this.root ? keccak_256(this.encode(this.root)) : keccak_256(rlpEncode(new Uint8Array(0))); }
// The proof nodes for a key, root first: every hash-referenced node on the path (embedded ones travel inside their parent).
proof(keyBytes) {
const out = []; let n = this.root; let path = nibblesOf(keyBytes); let embedded = false;
while (n) {
const e = this.encode(n);
if (!embedded) out.push(e);
let c;
if (n.kind === 'leaf') break;
if (n.kind === 'ext') { if (common(n.path, path) !== n.path.length) break; path = path.slice(n.path.length); c = n.child; }
else { if (!path.length) break; c = n.children[path[0]]; path = path.slice(1); if (!c) break; }
embedded = this.encode(c).length < 32; n = c;
}
return out;
}
}
// rlpEncode in core.js knows only bytes and lists; this one also splices a {__raw} item in as it is.
export function rlpEncodeRaw(item) {
if (item && item.__raw) return item.__raw;
if (item instanceof Uint8Array) return rlpEncode(item);
const body = concat(item.map(rlpEncodeRaw));
const n = body.length;
const prefix = n < 56 ? new Uint8Array([0xc0 + n]) : (() => { const bytes = []; let x = n; while (x > 0) { bytes.unshift(x & 0xff); x = Math.floor(x / 256); } return new Uint8Array([0xf7 + bytes.length, ...bytes]); })();
return concat([prefix, body]);
}
export const EMPTY_ROOT = '56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421';
export const encodeAccount = a => rlpEncode([trimBig(a.nonce), trimBig(a.balance), hexToBytes(strip(a.storageRoot)), hexToBytes(strip(a.codeHash))]);
// ---- RPC plumbing ----------------------------------------------------------------------------------------------
export const sepolia = defineChain({ id: 0xaa36a7, name: 'Sepolia', nativeCurrency: { name: 'Sepolia Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: { default: { http: [RPC] } } });
export const publicClient = () => createPublicClient({ chain: sepolia, transport: http(RPC, { timeout: 120_000 }) });
export function deployerAccount() {
const k = JSON.parse(readFileSync(path.join(process.env.HOME, '.config/igneum/sepolia-deployer'), 'utf8'));
return privateKeyToAccount(k.private_key);
}
export const walletClient = account => createWalletClient({ account, chain: sepolia, transport: http(RPC, { timeout: 120_000 }) });
export const artifact = name => JSON.parse(readFileSync(path.join(here, 'artifacts', name + '.json'), 'utf8'));
export const deployment = () => JSON.parse(readFileSync(path.join(here, 'deployment.json'), 'utf8'));
export const ukTime = () => new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', second: '2-digit' });
// The revert reason out of a viem error, or the message's first line.
export function reasonOf(e) {
const m = String(e.shortMessage || e.message || e);
const r = /reason:\s*([^\n]+)/.exec(String(e.message || '')) || /reverted with the following reason:\s*([^\n]+)/.exec(String(e.message || ''));
return r ? r[1].trim() : m.split('\n')[0];
}
// ---- a synthetic balance proof: a state trie, a segment record, a coinbase, a merkle path and a two-header path --
// Everything the contract checks is real; only the aggregator's signature and the certificate are made up, which is
// what the stub verifier accepts. The checkpoint index is 9002 so it never collides with a real certificate (9001 holds an earlier synthetic vector).
export function buildSynthetic(evmChainId = EVM_CHAIN_ID, seed = 0x1a2b3c4d) {
const randomBytes = seeded(seed);
const address = randomBytes(20);
const storage = new Trie();
const slot1 = new Uint8Array(32); slot1[31] = 1;
const slot5 = new Uint8Array(32); slot5[31] = 5;
const v1 = hexToBytes('abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789');
storage.put(keccak_256(slot1), rlpEncode(v1));
storage.put(keccak_256(slot5), rlpEncode(trimBig(42n)));
for (let i = 0; i < 6; i++) storage.put(keccak_256(randomBytes(32)), rlpEncode(trimBig(BigInt(1000 + i))));
const account = { nonce: 7n, balance: 123456789n * 10n ** 15n, storageRoot: bytesToHex(storage.rootHash()), codeHash: bytesToHex(keccak_256(new Uint8Array(0))) };
const state = new Trie();
state.put(keccak_256(address), encodeAccount(account));
for (let i = 0; i < 9; i++) state.put(keccak_256(randomBytes(20)), encodeAccount({ nonce: BigInt(i), balance: BigInt(i) * 10n ** 18n, storageRoot: EMPTY_ROOT, codeHash: account.codeHash }));
const postRoot = state.rootHash();
const number = 9_000_027_039n; // far above any Devnet 3 block number, so it never collides with a real record
const blockHash = randomBytes(32);
const record = encodeSegmentRecord({
version: 2, first: number - 3n, last: number, block: blockHash, pubkey: randomBytes(48), payout: randomBytes(20),
statement: { chain_id: BigInt(evmChainId), number, block_hash: blockHash, parent_hash: randomBytes(32), shard_count: 1, tx_commitment: randomBytes(32), pre_root: randomBytes(32), post_root: postRoot, receipts: randomBytes(32), gas_used: 21000n, pgas_used: 0n, executed: 1, skipped: 0, provers: randomBytes(32), shard_vk: randomBytes(32), agg_vk: randomBytes(32), chain_len: 4n },
proof_hash: randomBytes(32), signature: randomBytes(96),
});
const payload = buildCoinbasePayload({ blueScore: 62371n, subsidy: 50000000n, script: hexToBytes('20' + bytesToHex(randomBytes(32)) + 'ac'), minerBytes: te.encode('synthetic'), records: [record], ignf: randomBytes(40) });
const coinbase = { version: 1, inputs: [], outputs: [{ value: '50000000', scriptPublicKey: '0000' + '20' + bytesToHex(randomBytes(32)) + 'ac' }], lockTime: 0, subnetworkId: '0100000000000000000000000000000000000000', gas: 0, payload: bytesToHex(payload), mass: 0 };
const cbBytes = serializeCoinbase(coinbase);
const leaf = coinbaseTxHash(coinbase, blake2b);
const leaves = [leaf, randomBytes(32), randomBytes(32)];
const H = d => blake2b(d, { dkLen: 32, key: te.encode('MerkleBranchHash') });
const siblings = [leaves[1], H(concat([leaves[2], new Uint8Array(32)]))];
const merkle = merkleRootFromPath(leaf, 0, siblings, blake2b);
const mk = (merkleRoot, parents, blueScore) => ({ version: 1538, parents_by_level: [parents, parents], hash_merkle_root: bytesToHex(merkleRoot), accepted_id_merkle_root: bytesToHex(randomBytes(32)), utxo_commitment: bytesToHex(randomBytes(32)), timestamp: '1759900000000', bits: 486805716, nonce: '1234567890123', daa_score: '62684', blue_work: '00000000000000000000000000000000000020dfbc77ac31', blue_score: String(blueScore), pruning_point: bytesToHex(randomBytes(32)), vote_key_hash: bytesToHex(randomBytes(32)) });
const carrier = mk(merkle, [bytesToHex(randomBytes(32))], 62371); carrier.hash = headerHash(carrier, blake2b);
const mid = mk(randomBytes(32), [carrier.hash, bytesToHex(randomBytes(32))], 62372); mid.hash = headerHash(mid, blake2b);
const top = mk(randomBytes(32), [bytesToHex(randomBytes(32)), mid.hash], 62373); top.hash = headerHash(top, blake2b);
return {
kind: 'synthetic', certIndex: 9002n, checkpoint: top.hash, headers: [carrier, mid, top], coinbase, coinbaseBytes: cbBytes, leafIndex: 0, siblings, recordIndex: 0,
number, postRoot, blockHash, address, account, accountProof: state.proof(keccak_256(address)),
slots: [{ slot: slot1, value: v1, proof: storage.proof(keccak_256(slot1)) }, { slot: slot5, value: trimBig(42n), proof: storage.proof(keccak_256(slot5)) }],
absent: (() => { const s = new Uint8Array(32); s[31] = 9; return { slot: s, proof: storage.proof(keccak_256(s)) }; })(),
};
}
// ---- contract arguments and the two writes --------------------------------------------------------------------
export function proofArgs(v) {
return [v.certIndex, v.headers.map(h => hex0x(serializeHeader(h))), hex0x(v.coinbaseBytes), BigInt(v.leafIndex), v.siblings.map(hex0x), BigInt(v.recordIndex)];
}
// Appends a write to deployment.json so every transaction is on record.
export function recordWrite(entry) {
const p = path.join(here, 'deployment.json');
const d = JSON.parse(readFileSync(p, 'utf8'));
d.writes = d.writes || [];
d.writes.push({ at: new Date().toISOString(), ...entry });
writeFileSync(p, JSON.stringify(d, null, 1) + '\n');
}
// The public RPC is a pool of nodes; a read right after a receipt can land on one that lags. Poll until it agrees.
async function untilVisible(check, what) {
for (let i = 0; i < 30; i++) { if (await check()) return; await new Promise(r => setTimeout(r, 1000)); }
throw new Error(what + ' is not visible on the RPC 30 s after its receipt');
}
export async function ensureCertificate(pub, wallet, d, index, checkpoint, bitmap = '0x', signature = '0x') {
const stubAbi = artifact('StubCertificateVerifier').abi;
const verifierAddr = await pub.readContract({ address: d.oracle.address, abi: artifact('IgneumStateOracle').abi, functionName: 'verifier' });
const same = async () => (await pub.readContract({ address: verifierAddr, abi: stubAbi, functionName: 'finalCheckpoint', args: [index] })).toLowerCase() === checkpoint.toLowerCase();
if (await same()) return { verifier: verifierAddr, already: true };
const hash = await wallet.writeContract({ address: verifierAddr, abi: stubAbi, functionName: 'submitCertificate', args: [index, checkpoint, bitmap, signature] });
const r = await pub.waitForTransactionReceipt({ hash, timeout: 180_000 });
if (r.status !== 'success') throw new Error('submitCertificate reverted in ' + hash);
await untilVisible(same, 'certificate ' + index);
const out = { verifier: verifierAddr, tx: hash, block: Number(r.blockNumber), gasUsed: Number(r.gasUsed) };
recordWrite({ what: 'submitCertificate', index: String(index), checkpoint, ...out });
return out;
}
export async function ensureStateRoot(pub, wallet, d, v) {
const abi = artifact('IgneumStateOracle').abi;
try {
const [root] = await pub.readContract({ address: d.oracle.address, abi, functionName: 'stateRoot', args: [v.number] });
if (root.toLowerCase() === hex0x(v.postRoot).toLowerCase()) return { already: true };
} catch {}
const args = proofArgs(v);
const gas = await pub.estimateContractGas({ address: d.oracle.address, abi, functionName: 'submitStateRoot', args, account: wallet.account });
const hash = await wallet.writeContract({ address: d.oracle.address, abi, functionName: 'submitStateRoot', args, gas: gas + gas / 5n });
const r = await pub.waitForTransactionReceipt({ hash, timeout: 180_000 });
if (r.status !== 'success') throw new Error('submitStateRoot reverted in ' + hash);
await untilVisible(async () => { try { const [root] = await pub.readContract({ address: d.oracle.address, abi, functionName: 'stateRoot', args: [v.number] }); return root.toLowerCase() === hex0x(v.postRoot).toLowerCase(); } catch { return false; } }, 'state root ' + v.number);
const out = { tx: hash, block: Number(r.blockNumber), gasUsed: Number(r.gasUsed), calldataBytes: args[1].reduce((a, h) => a + (h.length - 2) / 2, 0) + (args[2].length - 2) / 2, headers: args[1].length };
recordWrite({ what: 'submitStateRoot', vector: v.kind, number: String(v.number), postRoot: hex0x(v.postRoot), certIndex: String(v.certIndex), ...out });
return out;
}
// ---- the balance fixture (the light service's /balance body, with the certificate beside it) -------------------
// Accepts the flat /balance body, or {proof, certificate} / {balance, checkpoint} wrappers; the certificate's bitmap
// and signature come from the fixture when present, else from dn3-checkpoint.json when its index matches.
export function vectorFromBalanceFixture(fx) {
const p = fx.proof || fx.balance || fx;
let cert = fx.certificate || (p.checkpoint && p.checkpoint.certificate) || fx.checkpoint_certificate || null;
if (!cert) { try { const c = JSON.parse(readFileSync(path.join(fixtures, 'dn3-checkpoint.json'), 'utf8')); if (Number(c.index) === Number(p.checkpoint.index)) cert = c; } catch {} }
const c = cert && cert.certificate ? cert.certificate : cert || {};
const records = segmentRecordsOf(hexToBytes(strip(p.carrier.coinbase.payload)));
const recordIndex = records.findIndex(r => bytesToHex(r.bytes) === strip(p.segment_record_hex));
if (recordIndex < 0) throw new Error('the carrier coinbase carries ' + records.length + ' record(s), none is segment_record_hex');
const st = records[recordIndex].statement;
const acct = p.account;
return {
kind: 'devnet-3', certIndex: BigInt(p.checkpoint.index), checkpoint: strip(p.checkpoint.hash), headers: p.headers,
bitmap: c.bitmap_hex ? '0x' + strip(c.bitmap_hex) : '0x', signature: c.aggregate_signature_hex ? '0x' + strip(c.aggregate_signature_hex) : '0x',
coinbase: p.carrier.coinbase, coinbaseBytes: serializeCoinbase(p.carrier.coinbase, p.carrier.amount_wire_len || 8),
leafIndex: Number(p.carrier.leaf_index), siblings: p.carrier.merkle_siblings.map(x => hexToBytes(strip(x))), recordIndex,
number: st.number, postRoot: hexToBytes(st.post_root), blockHash: hexToBytes(st.block_hash), evmChainId: Number(st.chain_id),
address: hexToBytes(strip(p.address)), balance: BigInt(acct.balance), nonce: BigInt(acct.nonce || 0), accountProof: acct.accountProof.map(x => hexToBytes(strip(x))),
reportedStateRoot: acct.stateRoot ? strip(acct.stateRoot) : null, reportedBlock: acct.blockNumber != null ? Number(acct.blockNumber) : null,
storageProofs: (acct.storageProof || []).map(sp => ({ slot: hexToBytes(strip(sp.key).padStart(64, '0')), value: BigInt(sp.value), proof: sp.proof.map(x => hexToBytes(strip(x))) })),
};
}

View file

@ -0,0 +1,381 @@
{
"name": "igneum-state-oracle",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "igneum-state-oracle",
"version": "0.1.0",
"dependencies": {
"@noble/hashes": "2.4.0",
"solc": "0.8.28",
"viem": "^2.21.0"
}
},
"node_modules/@adraffy/ens-normalize": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz",
"integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==",
"license": "MIT"
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
"integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz",
"integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.9.0",
"@noble/hashes": "~1.8.0",
"@scure/base": "~1.2.5"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
"integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "~1.8.0",
"@scure/base": "~1.2.5"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/abitype": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz",
"integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/wevm"
},
"peerDependencies": {
"typescript": ">=5.0.4",
"zod": "^3.22.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/command-exists": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
"integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==",
"license": "MIT"
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/isows": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
"integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/wevm"
}
],
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
"license": "MIT"
},
"node_modules/memorystream": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
"integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==",
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ox": {
"version": "0.14.54",
"resolved": "https://registry.npmjs.org/ox/-/ox-0.14.54.tgz",
"integrity": "sha512-52PGH6ldJmWY9+iy/TBSJUsCSy5NmLvkVBYTINmO8srsjRrCmCdO575kGizmZ1jpNQ1gnlpVwiciIpU37fcULA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/wevm"
}
],
"license": "MIT",
"dependencies": {
"@adraffy/ens-normalize": "^1.11.0",
"@noble/ciphers": "^1.3.0",
"@noble/curves": "1.9.1",
"@noble/hashes": "^1.8.0",
"@scure/bip32": "^1.7.0",
"@scure/bip39": "^1.6.0",
"abitype": "^1.2.3",
"eventemitter3": "5.0.1"
},
"peerDependencies": {
"typescript": ">=5.4.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ox/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/solc": {
"version": "0.8.28",
"resolved": "https://registry.npmjs.org/solc/-/solc-0.8.28.tgz",
"integrity": "sha512-AFCiJ+b4RosyyNhnfdVH4ZR1+TxiL91iluPjw0EJslIu4LXGM9NYqi2z5y8TqochC4tcH9QsHfwWhOIC9jPDKA==",
"license": "MIT",
"dependencies": {
"command-exists": "^1.2.8",
"commander": "^8.1.0",
"follow-redirects": "^1.12.1",
"js-sha3": "0.8.0",
"memorystream": "^0.3.1",
"semver": "^5.5.0",
"tmp": "0.0.33"
},
"bin": {
"solcjs": "solc.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"license": "MIT",
"dependencies": {
"os-tmpdir": "~1.0.2"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/viem": {
"version": "2.57.4",
"resolved": "https://registry.npmjs.org/viem/-/viem-2.57.4.tgz",
"integrity": "sha512-ro+8AKrcGU6tPfou0gBRYv6bxnU3tPxxJGY4qpJwjiR40eHCWzeGhrAgYMlIncFtlnry2/yGjz57WOC67oshCQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/wevm"
}
],
"license": "MIT",
"dependencies": {
"@noble/curves": "1.9.1",
"@noble/hashes": "1.8.0",
"@scure/bip32": "1.7.0",
"@scure/bip39": "1.6.0",
"abitype": "1.2.3",
"isows": "1.0.7",
"ox": "0.14.54",
"ws": "8.21.0"
},
"peerDependencies": {
"typescript": ">=5.0.4"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/viem/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View file

@ -0,0 +1,9 @@
{
"name": "igneum-state-oracle",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Igneum reference apps: the Devnet 3 state oracle on Sepolia (contracts, deploy, demo, tests)",
"scripts": { "compile": "node compile.mjs", "deploy": "node deploy.mjs", "demo": "node demo.mjs", "test": "node test.mjs" },
"dependencies": { "@noble/hashes": "2.4.0", "solc": "0.8.28", "viem": "^2.21.0" }
}

View file

@ -0,0 +1,124 @@
// The oracle's tests against Sepolia. Every negative runs through eth_call (no gas). The only transactions are the
// one-time stub certificate for the synthetic vector and its submitStateRoot, skipped when already on chain.
// Part A: the receipt fixture (real Devnet 3 headers, a real merkle path with an EVM transaction leaf)
// Part B: the synthetic vector (segment record, coinbase, two-header path, MPT account and storage proofs)
// Part C: the balance fixture when it exists (real segment record and eth_getProof)
import { readFileSync, existsSync } from 'node:fs';
import * as L from './lib.mjs';
const d = L.deployment();
const pub = L.publicClient();
const abi = L.artifact('IgneumStateOracle').abi;
const oracle = d.oracle.address;
const read = (functionName, args) => pub.readContract({ address: oracle, abi, functionName, args });
let pass = 0, fail = 0;
const ok = (name, cond, detail = '') => { cond ? pass++ : fail++; console.log((cond ? 'PASS ' : 'FAIL ') + name + (detail ? ' (' + detail + ')' : '')); };
async function expectRevert(name, fn, want) {
try { await fn(); ok(name, false, 'did not revert'); }
catch (e) { const r = L.reasonOf(e); ok(name, !want || r.includes(want), 'reverted: ' + r); }
}
const clone = x => JSON.parse(JSON.stringify(x));
const flipByte = (hex, i) => { const b = L.hexToBytes(L.strip(hex)); b[i] ^= 0x01; return L.hex0x(b); };
console.log(L.ukTime(), 'UK oracle', oracle, 'verifier', await read('verifier'));
// ---- Part A: the receipt fixture --------------------------------------------------------------------------------
const f = JSON.parse(readFileSync(L.fixtures + '/dn3-receipt.json'));
const H = f.headers.map(h => L.hex0x(L.serializeHeader(h)));
const cp = '0x' + L.strip(f.checkpoint.hash);
{
let same = 0;
for (let i = 0; i < H.length; i += 8) {
const got = await Promise.all(H.slice(i, i + 8).map(h => read('headerHash', [h])));
got.forEach((g, k) => { if (L.strip(g) === f.headers[i + k].hash) same++; });
}
ok('A1 headerHash on chain matches all fixture headers', same === H.length, same + ' of ' + H.length);
const [carrier, merkle] = await read('checkHeaderPath', [H, cp]);
ok('A2 checkHeaderPath accepts the 62-header path to checkpoint ' + f.checkpoint.index, L.strip(carrier) === f.headers[0].hash && L.strip(merkle) === f.headers[0].hash_merkle_root);
console.log(' gas estimate for the view', await pub.estimateContractGas({ address: oracle, abi, functionName: 'checkHeaderPath', args: [H, cp], account: d.deployer }));
await expectRevert('A3 a header removed from the path', () => read('checkHeaderPath', [H.filter((_, i) => i !== 30), cp]), 'does not name the previous one');
const altered = clone(f.headers[5]); altered.nonce = String(BigInt(altered.nonce) + 1n);
await expectRevert('A4 a header with its nonce altered', () => read('checkHeaderPath', [H.map((h, i) => (i === 5 ? L.hex0x(L.serializeHeader(altered)) : h)), cp]), 'does not name the previous one');
await expectRevert('A5 the right path to the wrong checkpoint', () => read('checkHeaderPath', [H, flipByte(cp, 0)]), 'not the certified checkpoint');
await expectRevert('A6 the path reversed', () => read('checkHeaderPath', [H.slice().reverse(), cp]), 'does not name the previous one');
const ib = f.including_block; const sib = ib.merkle_siblings.map(s => '0x' + s);
const root = await read('merkleRoot', ['0x' + f.tx_hash, BigInt(ib.leaf_index), sib]);
ok('A7 merkleRoot reaches hash_merkle_root (leaf ' + ib.leaf_index + ' of ' + ib.leaf_count + ')', L.strip(root) === f.headers[0].hash_merkle_root);
const root2 = await read('merkleRoot', ['0x' + f.tx_hash, BigInt(ib.leaf_index), sib.map((s, i) => (i === 2 ? flipByte(s, 3) : s))]);
ok('A8 a sibling altered gives another root', L.strip(root2) !== f.headers[0].hash_merkle_root);
const root3 = await read('merkleRoot', ['0x' + f.tx_hash, BigInt(ib.leaf_index ^ 1), sib]);
ok('A9 the wrong leaf index gives another root', L.strip(root3) !== f.headers[0].hash_merkle_root);
await expectRevert('A10 a leaf index beyond the path', () => read('merkleRoot', ['0x' + f.tx_hash, 1n << 40n, sib]), 'beyond the path');
ok('A11 keccak of the raw transaction is the leaf', L.bytesToHex(L.keccak_256(L.hexToBytes(f.raw_tx_hex))) === f.tx_hash);
}
// ---- Part B: the synthetic vector -----------------------------------------------------------------------------
const S = L.buildSynthetic();
const wallet = L.walletClient(L.deployerAccount());
{
const c = await L.ensureCertificate(pub, wallet, d, S.certIndex, L.hex0x(L.hexToBytes(S.checkpoint)));
console.log(' stub certificate', S.certIndex, c.already ? 'already recorded' : 'recorded in ' + c.tx + ' block ' + c.block + ' gas ' + c.gasUsed);
const args = L.proofArgs(S);
const [number, postRoot, blockHash] = await read('verifyStateRoot', args);
ok('B1 verifyStateRoot accepts the synthetic proof', number === S.number && L.strip(postRoot) === L.bytesToHex(S.postRoot) && L.strip(blockHash) === L.bytesToHex(S.blockHash), 'block ' + number + ' post_root ' + postRoot.slice(0, 14));
console.log(' gas estimate for the view', await pub.estimateContractGas({ address: oracle, abi, functionName: 'verifyStateRoot', args, account: d.deployer }));
const withArgs = (patch) => { const a = args.slice(); patch(a); return read('verifyStateRoot', a); };
await expectRevert('B2 a certificate index the verifier does not hold', () => withArgs(a => { a[0] = 9999n; }), 'no certificate');
await expectRevert('B3 the middle header removed', () => withArgs(a => { a[1] = [a[1][0], a[1][2]]; }), 'does not name the previous one');
const carrierAltered = clone(S.headers[0]); carrierAltered.nonce = '1';
await expectRevert('B4 the carrier header with its nonce altered', () => withArgs(a => { a[1] = [L.hex0x(L.serializeHeader(carrierAltered)), a[1][1], a[1][2]]; }), 'does not name the previous one');
const topAltered = clone(S.headers[2]); topAltered.timestamp = String(BigInt(topAltered.timestamp) + 1n);
await expectRevert('B5 the checkpoint header altered', () => withArgs(a => { a[1] = [a[1][0], a[1][1], L.hex0x(L.serializeHeader(topAltered))]; }), 'not the certified checkpoint');
await expectRevert('B6 a merkle sibling altered', () => withArgs(a => { a[4] = [flipByte(a[4][0], 0), a[4][1]]; }), 'hash_merkle_root');
await expectRevert('B7 the wrong leaf index', () => withArgs(a => { a[3] = 1n; }), 'hash_merkle_root');
const cb = L.hexToBytes(L.strip(args[2]));
const postRootOff = (() => { const want = L.bytesToHex(S.postRoot); const h = L.bytesToHex(cb); return h.indexOf(want) / 2; })();
const tampered = cb.slice(); tampered[postRootOff + 5] ^= 0xff;
await expectRevert('B8 the record\'s post_root altered inside the coinbase (offset ' + postRootOff + ')', () => withArgs(a => { a[2] = L.hex0x(tampered); }), 'hash_merkle_root');
await expectRevert('B9 a record index beyond the IGNS section', () => withArgs(a => { a[5] = 1n; }), 'beyond the section');
const tamperedNumber = cb.slice(); tamperedNumber[postRootOff - 148 + 8 + 7] ^= 0x01;
await expectRevert('B10 the statement number altered', () => withArgs(a => { a[2] = L.hex0x(tamperedNumber); }), 'hash_merkle_root');
const r = await L.ensureStateRoot(pub, wallet, d, S);
console.log(' synthetic root for block', S.number, r.already ? 'already stored' : 'stored in ' + r.tx + ' block ' + r.block + ' gas ' + r.gasUsed + ' calldata ' + r.calldataBytes + ' bytes');
const [root, bh, carrier, certIndex] = await read('stateRoot', [S.number]);
ok('B11 stateRoot stored', L.strip(root) === L.bytesToHex(S.postRoot) && certIndex === S.certIndex && L.strip(carrier) === S.headers[0].hash);
const addr = L.hex0x(S.address); const ap = S.accountProof.map(L.hex0x);
const bal = await read('provenBalance', [S.number, addr, ap]);
ok('B12 provenBalance returns the trie balance', bal === S.account.balance, bal + ' wei');
console.log(' gas estimate for provenBalance', await pub.estimateContractGas({ address: oracle, abi, functionName: 'provenBalance', args: [S.number, addr, ap], account: d.deployer }));
const acct = await read('provenAccount', [S.number, addr, ap]);
ok('B13 provenAccount nonce, storage root and code hash', acct[0] === true && acct[1] === 7n && L.strip(acct[3]) === S.account.storageRoot && L.strip(acct[4]) === S.account.codeHash);
for (const s of S.slots) {
const v = await read('provenStorage', [S.number, addr, L.hex0x(s.slot), ap, s.proof.map(L.hex0x)]);
ok('B14 provenStorage slot ' + s.slot[31], L.strip(v).replace(/^0+/, '') === L.bytesToHex(s.value).replace(/^0+/, ''), v);
}
const absent = await read('provenStorage', [S.number, addr, L.hex0x(S.absent.slot), ap, S.absent.proof.map(L.hex0x)]);
ok('B15 provenStorage of an unset slot reads zero (exclusion proof)', BigInt(absent) === 0n);
await expectRevert('B16 a flipped byte in the account proof\'s last node', () => read('provenBalance', [S.number, addr, ap.map((n, i) => (i === ap.length - 1 ? flipByte(n, 10) : n))]), 'mpt');
await expectRevert('B17 a flipped byte in the root node', () => read('provenBalance', [S.number, addr, ap.map((n, i) => (i === 0 ? flipByte(n, 40) : n))]), 'not the root');
await expectRevert('B18 the wrong block number', () => read('provenBalance', [S.number + 1n, addr, ap]), 'no proven root');
await expectRevert('B19 the proof with its nodes reordered', () => read('provenBalance', [S.number, addr, ap.slice().reverse()]), 'not the root');
await expectRevert('B20 a storage proof with a flipped node', () => read('provenStorage', [S.number, addr, L.hex0x(S.slots[0].slot), ap, S.slots[0].proof.map((n, i) => (i === 1 ? flipByte(L.hex0x(n), 5) : L.hex0x(n)))]), 'mpt');
await expectRevert('B21 the account proof used as a storage proof', () => read('provenStorage', [S.number, addr, L.hex0x(S.slots[0].slot), ap, ap]), 'not the root');
const other = L.hex0x(L.seeded(99)(20));
await expectRevert('B22 another address under this account proof', () => read('provenBalance', [S.number, other, ap]), 'mpt');
}
// ---- Part C: the balance fixture ------------------------------------------------------------------------------
if (existsSync(L.fixtures + '/dn3-balance.json')) {
const V = L.vectorFromBalanceFixture(JSON.parse(readFileSync(L.fixtures + '/dn3-balance.json')));
const c = await L.ensureCertificate(pub, wallet, d, V.certIndex, L.hex0x(L.hexToBytes(V.checkpoint)), V.bitmap, V.signature);
console.log(' certificate', V.certIndex, c.already ? 'already recorded' : 'recorded in ' + c.tx);
const args = L.proofArgs(V);
const [number, postRoot] = await read('verifyStateRoot', args);
ok('C1 verifyStateRoot accepts the real Devnet 3 proof', number === V.number && L.strip(postRoot) === L.bytesToHex(V.postRoot), 'block ' + number);
const r = await L.ensureStateRoot(pub, wallet, d, V);
console.log(' real root for block', V.number, r.already ? 'already stored' : 'stored in ' + r.tx + ' gas ' + r.gasUsed);
const bal = await read('provenBalance', [V.number, L.hex0x(V.address), V.accountProof.map(L.hex0x)]);
ok('C2 provenBalance equals the node\'s eth_getProof balance', bal === V.balance, bal + ' wei');
await expectRevert('C3 a flipped byte in the real account proof', () => read('provenBalance', [V.number, L.hex0x(V.address), V.accountProof.map((n, i) => (i === V.accountProof.length - 1 ? flipByte(L.hex0x(n), 10) : L.hex0x(n)))]), 'mpt');
await expectRevert('C4 the real path with a header removed', () => read('verifyStateRoot', [args[0], args[1].filter((_, i) => i !== 1), ...args.slice(2)]), 'does not name');
} else {
console.log(' Part C skipped: fixtures/dn3-balance.json is not there yet');
}
console.log(L.ukTime(), 'UK', pass, 'passed,', fail, 'failed');
process.exit(fail ? 1 : 0);