diff --git a/integration_test/precompile_tests/README.md b/integration_test/precompile_tests/README.md index 33eb4c7315..b1b2fa0c91 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` | +| 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` | -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. +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): @@ -173,7 +181,6 @@ accounts. | --- | --- | | `SEI_EVM_RPC` | `http://localhost:8545` | | `SEI_COSMOS_RPC` | `http://localhost:26657` | -| `SEI_REST` | `http://localhost:1317` | | `SEI_ADMIN_MNEMONIC` | unset (a random admin is minted and funded via the docker devnet) | | `PRECOMPILE_TESTS_RUNTIME_STATE` | `runtime/runtime.json` | | `PRECOMPILE_POLLING_INTERVAL_MS` | `100` (Sei blocks are ~400ms; ethers default 4s is too slow) | diff --git a/integration_test/precompile_tests/_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/config/endpoints.ts b/integration_test/precompile_tests/config/endpoints.ts index 54ffaa73b5..a79523bfcc 100644 --- a/integration_test/precompile_tests/config/endpoints.ts +++ b/integration_test/precompile_tests/config/endpoints.ts @@ -7,7 +7,6 @@ export const Endpoints = { sei: { evmRpc: env('SEI_EVM_RPC', 'http://localhost:8545'), cosmosRpc: env('SEI_COSMOS_RPC', 'http://localhost:26657'), - rest: env('SEI_REST', 'http://localhost:1317'), }, } as const; diff --git a/integration_test/precompile_tests/precompiles/auth.spec.ts b/integration_test/precompile_tests/precompiles/auth.spec.ts new file mode 100644 index 0000000000..b4424f05ee --- /dev/null +++ b/integration_test/precompile_tests/precompiles/auth.spec.ts @@ -0,0 +1,187 @@ +/** + * auth precompile (0x…100D) — account queries against a live Sei chain. + * + * All four methods are views. The parity oracle is the auth module's own Query + * service; the Go executor has no delegatecall guard (unlike staking/gov), so + * CALL, STATICCALL and DELEGATECALL all succeed. + * + * `nextAccountNumber` queries a keeper method that increments the persisted + * counter, which the executor neutralises by branching a CacheContext. That + * 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 { + authAccount, + authAccounts, + authNextAccountNumber, + authParams, + baseAccount, +} from '../utils/moduleQueries'; +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); + +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 the module address, account_number and sequence', async () => { + const sei = admin.seiAddress(); + const [via, stored] = await Promise.all([ + auth.account(admin.address), + authAccount(sei), + ]); + expect(stored, 'the auth module must know the admin account').to.not.equal(undefined); + const expected = baseAccount(stored!); + expect(via.accountAddress).to.equal(sei); + expect(via.accountAddress).to.equal(expected.address); + expect(via.accountNumber).to.equal(uint64(expected.account_number)); + expect(via.sequence).to.equal(uint64(expected.sequence)); + }); + + it('accounts(empty) returns the same first page as the auth module', async () => { + const [page, stored] = await Promise.all([auth.accounts(empty), authAccounts()]); + expect(page.accounts.length, 'first page of auth accounts').to.be.greaterThan(0); + // Both read the first page with the module's default page size, so the + // rows must line up in order, not merely in count. + expect( + [...page.accounts].map((a: { accountAddress: string }) => a.accountAddress), + 'accounts() first page vs the auth module', + ).to.deep.equal(stored.map(a => baseAccount(a).address)); + }); + + it('params() matches the auth module', async () => { + const [via, p] = await Promise.all([auth.params(), authParams()]); + expect(p, 'the auth module must report params').to.not.equal(undefined); + expect(via.maxMemoCharacters).to.equal(uint64(p!.max_memo_characters)); + expect(via.txSigLimit).to.equal(uint64(p!.tx_sig_limit)); + expect(via.txSizeCostPerByte).to.equal(uint64(p!.tx_size_cost_per_byte)); + expect(via.sigVerifyCostEd25519).to.equal(uint64(p!.sig_verify_cost_ed25519)); + expect(via.sigVerifyCostSecp256k1).to.equal(uint64(p!.sig_verify_cost_secp256k1)); + expect(via.disableSeqnoCheck).to.equal(Boolean(p!.disable_seqno_check)); + }); + + it('nextAccountNumber() matches the auth module and is past the admin account', async () => { + const [count, next, account] = await Promise.all([ + auth.nextAccountNumber() as Promise, + authNextAccountNumber(), + auth.account(admin.address), + ]); + expect(count, 'nextAccountNumber vs the auth module').to.equal(uint64(next)); + expect(count > account.accountNumber, 'next number is past every issued number').to.equal( + true, + ); + }); + }); + + 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..068be6c777 --- /dev/null +++ b/integration_test/precompile_tests/precompiles/authz.spec.ts @@ -0,0 +1,230 @@ +/** + * 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 + * the authz module's own Grants query for the same granter/grantee pair. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, waitUntil } from '../utils/chainUtils'; +import { EvmAccount, associateViaTx } from '../utils/evmUtils'; +import { bondedValidators } from '../utils/cosmosUtils'; +import { authzGrants } from '../utils/moduleQueries'; +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; + +const authorizationJson = (authorization: string): string => ethers.toUtf8String(authorization); + +const expirationUnix = (expiration: Date | undefined): bigint => { + return BigInt(Math.floor((expiration?.getTime() ?? 0) / 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('module parity / unassociated granter-grantee / STATICCALL', () => { + it('the authz module matches grants() count and expiration', async () => { + const [viaPrecompile, moduleGrants] = await Promise.all([ + authz.grants(granter.address, grantee.address, '', emptyPage), + authzGrants(granter.seiAddress(), grantee.seiAddress()), + ]); + expect(moduleGrants.length, 'authz module grant count').to.equal( + viaPrecompile.grants.length, + ); + expect(moduleGrants.length).to.be.greaterThan(0); + + const precompileTypes = [...viaPrecompile.grants] + .map((g: { authorization: string }) => JSON.parse(authorizationJson(g.authorization))['@type']) + .sort(); + const moduleTypes = moduleGrants.map(g => g.authorization?.type_url).sort(); + expect(precompileTypes).to.deep.equal(moduleTypes); + + // The two lists are not guaranteed to be in the same order, and all + // three grants in this fixture share one expiration, so an + // 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 moduleExpirations = moduleGrants.map(g => expirationUnix(g.expiration)).sort(); + expect(precompileExpirations).to.deep.equal(moduleExpirations); + }); + + 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..09cf906703 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, +} from '../utils/cosmosUtils'; +import { bankParams } from '../utils/moduleQueries'; 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,100 @@ 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 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'); + + // 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'); + 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 [viaPrecompile, params] = await Promise.all([bank.params(), bankParams()]); + expect( + viaPrecompile.defaultSendEnabled, + 'precompile vs the bank module', + ).to.equal(params?.default_send_enabled ?? false); + // Sends are on for this devnet; the sendNative test above depends on it. + expect(viaPrecompile.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 +315,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 +374,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..6a29563bba 100644 --- a/integration_test/precompile_tests/precompiles/distribution.spec.ts +++ b/integration_test/precompile_tests/precompiles/distribution.spec.ts @@ -15,6 +15,12 @@ import { expect } from 'chai'; import { seiRpc, waitUntil } from '../utils/chainUtils'; import { EvmAccount, associateViaTx } from '../utils/evmUtils'; import { bondedValidators, cosmosQuery, bankBalance } from '../utils/cosmosUtils'; +import { + decString, + delegatorWithdrawAddress, + distributionParams, + validatorSlashes, +} from '../utils/moduleQueries'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -25,6 +31,30 @@ import { } from '../utils/precompileUtils'; import { readRuntimeState, claimPool, RuntimeState } from '../utils/testUtils'; +type DistrCoin = { amount: bigint; decimals: bigint; denom: string }; + +/** + * 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'); + expect(coin.decimals, `${label} decimals`).to.equal(18n); + } +} + describe('distribution precompile (0x1007)', function () { this.timeout(180 * 1000); @@ -187,6 +217,166 @@ describe('distribution precompile (0x1007)', function () { }); }); + describe('query methods', () => { + it('params matches the distribution module', async () => { + const [viaPrecompile, params] = await Promise.all([ + distribution.params(), + distributionParams(), + ]); + expect(params, 'the distribution module must report params').to.not.equal(undefined); + expect(viaPrecompile.communityTax).to.equal(decString(params!.community_tax)); + expect(viaPrecompile.baseProposerReward).to.equal( + decString(params!.base_proposer_reward), + ); + expect(viaPrecompile.bonusProposerReward).to.equal( + decString(params!.bonus_proposer_reward), + ); + expect(viaPrecompile.withdrawAddrEnabled).to.equal(params!.withdraw_addr_enabled); + }); + + it('delegatorValidators includes the fixture validator', async () => { + const validators: string[] = await distribution.delegatorValidators(delegator.address); + expect(validators).to.include(validator); + }); + + it('delegatorWithdrawAddress matches the module rather than assuming the default', async () => { + const [viaPrecompile, withdrawAddress] = await Promise.all([ + distribution.delegatorWithdrawAddress(delegator.address) as Promise, + delegatorWithdrawAddress(delegator.seiAddress()), + ]); + expect(viaPrecompile).to.equal(withdrawAddress); + }); + + 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), + 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, moduleSlashes] = await Promise.all([ + distribution.validatorSlashes(validator, 1, endingHeight, new Uint8Array()), + validatorSlashes(validator, 1, endingHeight), + ]); + const slashes = (result.slashes ?? result[0]) as Array<{ + validatorPeriod: bigint; + fraction: string; + }>; + expect(slashes.length, 'validatorSlashes count vs the module').to.equal( + moduleSlashes.length, + ); + for (let i = 0; i < moduleSlashes.length; i++) { + expect(slashes[i].validatorPeriod).to.equal( + BigInt(moduleSlashes[i].validator_period), + ); + expect(slashes[i].fraction).to.equal(decString(moduleSlashes[i].fraction)); + } + }); + + 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, + }); + }); + }); + + 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 +394,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..8aa1f6e524 --- /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 { allEvidence } from '../utils/moduleQueries'; + +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, stored] = await Promise.all([ + evidence.allEvidence(EMPTY_PAGE), + allEvidence(), + ]); + expect(resp.evidenceList.length, 'allEvidence count vs the module').to.equal( + stored.length, + ); + for (const row of resp.evidenceList) { + // Each entry is the JSON encoding of the stored evidence. + 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..8278e9dcdb 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,10 +396,35 @@ 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)', () => { - 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, { @@ -223,5 +443,16 @@ describe('gov precompile (0x1006)', function () { 'cannot delegatecall gov', ); }); + + 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..e3159856df --- /dev/null +++ b/integration_test/precompile_tests/precompiles/mint.spec.ts @@ -0,0 +1,146 @@ +/** + * 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 Query service rather than a cosmos.* one. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { mintMinter, mintParams } from '../utils/moduleQueries'; +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; +} + +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; +} + +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, moduleParams] = await Promise.all([ + mint.params() as Promise, + mintParams(), + ]); + expect(params.mintDenom, 'precompile mintDenom vs the mint module').to.equal( + moduleParams?.mint_denom, + ); + expect(params.mintDenom, 'devnet genesis mints the bond denom').to.equal('usei'); + + const schedule = [...params.tokenReleaseSchedule]; + const moduleRows = moduleParams?.token_release_schedule ?? []; + expect(schedule.length, 'tokenReleaseSchedule length vs the mint module').to.equal( + moduleRows.length, + ); + for (let i = 0; i < schedule.length; i++) { + const row = moduleRows[i]; + expect(schedule[i].startDate).to.equal(row.start_date); + expect(schedule[i].endDate).to.equal(row.end_date); + expect(schedule[i].tokenReleaseAmount).to.equal(asBigInt(row.token_release_amount)); + } + }); + + it('minter() matches the mint module', async () => { + // QueryMinterResponse is flat — it carries the minter's fields at the + // top level rather than nesting them under a `minter` key the way the + // params response nests under `params`. + const [minter, body] = await Promise.all([mint.minter() as Promise, mintMinter()]); + + expect(minter.denom).to.equal(body.denom); + expect(minter.startDate).to.equal(body.start_date); + expect(minter.endDate).to.equal(body.end_date); + expect(minter.totalMintAmount).to.equal(asBigInt(body.total_mint_amount)); + expect(minter.remainingMintAmount).to.equal(asBigInt(body.remaining_mint_amount)); + expect(minter.lastMintAmount).to.equal(asBigInt(body.last_mint_amount)); + expect(minter.lastMintDate).to.equal(body.last_mint_date); + expect(minter.lastMintHeight).to.equal(asBigInt(body.last_mint_height)); + // Genesis may leave the minter unset; when it is set it mints the bond denom. + if (minter.denom !== '') { + expect(minter.denom).to.equal('usei'); + } + }); + }); + + 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..c7370668cf --- /dev/null +++ b/integration_test/precompile_tests/precompiles/slashing.spec.ts @@ -0,0 +1,198 @@ +/** + * slashing precompile (0x…1014) — end-to-end semantics against a live Sei chain. + * + * Query methods are checked against the slashing module itself. Write methods + * that would jail a validator are out of scope: grant/revoke of unjail + * authorization are exercised from a pool account, and unjail is only asserted + * to revert when the caller is not a jailed validator. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { EvmAccount, associateViaTx } from '../utils/evmUtils'; +import { decString, slashingParams } from '../utils/moduleQueries'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, + expectVmError, +} from '../utils/precompileUtils'; +import { readRuntimeState, claimPool, RuntimeState } from '../utils/testUtils'; + +const EMPTY_PAGE = new Uint8Array(); + +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 the slashing module', async () => { + const [viaPrecompile, p] = await Promise.all([ + slashing.params() as Promise, + slashingParams(), + ]); + expect(p, 'the slashing module must report params').to.not.equal(undefined); + expect(viaPrecompile.signedBlocksWindow).to.equal(BigInt(p!.signed_blocks_window)); + expect(viaPrecompile.minSignedPerWindow).to.equal(decString(p!.min_signed_per_window)); + // The precompile reports the jail duration as whole seconds (`.Seconds()`). + expect(viaPrecompile.downtimeJailDuration).to.equal( + BigInt(p!.downtime_jail_duration?.seconds ?? 0), + ); + expect(viaPrecompile.slashFractionDoubleSign).to.equal( + decString(p!.slash_fraction_double_sign), + ); + expect(viaPrecompile.slashFractionDowntime).to.equal( + decString(p!.slash_fraction_downtime), + ); + }); + + it('signingInfos(empty) is non-empty and signingInfo(that cons address) matches', async () => { + // 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, { + blockTag, + }); + 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..87f2cbedc9 100644 --- a/integration_test/precompile_tests/precompiles/staking.spec.ts +++ b/integration_test/precompile_tests/precompiles/staking.spec.ts @@ -18,6 +18,7 @@ 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 { stakingHistoricalInfo } from '../utils/moduleQueries'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -429,5 +430,351 @@ 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 module is therefore + // the oracle: the precompile must agree with it about the SAME height, + // both on the answer and on the absence of one. + it('historicalInfo agrees with the staking module about a recent height', async () => { + const height = Math.max((await provider.getBlockNumber()) - 2, 1); + const hist = await stakingHistoricalInfo(height); + + if (hist == null) { + // Not retained (historical_entries=0, or the height aged out): + // the precompile must refuse it rather than invent an answer. The + // reason surfaced is the staking querier's, not the executor's own + // "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( + BigInt(height), + ); + const ops = [...(info.validators as ethers.Result)].map( + (v: { operatorAddress: string }) => v.operatorAddress, + ); + expect(ops.slice().sort(), 'historical valset matches the staking module').to.deep.equal( + 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..5a9c484ce6 --- /dev/null +++ b/integration_test/precompile_tests/precompiles/upgrade.spec.ts @@ -0,0 +1,123 @@ +/** + * 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 the + * upgrade module's own CurrentPlan query. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { upgradeCurrentPlan } from '../utils/moduleQueries'; +import { EvmAccount } from '../utils/evmUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, +} from '../utils/precompileUtils'; +import { readRuntimeState, RuntimeState } from '../utils/testUtils'; + +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 (module parity)', async () => { + const [plan, scheduled] = await Promise.all([ + upgrade.currentPlan(), + upgradeCurrentPlan(), + ]); + if (scheduled == null || scheduled.name === '') { + expect(plan.name).to.equal(''); + expect(plan.height).to.equal(0n); + expect(plan.info).to.equal(''); + } else { + expect(plan.name).to.equal(scheduled.name); + expect(plan.height).to.equal(BigInt(scheduled.height)); + expect(plan.info).to.equal(scheduled.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..9f6d2e6b5e 100644 --- a/integration_test/precompile_tests/utils/cosmosUtils.ts +++ b/integration_test/precompile_tests/utils/cosmosUtils.ts @@ -42,8 +42,21 @@ export type CosmosQueryClient = QueryClient & GovExtension & DistributionExtension; +let tendermintPromise: Promise | undefined; let clientPromise: Promise | undefined; +/** + * The suite's shared Tendermint RPC connection (`SEI_COSMOS_RPC`). Every + * Cosmos-side read goes through it, including the raw `abci_query` calls in + * moduleQueries. + */ +export async function tendermintClient(): Promise { + if (!tendermintPromise) { + tendermintPromise = Tendermint34Client.connect(Endpoints.sei.cosmosRpc); + } + return tendermintPromise; +} + /** * The suite's Cosmos-side parity oracle: one shared query client over the * chain's own modules (bank, staking, gov, distribution). Precompile-reported @@ -52,7 +65,7 @@ let clientPromise: Promise | undefined; export async function cosmosQuery(): Promise { if (!clientPromise) { clientPromise = (async () => { - const tm = await Tendermint34Client.connect(Endpoints.sei.cosmosRpc); + const tm = await tendermintClient(); return QueryClient.withExtensions( tm, setupBankExtension, diff --git a/integration_test/precompile_tests/utils/moduleQueries.ts b/integration_test/precompile_tests/utils/moduleQueries.ts new file mode 100644 index 0000000000..69146a98e6 --- /dev/null +++ b/integration_test/precompile_tests/utils/moduleQueries.ts @@ -0,0 +1,331 @@ +/** + * The suite's Cosmos-side parity oracle for the modules cosmjs does not wrap: + * auth, authz, bank params, distribution, evidence, mint, slashing, staking + * historical info and upgrade. + * + * Each read calls the module's protobuf Query service through Tendermint's + * `abci_query`, which dispatches to the same handlers the node's gRPC server + * exposes. Responses are the module's own protobuf messages, so the field + * names below are the proto names. + */ +import { fromAscii } from '@cosmjs/encoding'; +import { Encoder } from '@sei-js/cosmos/encoding'; +import { tendermintClient } from './cosmosUtils'; + +const auth = Encoder.cosmos.auth.v1beta1; +const authz = Encoder.cosmos.authz.v1beta1; +const bank = Encoder.cosmos.bank.v1beta1; +const distribution = Encoder.cosmos.distribution.v1beta1; +const evidence = Encoder.cosmos.evidence.v1beta1; +const seiMint = Encoder.mint.v1beta1; +const slashing = Encoder.cosmos.slashing.v1beta1; +const staking = Encoder.cosmos.staking.v1beta1; +const upgrade = Encoder.cosmos.upgrade.v1beta1; +const vesting = Encoder.cosmos.vesting.v1beta1; + +/** The encode/decode half of a message type generated by `@sei-js/cosmos`. */ +interface ProtoCodec { + encode(message: T): { finish(): Uint8Array }; + decode(input: Uint8Array): T; +} + +/** A protobuf `Any`, as a module hands one back. */ +export interface ProtoAny { + type_url: string; + value: Uint8Array; +} + +// The ABCI answer for a value that does not exist: sei-cosmos maps a querier's +// gRPC NotFound onto the root codespace's ErrKeyNotFound (baseapp/abci.go, +// gRPCErrorToSDKError). Registered error codes are part of the query response, +// so this pair is as stable as the wire format itself. +const KEY_NOT_FOUND_CODESPACE = 'sdk'; +const KEY_NOT_FOUND_CODE = 22; + +/** + * A module answered a query with an error code. This is distinct from a + * transport failure: the chain was reached and the module declined. + */ +export class ModuleQueryError extends Error { + constructor( + readonly path: string, + readonly code: number, + readonly codespace: string, + readonly log: string, + ) { + super(`${path} failed with code ${codespace}/${code}: ${log}`); + this.name = 'ModuleQueryError'; + } + + /** True when the module reported that the queried value does not exist. */ + get notFound(): boolean { + return this.codespace === KEY_NOT_FOUND_CODESPACE && this.code === KEY_NOT_FOUND_CODE; + } +} + +/** + * Call `path` on a module's protobuf Query service at the latest height and + * decode the reply. `path` is the gRPC method route + * (`/cosmos.auth.v1beta1.Query/Params`). Throws ModuleQueryError when the + * module declines the query. + */ +async function queryModule( + path: string, + request: ProtoCodec, + value: Req, + response: ProtoCodec, +): Promise { + const tm = await tendermintClient(); + const reply = await tm.abciQuery({ + path, + data: request.encode(value).finish(), + prove: false, + }); + if (reply.code) { + throw new ModuleQueryError(path, reply.code, reply.codespace, reply.log ?? ''); + } + return response.decode(reply.value); +} + +/** + * An sdk.Dec in the fixed 18-decimal form the precompiles return + * (`"0.050000000000000000"`). Protobuf carries a Dec as the digits of its + * unscaled integer, so the decimal point has to be put back. + */ +export function decString(raw: Uint8Array | string): string { + const encoded = typeof raw === 'string' ? raw : fromAscii(raw); + const unscaled = encoded === '' ? '0' : encoded; + const negative = unscaled.startsWith('-'); + const digits = (negative ? unscaled.slice(1) : unscaled).padStart(19, '0'); + return `${negative ? '-' : ''}${digits.slice(0, -18)}.${digits.slice(-18)}`; +} + +/** Identity fields every account carries, however x/auth wraps them. */ +export interface BaseAccountFields { + address: string; + account_number: number; + sequence: number; +} + +/** An account as x/auth stores it: base fields, possibly wrapped one or two deep. */ +interface StoredAccount extends Partial { + base_account?: StoredAccount; + base_vesting_account?: StoredAccount; +} + +const ACCOUNT_CODECS: ReadonlyArray<{ $type: string } & ProtoCodec> = [ + auth.BaseAccount, + auth.ModuleAccount, + vesting.BaseVestingAccount, + vesting.ContinuousVestingAccount, + vesting.DelayedVestingAccount, + vesting.PeriodicVestingAccount, + vesting.PermanentLockedAccount, +]; + +/** + * The base account inside a stored account `Any`. Module and vesting accounts + * nest theirs one or two levels down; the precompile reads through AccountI, so + * it reports these fields for every account type and parity has to reach them. + */ +export function baseAccount(stored: ProtoAny): BaseAccountFields { + const codec = ACCOUNT_CODECS.find(c => `/${c.$type}` === stored.type_url); + if (codec === undefined) { + throw new Error( + `x/auth returned an account type this suite cannot decode: ${stored.type_url}`, + ); + } + let current: StoredAccount = codec.decode(stored.value); + while (current.address === undefined) { + const next = current.base_account ?? current.base_vesting_account; + if (next === undefined) break; + current = next; + } + return { + address: current.address ?? '', + account_number: current.account_number ?? 0, + sequence: current.sequence ?? 0, + }; +} + +/** The auth module's account for `seiAddress`, as the `Any` it is stored in. */ +export async function authAccount(seiAddress: string): Promise { + const { account } = await queryModule( + '/cosmos.auth.v1beta1.Query/Account', + auth.QueryAccountRequest, + { address: seiAddress }, + auth.QueryAccountResponse, + ); + return account; +} + +/** The first page of x/auth accounts, at the module's default page size. */ +export async function authAccounts(): Promise { + const { accounts } = await queryModule( + '/cosmos.auth.v1beta1.Query/Accounts', + auth.QueryAccountsRequest, + {}, + auth.QueryAccountsResponse, + ); + return accounts; +} + +/** x/auth's parameters. */ +export async function authParams() { + const { params } = await queryModule( + '/cosmos.auth.v1beta1.Query/Params', + auth.QueryParamsRequest, + {}, + auth.QueryParamsResponse, + ); + return params; +} + +/** The account number x/auth will issue next. */ +export async function authNextAccountNumber(): Promise { + const { count } = await queryModule( + '/cosmos.auth.v1beta1.Query/NextAccountNumber', + auth.QueryNextAccountNumberRequest, + {}, + auth.QueryNextAccountNumberResponse, + ); + return count; +} + +/** The grants `granter` holds for `grantee`, at the module's default page size. */ +export async function authzGrants(granter: string, grantee: string) { + const { grants } = await queryModule( + '/cosmos.authz.v1beta1.Query/Grants', + authz.QueryGrantsRequest, + { granter, grantee, msg_type_url: '' }, + authz.QueryGrantsResponse, + ); + return grants; +} + +/** x/bank's parameters. */ +export async function bankParams() { + const { params } = await queryModule( + '/cosmos.bank.v1beta1.Query/Params', + bank.QueryParamsRequest, + {}, + bank.QueryParamsResponse, + ); + return params; +} + +/** x/distribution's parameters. */ +export async function distributionParams() { + const { params } = await queryModule( + '/cosmos.distribution.v1beta1.Query/Params', + distribution.QueryParamsRequest, + {}, + distribution.QueryParamsResponse, + ); + return params; +} + +/** The address `delegator` withdraws rewards to. */ +export async function delegatorWithdrawAddress(delegator: string): Promise { + const { withdraw_address } = await queryModule( + '/cosmos.distribution.v1beta1.Query/DelegatorWithdrawAddress', + distribution.QueryDelegatorWithdrawAddressRequest, + { delegator_address: delegator }, + distribution.QueryDelegatorWithdrawAddressResponse, + ); + return withdraw_address; +} + +/** The slashes `validator` received between `startingHeight` and `endingHeight`. */ +export async function validatorSlashes( + validator: string, + startingHeight: number, + endingHeight: number, +) { + const { slashes } = await queryModule( + '/cosmos.distribution.v1beta1.Query/ValidatorSlashes', + distribution.QueryValidatorSlashesRequest, + { + validator_address: validator, + starting_height: startingHeight, + ending_height: endingHeight, + }, + distribution.QueryValidatorSlashesResponse, + ); + return slashes; +} + +/** Every piece of evidence x/evidence holds, at the module's default page size. */ +export async function allEvidence(): Promise { + const { evidence: rows } = await queryModule( + '/cosmos.evidence.v1beta1.Query/AllEvidence', + evidence.QueryAllEvidenceRequest, + {}, + evidence.QueryAllEvidenceResponse, + ); + return rows; +} + +/** Sei's mint parameters, including the token release schedule. */ +export async function mintParams() { + const { params } = await queryModule( + '/seiprotocol.seichain.mint.Query/Params', + seiMint.QueryParamsRequest, + {}, + seiMint.QueryParamsResponse, + ); + return params; +} + +/** Sei's current minter. The response is flat — the fields are not nested. */ +export async function mintMinter() { + return queryModule( + '/seiprotocol.seichain.mint.Query/Minter', + seiMint.QueryMinterRequest, + {}, + seiMint.QueryMinterResponse, + ); +} + +/** x/slashing's parameters. */ +export async function slashingParams() { + const { params } = await queryModule( + '/cosmos.slashing.v1beta1.Query/Params', + slashing.QueryParamsRequest, + {}, + slashing.QueryParamsResponse, + ); + return params; +} + +/** + * The staking module's historical info for `height`, or undefined when the + * module does not retain it. Only the module's own not-found answer reads as + * undefined: a transport failure or any other module error still throws, so an + * unreachable chain never reads as a chain that answered "not retained". + */ +export async function stakingHistoricalInfo(height: number) { + try { + const { hist } = await queryModule( + '/cosmos.staking.v1beta1.Query/HistoricalInfo', + staking.QueryHistoricalInfoRequest, + { height }, + staking.QueryHistoricalInfoResponse, + ); + return hist; + } catch (e) { + if (e instanceof ModuleQueryError && e.notFound) return undefined; + throw e; + } +} + +/** The scheduled upgrade plan, or undefined when none is scheduled. */ +export async function upgradeCurrentPlan() { + const { plan } = await queryModule( + '/cosmos.upgrade.v1beta1.Query/CurrentPlan', + upgrade.QueryCurrentPlanRequest, + {}, + upgrade.QueryCurrentPlanResponse, + ); + return plan; +} 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;