From d1c48028a4144efd8b37105dd5efc8d920d774af Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Mon, 24 Aug 2026 01:42:19 +0200 Subject: [PATCH 1/2] 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/2] 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)); });