diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2db091..367e9f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: # Run inside the image, so the modules under test are the ones the image ships. - name: Unit tests if: matrix.bot == 'noter' - run: docker run --rm -v "$PWD/noter/test:/app/test:ro" bot node --test test/submit.test.mjs test/confirm.test.mjs + run: docker run --rm -v "$PWD/noter/test:/app/test:ro" bot node --test test/submit.test.mjs test/confirm.test.mjs test/prove.test.mjs - name: The bot loads and stops at its missing mnemonic env: diff --git a/noter/Dockerfile b/noter/Dockerfile index 6ea75e4..6acffc0 100644 --- a/noter/Dockerfile +++ b/noter/Dockerfile @@ -10,6 +10,6 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev --omit=optional --no-audit --no-fund -COPY bot.js submit.js confirm.js ./ +COPY bot.js submit.js confirm.js prove.js ./ CMD ["node", "bot.js"] diff --git a/noter/bot.js b/noter/bot.js index e88aa00..b78066b 100644 --- a/noter/bot.js +++ b/noter/bot.js @@ -24,6 +24,7 @@ import { cryptoWaitReady } from '@pezkuwi/util-crypto'; import fs from 'fs'; import { watchSubmission, isStale, failedBatchItems } from './submit.js'; import { needsReconfirmation, DEFAULT_CONFIRM_EVERY_BLOCKS } from './confirm.js'; +import { buildStakeProof } from './prove.js'; // ======================================== // CONFIGURATION @@ -360,6 +361,22 @@ function doSignAndSend(peopleApi, noterKeypair, tx, description, onInBlock) { ); } +/** + * The call for one update: a stake proof when there is one, otherwise a noter's report. + */ +function updateCall(peopleApi, { address, source, data, proof }) { + if (proof) { + return peopleApi.tx.stakingScore.proveStake(address, proof.relayBlock, proof.relayProof, proof.hubProof); + } + return peopleApi.tx.stakingScore.receiveStakingDetails( + address, + source, + data.stakedAmount.toString(), + data.nominationsCount, + data.unlockingChunksCount, + ); +} + /** * Submit receive_staking_details for one or more (address, source) pairs. * Batches multiple calls into a single utility.batchAll transaction. @@ -367,15 +384,7 @@ function doSignAndSend(peopleApi, noterKeypair, tx, description, onInBlock) { function submitStakingDetails(peopleApi, noterKeypair, updates) { if (updates.length === 0) return Promise.resolve(); - const calls = updates.map(({ address, source, data }) => - peopleApi.tx.stakingScore.receiveStakingDetails( - address, - source, - data.stakedAmount.toString(), - data.nominationsCount, - data.unlockingChunksCount, - ) - ); + const calls = updates.map((u) => updateCall(peopleApi, u)); const tx = calls.length === 1 ? calls[0] : peopleApi.tx.utility.batchAll(calls); const addresses = updates.map(u => u.address.slice(0, 8) + '...').join(', '); @@ -397,15 +406,7 @@ async function submitScanGroup(peopleApi, noterKeypair, updates) { await submitStakingDetails(peopleApi, noterKeypair, updates); return 0; } - const calls = updates.map(({ address, source, data }) => - peopleApi.tx.stakingScore.receiveStakingDetails( - address, - source, - data.stakedAmount.toString(), - data.nominationsCount, - data.unlockingChunksCount, - ) - ); + const calls = updates.map((u) => updateCall(peopleApi, u)); let failed = []; await queueSubmit( peopleApi, @@ -471,6 +472,9 @@ async function finalizeMaturedPending(peopleApi, noterKeypair) { * collects a whole group's updates and sends them together. Returns the updates. */ async function processAccount(relayApi, assetHubApi, peopleApi, noterKeypair, address, { submit = true } = {}) { + if (proofMode(peopleApi)) { + return processAccountByProof(relayApi, assetHubApi, peopleApi, noterKeypair, address, { submit }); + } // The chain refuses a noter's report about themselves (`NoterCannotAttestSelf`), so a // noter that has opted in itself failed on that account in every scan. Its stake has to // come from another noter; compared as account ids, since the address format may differ. @@ -562,6 +566,74 @@ async function processAccount(relayApi, assetHubApi, peopleApi, noterKeypair, ad return updates; } +/** + * Whether the People chain takes stake proofs (`prove_stake`). Once it does, a stake is proven + * from the hub's own state rather than reported, and nothing unchanged is ever re-confirmed: + * a change can be proven by anyone, free, so no proof means no change. + */ +function proofMode(peopleApi) { + return Boolean(peopleApi.tx.stakingScore?.proveStake); +} + +/** + * Prove `address`'s stake when it differs from what the People chain holds, or when what it + * holds was never proven (a noter's figure from before proofs). Unchanged and proven: nothing. + */ +async function processAccountByProof(relayApi, assetHubApi, peopleApi, noterKeypair, address, { submit = true } = {}) { + const ahStakingData = await getAssetHubStakingData(assetHubApi, address); + const poolData = await getAssetHubPoolData(assetHubApi, address); + if (ahStakingData.queryFailed || poolData.queryFailed) { + log('WARN', `Skipping ${address.slice(0, 8)}... — a staking query failed`); + return []; + } + const combinedData = { + stakedAmount: ahStakingData.stakedAmount + poolData.stakedAmount, + nominationsCount: ahStakingData.nominationsCount, + unlockingChunksCount: ahStakingData.unlockingChunksCount + poolData.unlockingChunksCount, + }; + const cached = await getCachedData(peopleApi, address, 'AssetHub'); + const proven = (await peopleApi.query.stakingScore.reportedByChain(address)).isSome; + // The bot's own reading is only a trigger; what lands is what the proof says. + if (!hasDataChanged(combinedData, cached) && (proven || combinedData.stakedAmount === 0n)) return []; + + const proof = await buildStakeProof({ relayApi, assetHubApi, peopleApi, address }); + if (!proof) return []; + log('INFO', `Proving AH stake for ${address.slice(0, 8)}...`, { + stakedHEZ: Number(combinedData.stakedAmount / UNITS), + relayBlock: proof.relayBlock, + }); + const updates = [{ address, source: 'AssetHub', proof }]; + if (submit) await submitStakingDetails(peopleApi, noterKeypair, updates); + return updates; +} + +/** + * Watch the Asset Hub for stake changes and prove the tracked accounts they touch, as they + * happen. The periodic scan stays behind it as a safety net. + */ +async function startHubListener(relayApi, assetHubApi, peopleApi, noterKeypair) { + if (!proofMode(peopleApi)) return; + log('INFO', 'Watching the Asset Hub for stake changes...'); + await assetHubApi.rpc.chain.subscribeFinalizedHeads(async (header) => { + try { + const events = await (await assetHubApi.at(header.hash)).query.system.events(); + const touched = new Set(); + for (const { event } of events) { + if (event.section !== 'staking' && event.section !== 'nominationPools') continue; + for (const value of event.data) { + if (value?.toRawType?.() === 'AccountId32') touched.add(value.toString()); + } + } + for (const address of touched) { + if ((await peopleApi.query.stakingScore.trackingOptIn(address)).isNone) continue; + await processAccount(relayApi, assetHubApi, peopleApi, noterKeypair, address); + } + } catch (err) { + log('ERROR', 'Hub event processing error', { error: err.message }); + } + }); +} + // ======================================== // FULL SCAN // ======================================== @@ -739,6 +811,7 @@ async function main() { // Start event listener for real-time processing await startEventListener(relayApi, assetHubApi, peopleApi, noterKeypair); + await startHubListener(relayApi, assetHubApi, peopleApi, noterKeypair); // Schedule periodic full scans log('INFO', `Periodic scan scheduled every ${SCAN_INTERVAL / 1000}s`); diff --git a/noter/prove.js b/noter/prove.js new file mode 100644 index 0000000..c008ea0 --- /dev/null +++ b/noter/prove.js @@ -0,0 +1,78 @@ +/** + * Build a stake proof for `prove_stake` on the People chain. + * + * The stake is on the Asset Hub, and the People chain does not take anyone's word for it -- + * not this bot's, not the hub's governance's. It takes a proof: a relay proof of the hub's head + * under a relay root the People chain kept, and a hub proof of the account's staking records + * under that head's state root. This bot only carries what the chains themselves wrote; it + * cannot make a proof say anything they did not. + */ +import { u8aConcat, u8aToHex, stringToU8a } from '@pezkuwi/util'; + +/** The account a nomination pool's bond sits in: `modl`, the pools pallet id, `Bonded`, the id. */ +export function poolBondedAccount(palletId, poolId) { + const id = new Uint8Array(4); + new DataView(id.buffer).setUint32(0, poolId, true); + const out = new Uint8Array(32); + out.set(u8aConcat(stringToU8a('modl'), palletId, new Uint8Array([0]), id)); + return out; +} + +/** + * The relay block to anchor on: the relay parent of the People chain's best block, which that + * block kept in `RelayRoots`. `null` if the People chain does not have it (not upgraded yet). + */ +export async function anchorRelayBlock(peopleApi) { + if (!peopleApi.query.stakingScore?.relayRoots) return null; + const vd = await peopleApi.query.teyrchainSystem.validationData(); + if (vd.isNone) return null; + const relayBlock = vd.unwrap().relayParentNumber.toNumber(); + const root = await peopleApi.query.stakingScore.relayRoots(relayBlock); + return root.isSome ? relayBlock : null; +} + +/** The hub keys a stake is read from, in the order the People chain reads them. */ +export async function stakeKeys(assetHubApi, ahHash, address) { + const at = await assetHubApi.at(ahHash); + const keys = [ + assetHubApi.query.staking.bonded.key(address), + assetHubApi.query.staking.nominators.key(address), + assetHubApi.query.nominationPools.poolMembers.key(address), + ]; + const bonded = await at.query.staking.bonded(address); + keys.push(assetHubApi.query.staking.ledger.key(bonded.isSome ? bonded.unwrap() : address)); + + const member = await at.query.nominationPools.poolMembers(address); + if (member.isSome) { + const poolId = member.unwrap().poolId.toNumber(); + const pool = poolBondedAccount(assetHubApi.consts.nominationPools.palletId.toU8a(), poolId); + keys.push(assetHubApi.query.nominationPools.bondedPools.key(poolId)); + keys.push(assetHubApi.query.staking.bonded.key(pool)); + const poolBonded = await at.query.staking.bonded(pool); + keys.push(assetHubApi.query.staking.ledger.key(poolBonded.isSome ? poolBonded.unwrap() : pool)); + } + return keys; +} + +/** + * Everything `prove_stake(who, relay_block, relay_proof, hub_proof)` needs for `address`, or + * `null` when the People chain cannot take a proof yet. + */ +export async function buildStakeProof({ relayApi, assetHubApi, peopleApi, address, hubParaId = 1000 }) { + const relayBlock = await anchorRelayBlock(peopleApi); + if (relayBlock === null) return null; + const relayHash = await relayApi.rpc.chain.getBlockHash(relayBlock); + const headKey = relayApi.query.paras.heads.key(hubParaId); + const relayProof = await relayApi.rpc.state.getReadProof([headKey], relayHash); + const head = (await relayApi.query.paras.heads.at(relayHash, hubParaId)).unwrap(); + const ahHash = assetHubApi.registry.createType('Header', head.toU8a(true)).hash; + const hubProof = await assetHubApi.rpc.state.getReadProof( + await stakeKeys(assetHubApi, ahHash, address), + ahHash, + ); + return { + relayBlock, + relayProof: relayProof.proof.map((n) => u8aToHex(n)), + hubProof: hubProof.proof.map((n) => u8aToHex(n)), + }; +} diff --git a/noter/test/prove.test.mjs b/noter/test/prove.test.mjs new file mode 100644 index 0000000..dfb1895 --- /dev/null +++ b/noter/test/prove.test.mjs @@ -0,0 +1,15 @@ +// node --test noter/test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { stringToU8a } from '@pezkuwi/util'; +import { poolBondedAccount } from '../prove.js'; + +// The People chain derives the same account (`hub_proof::pool_bonded_account`); pool 10 on +// Zagros, read off the live chain on 2026-10-28. +test('a pool bonded account is derived as the hub derives it', () => { + const zagrosPool10 = [ + 109, 111, 100, 108, 112, 121, 47, 110, 111, 112, 108, 115, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]; + assert.deepEqual(Array.from(poolBondedAccount(stringToU8a('py/nopls'), 10)), zagrosPool10); +});