diff --git a/integration_test/precompile_tests/README.md b/integration_test/precompile_tests/README.md index 33eb4c7315..b9a1e6a695 100644 --- a/integration_test/precompile_tests/README.md +++ b/integration_test/precompile_tests/README.md @@ -45,7 +45,7 @@ balances, associations, …) over RPC/REST. For every precompile, the spec file ABIs are loaded from the repo's own `precompiles//abi.json` (the files the chain binary embeds), so specs can never drift from the deployed interface. -### Current coverage (phases 1–2, wasm-free) +### Current coverage (phases 1–3, wasm-free) | Precompile | Spec | |---|---| @@ -58,12 +58,20 @@ chain binary embeds), so specs can never drift from the deployed interface. | oracle (0x…1008) | `precompiles/oracle.spec.ts` (retirement assertion) | | pointerview (0x…100A) | `precompiles/pointerview.spec.ts` | | pointer (0x…100b) | `precompiles/pointer.spec.ts` (`addNativePointer`; CW methods are wasm-gated) | +| auth (0x…100D) | `precompiles/auth.spec.ts` | +| authz (0x…100E) | `precompiles/authz.spec.ts` | +| evidence (0x…100F) | `precompiles/evidence.spec.ts` | | p256 (0x…1011) | `precompiles/p256.spec.ts` | - -Planned next: wasm-gated flows (wasmd, pointer `addCW*`, solo CW claims) in a -separate `wasm/` spec dir behind a live `isWasmEnabled()` check, since wasm -deployments are blocked on production chains. The ibc precompile is out of -scope. +| mint (0x…1012) | `precompiles/mint.spec.ts` | +| params (0x…1013) | `precompiles/params.spec.ts` | +| slashing (0x…1014) | `precompiles/slashing.spec.ts` | +| upgrade (0x…1015) | `precompiles/upgrade.spec.ts` | + +Phase 3 (PLT-372) covers the v6.7 non-wasm precompiles and the post-phase-2 +methods on bank/staking/gov/distribution (query surface + scoped authz). +Wasm-gated flows (wasmd, pointer `addCW*`, solo CW claims) stay in a later +`wasm/` spec dir behind a live `isWasmEnabled()` check. The ibc precompile +and the unregistered feegrant precompile (0x…1010) are out of scope. Hard-won facts encoded in these specs (read before writing a new one): diff --git a/integration_test/precompile_tests/_start/00_bootstrap.spec.ts b/integration_test/precompile_tests/_start/00_bootstrap.spec.ts index fad842d048..71b6df7f51 100644 --- a/integration_test/precompile_tests/_start/00_bootstrap.spec.ts +++ b/integration_test/precompile_tests/_start/00_bootstrap.spec.ts @@ -20,7 +20,16 @@ import { fundAdminOnSei, generateMnemonic, seiAddressFromMnemonic } from '../uti import { writeRuntimeState, RuntimeState } from '../utils/testUtils'; import { ADDRESS } from '../utils/format'; -const POOL_SIZE = 48; +/** + * Size of the single-use account pool. Demand is the sum of every `claimPool` + * count across `precompiles/`, which phase 3 leaves at 29: + * + * rg -o 'claimPool\(runtime, provider, (\d+)' -r '$1' precompiles | paste -sd+ | bc + * + * The headroom is deliberate — exhaustion throws from `claimPool` partway + * through a run, so it is cheaper to over-fund than to re-bootstrap. + */ +const POOL_SIZE = 80; const POOL_FUND_WEI = ethers.parseEther('5'); describe('precompile_tests bootstrap', function () { diff --git a/integration_test/precompile_tests/precompiles/auth.spec.ts b/integration_test/precompile_tests/precompiles/auth.spec.ts new file mode 100644 index 0000000000..8bf1cf6834 --- /dev/null +++ b/integration_test/precompile_tests/precompiles/auth.spec.ts @@ -0,0 +1,226 @@ +/** + * auth precompile (0x…100D) — account queries against a live Sei chain. + * + * All four methods are views. The parity oracle is the auth module's LCD + * (`/cosmos/auth/v1beta1/...`); the Go executor has no delegatecall guard + * (unlike staking/gov), so CALL, STATICCALL and DELEGATECALL all succeed. + * + * `nextAccountNumber` queries a keeper method that increments the persisted + * counter, which the executor neutralises by branching a CacheContext. That + * discard is not observable from here — an `eth_call` commits nothing either + * way — so it stays pinned by auth_test.go:TestNextAccountNumber, and this + * spec only asserts the value the chain actually reports. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { EvmAccount } from '../utils/evmUtils'; +import { cosmosRest } from '../utils/cosmosUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, +} from '../utils/precompileUtils'; +import { readRuntimeState, RuntimeState } from '../utils/testUtils'; + +const empty = new Uint8Array(); + +const uint64 = (v: string | number | bigint | undefined): bigint => BigInt(v ?? 0); + +/** + * A row from /cosmos/auth/v1beta1/accounts, which is a protojson `Any`: a plain + * account carries its fields at the top level, while module and vesting + * accounts nest a base account one or two levels down. + */ +interface LcdAccount { + address?: string; + account_number?: string; + sequence?: string; + base_account?: LcdAccount; + base_vesting_account?: LcdAccount; +} + +/** + * The account fields for an LCD row, unwrapping the nested base account that + * module and vesting accounts wrap theirs in. The precompile reads through + * AccountI, so it reports these for every account type and the comparison has + * to reach them too. + */ +function lcdBaseAccount(row: LcdAccount): LcdAccount { + let current = row; + while (current.address === undefined) { + const next = current.base_account ?? current.base_vesting_account; + if (next === undefined) return current; + current = next; + } + return current; +} + +interface LcdAuthParams { + max_memo_characters: string; + tx_sig_limit: string; + tx_size_cost_per_byte: string; + sig_verify_cost_ed25519: string; + sig_verify_cost_secp256k1: string; + disable_seqno_check?: boolean; +} + +describe('auth precompile (0x100D)', function () { + this.timeout(120 * 1000); + + const provider = seiRpc(); + const authIface = precompileInterface('auth'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let auth: ethers.Contract; + let caller: ethers.Contract; + + before(() => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + auth = precompileContract('auth', admin.wallet); + caller = callerContract(runtime, admin.wallet); + }); + + describe('happy path & state parity', () => { + it('account(admin) matches LCD address, account_number and sequence', async () => { + const sei = admin.seiAddress(); + const [via, lcd] = await Promise.all([ + auth.account(admin.address), + cosmosRest<{ account: LcdAccount }>(`/cosmos/auth/v1beta1/accounts/${sei}`), + ]); + const expected = lcdBaseAccount(lcd.account); + expect(via.accountAddress).to.equal(sei); + expect(via.accountAddress).to.equal(expected.address); + expect(via.accountNumber).to.equal(uint64(expected.account_number)); + expect(via.sequence).to.equal(uint64(expected.sequence)); + }); + + it('accounts(empty) returns the same first page as the auth module', async () => { + const [page, lcd] = await Promise.all([ + auth.accounts(empty), + cosmosRest<{ accounts: LcdAccount[] }>('/cosmos/auth/v1beta1/accounts'), + ]); + expect(page.accounts.length, 'first page of auth accounts').to.be.greaterThan(0); + // Both read the first page with the module's default page size, so the + // rows must line up in order, not merely in count. + expect( + [...page.accounts].map((a: { accountAddress: string }) => a.accountAddress), + 'accounts() first page vs LCD', + ).to.deep.equal(lcd.accounts.map(a => lcdBaseAccount(a).address)); + }); + + it('params() matches LCD /cosmos/auth/v1beta1/params', async () => { + const [via, lcd] = await Promise.all([ + auth.params(), + cosmosRest<{ params: LcdAuthParams }>('/cosmos/auth/v1beta1/params'), + ]); + const p = lcd.params; + expect(via.maxMemoCharacters).to.equal(uint64(p.max_memo_characters)); + expect(via.txSigLimit).to.equal(uint64(p.tx_sig_limit)); + expect(via.txSizeCostPerByte).to.equal(uint64(p.tx_size_cost_per_byte)); + expect(via.sigVerifyCostEd25519).to.equal(uint64(p.sig_verify_cost_ed25519)); + expect(via.sigVerifyCostSecp256k1).to.equal(uint64(p.sig_verify_cost_secp256k1)); + expect(via.disableSeqnoCheck).to.equal(Boolean(p.disable_seqno_check)); + }); + + it('nextAccountNumber() matches the auth module and is past the admin account', async () => { + const [count, lcd, account] = await Promise.all([ + auth.nextAccountNumber() as Promise, + // QueryNextAccountNumberResponse names the field `count`, not + // `next_account_number`. + cosmosRest<{ count: string }>('/cosmos/auth/v1beta1/nextaccountnumber'), + auth.account(admin.address), + ]); + expect(count, 'nextAccountNumber vs LCD').to.equal(uint64(lcd.count)); + expect(count > account.accountNumber, 'next number is past every issued number').to.equal( + true, + ); + }); + }); + + describe('error handling', () => { + it('account of a never-associated random EVM address reverts (eth_call)', async () => { + await expectExecutionReverted( + auth.account(EvmAccount.random(provider).address), + 'auth.account of a never-associated EVM address', + ); + }); + + it('view methods reject value (eth_call with value 0x1)', async () => { + const cases: Array<[string, unknown[]]> = [ + ['account', [admin.address]], + ['accounts', [empty]], + ['params', []], + ['nextAccountNumber', []], + ]; + for (const [method, args] of cases) { + const envelope = await rawSei('eth_call', [ + { + from: admin.address, + to: PRECOMPILE_ADDRESSES.auth, + data: authIface.encodeFunctionData(method, args), + value: '0x1', + }, + 'latest', + ]); + expect(envelope.error, `${method} with value must revert`).to.not.equal(undefined); + expect(envelope.error!.message).to.match(/execution reverted|revert/i); + } + }); + }); + + describe('dispatch semantics (via PrecompileCaller)', () => { + it('account and params respond through a real CALL from contract bytecode', async () => { + const accountData = authIface.encodeFunctionData('account', [admin.address]); + const paramsData = authIface.encodeFunctionData('params', []); + const [accountRet, paramsRet, directAccount, directParams] = await Promise.all([ + caller.callTarget.staticCall(PRECOMPILE_ADDRESSES.auth, accountData) as Promise, + caller.callTarget.staticCall(PRECOMPILE_ADDRESSES.auth, paramsData) as Promise, + auth.account(admin.address), + auth.params(), + ]); + const [decodedAccount] = authIface.decodeFunctionResult('account', accountRet); + const [decodedParams] = authIface.decodeFunctionResult('params', paramsRet); + expect(decodedAccount.accountAddress).to.equal(directAccount.accountAddress); + expect(decodedAccount.accountNumber).to.equal(directAccount.accountNumber); + expect(decodedAccount.sequence).to.equal(directAccount.sequence); + expect(decodedParams.maxMemoCharacters).to.equal(directParams.maxMemoCharacters); + expect(decodedParams.txSigLimit).to.equal(directParams.txSigLimit); + }); + + it('account and params respond under STATICCALL', async () => { + const accountData = authIface.encodeFunctionData('account', [admin.address]); + const paramsData = authIface.encodeFunctionData('params', []); + const [accountRet, paramsRet, directAccount, directParams] = await Promise.all([ + caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.auth, + accountData, + ) as Promise, + caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.auth, + paramsData, + ) as Promise, + auth.account(admin.address), + auth.params(), + ]); + const [decodedAccount] = authIface.decodeFunctionResult('account', accountRet); + const [decodedParams] = authIface.decodeFunctionResult('params', paramsRet); + expect(decodedAccount.accountAddress).to.equal(directAccount.accountAddress); + expect(decodedParams.maxMemoCharacters).to.equal(directParams.maxMemoCharacters); + }); + + it('responds under DELEGATECALL (auth has no delegatecall guard)', async () => { + const data = authIface.encodeFunctionData('account', [admin.address]); + const ret: string = await caller.delegatecallTarget.staticCall( + PRECOMPILE_ADDRESSES.auth, + data, + ); + const [decoded] = authIface.decodeFunctionResult('account', ret); + expect(decoded.accountAddress).to.equal(admin.seiAddress()); + }); + }); +}); diff --git a/integration_test/precompile_tests/precompiles/authz.spec.ts b/integration_test/precompile_tests/precompiles/authz.spec.ts new file mode 100644 index 0000000000..d43314ffb3 --- /dev/null +++ b/integration_test/precompile_tests/precompiles/authz.spec.ts @@ -0,0 +1,243 @@ +/** + * authz precompile (0x…100E) — grant queries against a live Sei chain. + * + * All three methods are views. The precompile cannot create grants, so the + * non-empty fixture is a staking.grantStakingAuthorization (three + * StakeAuthorization grants: delegate / redelegate / undelegate). Empty-pair + * checks use a separate associated pair that never grants. Parity oracle is + * LCD GET /cosmos/authz/v1beta1/grants?granter=&grantee=. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, waitUntil } from '../utils/chainUtils'; +import { EvmAccount, associateViaTx } from '../utils/evmUtils'; +import { bondedValidators, cosmosRest } from '../utils/cosmosUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, +} from '../utils/precompileUtils'; +import { readRuntimeState, claimPool, RuntimeState } from '../utils/testUtils'; + +const emptyPage = new Uint8Array(); +const MAX_TOKENS = 1_000_000n; + +interface LcdGrant { + authorization?: { '@type'?: string }; + expiration?: string; +} + +interface LcdGrants { + grants?: LcdGrant[] | null; +} + +const authorizationJson = (authorization: string): string => ethers.toUtf8String(authorization); + +const lcdExpirationUnix = (expiration: string): bigint => { + const trimmed = expiration.replace(/\.\d+(Z|[+-]\d{2}:\d{2})$/, '$1'); + return BigInt(Math.floor(Date.parse(trimmed) / 1000)); +}; + +describe('authz precompile (0x100E)', function () { + this.timeout(180 * 1000); + + const provider = seiRpc(); + const authzIface = precompileInterface('authz'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let authz: ethers.Contract; + let staking: ethers.Contract; + let caller: ethers.Contract; + let granter: EvmAccount; + let grantee: EvmAccount; + let validator: string; + let expiration: bigint; + + before(async () => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + authz = precompileContract('authz', admin.wallet); + staking = precompileContract('staking', admin.wallet); + caller = callerContract(runtime, admin.wallet); + + [granter, grantee] = claimPool(runtime, provider, 2, 'authz:grant'); + await associateViaTx(granter); + await associateViaTx(grantee); + + const validators = await bondedValidators(); + expect(validators.length, 'devnet must have a bonded validator').to.be.greaterThan(0); + validator = validators[0]; + + expiration = BigInt(Math.floor(Date.now() / 1000) + 86_400); + const tx = await (staking.connect(granter.wallet) as ethers.Contract).grantStakingAuthorization( + grantee.address, + [validator], + MAX_TOKENS, + expiration, + { gasLimit: 1_000_000 }, + ); + expect((await tx.wait())!.status, 'grantStakingAuthorization tx must succeed').to.equal(1); + + await waitUntil( + async () => { + const resp = await authz.grants(granter.address, grantee.address, '', emptyPage); + return resp.grants.length > 0 ? true : null; + }, + { timeoutMs: 30_000, label: 'authz grants after staking grant' }, + ); + }); + + describe('empty grants for a pair with no grant', () => { + let unusedGranter: EvmAccount; + let unusedGrantee: EvmAccount; + + before(async () => { + [unusedGranter, unusedGrantee] = claimPool(runtime, provider, 2, 'authz:empty'); + await associateViaTx(unusedGranter); + await associateViaTx(unusedGrantee); + }); + + it('grants returns an empty list', async () => { + const resp = await authz.grants( + unusedGranter.address, + unusedGrantee.address, + '', + emptyPage, + ); + expect(resp.grants.length).to.equal(0); + }); + + it('granterGrants and granteeGrants do not include the pair', async () => { + const [fromGranter, fromGrantee] = await Promise.all([ + authz.granterGrants(unusedGranter.address, emptyPage), + authz.granteeGrants(unusedGrantee.address, emptyPage), + ]); + const pair = (g: { granter: string; grantee: string }) => + g.granter === unusedGranter.seiAddress() && g.grantee === unusedGrantee.seiAddress(); + expect([...fromGranter.grants].some(pair)).to.equal(false); + expect([...fromGrantee.grants].some(pair)).to.equal(false); + }); + }); + + describe('non-empty after staking grant', () => { + it('grants(granter, grantee, "", emptyPage) is non-empty', async () => { + const resp = await authz.grants(granter.address, grantee.address, '', emptyPage); + expect(resp.grants.length, 'staking grant creates StakeAuthorization rows').to.be.greaterThan( + 0, + ); + for (const grant of resp.grants) { + const json = authorizationJson(grant.authorization); + expect(json).to.include('@type'); + expect(json).to.include('StakeAuthorization'); + expect(grant.expiration).to.equal(expiration); + } + }); + + it('granterGrants includes the granter/grantee pair', async () => { + const resp = await authz.granterGrants(granter.address, emptyPage); + const pair = [...resp.grants].filter( + (g: { granter: string; grantee: string }) => + g.granter === granter.seiAddress() && g.grantee === grantee.seiAddress(), + ); + expect(pair.length, 'granterGrants must include the staking grant pair').to.be.greaterThan( + 0, + ); + for (const grant of pair) { + expect(authorizationJson(grant.authorization)).to.include('StakeAuthorization'); + expect(grant.expiration).to.equal(expiration); + } + }); + + it('granteeGrants includes the granter/grantee pair', async () => { + const resp = await authz.granteeGrants(grantee.address, emptyPage); + const pair = [...resp.grants].filter( + (g: { granter: string; grantee: string }) => + g.granter === granter.seiAddress() && g.grantee === grantee.seiAddress(), + ); + expect(pair.length, 'granteeGrants must include the staking grant pair').to.be.greaterThan( + 0, + ); + for (const grant of pair) { + expect(authorizationJson(grant.authorization)).to.include('StakeAuthorization'); + expect(grant.expiration).to.equal(expiration); + } + }); + }); + + describe('LCD parity / unassociated granter-grantee / STATICCALL', () => { + it('LCD /cosmos/authz/v1beta1/grants matches grants() count and expiration', async () => { + const path = + `/cosmos/authz/v1beta1/grants?granter=${encodeURIComponent(granter.seiAddress())}` + + `&grantee=${encodeURIComponent(grantee.seiAddress())}`; + const [viaPrecompile, lcd] = await Promise.all([ + authz.grants(granter.address, grantee.address, '', emptyPage), + cosmosRest(path), + ]); + const lcdGrants = lcd.grants ?? []; + expect(lcdGrants.length, 'LCD grant count').to.equal(viaPrecompile.grants.length); + expect(lcdGrants.length).to.be.greaterThan(0); + + const precompileTypes = [...viaPrecompile.grants] + .map((g: { authorization: string }) => JSON.parse(authorizationJson(g.authorization))['@type']) + .sort(); + const lcdTypes = lcdGrants.map(g => g.authorization?.['@type']).sort(); + expect(precompileTypes).to.deep.equal(lcdTypes); + + // The two lists are not guaranteed to be in the same order, and all + // three grants in this fixture share one expiration, so an + // index-by-index comparison would pass even if the orders diverged. + // Compare them as sorted sets instead. + const precompileExpirations = [...viaPrecompile.grants] + .map((g: { expiration: bigint }) => g.expiration) + .sort(); + const lcdExpirations = lcdGrants + .map(g => lcdExpirationUnix(g.expiration ?? '')) + .sort(); + expect(precompileExpirations).to.deep.equal(lcdExpirations); + }); + + it('grants reverts for an unassociated granter or grantee', async () => { + const unassociated = EvmAccount.random(provider); + await expectExecutionReverted( + authz.grants(unassociated.address, grantee.address, '', emptyPage), + 'authz.grants with an unassociated granter', + ); + await expectExecutionReverted( + authz.grants(granter.address, unassociated.address, '', emptyPage), + 'authz.grants with an unassociated grantee', + ); + }); + + it('granterGrants and granteeGrants revert for an unassociated address', async () => { + const unassociated = EvmAccount.random(provider); + await expectExecutionReverted( + authz.granterGrants(unassociated.address, emptyPage), + 'authz.granterGrants with an unassociated granter', + ); + await expectExecutionReverted( + authz.granteeGrants(unassociated.address, emptyPage), + 'authz.granteeGrants with an unassociated grantee', + ); + }); + + it('grants responds under STATICCALL', async () => { + const data = authzIface.encodeFunctionData('grants', [ + granter.address, + grantee.address, + '', + emptyPage, + ]); + const ret: string = await caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.authz, + data, + ); + const [decoded] = authzIface.decodeFunctionResult('grants', ret); + const direct = await authz.grants(granter.address, grantee.address, '', emptyPage); + expect(decoded.grants.length).to.equal(direct.grants.length); + expect(decoded.grants.length).to.be.greaterThan(0); + }); + }); +}); diff --git a/integration_test/precompile_tests/precompiles/bank.spec.ts b/integration_test/precompile_tests/precompiles/bank.spec.ts index dc70f7d8e2..e679442110 100644 --- a/integration_test/precompile_tests/precompiles/bank.spec.ts +++ b/integration_test/precompile_tests/precompiles/bank.spec.ts @@ -11,7 +11,13 @@ import { ethers } from 'ethers'; import { expect } from 'chai'; import { seiRpc, rawSei, waitUntil } from '../utils/chainUtils'; import { EvmAccount, associateViaTx } from '../utils/evmUtils'; -import { bankBalance, bankSupplyOf, generateSeiAddress } from '../utils/cosmosUtils'; +import { + bankBalance, + bankSupplyOf, + generateSeiAddress, + createTokenfactoryDenom, + cosmosRest, +} from '../utils/cosmosUtils'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -33,6 +39,8 @@ describe('bank precompile (0x1001)', function () { let admin: EvmAccount; let bank: ethers.Contract; let caller: ethers.Contract; + /** Tokenfactory denom minted by the denomMetadata test, reused by denomsMetadata. */ + let metadataDenom = ''; before(() => { runtime = readRuntimeState(); @@ -142,6 +150,105 @@ 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 [bankParams, lcd] = await Promise.all([ + bank.params(), + cosmosRest<{ params?: { default_send_enabled?: boolean } }>( + '/cosmos/bank/v1beta1/params', + ), + ]); + // The LCD marshals with OrigName and EmitDefaults, so the flag is + // present and snake_case even when false. + const lcdFlag = lcd.params?.default_send_enabled; + expect(bankParams.defaultSendEnabled, 'precompile vs LCD').to.equal(lcdFlag); + // Sends are on for this devnet; the sendNative test above depends on it. + expect(bankParams.defaultSendEnabled).to.equal(true); + }); + + it('denomMetadata returns tokenfactory metadata for a created denom', async () => { + // Fresh devnets may not register usei metadata (see name/symbol above); + // tokenfactory always writes name/symbol/base/display = the full denom + // and a single exponent-0 unit, which is the fixture this query needs. + metadataDenom = await createTokenfactoryDenom( + runtime.funded.adminMnemonic, + `bnk${Date.now().toString(36)}`, + ); + const meta = await bank.denomMetadata(metadataDenom); + expect(meta.base).to.equal(metadataDenom); + expect(meta.name).to.equal(metadataDenom); + expect(meta.symbol).to.equal(metadataDenom); + expect(meta.display).to.equal(metadataDenom); + expect(meta.denomUnits.length, 'tokenfactory writes one denom unit').to.be.greaterThan(0); + expect(meta.denomUnits[0].denom).to.equal(metadataDenom); + expect(Number(meta.denomUnits[0].exponent)).to.equal(0); + }); + + it('denomsMetadata pages until it reaches the denom denomMetadata just returned', async () => { + expect(metadataDenom, 'the denomMetadata test must run first').to.not.equal(''); + // Walking the pages is the point: it pins that nextKey round-trips, + // which asserting "returns an array" would pass without doing. + let pageKey: Uint8Array = new Uint8Array(); + let found = false; + for (let page = 0; page < 50 && !found; page++) { + const [metadatas, nextKey] = (await bank.denomsMetadata(pageKey)) as [ + Array<{ base: string }>, + string, + ]; + expect(metadatas, 'denomsMetadata page').to.be.an('array'); + found = metadatas.some(m => m.base === metadataDenom); + const next = ethers.getBytes(nextKey); + if (next.length === 0) break; + pageKey = next; + } + expect(found, `denomsMetadata must list ${metadataDenom}`).to.equal(true); + }); }); describe('error handling', () => { @@ -213,6 +320,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 +379,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..55b143172a 100644 --- a/integration_test/precompile_tests/precompiles/distribution.spec.ts +++ b/integration_test/precompile_tests/precompiles/distribution.spec.ts @@ -14,7 +14,7 @@ import { ethers } from 'ethers'; import { expect } from 'chai'; import { seiRpc, waitUntil } from '../utils/chainUtils'; import { EvmAccount, associateViaTx } from '../utils/evmUtils'; -import { bondedValidators, cosmosQuery, bankBalance } from '../utils/cosmosUtils'; +import { bondedValidators, cosmosQuery, cosmosRest, bankBalance } from '../utils/cosmosUtils'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -25,6 +25,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 +211,171 @@ describe('distribution precompile (0x1007)', function () { }); }); + describe('query methods', () => { + it('params matches LCD /cosmos/distribution/v1beta1/params', async () => { + const [viaPrecompile, lcd] = await Promise.all([ + distribution.params(), + cosmosRest<{ + params: { + community_tax: string; + base_proposer_reward: string; + bonus_proposer_reward: string; + withdraw_addr_enabled: boolean; + }; + }>('/cosmos/distribution/v1beta1/params'), + ]); + expect(viaPrecompile.communityTax).to.equal(lcd.params.community_tax); + expect(viaPrecompile.baseProposerReward).to.equal(lcd.params.base_proposer_reward); + expect(viaPrecompile.bonusProposerReward).to.equal(lcd.params.bonus_proposer_reward); + expect(viaPrecompile.withdrawAddrEnabled).to.equal(lcd.params.withdraw_addr_enabled); + }); + + it('delegatorValidators includes the fixture validator', async () => { + const validators: string[] = await distribution.delegatorValidators(delegator.address); + expect(validators).to.include(validator); + }); + + it('delegatorWithdrawAddress matches LCD rather than assuming the default', async () => { + const sei = delegator.seiAddress(); + const [viaPrecompile, lcd] = await Promise.all([ + distribution.delegatorWithdrawAddress(delegator.address) as Promise, + cosmosRest<{ withdraw_address: string }>( + `/cosmos/distribution/v1beta1/delegators/${sei}/withdraw_address`, + ), + ]); + expect(viaPrecompile).to.equal(lcd.withdraw_address); + }); + + it('delegationRewards, validatorOutstandingRewards and validatorCommission 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, lcd] = await Promise.all([ + distribution.validatorSlashes(validator, 1, endingHeight, new Uint8Array()), + cosmosRest<{ slashes?: Array<{ validator_period: string; fraction: string }> }>( + `/cosmos/distribution/v1beta1/validators/${validator}/slashes` + + `?starting_height=1&ending_height=${endingHeight}`, + ), + ]); + const slashes = (result.slashes ?? result[0]) as Array<{ + validatorPeriod: bigint; + fraction: string; + }>; + const lcdSlashes = lcd.slashes ?? []; + expect(slashes.length, 'validatorSlashes count vs LCD').to.equal(lcdSlashes.length); + for (let i = 0; i < lcdSlashes.length; i++) { + expect(slashes[i].validatorPeriod).to.equal(BigInt(lcdSlashes[i].validator_period)); + expect(slashes[i].fraction).to.equal(lcdSlashes[i].fraction); + } + }); + + it('communityPool returns coins, 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 +393,22 @@ describe('distribution precompile (0x1007)', function () { expect(viaStatic, 'CALL and STATICCALL return identical bytes').to.equal(viaCall); }); + it('params is callable via STATICCALL', async () => { + const data = distrIface.encodeFunctionData('params', []); + const [ret, direct] = await Promise.all([ + caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.distribution, + data, + ) as Promise, + distribution.params(), + ]); + const [decoded] = distrIface.decodeFunctionResult('params', ret); + expect(decoded.communityTax).to.equal(direct.communityTax); + expect(decoded.baseProposerReward).to.equal(direct.baseProposerReward); + expect(decoded.bonusProposerReward).to.equal(direct.bonusProposerReward); + expect(decoded.withdrawAddrEnabled).to.equal(direct.withdrawAddrEnabled); + }); + it('write methods are rejected under STATICCALL (readOnly guard)', async () => { const data = distrIface.encodeFunctionData('withdrawDelegationRewards', [validator]); await expectVmError( diff --git a/integration_test/precompile_tests/precompiles/evidence.spec.ts b/integration_test/precompile_tests/precompiles/evidence.spec.ts new file mode 100644 index 0000000000..668b969aaf --- /dev/null +++ b/integration_test/precompile_tests/precompiles/evidence.spec.ts @@ -0,0 +1,119 @@ +/** + * evidence precompile (0x…100F) — end-to-end query semantics against a live Sei chain. + * + * Both methods are views over x/evidence. A clean chain typically stores no + * evidence, so allEvidence(empty page) is an empty list that matches LCD + * GET /cosmos/evidence/v1beta1/evidence, and evidence(hash) reverts for a + * hash that is not on chain. Populated Equivocation rows are injected in the + * Go unit tests (evidence_test.go) via the keeper and are not repeated here. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { EvmAccount } from '../utils/evmUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, +} from '../utils/precompileUtils'; +import { readRuntimeState, RuntimeState } from '../utils/testUtils'; +import { cosmosRest } from '../utils/cosmosUtils'; + +const EMPTY_PAGE = new Uint8Array(); +/** Nonzero 32-byte hash that will not exist on a clean chain. */ +const FAKE_HASH = new Uint8Array(32).fill(0xab); + +describe('evidence precompile (0x100F)', function () { + this.timeout(120 * 1000); + + const provider = seiRpc(); + const evidenceIface = precompileInterface('evidence'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let evidence: ethers.Contract; + let caller: ethers.Contract; + + before(() => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + evidence = precompileContract('evidence', admin.wallet); + caller = callerContract(runtime, admin.wallet); + }); + + describe('happy path & state parity', () => { + // Evidence only exists after a validator equivocates, which a healthy + // devnet never does — but asserting "empty" would turn a genuinely + // slashed validator into a spurious failure, so the module's own list is + // the oracle and the count has to agree either way. + it('allEvidence matches the evidence module', async () => { + const [resp, lcd] = await Promise.all([ + evidence.allEvidence(EMPTY_PAGE), + cosmosRest<{ evidence?: unknown[] | null }>('/cosmos/evidence/v1beta1/evidence'), + ]); + expect(resp.evidenceList.length, 'allEvidence count vs LCD').to.equal( + (lcd.evidence ?? []).length, + ); + for (const row of resp.evidenceList) { + // Each entry is the JSON encoding of the stored evidence. + expect(() => JSON.parse(ethers.toUtf8String(row))).to.not.throw(); + } + }); + }); + + describe('error handling', () => { + it('evidence with a nonzero fake hash reverts', async () => { + await expectExecutionReverted( + evidence.evidence(FAKE_HASH), + 'evidence.evidence with a hash that does not exist', + ); + }); + + it('rejects value on every method (non-payable)', async () => { + for (const [method, args] of [ + ['allEvidence', [EMPTY_PAGE]], + ['evidence', [FAKE_HASH]], + ] as const) { + const envelope = await rawSei('eth_call', [ + { + from: admin.address, + to: PRECOMPILE_ADDRESSES.evidence, + data: evidenceIface.encodeFunctionData(method, args), + value: '0x1', + }, + 'latest', + ]); + expect(envelope.error, `${method}: value-bearing call must revert`).to.not.equal( + undefined, + ); + expect(envelope.error!.message).to.match(/execution reverted|revert/i); + } + }); + }); + + describe('dispatch semantics (via PrecompileCaller)', () => { + // Compare each dispatch path against the direct call rather than against + // a hardcoded empty list, so these stay guards on dispatch instead of + // re-asserting that the devnet has no evidence. + it('CALL, STATICCALL and DELEGATECALL all return the direct answer', async () => { + const data = evidenceIface.encodeFunctionData('allEvidence', [EMPTY_PAGE]); + const envelope = await rawSei('eth_call', [ + { to: PRECOMPILE_ADDRESSES.evidence, data }, + 'latest', + ]); + const direct = envelope.result; + expect(direct, 'direct eth_call must answer').to.not.equal(undefined); + for (const fn of ['callTarget', 'staticcallTarget', 'delegatecallTarget'] as const) { + const ret: string = await caller[fn].staticCall(PRECOMPILE_ADDRESSES.evidence, data); + const [decoded] = evidenceIface.decodeFunctionResult('allEvidence', ret); + const [expected] = evidenceIface.decodeFunctionResult('allEvidence', direct!); + expect( + decoded.evidenceList.length, + `${fn} must return the direct answer`, + ).to.equal(expected.evidenceList.length); + } + }); + }); +}); diff --git a/integration_test/precompile_tests/precompiles/gov.spec.ts b/integration_test/precompile_tests/precompiles/gov.spec.ts index de542bef0c..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..8c27727de7 --- /dev/null +++ b/integration_test/precompile_tests/precompiles/mint.spec.ts @@ -0,0 +1,182 @@ +/** + * mint precompile (0x…1012) — query surface over Sei's custom mint module. + * + * Two views: params() and minter(). The backing module is Sei's own + * seiprotocol.seichain.mint, not cosmos-sdk x/mint, so the parity oracle is + * that module's LCD routes rather than a /cosmos/... path. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { cosmosRest } from '../utils/cosmosUtils'; +import { EvmAccount } from '../utils/evmUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, +} from '../utils/precompileUtils'; +import { readRuntimeState, RuntimeState } from '../utils/testUtils'; + +interface TokenRelease { + startDate: string; + endDate: string; + tokenReleaseAmount: bigint; +} + +interface MintParams { + mintDenom: string; + tokenReleaseSchedule: TokenRelease[]; +} + +interface Minter { + startDate: string; + endDate: string; + denom: string; + totalMintAmount: bigint; + remainingMintAmount: bigint; + lastMintAmount: bigint; + lastMintDate: string; + lastMintHeight: bigint; +} + +// The gRPC-gateway routes registered by x/mint (see the patterns in +// x/mint/types/query.pb.gw.go). Sei's mint module keeps the bare `seichain` +// prefix rather than the `seiprotocol.seichain` proto package path. +const PARAMS_LCD_PATH = '/seichain/mint/v1beta1/params'; +const MINTER_LCD_PATH = '/seichain/mint/v1beta1/minter'; + +function asBigInt(v: unknown): bigint { + if (typeof v === 'bigint') return v; + if (typeof v === 'number') return BigInt(v); + if (typeof v === 'string' && v !== '') return BigInt(v); + return 0n; +} + +function lcdMintDenom(body: any): string | undefined { + const p = body?.params ?? body; + const denom = p?.mint_denom ?? p?.mintDenom; + return typeof denom === 'string' ? denom : undefined; +} + +function lcdSchedule(body: any): any[] { + const p = body?.params ?? body; + const s = p?.token_release_schedule ?? p?.tokenReleaseSchedule; + return Array.isArray(s) ? s : []; +} + +/** + * QueryMinterResponse is flat — it carries the minter's fields at the top + * level rather than nesting them under a `minter` key the way the params + * response nests under `params`. + */ +function lcdMinter(body: any): any { + return body ?? {}; +} + +describe('mint precompile (0x1012)', function () { + this.timeout(120 * 1000); + + const provider = seiRpc(); + const mintIface = precompileInterface('mint'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let mint: ethers.Contract; + let caller: ethers.Contract; + + before(() => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + mint = precompileContract('mint', admin.wallet); + caller = callerContract(runtime, admin.wallet); + }); + + describe('happy path & state parity', () => { + it('params() matches the mint module', async () => { + const [params, lcd] = await Promise.all([ + mint.params() as Promise, + cosmosRest(PARAMS_LCD_PATH), + ]); + expect(params.mintDenom, 'precompile mintDenom vs LCD').to.equal(lcdMintDenom(lcd)); + expect(params.mintDenom, 'devnet genesis mints the bond denom').to.equal('usei'); + + const schedule = [...params.tokenReleaseSchedule]; + const lcdRows = lcdSchedule(lcd); + expect(schedule.length, 'tokenReleaseSchedule length vs LCD').to.equal(lcdRows.length); + for (let i = 0; i < schedule.length; i++) { + const row = lcdRows[i]; + expect(schedule[i].startDate).to.equal(String(row.start_date ?? row.startDate ?? '')); + expect(schedule[i].endDate).to.equal(String(row.end_date ?? row.endDate ?? '')); + expect(schedule[i].tokenReleaseAmount).to.equal( + asBigInt(row.token_release_amount ?? row.tokenReleaseAmount), + ); + } + }); + + it('minter() matches the mint module', async () => { + const [minter, lcd] = await Promise.all([ + mint.minter() as Promise, + cosmosRest(MINTER_LCD_PATH), + ]); + const body = lcdMinter(lcd); + + expect(minter.denom).to.equal(String(body.denom ?? '')); + expect(minter.startDate).to.equal(String(body.start_date ?? body.startDate ?? '')); + expect(minter.endDate).to.equal(String(body.end_date ?? body.endDate ?? '')); + expect(minter.totalMintAmount).to.equal( + asBigInt(body.total_mint_amount ?? body.totalMintAmount), + ); + expect(minter.remainingMintAmount).to.equal( + asBigInt(body.remaining_mint_amount ?? body.remainingMintAmount), + ); + expect(minter.lastMintAmount).to.equal( + asBigInt(body.last_mint_amount ?? body.lastMintAmount), + ); + expect(minter.lastMintDate).to.equal( + String(body.last_mint_date ?? body.lastMintDate ?? ''), + ); + expect(minter.lastMintHeight).to.equal( + asBigInt(body.last_mint_height ?? body.lastMintHeight), + ); + // Genesis may leave the minter unset; when it is set it mints the bond denom. + if (minter.denom !== '') { + expect(minter.denom).to.equal('usei'); + } + }); + }); + + describe('error handling', () => { + it('rejects value on view methods (non-payable)', async () => { + for (const method of ['params', 'minter'] as const) { + const envelope = await rawSei('eth_call', [ + { + from: admin.address, + to: PRECOMPILE_ADDRESSES.mint, + data: mintIface.encodeFunctionData(method, []), + value: '0x1', + }, + 'latest', + ]); + expect(envelope.error, `${method}: value-bearing call must revert`).to.not.equal( + undefined, + ); + expect(envelope.error!.message).to.match(/execution reverted|revert/i); + } + }); + }); + + describe('dispatch semantics (via PrecompileCaller)', () => { + it('responds under STATICCALL', async () => { + const data = mintIface.encodeFunctionData('params', []); + const ret: string = await caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.mint, + data, + ); + const [decoded] = mintIface.decodeFunctionResult('params', ret); + const direct: MintParams = await mint.params(); + expect(decoded.mintDenom).to.equal(direct.mintDenom); + expect(decoded.mintDenom).to.equal('usei'); + }); + }); +}); diff --git a/integration_test/precompile_tests/precompiles/params.spec.ts b/integration_test/precompile_tests/precompiles/params.spec.ts new file mode 100644 index 0000000000..0ae4219027 --- /dev/null +++ b/integration_test/precompile_tests/precompiles/params.spec.ts @@ -0,0 +1,95 @@ +/** + * params precompile (0x…1013) — subspace parameter lookups against a live Sei chain. + * + * One view method: params(subspace, key) returns the stored value as a string. + * The Go unit test pins staking/MaxValidators as a decimal encoding of the + * staking keeper's MaxValidators; this spec uses the same key and asserts + * parity against cosmosQuery().staking.params(). + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { cosmosQuery } from '../utils/cosmosUtils'; +import { EvmAccount } from '../utils/evmUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, +} from '../utils/precompileUtils'; +import { readRuntimeState, RuntimeState } from '../utils/testUtils'; + +describe('params precompile (0x1013)', function () { + this.timeout(120 * 1000); + + const provider = seiRpc(); + const paramsIface = precompileInterface('params'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let params: ethers.Contract; + let caller: ethers.Contract; + + before(() => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + params = precompileContract('params', admin.wallet); + caller = callerContract(runtime, admin.wallet); + }); + + describe('happy path & state parity', () => { + it("params('staking', 'MaxValidators') matches the staking module", async () => { + const [value, qc] = await Promise.all([ + params.params('staking', 'MaxValidators') as Promise, + cosmosQuery(), + ]); + const expected = String((await qc.staking.params()).params!.maxValidators); + expect(value).to.match(/^\d+$/); + expect(value).to.equal(expected); + }); + }); + + describe('error handling', () => { + it('unknown subspace reverts', async () => { + await expectExecutionReverted( + params.params('notasubspace', 'NotAKey'), + "params.params with subspace 'notasubspace'", + ); + }); + + it('empty subspace reverts', async () => { + await expectExecutionReverted( + params.params('', 'MaxValidators'), + 'params.params with an empty subspace', + ); + }); + + it('rejects value (non-payable)', async () => { + const envelope = await rawSei('eth_call', [ + { + from: admin.address, + to: PRECOMPILE_ADDRESSES.params, + data: paramsIface.encodeFunctionData('params', ['staking', 'MaxValidators']), + value: '0x1', + }, + 'latest', + ]); + expect(envelope.error, 'value-bearing call must revert').to.not.equal(undefined); + expect(envelope.error!.message).to.match(/execution reverted|revert/i); + }); + }); + + describe('dispatch semantics (via PrecompileCaller)', () => { + it('responds under STATICCALL', async () => { + const data = paramsIface.encodeFunctionData('params', ['staking', 'MaxValidators']); + const ret: string = await caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.params, + data, + ); + const [decoded] = paramsIface.decodeFunctionResult('params', ret); + const direct: string = await params.params('staking', 'MaxValidators'); + expect(decoded).to.equal(direct); + }); + }); +}); diff --git a/integration_test/precompile_tests/precompiles/slashing.spec.ts b/integration_test/precompile_tests/precompiles/slashing.spec.ts new file mode 100644 index 0000000000..12860b55ef --- /dev/null +++ b/integration_test/precompile_tests/precompiles/slashing.spec.ts @@ -0,0 +1,212 @@ +/** + * slashing precompile (0x…1014) — end-to-end semantics against a live Sei chain. + * + * Query methods are checked against the slashing module's LCD. Write methods + * that would jail a validator are out of scope: grant/revoke of unjail + * authorization are exercised from a pool account, and unjail is only asserted + * to revert when the caller is not a jailed validator. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { EvmAccount, associateViaTx } from '../utils/evmUtils'; +import { cosmosRest } from '../utils/cosmosUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, + expectVmError, +} from '../utils/precompileUtils'; +import { readRuntimeState, claimPool, RuntimeState } from '../utils/testUtils'; + +const EMPTY_PAGE = new Uint8Array(); + +interface LcdSlashingParams { + params: { + signed_blocks_window: string; + min_signed_per_window: string; + downtime_jail_duration: string; + slash_fraction_double_sign: string; + slash_fraction_downtime: string; + }; +} + +/** protojson duration (`"600s"`) to whole seconds, matching the precompile's `.Seconds()`. */ +function protoDurationSeconds(raw: string): bigint { + const m = /^(-?[0-9]+(?:\.[0-9]+)?)s$/.exec(raw); + if (!m) { + throw new Error(`unexpected protojson duration: ${raw}`); + } + return BigInt(Math.trunc(Number(m[1]))); +} + +function asSigningInfo(info: ethers.Result) { + return { + validatorAddress: String(info.validatorAddress), + startHeight: BigInt(info.startHeight), + indexOffset: BigInt(info.indexOffset), + jailedUntil: BigInt(info.jailedUntil), + tombstoned: Boolean(info.tombstoned), + missedBlocksCounter: BigInt(info.missedBlocksCounter), + }; +} + +describe('slashing precompile (0x1014)', function () { + this.timeout(180 * 1000); + + const provider = seiRpc(); + const slashingIface = precompileInterface('slashing'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let slashing: ethers.Contract; + let caller: ethers.Contract; + let associated: EvmAccount; + + before(async () => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + slashing = precompileContract('slashing', admin.wallet); + caller = callerContract(runtime, admin.wallet); + [associated] = claimPool(runtime, provider, 1, 'slashing:associated'); + await associateViaTx(associated); + }); + + describe('happy path & state parity', () => { + it('params() matches LCD /cosmos/slashing/v1beta1/params', async () => { + const [viaPrecompile, lcd] = await Promise.all([ + slashing.params() as Promise, + cosmosRest('/cosmos/slashing/v1beta1/params'), + ]); + const p = lcd.params; + expect(viaPrecompile.signedBlocksWindow).to.equal(BigInt(p.signed_blocks_window)); + expect(viaPrecompile.minSignedPerWindow).to.equal(p.min_signed_per_window); + expect(viaPrecompile.downtimeJailDuration).to.equal( + protoDurationSeconds(p.downtime_jail_duration), + ); + expect(viaPrecompile.slashFractionDoubleSign).to.equal(p.slash_fraction_double_sign); + expect(viaPrecompile.slashFractionDowntime).to.equal(p.slash_fraction_downtime); + }); + + it('signingInfos(empty) is non-empty and signingInfo(that cons address) matches', async () => { + // 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..961d2a1b1c 100644 --- a/integration_test/precompile_tests/precompiles/staking.spec.ts +++ b/integration_test/precompile_tests/precompiles/staking.spec.ts @@ -17,7 +17,7 @@ import { generateKeyPairSync } from 'node:crypto'; import { fromBech32, toBech32 } from '@cosmjs/encoding'; import { seiRpc, waitUntil } from '../utils/chainUtils'; import { EvmAccount, associateViaTx } from '../utils/evmUtils'; -import { bondedValidators, cosmosQuery, bankBalance } from '../utils/cosmosUtils'; +import { bondedValidators, cosmosQuery, cosmosRest, bankBalance } from '../utils/cosmosUtils'; import { PRECOMPILE_ADDRESSES, precompileContract, @@ -429,5 +429,352 @@ describe('staking precompile (0x1005)', function () { 'cannot delegatecall staking', ); }); + + it('grantStakingAuthorization is rejected under STATICCALL', async () => { + const expiration = BigInt(Math.floor(Date.now() / 1000) + 86400); + const data = stakingIface.encodeFunctionData('grantStakingAuthorization', [ + delegator.address, + [validators[0], validators[1]], + DELEGATE_USEI, + expiration, + ]); + await expectVmError( + caller.getFunction('staticcallTarget').send(PRECOMPILE_ADDRESSES.staking, data, { + gasLimit: 1_000_000, + }), + 'cannot call staking precompile from staticcall', + ); + }); + + it('validators is callable via STATICCALL', async () => { + const data = stakingIface.encodeFunctionData('validators', [ + 'BOND_STATUS_BONDED', + new Uint8Array(), + ]); + const ret: string = await caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.staking, + data, + ); + const [decoded] = stakingIface.decodeFunctionResult('validators', ret); + const ops = [...decoded.validators].map((v: { operatorAddress: string }) => v.operatorAddress); + expect(ops, 'STATICCALL validators includes the first bonded validator').to.include( + validators[0], + ); + }); + }); + + describe('authz (grant / execute / revoke)', () => { + it('grantStakingAuthorization lets the grantee delegateWithAuthorization; revoke then reverts', async () => { + const [granter, grantee] = claimPool(runtime, provider, 2, 'staking:authz'); + await associateViaTx(granter); + await associateViaTx(grantee); + + const expiration = BigInt(Math.floor(Date.now() / 1000) + 86400); + const maxTokens = DELEGATE_USEI * 10n; + const grantTx = await ( + staking.connect(granter.wallet) as ethers.Contract + ).grantStakingAuthorization( + grantee.address, + [validators[0], validators[1]], + maxTokens, + expiration, + { gasLimit: 1_000_000 }, + ); + expect((await grantTx.wait())!.status, 'grantStakingAuthorization tx must succeed').to.equal( + 1, + ); + + // Staking.sol: the CALLER (grantee) supplies msg.value; the delegation + // is recorded on the granter. + const delegateTx = await ( + staking.connect(grantee.wallet) as ethers.Contract + ).delegateWithAuthorization(granter.address, validators[0], { + value: DELEGATE_WEI, + gasLimit: 1_000_000, + }); + expect( + (await delegateTx.wait())!.status, + 'delegateWithAuthorization tx must succeed', + ).to.equal(1); + + const qc = await cosmosQuery(); + const cosmosDelegation = await waitUntil( + async () => { + const d = await qc.staking.delegation(granter.seiAddress(), validators[0]); + return d.delegationResponse?.balance ?? null; + }, + { timeoutMs: 30_000, label: 'cosmos delegation after delegateWithAuthorization' }, + ); + expect(cosmosDelegation.amount).to.equal(DELEGATE_USEI.toString()); + expect(cosmosDelegation.denom).to.equal('usei'); + + const revokeTx = await ( + staking.connect(granter.wallet) as ethers.Contract + ).revokeStakingAuthorization(grantee.address, { gasLimit: 1_000_000 }); + expect((await revokeTx.wait())!.status, 'revokeStakingAuthorization tx must succeed').to.equal( + 1, + ); + + await expectVmError( + (staking.connect(grantee.wallet) as ethers.Contract).delegateWithAuthorization( + granter.address, + validators[0], + { value: DELEGATE_WEI, gasLimit: 1_000_000 }, + ), + 'authorization not found', + ); + }); + + it('redelegateWithAuthorization and undelegateWithAuthorization consume the grant', async () => { + const [granter, grantee] = claimPool(runtime, provider, 2, 'staking:authz-redelegate'); + await associateViaTx(granter); + await associateViaTx(grantee); + + const expiration = BigInt(Math.floor(Date.now() / 1000) + 86400); + const grantTx = await ( + staking.connect(granter.wallet) as ethers.Contract + ).grantStakingAuthorization( + grantee.address, + [validators[0], validators[1]], + DELEGATE_USEI * 10n, + expiration, + { gasLimit: 1_000_000 }, + ); + expect((await grantTx.wait())!.status).to.equal(1); + + const delegateTx = await ( + staking.connect(grantee.wallet) as ethers.Contract + ).delegateWithAuthorization(granter.address, validators[0], { + value: DELEGATE_WEI, + gasLimit: 1_000_000, + }); + expect((await delegateTx.wait())!.status).to.equal(1); + + const qc = await cosmosQuery(); + await waitUntil( + async () => { + const d = await qc.staking.delegation(granter.seiAddress(), validators[0]); + return d.delegationResponse?.balance ?? null; + }, + { timeoutMs: 30_000, label: 'authz granter delegation before redelegate' }, + ); + + const moved = DELEGATE_USEI / 4n; + const redelegateTx = await ( + staking.connect(grantee.wallet) as ethers.Contract + ).redelegateWithAuthorization( + granter.address, + validators[0], + validators[1], + moved, + { gasLimit: 2_000_000 }, + ); + expect((await redelegateTx.wait())!.status, 'redelegateWithAuthorization').to.equal(1); + + const dst = await waitUntil( + async () => { + const d = await qc.staking.delegation(granter.seiAddress(), validators[1]); + return d.delegationResponse?.balance ?? null; + }, + { timeoutMs: 30_000, label: 'authz destination delegation after redelegate' }, + ); + expect(BigInt(dst.amount)).to.equal(moved); + + const undelegateTx = await ( + staking.connect(grantee.wallet) as ethers.Contract + ).undelegateWithAuthorization(granter.address, validators[0], moved, { + gasLimit: 2_000_000, + }); + expect((await undelegateTx.wait())!.status, 'undelegateWithAuthorization').to.equal(1); + }); + }); + + describe('query methods (remaining surface)', () => { + const emptyPageKey = new Uint8Array(); + + it('validators(BOND_STATUS_BONDED, empty) includes validators[0]', async () => { + const resp = await staking.validators('BOND_STATUS_BONDED', emptyPageKey); + const list = resp.validators as ethers.Result; + expect(list.length, 'bonded validator set is non-empty').to.be.greaterThan(0); + const ops = [...list].map((v: { operatorAddress: string }) => v.operatorAddress); + expect(ops).to.include(validators[0]); + }); + + it('delegatorDelegations / delegatorValidators / delegatorValidator see a fresh delegation', async () => { + const [account] = claimPool(runtime, provider, 1, 'staking:query-delegator'); + await associateViaTx(account); + const tx = await (staking.connect(account.wallet) as ethers.Contract).delegate( + validators[0], + { value: DELEGATE_WEI, gasLimit: 1_000_000 }, + ); + expect((await tx.wait())!.status).to.equal(1); + + const dels = await waitUntil( + async () => { + const resp = await staking.delegatorDelegations(account.address, emptyPageKey); + const list = resp.delegations as ethers.Result; + return [...list].some( + (d: any) => d.delegation.validator_address === validators[0], + ) + ? list + : null; + }, + { timeoutMs: 30_000, label: 'delegatorDelegations after fresh delegate' }, + ); + expect( + [...dels].some((d: any) => d.delegation.delegator_address === account.seiAddress()), + ).to.equal(true); + + const dvals = await staking.delegatorValidators(account.address, emptyPageKey); + const ops = [...(dvals.validators as ethers.Result)].map( + (v: { operatorAddress: string }) => v.operatorAddress, + ); + expect(ops).to.include(validators[0]); + + const dv = await staking.delegatorValidator(account.address, validators[0]); + expect(dv.operatorAddress).to.equal(validators[0]); + + const valDels = await staking.validatorDelegations(validators[0], emptyPageKey); + expect( + (valDels.delegations as ethers.Result).length, + 'validatorDelegations is non-empty', + ).to.be.greaterThan(0); + expect( + [...(valDels.delegations as ethers.Result)].every( + (d: any) => d.delegation.validator_address === validators[0], + ), + ).to.equal(true); + }); + + it('unbondingDelegation / delegatorUnbondingDelegations / validatorUnbondingDelegations after undelegate', async () => { + const [account] = claimPool(runtime, provider, 1, 'staking:query-unbond'); + await associateViaTx(account); + const delegateTx = await (staking.connect(account.wallet) as ethers.Contract).delegate( + validators[0], + { value: DELEGATE_WEI, gasLimit: 1_000_000 }, + ); + expect((await delegateTx.wait())!.status).to.equal(1); + + const amount = DELEGATE_USEI / 2n; + const undelegateTx = await (staking.connect(account.wallet) as ethers.Contract).undelegate( + validators[0], + amount, + { gasLimit: 2_000_000 }, + ); + expect((await undelegateTx.wait())!.status).to.equal(1); + + const ubd = await waitUntil( + async () => { + const u = await staking.unbondingDelegation(account.address, validators[0]); + const entries = u.getValue('entries') as ethers.Result; + return entries.length > 0 ? u : null; + }, + { timeoutMs: 8_000, intervalMs: 200, label: 'unbondingDelegation after undelegate' }, + ); + expect(ubd.delegatorAddress).to.equal(account.seiAddress()); + expect(ubd.validatorAddress).to.equal(validators[0]); + expect(BigInt((ubd.getValue('entries') as ethers.Result)[0].balance)).to.equal(amount); + + const byDelegator = await staking.delegatorUnbondingDelegations( + account.address, + emptyPageKey, + ); + const duList = byDelegator.unbondingDelegations as ethers.Result; + expect( + [...duList].some((u: any) => u.validatorAddress === validators[0]), + 'delegatorUnbondingDelegations includes this validator', + ).to.equal(true); + + const byValidator = await waitUntil( + async () => { + const resp = await staking.validatorUnbondingDelegations( + validators[0], + emptyPageKey, + ); + const list = resp.unbondingDelegations as ethers.Result; + return [...list].some((u: any) => u.delegatorAddress === account.seiAddress()) + ? list + : null; + }, + { timeoutMs: 8_000, intervalMs: 200, label: 'validatorUnbondingDelegations' }, + ); + expect(byValidator.length).to.be.greaterThan(0); + }); + + it('redelegations sees a fresh redelegate', async () => { + const [account] = claimPool(runtime, provider, 1, 'staking:query-redelegate'); + await associateViaTx(account); + const delegateTx = await (staking.connect(account.wallet) as ethers.Contract).delegate( + validators[0], + { value: DELEGATE_WEI, gasLimit: 1_000_000 }, + ); + expect((await delegateTx.wait())!.status).to.equal(1); + + const moved = DELEGATE_USEI / 2n; + const redelegateTx = await (staking.connect(account.wallet) as ethers.Contract).redelegate( + validators[0], + validators[1], + moved, + { gasLimit: 2_000_000 }, + ); + expect((await redelegateTx.wait())!.status).to.equal(1); + + const resp = await waitUntil( + async () => { + const r = await staking.redelegations( + account.seiAddress(), + validators[0], + validators[1], + emptyPageKey, + ); + const list = r.redelegations as ethers.Result; + return list.length > 0 ? r : null; + }, + { timeoutMs: 30_000, label: 'redelegations after redelegate' }, + ); + const redels = resp.redelegations as ethers.Result; + expect(redels[0].delegatorAddress).to.equal(account.seiAddress()); + expect(redels[0].validatorSrcAddress).to.equal(validators[0]); + expect(redels[0].validatorDstAddress).to.equal(validators[1]); + }); + + // The staking module only keeps historical info for the last + // `historical_entries` heights, so whether a given height answers is a + // property of the chain, not of the precompile. The LCD is therefore the + // oracle: the precompile must agree with it about the SAME height, both + // on the answer and on the absence of one. + it('historicalInfo agrees with the staking module about a recent height', async () => { + const height = BigInt(Math.max((await provider.getBlockNumber()) - 2, 1)); + const lcd = await cosmosRest<{ + hist?: { valset?: Array<{ operator_address: string }> } | null; + code?: number; + }>(`/cosmos/staking/v1beta1/historical_info/${height}`).catch(() => undefined); + + if (lcd?.hist == null) { + // Not retained (historical_entries=0, or the height aged out): + // the precompile must refuse it rather than invent an answer. The + // reason surfaced is the staking querier's, not the executor's own + // "historical info not found" fallback, which a NotFound status + // error means is unreachable. + await expectVmError( + admin.wallet.sendTransaction({ + to: PRECOMPILE_ADDRESSES.staking, + data: stakingIface.encodeFunctionData('historicalInfo', [height]), + gasLimit: 1_000_000, + }), + `historical info for height ${height} not found`, + ); + return; + } + + const info = await staking.historicalInfo(height); + expect(info.height, 'historicalInfo echoes the requested height').to.equal(height); + const ops = [...(info.validators as ethers.Result)].map( + (v: { operatorAddress: string }) => v.operatorAddress, + ); + expect(ops.slice().sort(), 'historical valset matches the staking module').to.deep.equal( + (lcd.hist.valset ?? []).map(v => v.operator_address).sort(), + ); + }); }); }); diff --git a/integration_test/precompile_tests/precompiles/upgrade.spec.ts b/integration_test/precompile_tests/precompiles/upgrade.spec.ts new file mode 100644 index 0000000000..153d0544b2 --- /dev/null +++ b/integration_test/precompile_tests/precompiles/upgrade.spec.ts @@ -0,0 +1,134 @@ +/** + * upgrade precompile (0x…1015) — x/upgrade queries against a live Sei chain. + * + * All four methods are views. A local cluster has no scheduled upgrade and no + * applied plan, so currentPlan is the zero plan, appliedPlan of an unknown + * name is 0, and upgradedConsensusState is empty bytes. moduleVersions reads + * the app's real module consensus versions. currentPlan is checked against + * LCD /cosmos/upgrade/v1beta1/current_plan. + */ +import { ethers } from 'ethers'; +import { expect } from 'chai'; +import { seiRpc, rawSei } from '../utils/chainUtils'; +import { cosmosRest } from '../utils/cosmosUtils'; +import { EvmAccount } from '../utils/evmUtils'; +import { + PRECOMPILE_ADDRESSES, + precompileContract, + precompileInterface, + callerContract, + expectExecutionReverted, +} from '../utils/precompileUtils'; +import { readRuntimeState, RuntimeState } from '../utils/testUtils'; + +interface LcdPlan { + name?: string; + height?: string; + info?: string; +} + +interface LcdCurrentPlan { + plan?: LcdPlan | null; +} + +describe('upgrade precompile (0x1015)', function () { + this.timeout(120 * 1000); + + const provider = seiRpc(); + const upgradeIface = precompileInterface('upgrade'); + + let runtime: RuntimeState; + let admin: EvmAccount; + let upgrade: ethers.Contract; + let caller: ethers.Contract; + + before(() => { + runtime = readRuntimeState(); + admin = EvmAccount.fromMnemonic(runtime.funded.adminMnemonic, provider); + upgrade = precompileContract('upgrade', admin.wallet); + caller = callerContract(runtime, admin.wallet); + }); + + describe('happy path & state parity', () => { + it('currentPlan is the zero plan when none is scheduled (LCD parity)', async () => { + const [plan, lcd] = await Promise.all([ + upgrade.currentPlan(), + cosmosRest('/cosmos/upgrade/v1beta1/current_plan'), + ]); + const lcdPlan = lcd.plan; + if (lcdPlan == null || lcdPlan.name == null || lcdPlan.name === '') { + expect(plan.name).to.equal(''); + expect(plan.height).to.equal(0n); + expect(plan.info).to.equal(''); + } else { + expect(plan.name).to.equal(lcdPlan.name); + expect(plan.height).to.equal(BigInt(lcdPlan.height ?? 0)); + expect(plan.info).to.equal(lcdPlan.info ?? ''); + } + }); + + it("moduleVersions('') returns a non-empty list", async () => { + const versions: Array<{ name: string; version: bigint }> = + await upgrade.moduleVersions(''); + expect(versions.length).to.be.greaterThan(0); + }); + + it("moduleVersions('bank') is a 1-element list named bank", async () => { + // Go unit test (TestModuleVersions): a specific module name returns + // exactly one entry. An unknown name reverts (strict filter). + const versions: Array<{ name: string; version: bigint }> = + await upgrade.moduleVersions('bank'); + expect(versions).to.have.length(1); + expect(versions[0].name).to.equal('bank'); + expect(versions[0].version > 0n).to.equal(true); + }); + + it("appliedPlan('definitely-not-an-upgrade') returns 0", async () => { + const height: bigint = await upgrade.appliedPlan('definitely-not-an-upgrade'); + expect(height).to.equal(0n); + }); + + it('upgradedConsensusState(1) returns empty bytes', async () => { + const state: string = await upgrade.upgradedConsensusState(1n); + expect(state).to.equal('0x'); + }); + }); + + describe('error handling', () => { + it('unknown module name reverts', async () => { + await expectExecutionReverted( + upgrade.moduleVersions('notamodule'), + "upgrade.moduleVersions('notamodule')", + ); + }); + + it('rejects value (non-payable)', async () => { + const envelope = await rawSei('eth_call', [ + { + from: admin.address, + to: PRECOMPILE_ADDRESSES.upgrade, + data: upgradeIface.encodeFunctionData('currentPlan', []), + value: '0x1', + }, + 'latest', + ]); + expect(envelope.error, 'value-bearing call must revert').to.not.equal(undefined); + expect(envelope.error!.message).to.match(/execution reverted|revert/i); + }); + }); + + describe('dispatch semantics (via PrecompileCaller)', () => { + it('responds under STATICCALL', async () => { + const data = upgradeIface.encodeFunctionData('currentPlan', []); + const ret: string = await caller.staticcallTarget.staticCall( + PRECOMPILE_ADDRESSES.upgrade, + data, + ); + const [decoded] = upgradeIface.decodeFunctionResult('currentPlan', ret); + const direct = await upgrade.currentPlan(); + expect(decoded.name).to.equal(direct.name); + expect(decoded.height).to.equal(direct.height); + expect(decoded.info).to.equal(direct.info); + }); + }); +}); diff --git a/integration_test/precompile_tests/utils/cosmosUtils.ts b/integration_test/precompile_tests/utils/cosmosUtils.ts index ea580642c5..1374899b0c 100644 --- a/integration_test/precompile_tests/utils/cosmosUtils.ts +++ b/integration_test/precompile_tests/utils/cosmosUtils.ts @@ -65,6 +65,21 @@ export async function cosmosQuery(): Promise { return clientPromise; } +/** + * GET `path` from the chain's LCD (`SEI_REST`, default http://localhost:1317) + * and return the parsed JSON. Used for modules cosmjs does not wrap (mint, + * params, upgrade, evidence, slashing, authz, auth). `path` must start with `/`. + */ +export async function cosmosRest(path: string): Promise { + const base = Endpoints.sei.rest.replace(/\/$/, ''); + const url = `${base}${path.startsWith('/') ? path : `/${path}`}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`cosmosRest GET ${url} -> HTTP ${res.status}: ${await res.text()}`); + } + return (await res.json()) as T; +} + const bankClient = cosmosQuery; /** Operator addresses (seivaloper1…) of the currently bonded validators. */ diff --git a/integration_test/precompile_tests/utils/precompileUtils.ts b/integration_test/precompile_tests/utils/precompileUtils.ts index d6dad56806..8a4f4d78d9 100644 --- a/integration_test/precompile_tests/utils/precompileUtils.ts +++ b/integration_test/precompile_tests/utils/precompileUtils.ts @@ -23,7 +23,14 @@ export const PRECOMPILE_ADDRESSES = { pointerview: '0x000000000000000000000000000000000000100A', pointer: '0x000000000000000000000000000000000000100b', solo: '0x000000000000000000000000000000000000100C', + auth: '0x000000000000000000000000000000000000100D', + authz: '0x000000000000000000000000000000000000100E', + evidence: '0x000000000000000000000000000000000000100F', p256: '0x0000000000000000000000000000000000001011', + mint: '0x0000000000000000000000000000000000001012', + params: '0x0000000000000000000000000000000000001013', + slashing: '0x0000000000000000000000000000000000001014', + upgrade: '0x0000000000000000000000000000000000001015', } as const; export type PrecompileName = keyof typeof PRECOMPILE_ADDRESSES;