mirror of
https://github.com/pezkuwichain/pezkuwi-subquery.git
synced 2026-11-02 23:21:06 +00:00
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:
1 parent
af7a0b4d6f
commit
c5be7f62f8
5 files changed
+186
-20
No files matched your search
@@ -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
@@ -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
@@ -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`);
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user