noter: prove a stake from the hub's own state once the People chain takes proofs (#39)

The People chain is gaining `prove_stake` (pezkuwi-DKS #132): a stake is proven from the Asset
Hub's own state through a relay-anchored storage proof, instead of a noter reporting it and
re-confirming it every three days -- which does not fit at forty million.

When the People chain's metadata has `stakingScore.proveStake`, the bot:
- builds the proof (`prove.js`): the relay parent of People's best block, which People kept in
  `RelayRoots`; the relay proof of `Paras::Heads(1000)` there; the hub proof of the account's
  `Staking::Bonded`/`Ledger`/`Nominators`, `NominationPools::PoolMembers`, and for a member the
  pool and its bonded account;
- submits it when the hub's figure differs from the People chain's, or when what People holds
  was never proven; never re-confirms an unchanged, proven stake;
- watches the Asset Hub's finalized blocks for staking and pool events and proves the tracked
  accounts they touch as they happen; the periodic scan stays as a safety net.

Before the upgrade nothing changes: the noter path is used as today. The bot's own reading only
decides when to prove; what lands is what the proof says. `prove.test.mjs` pins the pool account
derivation against Zagros pool 10.
This commit is contained in:
SatoshiQaziMuhammed authored and GitHub committed 2026-10-29 04:21:56 +04:00
1 parent af7a0b4d6f
commit c5be7f62f8
5 files changed
+186 -20

No files matched your search

+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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"]
+91 -18
View File
@@ -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`);
+78
View File
@@ -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)),
};
}
+15
View File
@@ -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);
});