From d1c48028a4144efd8b37105dd5efc8d920d774af Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Mon, 24 Aug 2026 01:42:19 +0200 Subject: [PATCH 1/3] Tests: Precompile Tests Phase 3 (PLT-372) Extends the precompile suite to the non-wasm precompiles generated for v6.7 and to the methods added to the existing ones since phase 2, so the suite tracks the deployed interface rather than the phase 2 snapshot. New specs: auth (0x100D), authz (0x100E), evidence (0x100F), mint (0x1012), params (0x1013), slashing (0x1014), upgrade (0x1015). Existing specs grow to cover the scoped module authorizations and the query surface they shipped alongside: bank spendableBalances/totalSupply/ params/denomMetadata/denomsMetadata; staking grant/delegate/redelegate/ undelegate/revokeWithAuthorization plus the remaining delegation and validator queries; gov vote and proposal authorizations plus proposal/votes/deposits/tally; distribution withdrawValidatorCommission, the withdraw authorizations, and the validator/delegator queries. Framework: the new addresses join PRECOMPILE_ADDRESSES, cosmosUtils gains a cosmosRest LCD reader for the modules cosmjs does not wrap, and the bootstrap pool grows 48 -> 80 with the claim budget recorded. Wasm-gated flows (wasmd, pointer addCW*, solo), ibc, and the unregistered feegrant precompile stay out of scope. Co-Authored-By: Cursor --- integration_test/precompile_tests/README.md | 20 +- .../_start/00_bootstrap.spec.ts | 11 +- .../precompile_tests/precompiles/auth.spec.ts | 226 ++++++++++++ .../precompiles/authz.spec.ts | 243 ++++++++++++ .../precompile_tests/precompiles/bank.spec.ts | 140 ++++++- .../precompiles/distribution.spec.ts | 185 +++++++++- .../precompiles/evidence.spec.ts | 119 ++++++ .../precompile_tests/precompiles/gov.spec.ts | 240 +++++++++++- .../precompile_tests/precompiles/mint.spec.ts | 182 +++++++++ .../precompiles/params.spec.ts | 95 +++++ .../precompiles/slashing.spec.ts | 206 +++++++++++ .../precompiles/staking.spec.ts | 349 +++++++++++++++++- .../precompiles/upgrade.spec.ts | 134 +++++++ .../precompile_tests/utils/cosmosUtils.ts | 15 + .../precompile_tests/utils/precompileUtils.ts | 7 + 15 files changed, 2161 insertions(+), 11 deletions(-) create mode 100644 integration_test/precompile_tests/precompiles/auth.spec.ts create mode 100644 integration_test/precompile_tests/precompiles/authz.spec.ts create mode 100644 integration_test/precompile_tests/precompiles/evidence.spec.ts create mode 100644 integration_test/precompile_tests/precompiles/mint.spec.ts create mode 100644 integration_test/precompile_tests/precompiles/params.spec.ts create mode 100644 integration_test/precompile_tests/precompiles/slashing.spec.ts create mode 100644 integration_test/precompile_tests/precompiles/upgrade.spec.ts diff --git a/integration_test/precompile_tests/README.md b/integration_test/precompile_tests/README.md index 33eb4c7315..b9a1e6a695 100644 --- a/integration_test/precompile_tests/README.md +++ b/integration_test/precompile_tests/README.md @@ -45,7 +45,7 @@ balances, associations, …) over RPC/REST. For every precompile, the spec file ABIs are loaded from the repo's own `precompiles//abi.json` (the files the chain binary embeds), so specs can never drift from the deployed interface. -### Current coverage (phases 1–2, wasm-free) +### Current coverage (phases 1–3, wasm-free) | Precompile | Spec | |---|---| @@ -58,12 +58,20 @@ chain binary embeds), so specs can never drift from the deployed interface. | oracle (0x…1008) | `precompiles/oracle.spec.ts` (retirement assertion) | | pointerview (0x…100A) | `precompiles/pointerview.spec.ts` | | pointer (0x…100b) | `precompiles/pointer.spec.ts` (`addNativePointer`; CW methods are wasm-gated) | +| auth (0x…100D) | `precompiles/auth.spec.ts` | +| authz (0x…100E) | `precompiles/authz.spec.ts` | +| evidence (0x…100F) | `precompiles/evidence.spec.ts` | | p256 (0x…1011) | `precompiles/p256.spec.ts` | - -Planned next: wasm-gated flows (wasmd, pointer `addCW*`, solo CW claims) in a -separate `wasm/` spec dir behind a live `isWasmEnabled()` check, since wasm -deployments are blocked on production chains. The ibc precompile is out of -scope. +| mint (0x…1012) | `precompiles/mint.spec.ts` | +| params (0x…1013) | `precompiles/params.spec.ts` | +| slashing (0x…1014) | `precompiles/slashing.spec.ts` | +| upgrade (0x…1015) | `precompiles/upgrade.spec.ts` | + +Phase 3 (PLT-372) covers the v6.7 non-wasm precompiles and the post-phase-2 +methods on bank/staking/gov/distribution (query surface + scoped authz). +Wasm-gated flows (wasmd, pointer `addCW*`, solo CW claims) stay in a later +`wasm/` spec dir behind a live `isWasmEnabled()` check. The ibc precompile +and the unregistered feegrant precompile (0x…1010) are out of scope. Hard-won facts encoded in these specs (read before writing a new one): diff --git a/integration_test/precompile_tests/_start/00_bootstrap.spec.ts b/integration_test/precompile_tests/_start/00_bootstrap.spec.ts index fad842d048..71b6df7f51 100644 --- a/integration_test/precompile_tests/_start/00_bootstrap.spec.ts +++ b/integration_test/precompile_tests/_start/00_bootstrap.spec.ts @@ -20,7 +20,16 @@ import { fundAdminOnSei, generateMnemonic, seiAddressFromMnemonic } from '../uti import { writeRuntimeState, RuntimeState } from '../utils/testUtils'; import { ADDRESS } from '../utils/format'; -const POOL_SIZE = 48; +/** + * Size of the single-use account pool. Demand is the sum of every `claimPool` + * count across `precompiles/`, which phase 3 leaves at 29: + * + * rg -o 'claimPool\(runtime, provider, (\d+)' -r '$1' precompiles | paste -sd+ | bc + * + * The headroom is deliberate — exhaustion throws from `claimPool` partway + * through a run, so it is cheaper to over-fund than to re-bootstrap. + */ +const POOL_SIZE = 80; const POOL_FUND_WEI = ethers.parseEther('5'); describe('precompile_tests bootstrap', function () { diff --git a/integration_test/precompile_tests/precompiles/auth.spec.ts b/integration_test/precompile_tests/precompiles/auth.spec.ts new file mode 100644 index 0000000000..8bf1cf6834 --- /dev/null +++ b/integration_test/precompile_tests/precompiles/auth.spec.ts @@ -0,0 +1,226 @@ +/** + * auth precompile (0x…100D) — account queries against a live Sei chain. + * + * All four methods are views. The parity oracle is the auth module's LCD + * (`/cosmos/auth/v1beta1/...`); the Go executor has no delegatecall guard + * (unlike staking/gov), so CALL, STATICCALL and DELEGATECALL all succeed. + * + * `nextAccountNumber` queries a keeper method that increments the persisted + * counter, which the executor neutralises by branching a CacheContext. That + * discard is not observable from here — an `eth_call` commits nothing either + * way — so it stays pinned by auth_test.go:TestNextAccountNumber, and this + * spec only asserts the value the chain actually reports. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { EvmAccount } from '../utils/evmUtils'; +import { cosmosRest } from '../utils/cosmosUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, +} from '../utils/precompileUtils'; +import { readRuntimeState, RuntimeState } from '../utils/testUtils'; + +const empty = new Uint8Array(); + +const uint64 = (v: string | number | bigint | undefined): bigint => BigInt(v ?? 0); + +/** + * A row from /cosmos/auth/v1beta1/accounts, which is a protojson `Any`: a plain + * account carries its fields at the top level, while module and vesting + * accounts nest a base account one or two levels down. + */ +interface LcdAccount { + address?: string; + account_number?: string; + sequence?: string; + base_account?: LcdAccount; + base_vesting_account?: LcdAccount; +} + +/** + * The account fields for an LCD row, unwrapping the nested base account that + * module and vesting accounts wrap theirs in. The precompile reads through + * AccountI, so it reports these for every account type and the comparison has + * to reach them too. + */ +function lcdBaseAccount(row: LcdAccount): LcdAccount { + let current = row; + while (current.address === undefined) { + const next = current.base_account ?? current.base_vesting_account; + if (next === undefined) return current; + current = next; + } + return current; +} + +interface LcdAuthParams { + max_memo_characters: string; + tx_sig_limit: string; + tx_size_cost_per_byte: string; + sig_verify_cost_ed25519: string; + sig_verify_cost_secp256k1: string; + disable_seqno_check?: boolean; +} + +describe('auth precompile (0x100D)', function () { + this.timeout(120 * 1000); + + const provider = seiRpc(); + const authIface = precompileInterface('auth'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let auth: ethers.Contract; + let caller: ethers.Contract; + + before(() => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + auth = precompileContract('auth', admin.wallet); + caller = callerContract(runtime, admin.wallet); + }); + + describe('happy path & state parity', () => { + it('account(admin) matches LCD address, account_number and sequence', async () => { + const sei = admin.seiAddress(); + const [via, lcd] = await Promise.all([ + auth.account(admin.address), + cosmosRest<{ account: LcdAccount }>(`/cosmos/auth/v1beta1/accounts/${sei}`), + ]); + const expected = lcdBaseAccount(lcd.account); + expect(via.accountAddress).to.equal(sei); + expect(via.accountAddress).to.equal(expected.address); + expect(via.accountNumber).to.equal(uint64(expected.account_number)); + expect(via.sequence).to.equal(uint64(expected.sequence)); + }); + + it('accounts(empty) returns the same first page as the auth module', async () => { + const [page, lcd] = await Promise.all([ + auth.accounts(empty), + cosmosRest<{ accounts: LcdAccount[] }>('/cosmos/auth/v1beta1/accounts'), + ]); + expect(page.accounts.length, 'first page of auth accounts').to.be.greaterThan(0); + // Both read the first page with the module's default page size, so the + // rows must line up in order, not merely in count. + expect( + [...page.accounts].map((a: { accountAddress: string }) => a.accountAddress), + 'accounts() first page vs LCD', + ).to.deep.equal(lcd.accounts.map(a => lcdBaseAccount(a).address)); + }); + + it('params() matches LCD /cosmos/auth/v1beta1/params', async () => { + const [via, lcd] = await Promise.all([ + auth.params(), + cosmosRest<{ params: LcdAuthParams }>('/cosmos/auth/v1beta1/params'), + ]); + const p = lcd.params; + expect(via.maxMemoCharacters).to.equal(uint64(p.max_memo_characters)); + expect(via.txSigLimit).to.equal(uint64(p.tx_sig_limit)); + expect(via.txSizeCostPerByte).to.equal(uint64(p.tx_size_cost_per_byte)); + expect(via.sigVerifyCostEd25519).to.equal(uint64(p.sig_verify_cost_ed25519)); + expect(via.sigVerifyCostSecp256k1).to.equal(uint64(p.sig_verify_cost_secp256k1)); + expect(via.disableSeqnoCheck).to.equal(Boolean(p.disable_seqno_check)); + }); + + it('nextAccountNumber() matches the auth module and is past the admin account', async () => { + const [count, lcd, account] = await Promise.all([ + auth.nextAccountNumber() as Promise, + // QueryNextAccountNumberResponse names the field `count`, not + // `next_account_number`. + cosmosRest<{ count: string }>('/cosmos/auth/v1beta1/nextaccountnumber'), + auth.account(admin.address), + ]); + expect(count, 'nextAccountNumber vs LCD').to.equal(uint64(lcd.count)); + expect(count > account.accountNumber, 'next number is past every issued number').to.equal( + true, + ); + }); + }); + + describe('error handling', () => { + it('account of a never-associated random EVM address reverts (eth_call)', async () => { + await expectExecutionReverted( + auth.account(EvmAccount.random(provider).address), + 'auth.account of a never-associated EVM address', + ); + }); + + it('view methods reject value (eth_call with value 0x1)', async () => { + const cases: Array<[string, unknown[]]> = [ + ['account', [admin.address]], + ['accounts', [empty]], + ['params', []], + ['nextAccountNumber', []], + ]; + for (const [method, args] of cases) { + const envelope = await rawSei('eth_call', [ + { + from: admin.address, + to: PRECOMPILE_ADDRESSES.auth, + data: authIface.encodeFunctionData(method, args), + value: '0x1', + }, + 'latest', + ]); + expect(envelope.error, `${method} with value must revert`).to.not.equal(undefined); + expect(envelope.error!.message).to.match(/execution reverted|revert/i); + } + }); + }); + + describe('dispatch semantics (via PrecompileCaller)', () => { + it('account and params respond through a real CALL from contract bytecode', async () => { + const accountData = authIface.encodeFunctionData('account', [admin.address]); + const paramsData = authIface.encodeFunctionData('params', []); + const [accountRet, paramsRet, directAccount, directParams] = await Promise.all([ + caller.callTarget.staticCall(PRECOMPILE_ADDRESSES.auth, accountData) as Promise, + caller.callTarget.staticCall(PRECOMPILE_ADDRESSES.auth, paramsData) as Promise, + auth.account(admin.address), + auth.params(), + ]); + const [decodedAccount] = authIface.decodeFunctionResult('account', accountRet); + const [decodedParams] = authIface.decodeFunctionResult('params', paramsRet); + expect(decodedAccount.accountAddress).to.equal(directAccount.accountAddress); + expect(decodedAccount.accountNumber).to.equal(directAccount.accountNumber); + expect(decodedAccount.sequence).to.equal(directAccount.sequence); + expect(decodedParams.maxMemoCharacters).to.equal(directParams.maxMemoCharacters); + expect(decodedParams.txSigLimit).to.equal(directParams.txSigLimit); + }); + + it('account and params respond under STATICCALL', async () => { + const accountData = authIface.encodeFunctionData('account', [admin.address]); + const paramsData = authIface.encodeFunctionData('params', []); + const [accountRet, paramsRet, directAccount, directParams] = await Promise.all([ + caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.auth, + accountData, + ) as Promise, + caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.auth, + paramsData, + ) as Promise, + auth.account(admin.address), + auth.params(), + ]); + const [decodedAccount] = authIface.decodeFunctionResult('account', accountRet); + const [decodedParams] = authIface.decodeFunctionResult('params', paramsRet); + expect(decodedAccount.accountAddress).to.equal(directAccount.accountAddress); + expect(decodedParams.maxMemoCharacters).to.equal(directParams.maxMemoCharacters); + }); + + it('responds under DELEGATECALL (auth has no delegatecall guard)', async () => { + const data = authIface.encodeFunctionData('account', [admin.address]); + const ret: string = await caller.delegatecallTarget.staticCall( + PRECOMPILE_ADDRESSES.auth, + data, + ); + const [decoded] = authIface.decodeFunctionResult('account', ret); + expect(decoded.accountAddress).to.equal(admin.seiAddress()); + }); + }); +}); diff --git a/integration_test/precompile_tests/precompiles/authz.spec.ts b/integration_test/precompile_tests/precompiles/authz.spec.ts new file mode 100644 index 0000000000..d43314ffb3 --- /dev/null +++ b/integration_test/precompile_tests/precompiles/authz.spec.ts @@ -0,0 +1,243 @@ +/** + * authz precompile (0x…100E) — grant queries against a live Sei chain. + * + * All three methods are views. The precompile cannot create grants, so the + * non-empty fixture is a staking.grantStakingAuthorization (three + * StakeAuthorization grants: delegate / redelegate / undelegate). Empty-pair + * checks use a separate associated pair that never grants. Parity oracle is + * LCD GET /cosmos/authz/v1beta1/grants?granter=&grantee=. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, waitUntil } from '../utils/chainUtils'; +import { EvmAccount, associateViaTx } from '../utils/evmUtils'; +import { bondedValidators, cosmosRest } from '../utils/cosmosUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, +} from '../utils/precompileUtils'; +import { readRuntimeState, claimPool, RuntimeState } from '../utils/testUtils'; + +const emptyPage = new Uint8Array(); +const MAX_TOKENS = 1_000_000n; + +interface LcdGrant { + authorization?: { '@type'?: string }; + expiration?: string; +} + +interface LcdGrants { + grants?: LcdGrant[] | null; +} + +const authorizationJson = (authorization: string): string => ethers.toUtf8String(authorization); + +const lcdExpirationUnix = (expiration: string): bigint => { + const trimmed = expiration.replace(/\.\d+(Z|[+-]\d{2}:\d{2})$/, '$1'); + return BigInt(Math.floor(Date.parse(trimmed) / 1000)); +}; + +describe('authz precompile (0x100E)', function () { + this.timeout(180 * 1000); + + const provider = seiRpc(); + const authzIface = precompileInterface('authz'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let authz: ethers.Contract; + let staking: ethers.Contract; + let caller: ethers.Contract; + let granter: EvmAccount; + let grantee: EvmAccount; + let validator: string; + let expiration: bigint; + + before(async () => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + authz = precompileContract('authz', admin.wallet); + staking = precompileContract('staking', admin.wallet); + caller = callerContract(runtime, admin.wallet); + + [granter, grantee] = claimPool(runtime, provider, 2, 'authz:grant'); + await associateViaTx(granter); + await associateViaTx(grantee); + + const validators = await bondedValidators(); + expect(validators.length, 'devnet must have a bonded validator').to.be.greaterThan(0); + validator = validators[0]; + + expiration = BigInt(Math.floor(Date.now() / 1000) + 86_400); + const tx = await (staking.connect(granter.wallet) as ethers.Contract).grantStakingAuthorization( + grantee.address, + [validator], + MAX_TOKENS, + expiration, + { gasLimit: 1_000_000 }, + ); + expect((await tx.wait())!.status, 'grantStakingAuthorization tx must succeed').to.equal(1); + + await waitUntil( + async () => { + const resp = await authz.grants(granter.address, grantee.address, '', emptyPage); + return resp.grants.length > 0 ? true : null; + }, + { timeoutMs: 30_000, label: 'authz grants after staking grant' }, + ); + }); + + describe('empty grants for a pair with no grant', () => { + let unusedGranter: EvmAccount; + let unusedGrantee: EvmAccount; + + before(async () => { + [unusedGranter, unusedGrantee] = claimPool(runtime, provider, 2, 'authz:empty'); + await associateViaTx(unusedGranter); + await associateViaTx(unusedGrantee); + }); + + it('grants returns an empty list', async () => { + const resp = await authz.grants( + unusedGranter.address, + unusedGrantee.address, + '', + emptyPage, + ); + expect(resp.grants.length).to.equal(0); + }); + + it('granterGrants and granteeGrants do not include the pair', async () => { + const [fromGranter, fromGrantee] = await Promise.all([ + authz.granterGrants(unusedGranter.address, emptyPage), + authz.granteeGrants(unusedGrantee.address, emptyPage), + ]); + const pair = (g: { granter: string; grantee: string }) => + g.granter === unusedGranter.seiAddress() && g.grantee === unusedGrantee.seiAddress(); + expect([...fromGranter.grants].some(pair)).to.equal(false); + expect([...fromGrantee.grants].some(pair)).to.equal(false); + }); + }); + + describe('non-empty after staking grant', () => { + it('grants(granter, grantee, "", emptyPage) is non-empty', async () => { + const resp = await authz.grants(granter.address, grantee.address, '', emptyPage); + expect(resp.grants.length, 'staking grant creates StakeAuthorization rows').to.be.greaterThan( + 0, + ); + for (const grant of resp.grants) { + const json = authorizationJson(grant.authorization); + expect(json).to.include('@type'); + expect(json).to.include('StakeAuthorization'); + expect(grant.expiration).to.equal(expiration); + } + }); + + it('granterGrants includes the granter/grantee pair', async () => { + const resp = await authz.granterGrants(granter.address, emptyPage); + const pair = [...resp.grants].filter( + (g: { granter: string; grantee: string }) => + g.granter === granter.seiAddress() && g.grantee === grantee.seiAddress(), + ); + expect(pair.length, 'granterGrants must include the staking grant pair').to.be.greaterThan( + 0, + ); + for (const grant of pair) { + expect(authorizationJson(grant.authorization)).to.include('StakeAuthorization'); + expect(grant.expiration).to.equal(expiration); + } + }); + + it('granteeGrants includes the granter/grantee pair', async () => { + const resp = await authz.granteeGrants(grantee.address, emptyPage); + const pair = [...resp.grants].filter( + (g: { granter: string; grantee: string }) => + g.granter === granter.seiAddress() && g.grantee === grantee.seiAddress(), + ); + expect(pair.length, 'granteeGrants must include the staking grant pair').to.be.greaterThan( + 0, + ); + for (const grant of pair) { + expect(authorizationJson(grant.authorization)).to.include('StakeAuthorization'); + expect(grant.expiration).to.equal(expiration); + } + }); + }); + + describe('LCD parity / unassociated granter-grantee / STATICCALL', () => { + it('LCD /cosmos/authz/v1beta1/grants matches grants() count and expiration', async () => { + const path = + `/cosmos/authz/v1beta1/grants?granter=${encodeURIComponent(granter.seiAddress())}` + + `&grantee=${encodeURIComponent(grantee.seiAddress())}`; + const [viaPrecompile, lcd] = await Promise.all([ + authz.grants(granter.address, grantee.address, '', emptyPage), + cosmosRest(path), + ]); + const lcdGrants = lcd.grants ?? []; + expect(lcdGrants.length, 'LCD grant count').to.equal(viaPrecompile.grants.length); + expect(lcdGrants.length).to.be.greaterThan(0); + + const precompileTypes = [...viaPrecompile.grants] + .map((g: { authorization: string }) => JSON.parse(authorizationJson(g.authorization))['@type']) + .sort(); + const lcdTypes = lcdGrants.map(g => g.authorization?.['@type']).sort(); + expect(precompileTypes).to.deep.equal(lcdTypes); + + // The two lists are not guaranteed to be in the same order, and all + // three grants in this fixture share one expiration, so an + // index-by-index comparison would pass even if the orders diverged. + // Compare them as sorted sets instead. + const precompileExpirations = [...viaPrecompile.grants] + .map((g: { expiration: bigint }) => g.expiration) + .sort(); + const lcdExpirations = lcdGrants + .map(g => lcdExpirationUnix(g.expiration ?? '')) + .sort(); + expect(precompileExpirations).to.deep.equal(lcdExpirations); + }); + + it('grants reverts for an unassociated granter or grantee', async () => { + const unassociated = EvmAccount.random(provider); + await expectExecutionReverted( + authz.grants(unassociated.address, grantee.address, '', emptyPage), + 'authz.grants with an unassociated granter', + ); + await expectExecutionReverted( + authz.grants(granter.address, unassociated.address, '', emptyPage), + 'authz.grants with an unassociated grantee', + ); + }); + + it('granterGrants and granteeGrants revert for an unassociated address', async () => { + const unassociated = EvmAccount.random(provider); + await expectExecutionReverted( + authz.granterGrants(unassociated.address, emptyPage), + 'authz.granterGrants with an unassociated granter', + ); + await expectExecutionReverted( + authz.granteeGrants(unassociated.address, emptyPage), + 'authz.granteeGrants with an unassociated grantee', + ); + }); + + it('grants responds under STATICCALL', async () => { + const data = authzIface.encodeFunctionData('grants', [ + granter.address, + grantee.address, + '', + emptyPage, + ]); + const ret: string = await caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.authz, + data, + ); + const [decoded] = authzIface.decodeFunctionResult('grants', ret); + const direct = await authz.grants(granter.address, grantee.address, '', emptyPage); + expect(decoded.grants.length).to.equal(direct.grants.length); + expect(decoded.grants.length).to.be.greaterThan(0); + }); + }); +}); diff --git a/integration_test/precompile_tests/precompiles/bank.spec.ts b/integration_test/precompile_tests/precompiles/bank.spec.ts index dc70f7d8e2..a8caa3dfbd 100644 --- a/integration_test/precompile_tests/precompiles/bank.spec.ts +++ b/integration_test/precompile_tests/precompiles/bank.spec.ts @@ -11,7 +11,13 @@ import { ethers } from 'ethers'; import { expect } from 'chai'; import { seiRpc, rawSei, waitUntil } from '../utils/chainUtils'; import { EvmAccount, associateViaTx } from '../utils/evmUtils'; -import { bankBalance, bankSupplyOf, generateSeiAddress } from '../utils/cosmosUtils'; +import { + bankBalance, + bankSupplyOf, + generateSeiAddress, + createTokenfactoryDenom, + cosmosRest, +} from '../utils/cosmosUtils'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -33,6 +39,8 @@ describe('bank precompile (0x1001)', function () { let admin: EvmAccount; let bank: ethers.Contract; let caller: ethers.Contract; + /** Tokenfactory denom minted by the denomMetadata test, reused by denomsMetadata. */ + let metadataDenom = ''; before(() => { runtime = readRuntimeState(); @@ -142,6 +150,91 @@ describe('bank precompile (0x1001)', function () { senderBefore - sendWei - gasCost, ); }); + + it('spendableBalances(admin, empty) includes usei matching balance', async () => { + const [result, useiBalance] = await Promise.all([ + bank.spendableBalances(admin.address, new Uint8Array()) as Promise< + [Array<{ amount: bigint; denom: string }>, Uint8Array] + >, + bank.balance(admin.address, 'usei') as Promise, + ]); + const [balances] = result; + const usei = balances.find(c => c.denom === 'usei'); + expect(usei, 'spendableBalances must contain a usei entry').to.not.equal(undefined); + expect(usei!.amount).to.equal(useiBalance); + }); + + it('totalSupply(empty) includes usei consistent with supply', async () => { + // Supply can grow between reads (block rewards / mint); bracket the + // paginated totalSupply read with two supply(usei) reads. + const before: bigint = await bank.supply('usei'); + const [coins] = (await bank.totalSupply(new Uint8Array())) as [ + Array<{ amount: bigint; denom: string }>, + Uint8Array, + ]; + const after: bigint = await bank.supply('usei'); + const usei = coins.find(c => c.denom === 'usei'); + expect(usei, 'totalSupply must contain a usei entry').to.not.equal(undefined); + expect(usei!.amount >= before, `totalSupply ${usei!.amount} >= supply-before ${before}`).to.equal( + true, + ); + expect(usei!.amount <= after, `totalSupply ${usei!.amount} <= supply-after ${after}`).to.equal( + true, + ); + }); + + it('params().defaultSendEnabled matches the bank module', async () => { + const [bankParams, lcd] = await Promise.all([ + bank.params(), + cosmosRest<{ params?: { default_send_enabled?: boolean } }>( + '/cosmos/bank/v1beta1/params', + ), + ]); + // The LCD marshals with OrigName and EmitDefaults, so the flag is + // present and snake_case even when false. + const lcdFlag = lcd.params?.default_send_enabled; + expect(bankParams.defaultSendEnabled, 'precompile vs LCD').to.equal(lcdFlag); + // Sends are on for this devnet; the sendNative test above depends on it. + expect(bankParams.defaultSendEnabled).to.equal(true); + }); + + it('denomMetadata returns tokenfactory metadata for a created denom', async () => { + // Fresh devnets may not register usei metadata (see name/symbol above); + // tokenfactory always writes name/symbol/base/display = the full denom + // and a single exponent-0 unit, which is the fixture this query needs. + metadataDenom = await createTokenfactoryDenom( + runtime.funded.adminMnemonic, + `bnk${Date.now().toString(36)}`, + ); + const meta = await bank.denomMetadata(metadataDenom); + expect(meta.base).to.equal(metadataDenom); + expect(meta.name).to.equal(metadataDenom); + expect(meta.symbol).to.equal(metadataDenom); + expect(meta.display).to.equal(metadataDenom); + expect(meta.denomUnits.length, 'tokenfactory writes one denom unit').to.be.greaterThan(0); + expect(meta.denomUnits[0].denom).to.equal(metadataDenom); + expect(Number(meta.denomUnits[0].exponent)).to.equal(0); + }); + + it('denomsMetadata pages until it reaches the denom denomMetadata just returned', async () => { + expect(metadataDenom, 'the denomMetadata test must run first').to.not.equal(''); + // Walking the pages is the point: it pins that nextKey round-trips, + // which asserting "returns an array" would pass without doing. + let pageKey: Uint8Array = new Uint8Array(); + let found = false; + for (let page = 0; page < 50 && !found; page++) { + const [metadatas, nextKey] = (await bank.denomsMetadata(pageKey)) as [ + Array<{ base: string }>, + string, + ]; + expect(metadatas, 'denomsMetadata page').to.be.an('array'); + found = metadatas.some(m => m.base === metadataDenom); + const next = ethers.getBytes(nextKey); + if (next.length === 0) break; + pageKey = next; + } + expect(found, `denomsMetadata must list ${metadataDenom}`).to.equal(true); + }); }); describe('error handling', () => { @@ -213,6 +306,20 @@ describe('bank precompile (0x1001)', function () { expect(receipt.status, 'tx must fail').to.equal(0); await expectTraceRevertedNotPanicked(receipt.hash); }); + + it('params rejects value (non-payable)', async () => { + const envelope = await rawSei('eth_call', [ + { + from: admin.address, + to: PRECOMPILE_ADDRESSES.bank, + data: bankIface.encodeFunctionData('params', []), + value: '0x1', + }, + 'latest', + ]); + expect(envelope.error, 'params with value must revert').to.not.equal(undefined); + expect(envelope.error!.message).to.match(/execution reverted|revert/i); + }); }); describe('dispatch semantics (via PrecompileCaller)', () => { @@ -258,5 +365,36 @@ describe('bank precompile (0x1001)', function () { 'bank.sendNative via DELEGATECALL', ); }); + + it('params and spendableBalances are callable via STATICCALL', async () => { + const paramsData = bankIface.encodeFunctionData('params', []); + const spendableData = bankIface.encodeFunctionData('spendableBalances', [ + admin.address, + new Uint8Array(), + ]); + const [paramsRet, spendableRet, directParams, directSpendable] = await Promise.all([ + caller.staticcallTarget.staticCall(PRECOMPILE_ADDRESSES.bank, paramsData) as Promise, + caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.bank, + spendableData, + ) as Promise, + bank.params(), + bank.spendableBalances(admin.address, new Uint8Array()) as Promise< + [Array<{ amount: bigint; denom: string }>, Uint8Array] + >, + ]); + + const [decodedParams] = bankIface.decodeFunctionResult('params', paramsRet); + expect(decodedParams.defaultSendEnabled).to.equal(directParams.defaultSendEnabled); + + const [decodedBalances] = bankIface.decodeFunctionResult('spendableBalances', spendableRet); + const viaStatic = (decodedBalances as Array<{ amount: bigint; denom: string }>).find( + c => c.denom === 'usei', + ); + const viaDirect = directSpendable[0].find(c => c.denom === 'usei'); + expect(viaStatic, 'STATICCALL spendableBalances must contain usei').to.not.equal(undefined); + expect(viaDirect, 'direct spendableBalances must contain usei').to.not.equal(undefined); + expect(viaStatic!.amount).to.equal(viaDirect!.amount); + }); }); }); diff --git a/integration_test/precompile_tests/precompiles/distribution.spec.ts b/integration_test/precompile_tests/precompiles/distribution.spec.ts index e20fa2b371..423c20aba0 100644 --- a/integration_test/precompile_tests/precompiles/distribution.spec.ts +++ b/integration_test/precompile_tests/precompiles/distribution.spec.ts @@ -14,7 +14,7 @@ import { ethers } from 'ethers'; import { expect } from 'chai'; import { seiRpc, waitUntil } from '../utils/chainUtils'; import { EvmAccount, associateViaTx } from '../utils/evmUtils'; -import { bondedValidators, cosmosQuery, bankBalance } from '../utils/cosmosUtils'; +import { bondedValidators, cosmosQuery, cosmosRest, bankBalance } from '../utils/cosmosUtils'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -25,6 +25,17 @@ import { } from '../utils/precompileUtils'; import { readRuntimeState, claimPool, RuntimeState } from '../utils/testUtils'; +type DistrCoin = { amount: bigint; decimals: bigint; denom: string }; + +function expectCoinArray(coins: readonly DistrCoin[], label: string): void { + expect(coins, `${label} is an array`).to.be.an('array'); + for (const coin of coins) { + expect(coin.denom, `${label} denom`).to.be.a('string').and.not.equal(''); + expect(typeof coin.amount, `${label} amount`).to.equal('bigint'); + expect(coin.decimals, `${label} decimals`).to.equal(18n); + } +} + describe('distribution precompile (0x1007)', function () { this.timeout(180 * 1000); @@ -187,6 +198,162 @@ describe('distribution precompile (0x1007)', function () { }); }); + describe('query methods', () => { + it('params matches LCD /cosmos/distribution/v1beta1/params', async () => { + const [viaPrecompile, lcd] = await Promise.all([ + distribution.params(), + cosmosRest<{ + params: { + community_tax: string; + base_proposer_reward: string; + bonus_proposer_reward: string; + withdraw_addr_enabled: boolean; + }; + }>('/cosmos/distribution/v1beta1/params'), + ]); + expect(viaPrecompile.communityTax).to.equal(lcd.params.community_tax); + expect(viaPrecompile.baseProposerReward).to.equal(lcd.params.base_proposer_reward); + expect(viaPrecompile.bonusProposerReward).to.equal(lcd.params.bonus_proposer_reward); + expect(viaPrecompile.withdrawAddrEnabled).to.equal(lcd.params.withdraw_addr_enabled); + }); + + it('delegatorValidators includes the fixture validator', async () => { + const validators: string[] = await distribution.delegatorValidators(delegator.address); + expect(validators).to.include(validator); + }); + + it('delegatorWithdrawAddress matches LCD rather than assuming the default', async () => { + const sei = delegator.seiAddress(); + const [viaPrecompile, lcd] = await Promise.all([ + distribution.delegatorWithdrawAddress(delegator.address) as Promise, + cosmosRest<{ withdraw_address: string }>( + `/cosmos/distribution/v1beta1/delegators/${sei}/withdraw_address`, + ), + ]); + expect(viaPrecompile).to.equal(lcd.withdraw_address); + }); + + it('delegationRewards, validatorOutstandingRewards and validatorCommission return coin arrays', async () => { + const [delegation, outstanding, commission] = await Promise.all([ + distribution.delegationRewards(delegator.address, validator), + distribution.validatorOutstandingRewards(validator), + distribution.validatorCommission(validator), + ]); + expectCoinArray(delegation, 'delegationRewards'); + expectCoinArray(outstanding, 'validatorOutstandingRewards'); + expectCoinArray(commission, 'validatorCommission'); + }); + + it('validatorSlashes matches the distribution module over the same height range', async () => { + const endingHeight = 1_000_000_000; + const [result, lcd] = await Promise.all([ + distribution.validatorSlashes(validator, 1, endingHeight, new Uint8Array()), + cosmosRest<{ slashes?: Array<{ validator_period: string; fraction: string }> }>( + `/cosmos/distribution/v1beta1/validators/${validator}/slashes` + + `?starting_height=1&ending_height=${endingHeight}`, + ), + ]); + const slashes = (result.slashes ?? result[0]) as Array<{ + validatorPeriod: bigint; + fraction: string; + }>; + const lcdSlashes = lcd.slashes ?? []; + expect(slashes.length, 'validatorSlashes count vs LCD').to.equal(lcdSlashes.length); + for (let i = 0; i < lcdSlashes.length; i++) { + expect(slashes[i].validatorPeriod).to.equal(BigInt(lcdSlashes[i].validator_period)); + expect(slashes[i].fraction).to.equal(lcdSlashes[i].fraction); + } + }); + + it('communityPool returns coins', async () => { + expectCoinArray(await distribution.communityPool(), 'communityPool'); + }); + }); + + describe('authz', () => { + let grantee: EvmAccount; + before(async () => { + [grantee] = claimPool(runtime, provider, 1, 'distribution:authz-grantee'); + await associateViaTx(grantee); + }); + + it('withdrawValidatorCommission from an account that owns no validator reverts', async () => { + await expectVmError( + (distribution.connect(grantee.wallet) as ethers.Contract).withdrawValidatorCommission({ + gasLimit: 500_000, + }), + 'no validator commission to withdraw', + ); + }); + + it('withdrawValidatorCommissionWithAuthorization without a grant reverts', async () => { + await expectVmError( + (distribution.connect(grantee.wallet) as ethers.Contract) + .withdrawValidatorCommissionWithAuthorization(delegator.address, { + gasLimit: 500_000, + }), + 'authorization not found', + ); + }); + + it('grant lets the grantee withdraw rewards; revoke removes the grant', async () => { + const expiration = BigInt(Math.floor(Date.now() / 1000) + 86400); + + const grantTx = await ( + distribution.connect(delegator.wallet) as ethers.Contract + ).grantWithdrawAuthorization(grantee.address, expiration, { gasLimit: 500_000 }); + expect((await grantTx.wait())!.status).to.equal(1); + + // A successful receipt is not proof the rewards moved, so pin the + // credit the same way the non-authorized withdraw above does: the + // tx's own DelegationRewardsWithdrawn amount must land at the + // delegator's configured withdraw address. + const targetBefore = await bankBalance(withdrawTarget.seiAddress()); + const withdrawTx = await ( + distribution.connect(grantee.wallet) as ethers.Contract + ).withdrawDelegationRewardsWithAuthorization(delegator.address, validator, { + gasLimit: 2_000_000, + }); + const receipt = await withdrawTx.wait(); + expect(receipt!.status).to.equal(1); + + const withdrawn = receipt!.logs + .map((l: ethers.Log) => { + try { + return distrIface.parseLog({ topics: [...l.topics], data: l.data }); + } catch { + return null; + } + }) + .find((p: any) => p?.name === 'DelegationRewardsWithdrawn'); + expect(withdrawn, 'DelegationRewardsWithdrawn log emitted').to.not.equal(undefined); + const withdrawnUsei: bigint = withdrawn!.args[2]; + await waitUntil( + async () => { + const b = await bankBalance(withdrawTarget.seiAddress()); + return b === targetBefore + withdrawnUsei ? b : null; + }, + { + timeoutMs: 30_000, + label: 'withdraw address credited by the authorized withdraw', + }, + ); + + const revokeTx = await ( + distribution.connect(delegator.wallet) as ethers.Contract + ).revokeWithdrawAuthorization(grantee.address, { gasLimit: 500_000 }); + expect((await revokeTx.wait())!.status).to.equal(1); + + await expectVmError( + (distribution.connect(grantee.wallet) as ethers.Contract) + .withdrawDelegationRewardsWithAuthorization(delegator.address, validator, { + gasLimit: 2_000_000, + }), + 'authorization not found', + ); + }); + }); + describe('dispatch semantics (via PrecompileCaller)', () => { it('rewards responds through a real CALL and under STATICCALL', async () => { const data = distrIface.encodeFunctionData('rewards', [delegator.address]); @@ -204,6 +371,22 @@ describe('distribution precompile (0x1007)', function () { expect(viaStatic, 'CALL and STATICCALL return identical bytes').to.equal(viaCall); }); + it('params is callable via STATICCALL', async () => { + const data = distrIface.encodeFunctionData('params', []); + const [ret, direct] = await Promise.all([ + caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.distribution, + data, + ) as Promise, + distribution.params(), + ]); + const [decoded] = distrIface.decodeFunctionResult('params', ret); + expect(decoded.communityTax).to.equal(direct.communityTax); + expect(decoded.baseProposerReward).to.equal(direct.baseProposerReward); + expect(decoded.bonusProposerReward).to.equal(direct.bonusProposerReward); + expect(decoded.withdrawAddrEnabled).to.equal(direct.withdrawAddrEnabled); + }); + it('write methods are rejected under STATICCALL (readOnly guard)', async () => { const data = distrIface.encodeFunctionData('withdrawDelegationRewards', [validator]); await expectVmError( diff --git a/integration_test/precompile_tests/precompiles/evidence.spec.ts b/integration_test/precompile_tests/precompiles/evidence.spec.ts new file mode 100644 index 0000000000..668b969aaf --- /dev/null +++ b/integration_test/precompile_tests/precompiles/evidence.spec.ts @@ -0,0 +1,119 @@ +/** + * evidence precompile (0x…100F) — end-to-end query semantics against a live Sei chain. + * + * Both methods are views over x/evidence. A clean chain typically stores no + * evidence, so allEvidence(empty page) is an empty list that matches LCD + * GET /cosmos/evidence/v1beta1/evidence, and evidence(hash) reverts for a + * hash that is not on chain. Populated Equivocation rows are injected in the + * Go unit tests (evidence_test.go) via the keeper and are not repeated here. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { EvmAccount } from '../utils/evmUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, +} from '../utils/precompileUtils'; +import { readRuntimeState, RuntimeState } from '../utils/testUtils'; +import { cosmosRest } from '../utils/cosmosUtils'; + +const EMPTY_PAGE = new Uint8Array(); +/** Nonzero 32-byte hash that will not exist on a clean chain. */ +const FAKE_HASH = new Uint8Array(32).fill(0xab); + +describe('evidence precompile (0x100F)', function () { + this.timeout(120 * 1000); + + const provider = seiRpc(); + const evidenceIface = precompileInterface('evidence'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let evidence: ethers.Contract; + let caller: ethers.Contract; + + before(() => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + evidence = precompileContract('evidence', admin.wallet); + caller = callerContract(runtime, admin.wallet); + }); + + describe('happy path & state parity', () => { + // Evidence only exists after a validator equivocates, which a healthy + // devnet never does — but asserting "empty" would turn a genuinely + // slashed validator into a spurious failure, so the module's own list is + // the oracle and the count has to agree either way. + it('allEvidence matches the evidence module', async () => { + const [resp, lcd] = await Promise.all([ + evidence.allEvidence(EMPTY_PAGE), + cosmosRest<{ evidence?: unknown[] | null }>('/cosmos/evidence/v1beta1/evidence'), + ]); + expect(resp.evidenceList.length, 'allEvidence count vs LCD').to.equal( + (lcd.evidence ?? []).length, + ); + for (const row of resp.evidenceList) { + // Each entry is the JSON encoding of the stored evidence. + expect(() => JSON.parse(ethers.toUtf8String(row))).to.not.throw(); + } + }); + }); + + describe('error handling', () => { + it('evidence with a nonzero fake hash reverts', async () => { + await expectExecutionReverted( + evidence.evidence(FAKE_HASH), + 'evidence.evidence with a hash that does not exist', + ); + }); + + it('rejects value on every method (non-payable)', async () => { + for (const [method, args] of [ + ['allEvidence', [EMPTY_PAGE]], + ['evidence', [FAKE_HASH]], + ] as const) { + const envelope = await rawSei('eth_call', [ + { + from: admin.address, + to: PRECOMPILE_ADDRESSES.evidence, + data: evidenceIface.encodeFunctionData(method, args), + value: '0x1', + }, + 'latest', + ]); + expect(envelope.error, `${method}: value-bearing call must revert`).to.not.equal( + undefined, + ); + expect(envelope.error!.message).to.match(/execution reverted|revert/i); + } + }); + }); + + describe('dispatch semantics (via PrecompileCaller)', () => { + // Compare each dispatch path against the direct call rather than against + // a hardcoded empty list, so these stay guards on dispatch instead of + // re-asserting that the devnet has no evidence. + it('CALL, STATICCALL and DELEGATECALL all return the direct answer', async () => { + const data = evidenceIface.encodeFunctionData('allEvidence', [EMPTY_PAGE]); + const envelope = await rawSei('eth_call', [ + { to: PRECOMPILE_ADDRESSES.evidence, data }, + 'latest', + ]); + const direct = envelope.result; + expect(direct, 'direct eth_call must answer').to.not.equal(undefined); + for (const fn of ['callTarget', 'staticcallTarget', 'delegatecallTarget'] as const) { + const ret: string = await caller[fn].staticCall(PRECOMPILE_ADDRESSES.evidence, data); + const [decoded] = evidenceIface.decodeFunctionResult('allEvidence', ret); + const [expected] = evidenceIface.decodeFunctionResult('allEvidence', direct!); + expect( + decoded.evidenceList.length, + `${fn} must return the direct answer`, + ).to.equal(expected.evidenceList.length); + } + }); + }); +}); diff --git a/integration_test/precompile_tests/precompiles/gov.spec.ts b/integration_test/precompile_tests/precompiles/gov.spec.ts index de542bef0c..659535c07b 100644 --- a/integration_test/precompile_tests/precompiles/gov.spec.ts +++ b/integration_test/precompile_tests/precompiles/gov.spec.ts @@ -11,7 +11,7 @@ import { ethers } from 'ethers'; import { expect } from 'chai'; import { seiRpc, waitUntil } from '../utils/chainUtils'; -import { EvmAccount } from '../utils/evmUtils'; +import { EvmAccount, associateViaTx, fundEvm } from '../utils/evmUtils'; import { cosmosQuery } from '../utils/cosmosUtils'; import { PRECOMPILE_ADDRESSES, @@ -24,8 +24,25 @@ import { import { readRuntimeState, claimPool, RuntimeState } from '../utils/testUtils'; const MIN_DEPOSIT_WEI = ethers.parseEther('10'); // devnet min_deposit = 10 SEI +const MIN_DEPOSIT_USEI = 10_000_000n; const VOTING_PERIOD = 2; // PROPOSAL_STATUS_VOTING_PERIOD const DEPOSIT_PERIOD = 1; // PROPOSAL_STATUS_DEPOSIT_PERIOD +const EMPTY_PAGE_KEY = new Uint8Array(); + +const authExpiration = (): bigint => BigInt(Math.floor(Date.now() / 1000) + 86400); + +/** Cosmjs Duration (`{seconds}`) or a "30s" string → seconds as bigint. */ +function durationSeconds(d: unknown): bigint { + if (d == null) return 0n; + if (typeof d === 'object' && d !== null && 'seconds' in d) { + return BigInt(String((d as { seconds: { toString(): string } }).seconds)); + } + if (typeof d === 'string') { + const m = /^(\d+)/.exec(d); + return BigInt(m ? m[1] : 0); + } + return BigInt(d as number); +} const textProposal = (title: string): string => JSON.stringify({ title, description: 'precompile_tests e2e fixture', type: 'Text' }); @@ -141,6 +158,184 @@ describe('gov precompile (0x1006)', function () { const total = after.totalDeposit.find(c => c.denom === 'usei'); expect(total?.amount, 'total deposit reaches min_deposit').to.equal('10000000'); }); + + it('params() matches cosmosQuery().gov.params()', async () => { + const qc = await cosmosQuery(); + const [pre, voting, deposit] = await Promise.all([ + gov.params(), + qc.gov.params('voting'), + qc.gov.params('deposit'), + ]); + expect(pre.votingPeriod).to.equal(durationSeconds(voting.votingParams?.votingPeriod)); + expect(pre.maxDepositPeriod).to.equal( + durationSeconds(deposit.depositParams?.maxDepositPeriod), + ); + const min = deposit.depositParams?.minDeposit?.[0]; + expect(min, 'cosmos min_deposit is set').to.not.equal(undefined); + expect(pre.minDeposit[0].denom).to.equal(min!.denom); + expect(pre.minDeposit[0].amount).to.equal(BigInt(min!.amount)); + }); + + it('proposal/deposits/vote/tally queries reflect a freshly submitted and voted proposal', async () => { + const id = await submitProposal(textProposal('e2e queries'), MIN_DEPOSIT_WEI); + + const prop = await waitUntil( + async () => { + const p = await gov.proposal(id); + return Number(p.status) === VOTING_PERIOD ? p : null; + }, + { timeoutMs: 15_000, label: 'proposal() in voting period' }, + ); + expect(prop.id).to.equal(id); + + const dep = await gov.getDeposit(id, admin.address); + expect(dep.proposalId).to.equal(id); + expect(dep.depositor).to.equal(adminSeiAddress); + expect(dep.amount[0].denom).to.equal('usei'); + expect(dep.amount[0].amount).to.equal(MIN_DEPOSIT_USEI); + + const [allDeposits] = await gov.deposits(id, EMPTY_PAGE_KEY); + expect( + allDeposits.some((d: { depositor: string }) => d.depositor === adminSeiAddress), + ).to.equal(true); + + const voteTx = await gov.vote(id, 1, { gasLimit: 500_000 }); + expect((await voteTx.wait())!.status).to.equal(1); + + const recorded = await waitUntil( + async () => { + try { + const v = await gov.getVote(id, admin.address); + return v.options?.length ? v : null; + } catch { + return null; + } + }, + { timeoutMs: 15_000, label: 'getVote after vote()' }, + ); + expect(recorded.proposalId).to.equal(id); + expect(recorded.voter).to.equal(adminSeiAddress); + expect(Number(recorded.options[0].option)).to.equal(1); + + const [allVotes] = await gov.votes(id, EMPTY_PAGE_KEY); + expect(allVotes.some((v: { voter: string }) => v.voter === adminSeiAddress)).to.equal( + true, + ); + + // Every tally field is a decimal power string. The admin is an EOA + // with no delegation, so its Yes carries no weight — assert the + // format rather than a total, which would drift with the devnet's + // stake distribution. + const tally = await gov.tallyResult(id); + for (const field of ['yes', 'abstain', 'no', 'noWithVeto'] as const) { + expect(tally[field], `tallyResult.${field}`).to.match(/^\d+$/); + } + + let pageKey: Uint8Array = EMPTY_PAGE_KEY; + let found = false; + for (let i = 0; i < 20 && !found; i++) { + const [page, nextKey] = (await gov.proposals( + 0, + ethers.ZeroAddress, + ethers.ZeroAddress, + pageKey, + )) as [Array<{ id: bigint }>, string]; + found = page.some(p => p.id === id); + // nextKey arrives as a hex string, so an exhausted page is '0x', + // not a zero-length value — decode before testing it or the loop + // re-reads page one until the counter runs out. + const next = ethers.getBytes(nextKey); + if (next.length === 0) break; + pageKey = next; + } + expect(found, 'proposals(0, zero, zero, empty) includes the new id').to.equal(true); + }); + + it('grantVoteAuthorization lets the grantee vote as the admin; revoke then blocks them', async () => { + const [grantee] = claimPool(runtime, provider, 1, 'gov:vote-authz'); + await associateViaTx(grantee); + + const grantTx = await gov.grantVoteAuthorization(grantee.address, authExpiration(), { + gasLimit: 500_000, + }); + expect((await grantTx.wait())!.status).to.equal(1); + + const id = await submitProposal(textProposal('e2e authz vote'), MIN_DEPOSIT_WEI); + const govAsGrantee = gov.connect(grantee.wallet) as ethers.Contract; + const voteTx = await govAsGrantee.voteWithAuthorization(admin.address, id, 1, { + gasLimit: 500_000, + }); + expect((await voteTx.wait())!.status).to.equal(1); + + const recorded = await waitUntil( + async () => { + try { + const v = await gov.getVote(id, admin.address); + return v.options?.length ? v : null; + } catch { + return null; + } + }, + { timeoutMs: 15_000, label: 'authorized vote visible via getVote' }, + ); + expect(recorded.voter).to.equal(adminSeiAddress); + expect(Number(recorded.options[0].option)).to.equal(1); + + const revokeTx = await gov.revokeVoteAuthorization(grantee.address, { + gasLimit: 500_000, + }); + expect((await revokeTx.wait())!.status).to.equal(1); + + const id2 = await submitProposal(textProposal('e2e authz vote revoked'), MIN_DEPOSIT_WEI); + await expectVmError( + govAsGrantee.voteWithAuthorization(admin.address, id2, 1, { + gasLimit: 500_000, + }), + 'authorization not found', + ); + }); + + it('grantProposalAuthorization lets the grantee submit on behalf of the admin', async () => { + const [grantee] = claimPool(runtime, provider, 1, 'gov:proposal-authz'); + await associateViaTx(grantee); + await fundEvm(admin, grantee.address, MIN_DEPOSIT_WEI); + + const grantTx = await gov.grantProposalAuthorization(grantee.address, authExpiration(), { + gasLimit: 500_000, + }); + expect((await grantTx.wait())!.status).to.equal(1); + + const govAsGrantee = gov.connect(grantee.wallet) as ethers.Contract; + const json = textProposal('e2e authz submit'); + const id: bigint = await govAsGrantee.submitProposalWithAuthorization.staticCall( + admin.address, + json, + { value: MIN_DEPOSIT_WEI }, + ); + const tx = await govAsGrantee.submitProposalWithAuthorization(admin.address, json, { + value: MIN_DEPOSIT_WEI, + gasLimit: 1_000_000, + }); + expect((await tx.wait())!.status).to.equal(1); + + const prop = await waitUntil( + async () => { + try { + const p = await gov.proposal(id); + return p.id === id ? p : null; + } catch { + return null; + } + }, + { timeoutMs: 15_000, label: 'authorized proposal visible' }, + ); + expect(prop.id).to.equal(id); + + const revokeTx = await gov.revokeProposalAuthorization(grantee.address, { + gasLimit: 500_000, + }); + expect((await revokeTx.wait())!.status).to.equal(1); + }); }); describe('error handling', () => { @@ -201,6 +396,28 @@ describe('gov precompile (0x1006)', function () { 'gov.deposit with value 0', ); }); + + it('getVote on an unknown proposal reverts', async () => { + await expectExecutionReverted( + gov.getVote(999_999n, admin.address), + 'gov.getVote on an unknown proposal', + ); + }); + + it('voteWithAuthorization without a grant reverts', async () => { + const [grantee] = claimPool(runtime, provider, 1, 'gov:no-vote-grant'); + await associateViaTx(grantee); + const id = await submitProposal(textProposal('e2e no grant'), MIN_DEPOSIT_WEI); + await expectVmError( + (gov.connect(grantee.wallet) as ethers.Contract).voteWithAuthorization( + admin.address, + id, + 1, + { gasLimit: 500_000 }, + ), + 'authorization not found', + ); + }); }); describe('dispatch semantics (via PrecompileCaller)', () => { @@ -223,5 +440,26 @@ describe('gov precompile (0x1006)', function () { 'cannot delegatecall gov', ); }); + + it('vote is rejected under STATICCALL', async () => { + const data = govIface.encodeFunctionData('vote', [1n, 1]); + await expectVmError( + caller.getFunction('staticcallTarget').send(PRECOMPILE_ADDRESSES.gov, data, { + gasLimit: 500_000, + }), + 'cannot call gov precompile from staticcall', + ); + }); + + it('proposal() is callable via STATICCALL', async () => { + const id = await submitProposal(textProposal('e2e staticcall proposal'), MIN_DEPOSIT_WEI); + const data = govIface.encodeFunctionData('proposal', [id]); + const ret: string = await caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.gov, + data, + ); + const [decoded] = govIface.decodeFunctionResult('proposal', ret); + expect(decoded.id).to.equal(id); + }); }); }); diff --git a/integration_test/precompile_tests/precompiles/mint.spec.ts b/integration_test/precompile_tests/precompiles/mint.spec.ts new file mode 100644 index 0000000000..8c27727de7 --- /dev/null +++ b/integration_test/precompile_tests/precompiles/mint.spec.ts @@ -0,0 +1,182 @@ +/** + * mint precompile (0x…1012) — query surface over Sei's custom mint module. + * + * Two views: params() and minter(). The backing module is Sei's own + * seiprotocol.seichain.mint, not cosmos-sdk x/mint, so the parity oracle is + * that module's LCD routes rather than a /cosmos/... path. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { cosmosRest } from '../utils/cosmosUtils'; +import { EvmAccount } from '../utils/evmUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, +} from '../utils/precompileUtils'; +import { readRuntimeState, RuntimeState } from '../utils/testUtils'; + +interface TokenRelease { + startDate: string; + endDate: string; + tokenReleaseAmount: bigint; +} + +interface MintParams { + mintDenom: string; + tokenReleaseSchedule: TokenRelease[]; +} + +interface Minter { + startDate: string; + endDate: string; + denom: string; + totalMintAmount: bigint; + remainingMintAmount: bigint; + lastMintAmount: bigint; + lastMintDate: string; + lastMintHeight: bigint; +} + +// The gRPC-gateway routes registered by x/mint (see the patterns in +// x/mint/types/query.pb.gw.go). Sei's mint module keeps the bare `seichain` +// prefix rather than the `seiprotocol.seichain` proto package path. +const PARAMS_LCD_PATH = '/seichain/mint/v1beta1/params'; +const MINTER_LCD_PATH = '/seichain/mint/v1beta1/minter'; + +function asBigInt(v: unknown): bigint { + if (typeof v === 'bigint') return v; + if (typeof v === 'number') return BigInt(v); + if (typeof v === 'string' && v !== '') return BigInt(v); + return 0n; +} + +function lcdMintDenom(body: any): string | undefined { + const p = body?.params ?? body; + const denom = p?.mint_denom ?? p?.mintDenom; + return typeof denom === 'string' ? denom : undefined; +} + +function lcdSchedule(body: any): any[] { + const p = body?.params ?? body; + const s = p?.token_release_schedule ?? p?.tokenReleaseSchedule; + return Array.isArray(s) ? s : []; +} + +/** + * QueryMinterResponse is flat — it carries the minter's fields at the top + * level rather than nesting them under a `minter` key the way the params + * response nests under `params`. + */ +function lcdMinter(body: any): any { + return body ?? {}; +} + +describe('mint precompile (0x1012)', function () { + this.timeout(120 * 1000); + + const provider = seiRpc(); + const mintIface = precompileInterface('mint'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let mint: ethers.Contract; + let caller: ethers.Contract; + + before(() => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + mint = precompileContract('mint', admin.wallet); + caller = callerContract(runtime, admin.wallet); + }); + + describe('happy path & state parity', () => { + it('params() matches the mint module', async () => { + const [params, lcd] = await Promise.all([ + mint.params() as Promise, + cosmosRest(PARAMS_LCD_PATH), + ]); + expect(params.mintDenom, 'precompile mintDenom vs LCD').to.equal(lcdMintDenom(lcd)); + expect(params.mintDenom, 'devnet genesis mints the bond denom').to.equal('usei'); + + const schedule = [...params.tokenReleaseSchedule]; + const lcdRows = lcdSchedule(lcd); + expect(schedule.length, 'tokenReleaseSchedule length vs LCD').to.equal(lcdRows.length); + for (let i = 0; i < schedule.length; i++) { + const row = lcdRows[i]; + expect(schedule[i].startDate).to.equal(String(row.start_date ?? row.startDate ?? '')); + expect(schedule[i].endDate).to.equal(String(row.end_date ?? row.endDate ?? '')); + expect(schedule[i].tokenReleaseAmount).to.equal( + asBigInt(row.token_release_amount ?? row.tokenReleaseAmount), + ); + } + }); + + it('minter() matches the mint module', async () => { + const [minter, lcd] = await Promise.all([ + mint.minter() as Promise, + cosmosRest(MINTER_LCD_PATH), + ]); + const body = lcdMinter(lcd); + + expect(minter.denom).to.equal(String(body.denom ?? '')); + expect(minter.startDate).to.equal(String(body.start_date ?? body.startDate ?? '')); + expect(minter.endDate).to.equal(String(body.end_date ?? body.endDate ?? '')); + expect(minter.totalMintAmount).to.equal( + asBigInt(body.total_mint_amount ?? body.totalMintAmount), + ); + expect(minter.remainingMintAmount).to.equal( + asBigInt(body.remaining_mint_amount ?? body.remainingMintAmount), + ); + expect(minter.lastMintAmount).to.equal( + asBigInt(body.last_mint_amount ?? body.lastMintAmount), + ); + expect(minter.lastMintDate).to.equal( + String(body.last_mint_date ?? body.lastMintDate ?? ''), + ); + expect(minter.lastMintHeight).to.equal( + asBigInt(body.last_mint_height ?? body.lastMintHeight), + ); + // Genesis may leave the minter unset; when it is set it mints the bond denom. + if (minter.denom !== '') { + expect(minter.denom).to.equal('usei'); + } + }); + }); + + describe('error handling', () => { + it('rejects value on view methods (non-payable)', async () => { + for (const method of ['params', 'minter'] as const) { + const envelope = await rawSei('eth_call', [ + { + from: admin.address, + to: PRECOMPILE_ADDRESSES.mint, + data: mintIface.encodeFunctionData(method, []), + value: '0x1', + }, + 'latest', + ]); + expect(envelope.error, `${method}: value-bearing call must revert`).to.not.equal( + undefined, + ); + expect(envelope.error!.message).to.match(/execution reverted|revert/i); + } + }); + }); + + describe('dispatch semantics (via PrecompileCaller)', () => { + it('responds under STATICCALL', async () => { + const data = mintIface.encodeFunctionData('params', []); + const ret: string = await caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.mint, + data, + ); + const [decoded] = mintIface.decodeFunctionResult('params', ret); + const direct: MintParams = await mint.params(); + expect(decoded.mintDenom).to.equal(direct.mintDenom); + expect(decoded.mintDenom).to.equal('usei'); + }); + }); +}); diff --git a/integration_test/precompile_tests/precompiles/params.spec.ts b/integration_test/precompile_tests/precompiles/params.spec.ts new file mode 100644 index 0000000000..0ae4219027 --- /dev/null +++ b/integration_test/precompile_tests/precompiles/params.spec.ts @@ -0,0 +1,95 @@ +/** + * params precompile (0x…1013) — subspace parameter lookups against a live Sei chain. + * + * One view method: params(subspace, key) returns the stored value as a string. + * The Go unit test pins staking/MaxValidators as a decimal encoding of the + * staking keeper's MaxValidators; this spec uses the same key and asserts + * parity against cosmosQuery().staking.params(). + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { cosmosQuery } from '../utils/cosmosUtils'; +import { EvmAccount } from '../utils/evmUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, +} from '../utils/precompileUtils'; +import { readRuntimeState, RuntimeState } from '../utils/testUtils'; + +describe('params precompile (0x1013)', function () { + this.timeout(120 * 1000); + + const provider = seiRpc(); + const paramsIface = precompileInterface('params'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let params: ethers.Contract; + let caller: ethers.Contract; + + before(() => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + params = precompileContract('params', admin.wallet); + caller = callerContract(runtime, admin.wallet); + }); + + describe('happy path & state parity', () => { + it("params('staking', 'MaxValidators') matches the staking module", async () => { + const [value, qc] = await Promise.all([ + params.params('staking', 'MaxValidators') as Promise, + cosmosQuery(), + ]); + const expected = String((await qc.staking.params()).params!.maxValidators); + expect(value).to.match(/^\d+$/); + expect(value).to.equal(expected); + }); + }); + + describe('error handling', () => { + it('unknown subspace reverts', async () => { + await expectExecutionReverted( + params.params('notasubspace', 'NotAKey'), + "params.params with subspace 'notasubspace'", + ); + }); + + it('empty subspace reverts', async () => { + await expectExecutionReverted( + params.params('', 'MaxValidators'), + 'params.params with an empty subspace', + ); + }); + + it('rejects value (non-payable)', async () => { + const envelope = await rawSei('eth_call', [ + { + from: admin.address, + to: PRECOMPILE_ADDRESSES.params, + data: paramsIface.encodeFunctionData('params', ['staking', 'MaxValidators']), + value: '0x1', + }, + 'latest', + ]); + expect(envelope.error, 'value-bearing call must revert').to.not.equal(undefined); + expect(envelope.error!.message).to.match(/execution reverted|revert/i); + }); + }); + + describe('dispatch semantics (via PrecompileCaller)', () => { + it('responds under STATICCALL', async () => { + const data = paramsIface.encodeFunctionData('params', ['staking', 'MaxValidators']); + const ret: string = await caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.params, + data, + ); + const [decoded] = paramsIface.decodeFunctionResult('params', ret); + const direct: string = await params.params('staking', 'MaxValidators'); + expect(decoded).to.equal(direct); + }); + }); +}); diff --git a/integration_test/precompile_tests/precompiles/slashing.spec.ts b/integration_test/precompile_tests/precompiles/slashing.spec.ts new file mode 100644 index 0000000000..500c022d0f --- /dev/null +++ b/integration_test/precompile_tests/precompiles/slashing.spec.ts @@ -0,0 +1,206 @@ +/** + * slashing precompile (0x…1014) — end-to-end semantics against a live Sei chain. + * + * Query methods are checked against the slashing module's LCD. Write methods + * that would jail a validator are out of scope: grant/revoke of unjail + * authorization are exercised from a pool account, and unjail is only asserted + * to revert when the caller is not a jailed validator. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { EvmAccount, associateViaTx } from '../utils/evmUtils'; +import { cosmosRest } from '../utils/cosmosUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, + expectVmError, +} from '../utils/precompileUtils'; +import { readRuntimeState, claimPool, RuntimeState } from '../utils/testUtils'; + +const EMPTY_PAGE = new Uint8Array(); + +interface LcdSlashingParams { + params: { + signed_blocks_window: string; + min_signed_per_window: string; + downtime_jail_duration: string; + slash_fraction_double_sign: string; + slash_fraction_downtime: string; + }; +} + +/** protojson duration (`"600s"`) to whole seconds, matching the precompile's `.Seconds()`. */ +function protoDurationSeconds(raw: string): bigint { + const m = /^(-?[0-9]+(?:\.[0-9]+)?)s$/.exec(raw); + if (!m) { + throw new Error(`unexpected protojson duration: ${raw}`); + } + return BigInt(Math.trunc(Number(m[1]))); +} + +function asSigningInfo(info: ethers.Result) { + return { + validatorAddress: String(info.validatorAddress), + startHeight: BigInt(info.startHeight), + indexOffset: BigInt(info.indexOffset), + jailedUntil: BigInt(info.jailedUntil), + tombstoned: Boolean(info.tombstoned), + missedBlocksCounter: BigInt(info.missedBlocksCounter), + }; +} + +describe('slashing precompile (0x1014)', function () { + this.timeout(180 * 1000); + + const provider = seiRpc(); + const slashingIface = precompileInterface('slashing'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let slashing: ethers.Contract; + let caller: ethers.Contract; + let associated: EvmAccount; + + before(async () => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + slashing = precompileContract('slashing', admin.wallet); + caller = callerContract(runtime, admin.wallet); + [associated] = claimPool(runtime, provider, 1, 'slashing:associated'); + await associateViaTx(associated); + }); + + describe('happy path & state parity', () => { + it('params() matches LCD /cosmos/slashing/v1beta1/params', async () => { + const [viaPrecompile, lcd] = await Promise.all([ + slashing.params() as Promise, + cosmosRest('/cosmos/slashing/v1beta1/params'), + ]); + const p = lcd.params; + expect(viaPrecompile.signedBlocksWindow).to.equal(BigInt(p.signed_blocks_window)); + expect(viaPrecompile.minSignedPerWindow).to.equal(p.min_signed_per_window); + expect(viaPrecompile.downtimeJailDuration).to.equal( + protoDurationSeconds(p.downtime_jail_duration), + ); + expect(viaPrecompile.slashFractionDoubleSign).to.equal(p.slash_fraction_double_sign); + expect(viaPrecompile.slashFractionDowntime).to.equal(p.slash_fraction_downtime); + }); + + it('signingInfos(empty) is non-empty and signingInfo(that cons address) matches', async () => { + const listed: ethers.Result = await slashing.signingInfos(EMPTY_PAGE); + expect(listed.signingInfos.length, 'devnet validators must have signing info').to.be.greaterThan( + 0, + ); + const first = listed.signingInfos[0]; + const viaOne: ethers.Result = await slashing.signingInfo(first.validatorAddress); + expect(asSigningInfo(viaOne)).to.deep.equal(asSigningInfo(first)); + }); + + it('grantUnjailAuthorization then revokeUnjailAuthorization succeed from an associated pool account', async () => { + const expiration = BigInt(Math.floor(Date.now() / 1000) + 86_400); + const grantTx = await ( + slashing.connect(associated.wallet) as ethers.Contract + ).grantUnjailAuthorization(admin.address, expiration, { gasLimit: 1_000_000 }); + expect((await grantTx.wait())!.status, 'grantUnjailAuthorization tx must succeed').to.equal( + 1, + ); + + const revokeTx = await ( + slashing.connect(associated.wallet) as ethers.Contract + ).revokeUnjailAuthorization(admin.address, { gasLimit: 1_000_000 }); + expect((await revokeTx.wait())!.status, 'revokeUnjailAuthorization tx must succeed').to.equal( + 1, + ); + }); + }); + + describe('error handling', () => { + // The caller is associated but is not a validator operator, so the + // MsgUnjail is rejected by the slashing keeper rather than by the + // precompile's association check. + it('unjail from an account that owns no validator reverts', async () => { + await expectVmError( + (slashing.connect(associated.wallet) as ethers.Contract).unjail({ + gasLimit: 1_000_000, + }), + 'address is not associated with any known validator', + ); + }); + + it('signingInfo of a garbage cons address reverts', async () => { + await expectExecutionReverted( + slashing.signingInfo('notanaddress'), + 'slashing.signingInfo with a garbage cons address', + ); + }); + + it('unjailWithAuthorization without a grant reverts', async () => { + await expectVmError( + (slashing.connect(associated.wallet) as ethers.Contract).unjailWithAuthorization( + admin.address, + { gasLimit: 1_000_000 }, + ), + 'authorization not found', + ); + }); + + it('rejects value on a view method (non-payable)', async () => { + const envelope = await rawSei('eth_call', [ + { + from: admin.address, + to: PRECOMPILE_ADDRESSES.slashing, + data: slashingIface.encodeFunctionData('params', []), + value: '0x1', + }, + 'latest', + ]); + expect(envelope.error, 'value-bearing call must revert').to.not.equal(undefined); + expect(envelope.error!.message).to.match(/execution reverted|revert/i); + }); + + it('unjail via STATICCALL reverts', async () => { + const data = slashingIface.encodeFunctionData('unjail', []); + await expectVmError( + caller.getFunction('staticcallTarget').send(PRECOMPILE_ADDRESSES.slashing, data, { + gasLimit: 1_000_000, + }), + 'cannot call slashing precompile from staticcall', + ); + }); + + // The delegatecall guard is the security-relevant one: it stops a + // contract from unjailing on behalf of whoever called *it*. The + // executor checks it before the readOnly check, so this reports the + // delegatecall reason rather than the staticcall one. + it('unjail via DELEGATECALL reverts', async () => { + const data = slashingIface.encodeFunctionData('unjail', []); + await expectVmError( + caller.getFunction('delegatecallTarget').send(PRECOMPILE_ADDRESSES.slashing, data, { + gasLimit: 1_000_000, + }), + 'cannot delegatecall slashing', + ); + }); + }); + + describe('dispatch semantics (via PrecompileCaller)', () => { + it('params responds under STATICCALL', async () => { + const data = slashingIface.encodeFunctionData('params', []); + const ret: string = await caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.slashing, + data, + ); + const [decoded] = slashingIface.decodeFunctionResult('params', ret); + const direct: ethers.Result = await slashing.params(); + expect(decoded.signedBlocksWindow).to.equal(direct.signedBlocksWindow); + expect(decoded.minSignedPerWindow).to.equal(direct.minSignedPerWindow); + expect(decoded.downtimeJailDuration).to.equal(direct.downtimeJailDuration); + expect(decoded.slashFractionDoubleSign).to.equal(direct.slashFractionDoubleSign); + expect(decoded.slashFractionDowntime).to.equal(direct.slashFractionDowntime); + }); + }); +}); diff --git a/integration_test/precompile_tests/precompiles/staking.spec.ts b/integration_test/precompile_tests/precompiles/staking.spec.ts index ae270e6e1f..961d2a1b1c 100644 --- a/integration_test/precompile_tests/precompiles/staking.spec.ts +++ b/integration_test/precompile_tests/precompiles/staking.spec.ts @@ -17,7 +17,7 @@ import { generateKeyPairSync } from 'node:crypto'; import { fromBech32, toBech32 } from '@cosmjs/encoding'; import { seiRpc, waitUntil } from '../utils/chainUtils'; import { EvmAccount, associateViaTx } from '../utils/evmUtils'; -import { bondedValidators, cosmosQuery, bankBalance } from '../utils/cosmosUtils'; +import { bondedValidators, cosmosQuery, cosmosRest, bankBalance } from '../utils/cosmosUtils'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -429,5 +429,352 @@ describe('staking precompile (0x1005)', function () { 'cannot delegatecall staking', ); }); + + it('grantStakingAuthorization is rejected under STATICCALL', async () => { + const expiration = BigInt(Math.floor(Date.now() / 1000) + 86400); + const data = stakingIface.encodeFunctionData('grantStakingAuthorization', [ + delegator.address, + [validators[0], validators[1]], + DELEGATE_USEI, + expiration, + ]); + await expectVmError( + caller.getFunction('staticcallTarget').send(PRECOMPILE_ADDRESSES.staking, data, { + gasLimit: 1_000_000, + }), + 'cannot call staking precompile from staticcall', + ); + }); + + it('validators is callable via STATICCALL', async () => { + const data = stakingIface.encodeFunctionData('validators', [ + 'BOND_STATUS_BONDED', + new Uint8Array(), + ]); + const ret: string = await caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.staking, + data, + ); + const [decoded] = stakingIface.decodeFunctionResult('validators', ret); + const ops = [...decoded.validators].map((v: { operatorAddress: string }) => v.operatorAddress); + expect(ops, 'STATICCALL validators includes the first bonded validator').to.include( + validators[0], + ); + }); + }); + + describe('authz (grant / execute / revoke)', () => { + it('grantStakingAuthorization lets the grantee delegateWithAuthorization; revoke then reverts', async () => { + const [granter, grantee] = claimPool(runtime, provider, 2, 'staking:authz'); + await associateViaTx(granter); + await associateViaTx(grantee); + + const expiration = BigInt(Math.floor(Date.now() / 1000) + 86400); + const maxTokens = DELEGATE_USEI * 10n; + const grantTx = await ( + staking.connect(granter.wallet) as ethers.Contract + ).grantStakingAuthorization( + grantee.address, + [validators[0], validators[1]], + maxTokens, + expiration, + { gasLimit: 1_000_000 }, + ); + expect((await grantTx.wait())!.status, 'grantStakingAuthorization tx must succeed').to.equal( + 1, + ); + + // Staking.sol: the CALLER (grantee) supplies msg.value; the delegation + // is recorded on the granter. + const delegateTx = await ( + staking.connect(grantee.wallet) as ethers.Contract + ).delegateWithAuthorization(granter.address, validators[0], { + value: DELEGATE_WEI, + gasLimit: 1_000_000, + }); + expect( + (await delegateTx.wait())!.status, + 'delegateWithAuthorization tx must succeed', + ).to.equal(1); + + const qc = await cosmosQuery(); + const cosmosDelegation = await waitUntil( + async () => { + const d = await qc.staking.delegation(granter.seiAddress(), validators[0]); + return d.delegationResponse?.balance ?? null; + }, + { timeoutMs: 30_000, label: 'cosmos delegation after delegateWithAuthorization' }, + ); + expect(cosmosDelegation.amount).to.equal(DELEGATE_USEI.toString()); + expect(cosmosDelegation.denom).to.equal('usei'); + + const revokeTx = await ( + staking.connect(granter.wallet) as ethers.Contract + ).revokeStakingAuthorization(grantee.address, { gasLimit: 1_000_000 }); + expect((await revokeTx.wait())!.status, 'revokeStakingAuthorization tx must succeed').to.equal( + 1, + ); + + await expectVmError( + (staking.connect(grantee.wallet) as ethers.Contract).delegateWithAuthorization( + granter.address, + validators[0], + { value: DELEGATE_WEI, gasLimit: 1_000_000 }, + ), + 'authorization not found', + ); + }); + + it('redelegateWithAuthorization and undelegateWithAuthorization consume the grant', async () => { + const [granter, grantee] = claimPool(runtime, provider, 2, 'staking:authz-redelegate'); + await associateViaTx(granter); + await associateViaTx(grantee); + + const expiration = BigInt(Math.floor(Date.now() / 1000) + 86400); + const grantTx = await ( + staking.connect(granter.wallet) as ethers.Contract + ).grantStakingAuthorization( + grantee.address, + [validators[0], validators[1]], + DELEGATE_USEI * 10n, + expiration, + { gasLimit: 1_000_000 }, + ); + expect((await grantTx.wait())!.status).to.equal(1); + + const delegateTx = await ( + staking.connect(grantee.wallet) as ethers.Contract + ).delegateWithAuthorization(granter.address, validators[0], { + value: DELEGATE_WEI, + gasLimit: 1_000_000, + }); + expect((await delegateTx.wait())!.status).to.equal(1); + + const qc = await cosmosQuery(); + await waitUntil( + async () => { + const d = await qc.staking.delegation(granter.seiAddress(), validators[0]); + return d.delegationResponse?.balance ?? null; + }, + { timeoutMs: 30_000, label: 'authz granter delegation before redelegate' }, + ); + + const moved = DELEGATE_USEI / 4n; + const redelegateTx = await ( + staking.connect(grantee.wallet) as ethers.Contract + ).redelegateWithAuthorization( + granter.address, + validators[0], + validators[1], + moved, + { gasLimit: 2_000_000 }, + ); + expect((await redelegateTx.wait())!.status, 'redelegateWithAuthorization').to.equal(1); + + const dst = await waitUntil( + async () => { + const d = await qc.staking.delegation(granter.seiAddress(), validators[1]); + return d.delegationResponse?.balance ?? null; + }, + { timeoutMs: 30_000, label: 'authz destination delegation after redelegate' }, + ); + expect(BigInt(dst.amount)).to.equal(moved); + + const undelegateTx = await ( + staking.connect(grantee.wallet) as ethers.Contract + ).undelegateWithAuthorization(granter.address, validators[0], moved, { + gasLimit: 2_000_000, + }); + expect((await undelegateTx.wait())!.status, 'undelegateWithAuthorization').to.equal(1); + }); + }); + + describe('query methods (remaining surface)', () => { + const emptyPageKey = new Uint8Array(); + + it('validators(BOND_STATUS_BONDED, empty) includes validators[0]', async () => { + const resp = await staking.validators('BOND_STATUS_BONDED', emptyPageKey); + const list = resp.validators as ethers.Result; + expect(list.length, 'bonded validator set is non-empty').to.be.greaterThan(0); + const ops = [...list].map((v: { operatorAddress: string }) => v.operatorAddress); + expect(ops).to.include(validators[0]); + }); + + it('delegatorDelegations / delegatorValidators / delegatorValidator see a fresh delegation', async () => { + const [account] = claimPool(runtime, provider, 1, 'staking:query-delegator'); + await associateViaTx(account); + const tx = await (staking.connect(account.wallet) as ethers.Contract).delegate( + validators[0], + { value: DELEGATE_WEI, gasLimit: 1_000_000 }, + ); + expect((await tx.wait())!.status).to.equal(1); + + const dels = await waitUntil( + async () => { + const resp = await staking.delegatorDelegations(account.address, emptyPageKey); + const list = resp.delegations as ethers.Result; + return [...list].some( + (d: any) => d.delegation.validator_address === validators[0], + ) + ? list + : null; + }, + { timeoutMs: 30_000, label: 'delegatorDelegations after fresh delegate' }, + ); + expect( + [...dels].some((d: any) => d.delegation.delegator_address === account.seiAddress()), + ).to.equal(true); + + const dvals = await staking.delegatorValidators(account.address, emptyPageKey); + const ops = [...(dvals.validators as ethers.Result)].map( + (v: { operatorAddress: string }) => v.operatorAddress, + ); + expect(ops).to.include(validators[0]); + + const dv = await staking.delegatorValidator(account.address, validators[0]); + expect(dv.operatorAddress).to.equal(validators[0]); + + const valDels = await staking.validatorDelegations(validators[0], emptyPageKey); + expect( + (valDels.delegations as ethers.Result).length, + 'validatorDelegations is non-empty', + ).to.be.greaterThan(0); + expect( + [...(valDels.delegations as ethers.Result)].every( + (d: any) => d.delegation.validator_address === validators[0], + ), + ).to.equal(true); + }); + + it('unbondingDelegation / delegatorUnbondingDelegations / validatorUnbondingDelegations after undelegate', async () => { + const [account] = claimPool(runtime, provider, 1, 'staking:query-unbond'); + await associateViaTx(account); + const delegateTx = await (staking.connect(account.wallet) as ethers.Contract).delegate( + validators[0], + { value: DELEGATE_WEI, gasLimit: 1_000_000 }, + ); + expect((await delegateTx.wait())!.status).to.equal(1); + + const amount = DELEGATE_USEI / 2n; + const undelegateTx = await (staking.connect(account.wallet) as ethers.Contract).undelegate( + validators[0], + amount, + { gasLimit: 2_000_000 }, + ); + expect((await undelegateTx.wait())!.status).to.equal(1); + + const ubd = await waitUntil( + async () => { + const u = await staking.unbondingDelegation(account.address, validators[0]); + const entries = u.getValue('entries') as ethers.Result; + return entries.length > 0 ? u : null; + }, + { timeoutMs: 8_000, intervalMs: 200, label: 'unbondingDelegation after undelegate' }, + ); + expect(ubd.delegatorAddress).to.equal(account.seiAddress()); + expect(ubd.validatorAddress).to.equal(validators[0]); + expect(BigInt((ubd.getValue('entries') as ethers.Result)[0].balance)).to.equal(amount); + + const byDelegator = await staking.delegatorUnbondingDelegations( + account.address, + emptyPageKey, + ); + const duList = byDelegator.unbondingDelegations as ethers.Result; + expect( + [...duList].some((u: any) => u.validatorAddress === validators[0]), + 'delegatorUnbondingDelegations includes this validator', + ).to.equal(true); + + const byValidator = await waitUntil( + async () => { + const resp = await staking.validatorUnbondingDelegations( + validators[0], + emptyPageKey, + ); + const list = resp.unbondingDelegations as ethers.Result; + return [...list].some((u: any) => u.delegatorAddress === account.seiAddress()) + ? list + : null; + }, + { timeoutMs: 8_000, intervalMs: 200, label: 'validatorUnbondingDelegations' }, + ); + expect(byValidator.length).to.be.greaterThan(0); + }); + + it('redelegations sees a fresh redelegate', async () => { + const [account] = claimPool(runtime, provider, 1, 'staking:query-redelegate'); + await associateViaTx(account); + const delegateTx = await (staking.connect(account.wallet) as ethers.Contract).delegate( + validators[0], + { value: DELEGATE_WEI, gasLimit: 1_000_000 }, + ); + expect((await delegateTx.wait())!.status).to.equal(1); + + const moved = DELEGATE_USEI / 2n; + const redelegateTx = await (staking.connect(account.wallet) as ethers.Contract).redelegate( + validators[0], + validators[1], + moved, + { gasLimit: 2_000_000 }, + ); + expect((await redelegateTx.wait())!.status).to.equal(1); + + const resp = await waitUntil( + async () => { + const r = await staking.redelegations( + account.seiAddress(), + validators[0], + validators[1], + emptyPageKey, + ); + const list = r.redelegations as ethers.Result; + return list.length > 0 ? r : null; + }, + { timeoutMs: 30_000, label: 'redelegations after redelegate' }, + ); + const redels = resp.redelegations as ethers.Result; + expect(redels[0].delegatorAddress).to.equal(account.seiAddress()); + expect(redels[0].validatorSrcAddress).to.equal(validators[0]); + expect(redels[0].validatorDstAddress).to.equal(validators[1]); + }); + + // The staking module only keeps historical info for the last + // `historical_entries` heights, so whether a given height answers is a + // property of the chain, not of the precompile. The LCD is therefore the + // oracle: the precompile must agree with it about the SAME height, both + // on the answer and on the absence of one. + it('historicalInfo agrees with the staking module about a recent height', async () => { + const height = BigInt(Math.max((await provider.getBlockNumber()) - 2, 1)); + const lcd = await cosmosRest<{ + hist?: { valset?: Array<{ operator_address: string }> } | null; + code?: number; + }>(`/cosmos/staking/v1beta1/historical_info/${height}`).catch(() => undefined); + + if (lcd?.hist == null) { + // Not retained (historical_entries=0, or the height aged out): + // the precompile must refuse it rather than invent an answer. The + // reason surfaced is the staking querier's, not the executor's own + // "historical info not found" fallback, which a NotFound status + // error means is unreachable. + await expectVmError( + admin.wallet.sendTransaction({ + to: PRECOMPILE_ADDRESSES.staking, + data: stakingIface.encodeFunctionData('historicalInfo', [height]), + gasLimit: 1_000_000, + }), + `historical info for height ${height} not found`, + ); + return; + } + + const info = await staking.historicalInfo(height); + expect(info.height, 'historicalInfo echoes the requested height').to.equal(height); + const ops = [...(info.validators as ethers.Result)].map( + (v: { operatorAddress: string }) => v.operatorAddress, + ); + expect(ops.slice().sort(), 'historical valset matches the staking module').to.deep.equal( + (lcd.hist.valset ?? []).map(v => v.operator_address).sort(), + ); + }); }); }); diff --git a/integration_test/precompile_tests/precompiles/upgrade.spec.ts b/integration_test/precompile_tests/precompiles/upgrade.spec.ts new file mode 100644 index 0000000000..153d0544b2 --- /dev/null +++ b/integration_test/precompile_tests/precompiles/upgrade.spec.ts @@ -0,0 +1,134 @@ +/** + * upgrade precompile (0x…1015) — x/upgrade queries against a live Sei chain. + * + * All four methods are views. A local cluster has no scheduled upgrade and no + * applied plan, so currentPlan is the zero plan, appliedPlan of an unknown + * name is 0, and upgradedConsensusState is empty bytes. moduleVersions reads + * the app's real module consensus versions. currentPlan is checked against + * LCD /cosmos/upgrade/v1beta1/current_plan. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { cosmosRest } from '../utils/cosmosUtils'; +import { EvmAccount } from '../utils/evmUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, +} from '../utils/precompileUtils'; +import { readRuntimeState, RuntimeState } from '../utils/testUtils'; + +interface LcdPlan { + name?: string; + height?: string; + info?: string; +} + +interface LcdCurrentPlan { + plan?: LcdPlan | null; +} + +describe('upgrade precompile (0x1015)', function () { + this.timeout(120 * 1000); + + const provider = seiRpc(); + const upgradeIface = precompileInterface('upgrade'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let upgrade: ethers.Contract; + let caller: ethers.Contract; + + before(() => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + upgrade = precompileContract('upgrade', admin.wallet); + caller = callerContract(runtime, admin.wallet); + }); + + describe('happy path & state parity', () => { + it('currentPlan is the zero plan when none is scheduled (LCD parity)', async () => { + const [plan, lcd] = await Promise.all([ + upgrade.currentPlan(), + cosmosRest('/cosmos/upgrade/v1beta1/current_plan'), + ]); + const lcdPlan = lcd.plan; + if (lcdPlan == null || lcdPlan.name == null || lcdPlan.name === '') { + expect(plan.name).to.equal(''); + expect(plan.height).to.equal(0n); + expect(plan.info).to.equal(''); + } else { + expect(plan.name).to.equal(lcdPlan.name); + expect(plan.height).to.equal(BigInt(lcdPlan.height ?? 0)); + expect(plan.info).to.equal(lcdPlan.info ?? ''); + } + }); + + it("moduleVersions('') returns a non-empty list", async () => { + const versions: Array<{ name: string; version: bigint }> = + await upgrade.moduleVersions(''); + expect(versions.length).to.be.greaterThan(0); + }); + + it("moduleVersions('bank') is a 1-element list named bank", async () => { + // Go unit test (TestModuleVersions): a specific module name returns + // exactly one entry. An unknown name reverts (strict filter). + const versions: Array<{ name: string; version: bigint }> = + await upgrade.moduleVersions('bank'); + expect(versions).to.have.length(1); + expect(versions[0].name).to.equal('bank'); + expect(versions[0].version > 0n).to.equal(true); + }); + + it("appliedPlan('definitely-not-an-upgrade') returns 0", async () => { + const height: bigint = await upgrade.appliedPlan('definitely-not-an-upgrade'); + expect(height).to.equal(0n); + }); + + it('upgradedConsensusState(1) returns empty bytes', async () => { + const state: string = await upgrade.upgradedConsensusState(1n); + expect(state).to.equal('0x'); + }); + }); + + describe('error handling', () => { + it('unknown module name reverts', async () => { + await expectExecutionReverted( + upgrade.moduleVersions('notamodule'), + "upgrade.moduleVersions('notamodule')", + ); + }); + + it('rejects value (non-payable)', async () => { + const envelope = await rawSei('eth_call', [ + { + from: admin.address, + to: PRECOMPILE_ADDRESSES.upgrade, + data: upgradeIface.encodeFunctionData('currentPlan', []), + value: '0x1', + }, + 'latest', + ]); + expect(envelope.error, 'value-bearing call must revert').to.not.equal(undefined); + expect(envelope.error!.message).to.match(/execution reverted|revert/i); + }); + }); + + describe('dispatch semantics (via PrecompileCaller)', () => { + it('responds under STATICCALL', async () => { + const data = upgradeIface.encodeFunctionData('currentPlan', []); + const ret: string = await caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.upgrade, + data, + ); + const [decoded] = upgradeIface.decodeFunctionResult('currentPlan', ret); + const direct = await upgrade.currentPlan(); + expect(decoded.name).to.equal(direct.name); + expect(decoded.height).to.equal(direct.height); + expect(decoded.info).to.equal(direct.info); + }); + }); +}); diff --git a/integration_test/precompile_tests/utils/cosmosUtils.ts b/integration_test/precompile_tests/utils/cosmosUtils.ts index ea580642c5..1374899b0c 100644 --- a/integration_test/precompile_tests/utils/cosmosUtils.ts +++ b/integration_test/precompile_tests/utils/cosmosUtils.ts @@ -65,6 +65,21 @@ export async function cosmosQuery(): Promise { return clientPromise; } +/** + * GET `path` from the chain's LCD (`SEI_REST`, default http://localhost:1317) + * and return the parsed JSON. Used for modules cosmjs does not wrap (mint, + * params, upgrade, evidence, slashing, authz, auth). `path` must start with `/`. + */ +export async function cosmosRest(path: string): Promise { + const base = Endpoints.sei.rest.replace(/\/$/, ''); + const url = `${base}${path.startsWith('/') ? path : `/${path}`}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`cosmosRest GET ${url} -> HTTP ${res.status}: ${await res.text()}`); + } + return (await res.json()) as T; +} + const bankClient = cosmosQuery; /** Operator addresses (seivaloper1…) of the currently bonded validators. */ diff --git a/integration_test/precompile_tests/utils/precompileUtils.ts b/integration_test/precompile_tests/utils/precompileUtils.ts index d6dad56806..8a4f4d78d9 100644 --- a/integration_test/precompile_tests/utils/precompileUtils.ts +++ b/integration_test/precompile_tests/utils/precompileUtils.ts @@ -23,7 +23,14 @@ export const PRECOMPILE_ADDRESSES = { pointerview: '0x000000000000000000000000000000000000100A', pointer: '0x000000000000000000000000000000000000100b', solo: '0x000000000000000000000000000000000000100C', + auth: '0x000000000000000000000000000000000000100D', + authz: '0x000000000000000000000000000000000000100E', + evidence: '0x000000000000000000000000000000000000100F', p256: '0x0000000000000000000000000000000000001011', + mint: '0x0000000000000000000000000000000000001012', + params: '0x0000000000000000000000000000000000001013', + slashing: '0x0000000000000000000000000000000000001014', + upgrade: '0x0000000000000000000000000000000000001015', } as const; export type PrecompileName = keyof typeof PRECOMPILE_ADDRESSES; From f996dac5d227e7c241d6039343dc3d435032b2ad Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Mon, 24 Aug 2026 02:14:12 +0200 Subject: [PATCH 2/3] Address review on precompile tests phase 3 slashing: signingInfos and signingInfo were two eth_calls at latest, and HandleValidatorSignature advances indexOffset for every validator in the last commit, so a block committing between them failed the equality with no defect behind it. Both reads now pin the same blockTag, which keeps the full-struct comparison rather than dropping the moving fields. bank: totalSupply sends no page limit, so the module default of 100 applies and every factory/... denom this suite mints sorts before usei. It now walks pages the way denomsMetadata already does instead of assuming usei is on page one. distribution: expectCoinArray degenerated to an "is an array" check on an empty array. It now requires non-empty by default, which the fixture guarantees for the delegation and validator reads, and communityPool opts out because sei-cosmos defaults community_tax to 0. gov: the dispatch section claimed gov has no view methods while a new case asserted proposal() answers under STATICCALL. The executor dispatches its queries before the readOnly check, so the label now says transaction methods, and the duplicate vote rejection that mined the same failing tx twice is gone. Co-Authored-By: Cursor --- .../precompile_tests/precompiles/bank.spec.ts | 26 ++++++++++++---- .../precompiles/distribution.spec.ts | 30 ++++++++++++++++--- .../precompile_tests/precompiles/gov.spec.ts | 15 +++------- .../precompiles/slashing.spec.ts | 10 +++++-- 4 files changed, 58 insertions(+), 23 deletions(-) diff --git a/integration_test/precompile_tests/precompiles/bank.spec.ts b/integration_test/precompile_tests/precompiles/bank.spec.ts index a8caa3dfbd..e679442110 100644 --- a/integration_test/precompile_tests/precompiles/bank.spec.ts +++ b/integration_test/precompile_tests/precompiles/bank.spec.ts @@ -164,16 +164,30 @@ describe('bank precompile (0x1001)', function () { expect(usei!.amount).to.equal(useiBalance); }); - it('totalSupply(empty) includes usei consistent with supply', async () => { + it('totalSupply pages until it reaches usei, consistent with supply', async () => { // Supply can grow between reads (block rewards / mint); bracket the // paginated totalSupply read with two supply(usei) reads. const before: bigint = await bank.supply('usei'); - const [coins] = (await bank.totalSupply(new Uint8Array())) as [ - Array<{ amount: bigint; denom: string }>, - Uint8Array, - ]; + + // totalSupply sends no page limit, so the bank module's default of + // 100 applies. Supply is keyed by denom and every factory/… denom + // this suite mints sorts before usei, so on a long-lived devnet usei + // is not on page one and only walking the pages finds it. + let pageKey: Uint8Array = new Uint8Array(); + let usei: { amount: bigint; denom: string } | undefined; + for (let page = 0; page < 50 && usei === undefined; page++) { + const [coins, nextKey] = (await bank.totalSupply(pageKey)) as [ + Array<{ amount: bigint; denom: string }>, + string, + ]; + expect(coins, 'totalSupply page').to.be.an('array'); + usei = coins.find(c => c.denom === 'usei'); + const next = ethers.getBytes(nextKey); + if (next.length === 0) break; + pageKey = next; + } + const after: bigint = await bank.supply('usei'); - const usei = coins.find(c => c.denom === 'usei'); expect(usei, 'totalSupply must contain a usei entry').to.not.equal(undefined); expect(usei!.amount >= before, `totalSupply ${usei!.amount} >= supply-before ${before}`).to.equal( true, diff --git a/integration_test/precompile_tests/precompiles/distribution.spec.ts b/integration_test/precompile_tests/precompiles/distribution.spec.ts index 423c20aba0..55b143172a 100644 --- a/integration_test/precompile_tests/precompiles/distribution.spec.ts +++ b/integration_test/precompile_tests/precompiles/distribution.spec.ts @@ -27,8 +27,21 @@ import { readRuntimeState, claimPool, RuntimeState } from '../utils/testUtils'; type DistrCoin = { amount: bigint; decimals: bigint; denom: string }; -function expectCoinArray(coins: readonly DistrCoin[], label: string): void { +/** + * Asserts the shape of a decoded DecCoin array. Non-emptiness is required by + * default: the per-coin loop never runs on an empty array, which would leave + * the whole assertion vacuous. `mayBeEmpty` opts out where emptiness is a + * legitimate chain state rather than a defect. + */ +function expectCoinArray( + coins: readonly DistrCoin[], + label: string, + { mayBeEmpty = false }: { mayBeEmpty?: boolean } = {}, +): void { expect(coins, `${label} is an array`).to.be.an('array'); + if (!mayBeEmpty) { + expect(coins.length, `${label} must not be empty`).to.be.greaterThan(0); + } for (const coin of coins) { expect(coin.denom, `${label} denom`).to.be.a('string').and.not.equal(''); expect(typeof coin.amount, `${label} amount`).to.equal('bigint'); @@ -233,7 +246,11 @@ describe('distribution precompile (0x1007)', function () { expect(viaPrecompile).to.equal(lcd.withdraw_address); }); - it('delegationRewards, validatorOutstandingRewards and validatorCommission return coin arrays', async () => { + it('delegationRewards, validatorOutstandingRewards and validatorCommission are non-empty', async () => { + // All three are non-empty by construction: the fixture delegation + // keeps accruing to `validator` every block, the validator's own + // rewards are never withdrawn here, and its commission rate cannot + // be zero (the devnet's min_commission_rate is 5%). const [delegation, outstanding, commission] = await Promise.all([ distribution.delegationRewards(delegator.address, validator), distribution.validatorOutstandingRewards(validator), @@ -265,8 +282,13 @@ describe('distribution precompile (0x1007)', function () { } }); - it('communityPool returns coins', async () => { - expectCoinArray(await distribution.communityPool(), 'communityPool'); + it('communityPool returns coins, or nothing when community_tax is 0', async () => { + // sei-cosmos defaults community_tax to 0 and the devnet does not + // override it, so no reward is ever skimmed into the pool and an + // empty pool is the correct answer rather than a defect. + expectCoinArray(await distribution.communityPool(), 'communityPool', { + mayBeEmpty: true, + }); }); }); diff --git a/integration_test/precompile_tests/precompiles/gov.spec.ts b/integration_test/precompile_tests/precompiles/gov.spec.ts index 659535c07b..8278e9dcdb 100644 --- a/integration_test/precompile_tests/precompiles/gov.spec.ts +++ b/integration_test/precompile_tests/precompiles/gov.spec.ts @@ -421,7 +421,10 @@ describe('gov precompile (0x1006)', function () { }); describe('dispatch semantics (via PrecompileCaller)', () => { - it('all methods are rejected under STATICCALL (gov has no view methods)', async () => { + // The executor dispatches its query methods before the readOnly check, + // so gov views answer under STATICCALL and only the transaction methods + // are refused. + it('transaction methods are rejected under STATICCALL (readOnly guard)', async () => { const data = govIface.encodeFunctionData('vote', [1n, 1]); await expectVmError( caller.getFunction('staticcallTarget').send(PRECOMPILE_ADDRESSES.gov, data, { @@ -441,16 +444,6 @@ describe('gov precompile (0x1006)', function () { ); }); - it('vote is rejected under STATICCALL', async () => { - const data = govIface.encodeFunctionData('vote', [1n, 1]); - await expectVmError( - caller.getFunction('staticcallTarget').send(PRECOMPILE_ADDRESSES.gov, data, { - gasLimit: 500_000, - }), - 'cannot call gov precompile from staticcall', - ); - }); - it('proposal() is callable via STATICCALL', async () => { const id = await submitProposal(textProposal('e2e staticcall proposal'), MIN_DEPOSIT_WEI); const data = govIface.encodeFunctionData('proposal', [id]); diff --git a/integration_test/precompile_tests/precompiles/slashing.spec.ts b/integration_test/precompile_tests/precompiles/slashing.spec.ts index 500c022d0f..12860b55ef 100644 --- a/integration_test/precompile_tests/precompiles/slashing.spec.ts +++ b/integration_test/precompile_tests/precompiles/slashing.spec.ts @@ -91,12 +91,18 @@ describe('slashing precompile (0x1014)', function () { }); it('signingInfos(empty) is non-empty and signingInfo(that cons address) matches', async () => { - const listed: ethers.Result = await slashing.signingInfos(EMPTY_PAGE); + // indexOffset advances every block for every validator in the last + // commit (and missedBlocksCounter can move with it) — pin both reads + // to the same height or the equality below races block production. + const blockTag = await provider.getBlockNumber(); + const listed: ethers.Result = await slashing.signingInfos(EMPTY_PAGE, { blockTag }); expect(listed.signingInfos.length, 'devnet validators must have signing info').to.be.greaterThan( 0, ); const first = listed.signingInfos[0]; - const viaOne: ethers.Result = await slashing.signingInfo(first.validatorAddress); + const viaOne: ethers.Result = await slashing.signingInfo(first.validatorAddress, { + blockTag, + }); expect(asSigningInfo(viaOne)).to.deep.equal(asSigningInfo(first)); }); From 2d967257bf681fc6738173665377eb4d4e6b8191 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Wed, 26 Aug 2026 17:02:31 +0200 Subject: [PATCH 3/3] Query precompile parity oracles over ABCI instead of the LCD The precompile suite read its Cosmos-side oracle from the LCD at localhost:1317. No container in the integration cluster publishes that port, so every cosmosRest call was refused and 15 tests failed. Replace cosmosRest with utils/moduleQueries.ts, which calls each module's protobuf Query service through abci_query on the Tendermint RPC port the suite already uses. That routes through the same handlers the node's gRPC server exposes, so the answers are identical and no cluster port changes. stakingHistoricalInfo now separates the module's not-found answer from a transport failure. The old .catch(() => undefined) read a refused connection as "this height is not retained" and then asserted the precompile must revert, which is what turned a connection failure into a "tx must fail" assertion. Co-Authored-By: Claude Opus 5 --- integration_test/precompile_tests/README.md | 1 - .../precompile_tests/config/endpoints.ts | 1 - .../precompile_tests/precompiles/auth.spec.ts | 99 ++---- .../precompiles/authz.spec.ts | 47 +-- .../precompile_tests/precompiles/bank.spec.ts | 19 +- .../precompiles/distribution.spec.ts | 65 ++-- .../precompiles/evidence.spec.ts | 10 +- .../precompile_tests/precompiles/mint.spec.ts | 92 ++--- .../precompiles/slashing.spec.ts | 46 +-- .../precompiles/staking.spec.ts | 24 +- .../precompiles/upgrade.spec.ts | 31 +- .../precompile_tests/utils/cosmosUtils.ts | 30 +- .../precompile_tests/utils/moduleQueries.ts | 331 ++++++++++++++++++ 13 files changed, 503 insertions(+), 293 deletions(-) create mode 100644 integration_test/precompile_tests/utils/moduleQueries.ts diff --git a/integration_test/precompile_tests/README.md b/integration_test/precompile_tests/README.md index b9a1e6a695..b1b2fa0c91 100644 --- a/integration_test/precompile_tests/README.md +++ b/integration_test/precompile_tests/README.md @@ -181,7 +181,6 @@ accounts. | --- | --- | | `SEI_EVM_RPC` | `http://localhost:8545` | | `SEI_COSMOS_RPC` | `http://localhost:26657` | -| `SEI_REST` | `http://localhost:1317` | | `SEI_ADMIN_MNEMONIC` | unset (a random admin is minted and funded via the docker devnet) | | `PRECOMPILE_TESTS_RUNTIME_STATE` | `runtime/runtime.json` | | `PRECOMPILE_POLLING_INTERVAL_MS` | `100` (Sei blocks are ~400ms; ethers default 4s is too slow) | diff --git a/integration_test/precompile_tests/config/endpoints.ts b/integration_test/precompile_tests/config/endpoints.ts index 54ffaa73b5..a79523bfcc 100644 --- a/integration_test/precompile_tests/config/endpoints.ts +++ b/integration_test/precompile_tests/config/endpoints.ts @@ -7,7 +7,6 @@ export const Endpoints = { sei: { evmRpc: env('SEI_EVM_RPC', 'http://localhost:8545'), cosmosRpc: env('SEI_COSMOS_RPC', 'http://localhost:26657'), - rest: env('SEI_REST', 'http://localhost:1317'), }, } as const; diff --git a/integration_test/precompile_tests/precompiles/auth.spec.ts b/integration_test/precompile_tests/precompiles/auth.spec.ts index 8bf1cf6834..b4424f05ee 100644 --- a/integration_test/precompile_tests/precompiles/auth.spec.ts +++ b/integration_test/precompile_tests/precompiles/auth.spec.ts @@ -1,9 +1,9 @@ /** * auth precompile (0x…100D) — account queries against a live Sei chain. * - * All four methods are views. The parity oracle is the auth module's LCD - * (`/cosmos/auth/v1beta1/...`); the Go executor has no delegatecall guard - * (unlike staking/gov), so CALL, STATICCALL and DELEGATECALL all succeed. + * All four methods are views. The parity oracle is the auth module's own Query + * service; the Go executor has no delegatecall guard (unlike staking/gov), so + * CALL, STATICCALL and DELEGATECALL all succeed. * * `nextAccountNumber` queries a keeper method that increments the persisted * counter, which the executor neutralises by branching a CacheContext. That @@ -15,7 +15,13 @@ import { ethers } from 'ethers'; import { expect } from 'chai'; import { seiRpc, rawSei } from '../utils/chainUtils'; import { EvmAccount } from '../utils/evmUtils'; -import { cosmosRest } from '../utils/cosmosUtils'; +import { + authAccount, + authAccounts, + authNextAccountNumber, + authParams, + baseAccount, +} from '../utils/moduleQueries'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -29,44 +35,6 @@ const empty = new Uint8Array(); const uint64 = (v: string | number | bigint | undefined): bigint => BigInt(v ?? 0); -/** - * A row from /cosmos/auth/v1beta1/accounts, which is a protojson `Any`: a plain - * account carries its fields at the top level, while module and vesting - * accounts nest a base account one or two levels down. - */ -interface LcdAccount { - address?: string; - account_number?: string; - sequence?: string; - base_account?: LcdAccount; - base_vesting_account?: LcdAccount; -} - -/** - * The account fields for an LCD row, unwrapping the nested base account that - * module and vesting accounts wrap theirs in. The precompile reads through - * AccountI, so it reports these for every account type and the comparison has - * to reach them too. - */ -function lcdBaseAccount(row: LcdAccount): LcdAccount { - let current = row; - while (current.address === undefined) { - const next = current.base_account ?? current.base_vesting_account; - if (next === undefined) return current; - current = next; - } - return current; -} - -interface LcdAuthParams { - max_memo_characters: string; - tx_sig_limit: string; - tx_size_cost_per_byte: string; - sig_verify_cost_ed25519: string; - sig_verify_cost_secp256k1: string; - disable_seqno_check?: boolean; -} - describe('auth precompile (0x100D)', function () { this.timeout(120 * 1000); @@ -86,13 +54,14 @@ describe('auth precompile (0x100D)', function () { }); describe('happy path & state parity', () => { - it('account(admin) matches LCD address, account_number and sequence', async () => { + it('account(admin) matches the module address, account_number and sequence', async () => { const sei = admin.seiAddress(); - const [via, lcd] = await Promise.all([ + const [via, stored] = await Promise.all([ auth.account(admin.address), - cosmosRest<{ account: LcdAccount }>(`/cosmos/auth/v1beta1/accounts/${sei}`), + authAccount(sei), ]); - const expected = lcdBaseAccount(lcd.account); + expect(stored, 'the auth module must know the admin account').to.not.equal(undefined); + const expected = baseAccount(stored!); expect(via.accountAddress).to.equal(sei); expect(via.accountAddress).to.equal(expected.address); expect(via.accountNumber).to.equal(uint64(expected.account_number)); @@ -100,42 +69,34 @@ describe('auth precompile (0x100D)', function () { }); it('accounts(empty) returns the same first page as the auth module', async () => { - const [page, lcd] = await Promise.all([ - auth.accounts(empty), - cosmosRest<{ accounts: LcdAccount[] }>('/cosmos/auth/v1beta1/accounts'), - ]); + const [page, stored] = await Promise.all([auth.accounts(empty), authAccounts()]); expect(page.accounts.length, 'first page of auth accounts').to.be.greaterThan(0); // Both read the first page with the module's default page size, so the // rows must line up in order, not merely in count. expect( [...page.accounts].map((a: { accountAddress: string }) => a.accountAddress), - 'accounts() first page vs LCD', - ).to.deep.equal(lcd.accounts.map(a => lcdBaseAccount(a).address)); + 'accounts() first page vs the auth module', + ).to.deep.equal(stored.map(a => baseAccount(a).address)); }); - it('params() matches LCD /cosmos/auth/v1beta1/params', async () => { - const [via, lcd] = await Promise.all([ - auth.params(), - cosmosRest<{ params: LcdAuthParams }>('/cosmos/auth/v1beta1/params'), - ]); - const p = lcd.params; - expect(via.maxMemoCharacters).to.equal(uint64(p.max_memo_characters)); - expect(via.txSigLimit).to.equal(uint64(p.tx_sig_limit)); - expect(via.txSizeCostPerByte).to.equal(uint64(p.tx_size_cost_per_byte)); - expect(via.sigVerifyCostEd25519).to.equal(uint64(p.sig_verify_cost_ed25519)); - expect(via.sigVerifyCostSecp256k1).to.equal(uint64(p.sig_verify_cost_secp256k1)); - expect(via.disableSeqnoCheck).to.equal(Boolean(p.disable_seqno_check)); + it('params() matches the auth module', async () => { + const [via, p] = await Promise.all([auth.params(), authParams()]); + expect(p, 'the auth module must report params').to.not.equal(undefined); + expect(via.maxMemoCharacters).to.equal(uint64(p!.max_memo_characters)); + expect(via.txSigLimit).to.equal(uint64(p!.tx_sig_limit)); + expect(via.txSizeCostPerByte).to.equal(uint64(p!.tx_size_cost_per_byte)); + expect(via.sigVerifyCostEd25519).to.equal(uint64(p!.sig_verify_cost_ed25519)); + expect(via.sigVerifyCostSecp256k1).to.equal(uint64(p!.sig_verify_cost_secp256k1)); + expect(via.disableSeqnoCheck).to.equal(Boolean(p!.disable_seqno_check)); }); it('nextAccountNumber() matches the auth module and is past the admin account', async () => { - const [count, lcd, account] = await Promise.all([ + const [count, next, account] = await Promise.all([ auth.nextAccountNumber() as Promise, - // QueryNextAccountNumberResponse names the field `count`, not - // `next_account_number`. - cosmosRest<{ count: string }>('/cosmos/auth/v1beta1/nextaccountnumber'), + authNextAccountNumber(), auth.account(admin.address), ]); - expect(count, 'nextAccountNumber vs LCD').to.equal(uint64(lcd.count)); + expect(count, 'nextAccountNumber vs the auth module').to.equal(uint64(next)); expect(count > account.accountNumber, 'next number is past every issued number').to.equal( true, ); diff --git a/integration_test/precompile_tests/precompiles/authz.spec.ts b/integration_test/precompile_tests/precompiles/authz.spec.ts index d43314ffb3..068be6c777 100644 --- a/integration_test/precompile_tests/precompiles/authz.spec.ts +++ b/integration_test/precompile_tests/precompiles/authz.spec.ts @@ -5,13 +5,14 @@ * non-empty fixture is a staking.grantStakingAuthorization (three * StakeAuthorization grants: delegate / redelegate / undelegate). Empty-pair * checks use a separate associated pair that never grants. Parity oracle is - * LCD GET /cosmos/authz/v1beta1/grants?granter=&grantee=. + * the authz module's own Grants query for the same granter/grantee pair. */ import { ethers } from 'ethers'; import { expect } from 'chai'; import { seiRpc, waitUntil } from '../utils/chainUtils'; import { EvmAccount, associateViaTx } from '../utils/evmUtils'; -import { bondedValidators, cosmosRest } from '../utils/cosmosUtils'; +import { bondedValidators } from '../utils/cosmosUtils'; +import { authzGrants } from '../utils/moduleQueries'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -24,20 +25,10 @@ import { readRuntimeState, claimPool, RuntimeState } from '../utils/testUtils'; const emptyPage = new Uint8Array(); const MAX_TOKENS = 1_000_000n; -interface LcdGrant { - authorization?: { '@type'?: string }; - expiration?: string; -} - -interface LcdGrants { - grants?: LcdGrant[] | null; -} - const authorizationJson = (authorization: string): string => ethers.toUtf8String(authorization); -const lcdExpirationUnix = (expiration: string): bigint => { - const trimmed = expiration.replace(/\.\d+(Z|[+-]\d{2}:\d{2})$/, '$1'); - return BigInt(Math.floor(Date.parse(trimmed) / 1000)); +const expirationUnix = (expiration: Date | undefined): bigint => { + return BigInt(Math.floor((expiration?.getTime() ?? 0) / 1000)); }; describe('authz precompile (0x100E)', function () { @@ -167,24 +158,22 @@ describe('authz precompile (0x100E)', function () { }); }); - describe('LCD parity / unassociated granter-grantee / STATICCALL', () => { - it('LCD /cosmos/authz/v1beta1/grants matches grants() count and expiration', async () => { - const path = - `/cosmos/authz/v1beta1/grants?granter=${encodeURIComponent(granter.seiAddress())}` + - `&grantee=${encodeURIComponent(grantee.seiAddress())}`; - const [viaPrecompile, lcd] = await Promise.all([ + describe('module parity / unassociated granter-grantee / STATICCALL', () => { + it('the authz module matches grants() count and expiration', async () => { + const [viaPrecompile, moduleGrants] = await Promise.all([ authz.grants(granter.address, grantee.address, '', emptyPage), - cosmosRest(path), + authzGrants(granter.seiAddress(), grantee.seiAddress()), ]); - const lcdGrants = lcd.grants ?? []; - expect(lcdGrants.length, 'LCD grant count').to.equal(viaPrecompile.grants.length); - expect(lcdGrants.length).to.be.greaterThan(0); + expect(moduleGrants.length, 'authz module grant count').to.equal( + viaPrecompile.grants.length, + ); + expect(moduleGrants.length).to.be.greaterThan(0); const precompileTypes = [...viaPrecompile.grants] .map((g: { authorization: string }) => JSON.parse(authorizationJson(g.authorization))['@type']) .sort(); - const lcdTypes = lcdGrants.map(g => g.authorization?.['@type']).sort(); - expect(precompileTypes).to.deep.equal(lcdTypes); + const moduleTypes = moduleGrants.map(g => g.authorization?.type_url).sort(); + expect(precompileTypes).to.deep.equal(moduleTypes); // The two lists are not guaranteed to be in the same order, and all // three grants in this fixture share one expiration, so an @@ -193,10 +182,8 @@ describe('authz precompile (0x100E)', function () { const precompileExpirations = [...viaPrecompile.grants] .map((g: { expiration: bigint }) => g.expiration) .sort(); - const lcdExpirations = lcdGrants - .map(g => lcdExpirationUnix(g.expiration ?? '')) - .sort(); - expect(precompileExpirations).to.deep.equal(lcdExpirations); + const moduleExpirations = moduleGrants.map(g => expirationUnix(g.expiration)).sort(); + expect(precompileExpirations).to.deep.equal(moduleExpirations); }); it('grants reverts for an unassociated granter or grantee', async () => { diff --git a/integration_test/precompile_tests/precompiles/bank.spec.ts b/integration_test/precompile_tests/precompiles/bank.spec.ts index e679442110..09cf906703 100644 --- a/integration_test/precompile_tests/precompiles/bank.spec.ts +++ b/integration_test/precompile_tests/precompiles/bank.spec.ts @@ -16,8 +16,8 @@ import { bankSupplyOf, generateSeiAddress, createTokenfactoryDenom, - cosmosRest, } from '../utils/cosmosUtils'; +import { bankParams } from '../utils/moduleQueries'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -198,18 +198,13 @@ describe('bank precompile (0x1001)', function () { }); it('params().defaultSendEnabled matches the bank module', async () => { - const [bankParams, lcd] = await Promise.all([ - bank.params(), - cosmosRest<{ params?: { default_send_enabled?: boolean } }>( - '/cosmos/bank/v1beta1/params', - ), - ]); - // The LCD marshals with OrigName and EmitDefaults, so the flag is - // present and snake_case even when false. - const lcdFlag = lcd.params?.default_send_enabled; - expect(bankParams.defaultSendEnabled, 'precompile vs LCD').to.equal(lcdFlag); + const [viaPrecompile, params] = await Promise.all([bank.params(), bankParams()]); + expect( + viaPrecompile.defaultSendEnabled, + 'precompile vs the bank module', + ).to.equal(params?.default_send_enabled ?? false); // Sends are on for this devnet; the sendNative test above depends on it. - expect(bankParams.defaultSendEnabled).to.equal(true); + expect(viaPrecompile.defaultSendEnabled).to.equal(true); }); it('denomMetadata returns tokenfactory metadata for a created denom', async () => { diff --git a/integration_test/precompile_tests/precompiles/distribution.spec.ts b/integration_test/precompile_tests/precompiles/distribution.spec.ts index 55b143172a..6a29563bba 100644 --- a/integration_test/precompile_tests/precompiles/distribution.spec.ts +++ b/integration_test/precompile_tests/precompiles/distribution.spec.ts @@ -14,7 +14,13 @@ import { ethers } from 'ethers'; import { expect } from 'chai'; import { seiRpc, waitUntil } from '../utils/chainUtils'; import { EvmAccount, associateViaTx } from '../utils/evmUtils'; -import { bondedValidators, cosmosQuery, cosmosRest, bankBalance } from '../utils/cosmosUtils'; +import { bondedValidators, cosmosQuery, bankBalance } from '../utils/cosmosUtils'; +import { + decString, + delegatorWithdrawAddress, + distributionParams, + validatorSlashes, +} from '../utils/moduleQueries'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -212,22 +218,20 @@ describe('distribution precompile (0x1007)', function () { }); describe('query methods', () => { - it('params matches LCD /cosmos/distribution/v1beta1/params', async () => { - const [viaPrecompile, lcd] = await Promise.all([ + it('params matches the distribution module', async () => { + const [viaPrecompile, params] = await Promise.all([ distribution.params(), - cosmosRest<{ - params: { - community_tax: string; - base_proposer_reward: string; - bonus_proposer_reward: string; - withdraw_addr_enabled: boolean; - }; - }>('/cosmos/distribution/v1beta1/params'), + distributionParams(), ]); - expect(viaPrecompile.communityTax).to.equal(lcd.params.community_tax); - expect(viaPrecompile.baseProposerReward).to.equal(lcd.params.base_proposer_reward); - expect(viaPrecompile.bonusProposerReward).to.equal(lcd.params.bonus_proposer_reward); - expect(viaPrecompile.withdrawAddrEnabled).to.equal(lcd.params.withdraw_addr_enabled); + expect(params, 'the distribution module must report params').to.not.equal(undefined); + expect(viaPrecompile.communityTax).to.equal(decString(params!.community_tax)); + expect(viaPrecompile.baseProposerReward).to.equal( + decString(params!.base_proposer_reward), + ); + expect(viaPrecompile.bonusProposerReward).to.equal( + decString(params!.bonus_proposer_reward), + ); + expect(viaPrecompile.withdrawAddrEnabled).to.equal(params!.withdraw_addr_enabled); }); it('delegatorValidators includes the fixture validator', async () => { @@ -235,15 +239,12 @@ describe('distribution precompile (0x1007)', function () { expect(validators).to.include(validator); }); - it('delegatorWithdrawAddress matches LCD rather than assuming the default', async () => { - const sei = delegator.seiAddress(); - const [viaPrecompile, lcd] = await Promise.all([ + it('delegatorWithdrawAddress matches the module rather than assuming the default', async () => { + const [viaPrecompile, withdrawAddress] = await Promise.all([ distribution.delegatorWithdrawAddress(delegator.address) as Promise, - cosmosRest<{ withdraw_address: string }>( - `/cosmos/distribution/v1beta1/delegators/${sei}/withdraw_address`, - ), + delegatorWithdrawAddress(delegator.seiAddress()), ]); - expect(viaPrecompile).to.equal(lcd.withdraw_address); + expect(viaPrecompile).to.equal(withdrawAddress); }); it('delegationRewards, validatorOutstandingRewards and validatorCommission are non-empty', async () => { @@ -263,22 +264,22 @@ describe('distribution precompile (0x1007)', function () { it('validatorSlashes matches the distribution module over the same height range', async () => { const endingHeight = 1_000_000_000; - const [result, lcd] = await Promise.all([ + const [result, moduleSlashes] = await Promise.all([ distribution.validatorSlashes(validator, 1, endingHeight, new Uint8Array()), - cosmosRest<{ slashes?: Array<{ validator_period: string; fraction: string }> }>( - `/cosmos/distribution/v1beta1/validators/${validator}/slashes` + - `?starting_height=1&ending_height=${endingHeight}`, - ), + validatorSlashes(validator, 1, endingHeight), ]); const slashes = (result.slashes ?? result[0]) as Array<{ validatorPeriod: bigint; fraction: string; }>; - const lcdSlashes = lcd.slashes ?? []; - expect(slashes.length, 'validatorSlashes count vs LCD').to.equal(lcdSlashes.length); - for (let i = 0; i < lcdSlashes.length; i++) { - expect(slashes[i].validatorPeriod).to.equal(BigInt(lcdSlashes[i].validator_period)); - expect(slashes[i].fraction).to.equal(lcdSlashes[i].fraction); + expect(slashes.length, 'validatorSlashes count vs the module').to.equal( + moduleSlashes.length, + ); + for (let i = 0; i < moduleSlashes.length; i++) { + expect(slashes[i].validatorPeriod).to.equal( + BigInt(moduleSlashes[i].validator_period), + ); + expect(slashes[i].fraction).to.equal(decString(moduleSlashes[i].fraction)); } }); diff --git a/integration_test/precompile_tests/precompiles/evidence.spec.ts b/integration_test/precompile_tests/precompiles/evidence.spec.ts index 668b969aaf..8aa1f6e524 100644 --- a/integration_test/precompile_tests/precompiles/evidence.spec.ts +++ b/integration_test/precompile_tests/precompiles/evidence.spec.ts @@ -19,7 +19,7 @@ import { expectExecutionReverted, } from '../utils/precompileUtils'; import { readRuntimeState, RuntimeState } from '../utils/testUtils'; -import { cosmosRest } from '../utils/cosmosUtils'; +import { allEvidence } from '../utils/moduleQueries'; const EMPTY_PAGE = new Uint8Array(); /** Nonzero 32-byte hash that will not exist on a clean chain. */ @@ -49,12 +49,12 @@ describe('evidence precompile (0x100F)', function () { // slashed validator into a spurious failure, so the module's own list is // the oracle and the count has to agree either way. it('allEvidence matches the evidence module', async () => { - const [resp, lcd] = await Promise.all([ + const [resp, stored] = await Promise.all([ evidence.allEvidence(EMPTY_PAGE), - cosmosRest<{ evidence?: unknown[] | null }>('/cosmos/evidence/v1beta1/evidence'), + allEvidence(), ]); - expect(resp.evidenceList.length, 'allEvidence count vs LCD').to.equal( - (lcd.evidence ?? []).length, + expect(resp.evidenceList.length, 'allEvidence count vs the module').to.equal( + stored.length, ); for (const row of resp.evidenceList) { // Each entry is the JSON encoding of the stored evidence. diff --git a/integration_test/precompile_tests/precompiles/mint.spec.ts b/integration_test/precompile_tests/precompiles/mint.spec.ts index 8c27727de7..e3159856df 100644 --- a/integration_test/precompile_tests/precompiles/mint.spec.ts +++ b/integration_test/precompile_tests/precompiles/mint.spec.ts @@ -3,12 +3,12 @@ * * Two views: params() and minter(). The backing module is Sei's own * seiprotocol.seichain.mint, not cosmos-sdk x/mint, so the parity oracle is - * that module's LCD routes rather than a /cosmos/... path. + * that module's Query service rather than a cosmos.* one. */ import { ethers } from 'ethers'; import { expect } from 'chai'; import { seiRpc, rawSei } from '../utils/chainUtils'; -import { cosmosRest } from '../utils/cosmosUtils'; +import { mintMinter, mintParams } from '../utils/moduleQueries'; import { EvmAccount } from '../utils/evmUtils'; import { PRECOMPILE_ADDRESSES, @@ -40,12 +40,6 @@ interface Minter { lastMintHeight: bigint; } -// The gRPC-gateway routes registered by x/mint (see the patterns in -// x/mint/types/query.pb.gw.go). Sei's mint module keeps the bare `seichain` -// prefix rather than the `seiprotocol.seichain` proto package path. -const PARAMS_LCD_PATH = '/seichain/mint/v1beta1/params'; -const MINTER_LCD_PATH = '/seichain/mint/v1beta1/minter'; - function asBigInt(v: unknown): bigint { if (typeof v === 'bigint') return v; if (typeof v === 'number') return BigInt(v); @@ -53,27 +47,6 @@ function asBigInt(v: unknown): bigint { return 0n; } -function lcdMintDenom(body: any): string | undefined { - const p = body?.params ?? body; - const denom = p?.mint_denom ?? p?.mintDenom; - return typeof denom === 'string' ? denom : undefined; -} - -function lcdSchedule(body: any): any[] { - const p = body?.params ?? body; - const s = p?.token_release_schedule ?? p?.tokenReleaseSchedule; - return Array.isArray(s) ? s : []; -} - -/** - * QueryMinterResponse is flat — it carries the minter's fields at the top - * level rather than nesting them under a `minter` key the way the params - * response nests under `params`. - */ -function lcdMinter(body: any): any { - return body ?? {}; -} - describe('mint precompile (0x1012)', function () { this.timeout(120 * 1000); @@ -94,51 +67,42 @@ describe('mint precompile (0x1012)', function () { describe('happy path & state parity', () => { it('params() matches the mint module', async () => { - const [params, lcd] = await Promise.all([ + const [params, moduleParams] = await Promise.all([ mint.params() as Promise, - cosmosRest(PARAMS_LCD_PATH), + mintParams(), ]); - expect(params.mintDenom, 'precompile mintDenom vs LCD').to.equal(lcdMintDenom(lcd)); + expect(params.mintDenom, 'precompile mintDenom vs the mint module').to.equal( + moduleParams?.mint_denom, + ); expect(params.mintDenom, 'devnet genesis mints the bond denom').to.equal('usei'); const schedule = [...params.tokenReleaseSchedule]; - const lcdRows = lcdSchedule(lcd); - expect(schedule.length, 'tokenReleaseSchedule length vs LCD').to.equal(lcdRows.length); + const moduleRows = moduleParams?.token_release_schedule ?? []; + expect(schedule.length, 'tokenReleaseSchedule length vs the mint module').to.equal( + moduleRows.length, + ); for (let i = 0; i < schedule.length; i++) { - const row = lcdRows[i]; - expect(schedule[i].startDate).to.equal(String(row.start_date ?? row.startDate ?? '')); - expect(schedule[i].endDate).to.equal(String(row.end_date ?? row.endDate ?? '')); - expect(schedule[i].tokenReleaseAmount).to.equal( - asBigInt(row.token_release_amount ?? row.tokenReleaseAmount), - ); + const row = moduleRows[i]; + expect(schedule[i].startDate).to.equal(row.start_date); + expect(schedule[i].endDate).to.equal(row.end_date); + expect(schedule[i].tokenReleaseAmount).to.equal(asBigInt(row.token_release_amount)); } }); it('minter() matches the mint module', async () => { - const [minter, lcd] = await Promise.all([ - mint.minter() as Promise, - cosmosRest(MINTER_LCD_PATH), - ]); - const body = lcdMinter(lcd); - - expect(minter.denom).to.equal(String(body.denom ?? '')); - expect(minter.startDate).to.equal(String(body.start_date ?? body.startDate ?? '')); - expect(minter.endDate).to.equal(String(body.end_date ?? body.endDate ?? '')); - expect(minter.totalMintAmount).to.equal( - asBigInt(body.total_mint_amount ?? body.totalMintAmount), - ); - expect(minter.remainingMintAmount).to.equal( - asBigInt(body.remaining_mint_amount ?? body.remainingMintAmount), - ); - expect(minter.lastMintAmount).to.equal( - asBigInt(body.last_mint_amount ?? body.lastMintAmount), - ); - expect(minter.lastMintDate).to.equal( - String(body.last_mint_date ?? body.lastMintDate ?? ''), - ); - expect(minter.lastMintHeight).to.equal( - asBigInt(body.last_mint_height ?? body.lastMintHeight), - ); + // QueryMinterResponse is flat — it carries the minter's fields at the + // top level rather than nesting them under a `minter` key the way the + // params response nests under `params`. + const [minter, body] = await Promise.all([mint.minter() as Promise, mintMinter()]); + + expect(minter.denom).to.equal(body.denom); + expect(minter.startDate).to.equal(body.start_date); + expect(minter.endDate).to.equal(body.end_date); + expect(minter.totalMintAmount).to.equal(asBigInt(body.total_mint_amount)); + expect(minter.remainingMintAmount).to.equal(asBigInt(body.remaining_mint_amount)); + expect(minter.lastMintAmount).to.equal(asBigInt(body.last_mint_amount)); + expect(minter.lastMintDate).to.equal(body.last_mint_date); + expect(minter.lastMintHeight).to.equal(asBigInt(body.last_mint_height)); // Genesis may leave the minter unset; when it is set it mints the bond denom. if (minter.denom !== '') { expect(minter.denom).to.equal('usei'); diff --git a/integration_test/precompile_tests/precompiles/slashing.spec.ts b/integration_test/precompile_tests/precompiles/slashing.spec.ts index 12860b55ef..c7370668cf 100644 --- a/integration_test/precompile_tests/precompiles/slashing.spec.ts +++ b/integration_test/precompile_tests/precompiles/slashing.spec.ts @@ -1,7 +1,7 @@ /** * slashing precompile (0x…1014) — end-to-end semantics against a live Sei chain. * - * Query methods are checked against the slashing module's LCD. Write methods + * Query methods are checked against the slashing module itself. Write methods * that would jail a validator are out of scope: grant/revoke of unjail * authorization are exercised from a pool account, and unjail is only asserted * to revert when the caller is not a jailed validator. @@ -10,7 +10,7 @@ import { ethers } from 'ethers'; import { expect } from 'chai'; import { seiRpc, rawSei } from '../utils/chainUtils'; import { EvmAccount, associateViaTx } from '../utils/evmUtils'; -import { cosmosRest } from '../utils/cosmosUtils'; +import { decString, slashingParams } from '../utils/moduleQueries'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -23,25 +23,6 @@ import { readRuntimeState, claimPool, RuntimeState } from '../utils/testUtils'; const EMPTY_PAGE = new Uint8Array(); -interface LcdSlashingParams { - params: { - signed_blocks_window: string; - min_signed_per_window: string; - downtime_jail_duration: string; - slash_fraction_double_sign: string; - slash_fraction_downtime: string; - }; -} - -/** protojson duration (`"600s"`) to whole seconds, matching the precompile's `.Seconds()`. */ -function protoDurationSeconds(raw: string): bigint { - const m = /^(-?[0-9]+(?:\.[0-9]+)?)s$/.exec(raw); - if (!m) { - throw new Error(`unexpected protojson duration: ${raw}`); - } - return BigInt(Math.trunc(Number(m[1]))); -} - function asSigningInfo(info: ethers.Result) { return { validatorAddress: String(info.validatorAddress), @@ -75,19 +56,24 @@ describe('slashing precompile (0x1014)', function () { }); describe('happy path & state parity', () => { - it('params() matches LCD /cosmos/slashing/v1beta1/params', async () => { - const [viaPrecompile, lcd] = await Promise.all([ + it('params() matches the slashing module', async () => { + const [viaPrecompile, p] = await Promise.all([ slashing.params() as Promise, - cosmosRest('/cosmos/slashing/v1beta1/params'), + slashingParams(), ]); - const p = lcd.params; - expect(viaPrecompile.signedBlocksWindow).to.equal(BigInt(p.signed_blocks_window)); - expect(viaPrecompile.minSignedPerWindow).to.equal(p.min_signed_per_window); + expect(p, 'the slashing module must report params').to.not.equal(undefined); + expect(viaPrecompile.signedBlocksWindow).to.equal(BigInt(p!.signed_blocks_window)); + expect(viaPrecompile.minSignedPerWindow).to.equal(decString(p!.min_signed_per_window)); + // The precompile reports the jail duration as whole seconds (`.Seconds()`). expect(viaPrecompile.downtimeJailDuration).to.equal( - protoDurationSeconds(p.downtime_jail_duration), + BigInt(p!.downtime_jail_duration?.seconds ?? 0), + ); + expect(viaPrecompile.slashFractionDoubleSign).to.equal( + decString(p!.slash_fraction_double_sign), + ); + expect(viaPrecompile.slashFractionDowntime).to.equal( + decString(p!.slash_fraction_downtime), ); - expect(viaPrecompile.slashFractionDoubleSign).to.equal(p.slash_fraction_double_sign); - expect(viaPrecompile.slashFractionDowntime).to.equal(p.slash_fraction_downtime); }); it('signingInfos(empty) is non-empty and signingInfo(that cons address) matches', async () => { diff --git a/integration_test/precompile_tests/precompiles/staking.spec.ts b/integration_test/precompile_tests/precompiles/staking.spec.ts index 961d2a1b1c..87f2cbedc9 100644 --- a/integration_test/precompile_tests/precompiles/staking.spec.ts +++ b/integration_test/precompile_tests/precompiles/staking.spec.ts @@ -17,7 +17,8 @@ import { generateKeyPairSync } from 'node:crypto'; import { fromBech32, toBech32 } from '@cosmjs/encoding'; import { seiRpc, waitUntil } from '../utils/chainUtils'; import { EvmAccount, associateViaTx } from '../utils/evmUtils'; -import { bondedValidators, cosmosQuery, cosmosRest, bankBalance } from '../utils/cosmosUtils'; +import { bondedValidators, cosmosQuery, bankBalance } from '../utils/cosmosUtils'; +import { stakingHistoricalInfo } from '../utils/moduleQueries'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -740,17 +741,14 @@ describe('staking precompile (0x1005)', function () { // The staking module only keeps historical info for the last // `historical_entries` heights, so whether a given height answers is a - // property of the chain, not of the precompile. The LCD is therefore the - // oracle: the precompile must agree with it about the SAME height, both - // on the answer and on the absence of one. + // property of the chain, not of the precompile. The module is therefore + // the oracle: the precompile must agree with it about the SAME height, + // both on the answer and on the absence of one. it('historicalInfo agrees with the staking module about a recent height', async () => { - const height = BigInt(Math.max((await provider.getBlockNumber()) - 2, 1)); - const lcd = await cosmosRest<{ - hist?: { valset?: Array<{ operator_address: string }> } | null; - code?: number; - }>(`/cosmos/staking/v1beta1/historical_info/${height}`).catch(() => undefined); + const height = Math.max((await provider.getBlockNumber()) - 2, 1); + const hist = await stakingHistoricalInfo(height); - if (lcd?.hist == null) { + if (hist == null) { // Not retained (historical_entries=0, or the height aged out): // the precompile must refuse it rather than invent an answer. The // reason surfaced is the staking querier's, not the executor's own @@ -768,12 +766,14 @@ describe('staking precompile (0x1005)', function () { } const info = await staking.historicalInfo(height); - expect(info.height, 'historicalInfo echoes the requested height').to.equal(height); + expect(info.height, 'historicalInfo echoes the requested height').to.equal( + BigInt(height), + ); const ops = [...(info.validators as ethers.Result)].map( (v: { operatorAddress: string }) => v.operatorAddress, ); expect(ops.slice().sort(), 'historical valset matches the staking module').to.deep.equal( - (lcd.hist.valset ?? []).map(v => v.operator_address).sort(), + hist.valset.map(v => v.operator_address).sort(), ); }); }); diff --git a/integration_test/precompile_tests/precompiles/upgrade.spec.ts b/integration_test/precompile_tests/precompiles/upgrade.spec.ts index 153d0544b2..5a9c484ce6 100644 --- a/integration_test/precompile_tests/precompiles/upgrade.spec.ts +++ b/integration_test/precompile_tests/precompiles/upgrade.spec.ts @@ -4,13 +4,13 @@ * All four methods are views. A local cluster has no scheduled upgrade and no * applied plan, so currentPlan is the zero plan, appliedPlan of an unknown * name is 0, and upgradedConsensusState is empty bytes. moduleVersions reads - * the app's real module consensus versions. currentPlan is checked against - * LCD /cosmos/upgrade/v1beta1/current_plan. + * the app's real module consensus versions. currentPlan is checked against the + * upgrade module's own CurrentPlan query. */ import { ethers } from 'ethers'; import { expect } from 'chai'; import { seiRpc, rawSei } from '../utils/chainUtils'; -import { cosmosRest } from '../utils/cosmosUtils'; +import { upgradeCurrentPlan } from '../utils/moduleQueries'; import { EvmAccount } from '../utils/evmUtils'; import { PRECOMPILE_ADDRESSES, @@ -21,16 +21,6 @@ import { } from '../utils/precompileUtils'; import { readRuntimeState, RuntimeState } from '../utils/testUtils'; -interface LcdPlan { - name?: string; - height?: string; - info?: string; -} - -interface LcdCurrentPlan { - plan?: LcdPlan | null; -} - describe('upgrade precompile (0x1015)', function () { this.timeout(120 * 1000); @@ -50,20 +40,19 @@ describe('upgrade precompile (0x1015)', function () { }); describe('happy path & state parity', () => { - it('currentPlan is the zero plan when none is scheduled (LCD parity)', async () => { - const [plan, lcd] = await Promise.all([ + it('currentPlan is the zero plan when none is scheduled (module parity)', async () => { + const [plan, scheduled] = await Promise.all([ upgrade.currentPlan(), - cosmosRest('/cosmos/upgrade/v1beta1/current_plan'), + upgradeCurrentPlan(), ]); - const lcdPlan = lcd.plan; - if (lcdPlan == null || lcdPlan.name == null || lcdPlan.name === '') { + if (scheduled == null || scheduled.name === '') { expect(plan.name).to.equal(''); expect(plan.height).to.equal(0n); expect(plan.info).to.equal(''); } else { - expect(plan.name).to.equal(lcdPlan.name); - expect(plan.height).to.equal(BigInt(lcdPlan.height ?? 0)); - expect(plan.info).to.equal(lcdPlan.info ?? ''); + expect(plan.name).to.equal(scheduled.name); + expect(plan.height).to.equal(BigInt(scheduled.height)); + expect(plan.info).to.equal(scheduled.info); } }); diff --git a/integration_test/precompile_tests/utils/cosmosUtils.ts b/integration_test/precompile_tests/utils/cosmosUtils.ts index 1374899b0c..9f6d2e6b5e 100644 --- a/integration_test/precompile_tests/utils/cosmosUtils.ts +++ b/integration_test/precompile_tests/utils/cosmosUtils.ts @@ -42,8 +42,21 @@ export type CosmosQueryClient = QueryClient & GovExtension & DistributionExtension; +let tendermintPromise: Promise | undefined; let clientPromise: Promise | undefined; +/** + * The suite's shared Tendermint RPC connection (`SEI_COSMOS_RPC`). Every + * Cosmos-side read goes through it, including the raw `abci_query` calls in + * moduleQueries. + */ +export async function tendermintClient(): Promise { + if (!tendermintPromise) { + tendermintPromise = Tendermint34Client.connect(Endpoints.sei.cosmosRpc); + } + return tendermintPromise; +} + /** * The suite's Cosmos-side parity oracle: one shared query client over the * chain's own modules (bank, staking, gov, distribution). Precompile-reported @@ -52,7 +65,7 @@ let clientPromise: Promise | undefined; export async function cosmosQuery(): Promise { if (!clientPromise) { clientPromise = (async () => { - const tm = await Tendermint34Client.connect(Endpoints.sei.cosmosRpc); + const tm = await tendermintClient(); return QueryClient.withExtensions( tm, setupBankExtension, @@ -65,21 +78,6 @@ export async function cosmosQuery(): Promise { return clientPromise; } -/** - * GET `path` from the chain's LCD (`SEI_REST`, default http://localhost:1317) - * and return the parsed JSON. Used for modules cosmjs does not wrap (mint, - * params, upgrade, evidence, slashing, authz, auth). `path` must start with `/`. - */ -export async function cosmosRest(path: string): Promise { - const base = Endpoints.sei.rest.replace(/\/$/, ''); - const url = `${base}${path.startsWith('/') ? path : `/${path}`}`; - const res = await fetch(url); - if (!res.ok) { - throw new Error(`cosmosRest GET ${url} -> HTTP ${res.status}: ${await res.text()}`); - } - return (await res.json()) as T; -} - const bankClient = cosmosQuery; /** Operator addresses (seivaloper1…) of the currently bonded validators. */ diff --git a/integration_test/precompile_tests/utils/moduleQueries.ts b/integration_test/precompile_tests/utils/moduleQueries.ts new file mode 100644 index 0000000000..69146a98e6 --- /dev/null +++ b/integration_test/precompile_tests/utils/moduleQueries.ts @@ -0,0 +1,331 @@ +/** + * The suite's Cosmos-side parity oracle for the modules cosmjs does not wrap: + * auth, authz, bank params, distribution, evidence, mint, slashing, staking + * historical info and upgrade. + * + * Each read calls the module's protobuf Query service through Tendermint's + * `abci_query`, which dispatches to the same handlers the node's gRPC server + * exposes. Responses are the module's own protobuf messages, so the field + * names below are the proto names. + */ +import { fromAscii } from '@cosmjs/encoding'; +import { Encoder } from '@sei-js/cosmos/encoding'; +import { tendermintClient } from './cosmosUtils'; + +const auth = Encoder.cosmos.auth.v1beta1; +const authz = Encoder.cosmos.authz.v1beta1; +const bank = Encoder.cosmos.bank.v1beta1; +const distribution = Encoder.cosmos.distribution.v1beta1; +const evidence = Encoder.cosmos.evidence.v1beta1; +const seiMint = Encoder.mint.v1beta1; +const slashing = Encoder.cosmos.slashing.v1beta1; +const staking = Encoder.cosmos.staking.v1beta1; +const upgrade = Encoder.cosmos.upgrade.v1beta1; +const vesting = Encoder.cosmos.vesting.v1beta1; + +/** The encode/decode half of a message type generated by `@sei-js/cosmos`. */ +interface ProtoCodec { + encode(message: T): { finish(): Uint8Array }; + decode(input: Uint8Array): T; +} + +/** A protobuf `Any`, as a module hands one back. */ +export interface ProtoAny { + type_url: string; + value: Uint8Array; +} + +// The ABCI answer for a value that does not exist: sei-cosmos maps a querier's +// gRPC NotFound onto the root codespace's ErrKeyNotFound (baseapp/abci.go, +// gRPCErrorToSDKError). Registered error codes are part of the query response, +// so this pair is as stable as the wire format itself. +const KEY_NOT_FOUND_CODESPACE = 'sdk'; +const KEY_NOT_FOUND_CODE = 22; + +/** + * A module answered a query with an error code. This is distinct from a + * transport failure: the chain was reached and the module declined. + */ +export class ModuleQueryError extends Error { + constructor( + readonly path: string, + readonly code: number, + readonly codespace: string, + readonly log: string, + ) { + super(`${path} failed with code ${codespace}/${code}: ${log}`); + this.name = 'ModuleQueryError'; + } + + /** True when the module reported that the queried value does not exist. */ + get notFound(): boolean { + return this.codespace === KEY_NOT_FOUND_CODESPACE && this.code === KEY_NOT_FOUND_CODE; + } +} + +/** + * Call `path` on a module's protobuf Query service at the latest height and + * decode the reply. `path` is the gRPC method route + * (`/cosmos.auth.v1beta1.Query/Params`). Throws ModuleQueryError when the + * module declines the query. + */ +async function queryModule( + path: string, + request: ProtoCodec, + value: Req, + response: ProtoCodec, +): Promise { + const tm = await tendermintClient(); + const reply = await tm.abciQuery({ + path, + data: request.encode(value).finish(), + prove: false, + }); + if (reply.code) { + throw new ModuleQueryError(path, reply.code, reply.codespace, reply.log ?? ''); + } + return response.decode(reply.value); +} + +/** + * An sdk.Dec in the fixed 18-decimal form the precompiles return + * (`"0.050000000000000000"`). Protobuf carries a Dec as the digits of its + * unscaled integer, so the decimal point has to be put back. + */ +export function decString(raw: Uint8Array | string): string { + const encoded = typeof raw === 'string' ? raw : fromAscii(raw); + const unscaled = encoded === '' ? '0' : encoded; + const negative = unscaled.startsWith('-'); + const digits = (negative ? unscaled.slice(1) : unscaled).padStart(19, '0'); + return `${negative ? '-' : ''}${digits.slice(0, -18)}.${digits.slice(-18)}`; +} + +/** Identity fields every account carries, however x/auth wraps them. */ +export interface BaseAccountFields { + address: string; + account_number: number; + sequence: number; +} + +/** An account as x/auth stores it: base fields, possibly wrapped one or two deep. */ +interface StoredAccount extends Partial { + base_account?: StoredAccount; + base_vesting_account?: StoredAccount; +} + +const ACCOUNT_CODECS: ReadonlyArray<{ $type: string } & ProtoCodec> = [ + auth.BaseAccount, + auth.ModuleAccount, + vesting.BaseVestingAccount, + vesting.ContinuousVestingAccount, + vesting.DelayedVestingAccount, + vesting.PeriodicVestingAccount, + vesting.PermanentLockedAccount, +]; + +/** + * The base account inside a stored account `Any`. Module and vesting accounts + * nest theirs one or two levels down; the precompile reads through AccountI, so + * it reports these fields for every account type and parity has to reach them. + */ +export function baseAccount(stored: ProtoAny): BaseAccountFields { + const codec = ACCOUNT_CODECS.find(c => `/${c.$type}` === stored.type_url); + if (codec === undefined) { + throw new Error( + `x/auth returned an account type this suite cannot decode: ${stored.type_url}`, + ); + } + let current: StoredAccount = codec.decode(stored.value); + while (current.address === undefined) { + const next = current.base_account ?? current.base_vesting_account; + if (next === undefined) break; + current = next; + } + return { + address: current.address ?? '', + account_number: current.account_number ?? 0, + sequence: current.sequence ?? 0, + }; +} + +/** The auth module's account for `seiAddress`, as the `Any` it is stored in. */ +export async function authAccount(seiAddress: string): Promise { + const { account } = await queryModule( + '/cosmos.auth.v1beta1.Query/Account', + auth.QueryAccountRequest, + { address: seiAddress }, + auth.QueryAccountResponse, + ); + return account; +} + +/** The first page of x/auth accounts, at the module's default page size. */ +export async function authAccounts(): Promise { + const { accounts } = await queryModule( + '/cosmos.auth.v1beta1.Query/Accounts', + auth.QueryAccountsRequest, + {}, + auth.QueryAccountsResponse, + ); + return accounts; +} + +/** x/auth's parameters. */ +export async function authParams() { + const { params } = await queryModule( + '/cosmos.auth.v1beta1.Query/Params', + auth.QueryParamsRequest, + {}, + auth.QueryParamsResponse, + ); + return params; +} + +/** The account number x/auth will issue next. */ +export async function authNextAccountNumber(): Promise { + const { count } = await queryModule( + '/cosmos.auth.v1beta1.Query/NextAccountNumber', + auth.QueryNextAccountNumberRequest, + {}, + auth.QueryNextAccountNumberResponse, + ); + return count; +} + +/** The grants `granter` holds for `grantee`, at the module's default page size. */ +export async function authzGrants(granter: string, grantee: string) { + const { grants } = await queryModule( + '/cosmos.authz.v1beta1.Query/Grants', + authz.QueryGrantsRequest, + { granter, grantee, msg_type_url: '' }, + authz.QueryGrantsResponse, + ); + return grants; +} + +/** x/bank's parameters. */ +export async function bankParams() { + const { params } = await queryModule( + '/cosmos.bank.v1beta1.Query/Params', + bank.QueryParamsRequest, + {}, + bank.QueryParamsResponse, + ); + return params; +} + +/** x/distribution's parameters. */ +export async function distributionParams() { + const { params } = await queryModule( + '/cosmos.distribution.v1beta1.Query/Params', + distribution.QueryParamsRequest, + {}, + distribution.QueryParamsResponse, + ); + return params; +} + +/** The address `delegator` withdraws rewards to. */ +export async function delegatorWithdrawAddress(delegator: string): Promise { + const { withdraw_address } = await queryModule( + '/cosmos.distribution.v1beta1.Query/DelegatorWithdrawAddress', + distribution.QueryDelegatorWithdrawAddressRequest, + { delegator_address: delegator }, + distribution.QueryDelegatorWithdrawAddressResponse, + ); + return withdraw_address; +} + +/** The slashes `validator` received between `startingHeight` and `endingHeight`. */ +export async function validatorSlashes( + validator: string, + startingHeight: number, + endingHeight: number, +) { + const { slashes } = await queryModule( + '/cosmos.distribution.v1beta1.Query/ValidatorSlashes', + distribution.QueryValidatorSlashesRequest, + { + validator_address: validator, + starting_height: startingHeight, + ending_height: endingHeight, + }, + distribution.QueryValidatorSlashesResponse, + ); + return slashes; +} + +/** Every piece of evidence x/evidence holds, at the module's default page size. */ +export async function allEvidence(): Promise { + const { evidence: rows } = await queryModule( + '/cosmos.evidence.v1beta1.Query/AllEvidence', + evidence.QueryAllEvidenceRequest, + {}, + evidence.QueryAllEvidenceResponse, + ); + return rows; +} + +/** Sei's mint parameters, including the token release schedule. */ +export async function mintParams() { + const { params } = await queryModule( + '/seiprotocol.seichain.mint.Query/Params', + seiMint.QueryParamsRequest, + {}, + seiMint.QueryParamsResponse, + ); + return params; +} + +/** Sei's current minter. The response is flat — the fields are not nested. */ +export async function mintMinter() { + return queryModule( + '/seiprotocol.seichain.mint.Query/Minter', + seiMint.QueryMinterRequest, + {}, + seiMint.QueryMinterResponse, + ); +} + +/** x/slashing's parameters. */ +export async function slashingParams() { + const { params } = await queryModule( + '/cosmos.slashing.v1beta1.Query/Params', + slashing.QueryParamsRequest, + {}, + slashing.QueryParamsResponse, + ); + return params; +} + +/** + * The staking module's historical info for `height`, or undefined when the + * module does not retain it. Only the module's own not-found answer reads as + * undefined: a transport failure or any other module error still throws, so an + * unreachable chain never reads as a chain that answered "not retained". + */ +export async function stakingHistoricalInfo(height: number) { + try { + const { hist } = await queryModule( + '/cosmos.staking.v1beta1.Query/HistoricalInfo', + staking.QueryHistoricalInfoRequest, + { height }, + staking.QueryHistoricalInfoResponse, + ); + return hist; + } catch (e) { + if (e instanceof ModuleQueryError && e.notFound) return undefined; + throw e; + } +} + +/** The scheduled upgrade plan, or undefined when none is scheduled. */ +export async function upgradeCurrentPlan() { + const { plan } = await queryModule( + '/cosmos.upgrade.v1beta1.Query/CurrentPlan', + upgrade.QueryCurrentPlanRequest, + {}, + upgrade.QueryCurrentPlanResponse, + ); + return plan; +}