cosmosevm-js-eip712
v0.0.4
Published
EIP-712 typed-data builder for signing a caller-chosen batch of Cosmos SDK authz/feegrant grants with an EVM wallet (MetaMask) on KiiChain and other cosmos/evm-based chains
Maintainers
Readme
cosmosevm-js-eip712
Small JS/TS library for signing a batch of Cosmos SDK authz/feegrant
Grants on KiiChain -- a caller-chosen combination of a generic authz
grant (e.g. staking redelegate, withdraw-rewards), a bank send authz grant,
and/or an unlimited feegrant, all to the same grantee, in one transaction --
with an EVM wallet (MetaMask) via eth_signTypedData_v4, instead of a
Cosmos wallet like Keplr.
Why this is narrow, not a generic Cosmos-EVM EIP-712 engine
cosmos/evm's own ethereum/eip712 package (Go) is a generic amino-JSON →
EIP-712 encoder -- ~1500-2500 lines across encoding/preprocess/type-mapping
files, for arbitrary Cosmos messages. Porting that generically to JS was
judged too large and risky a task for what's actually needed here. Instead,
this library supports exactly three grant kinds -- genericAuthorization
(repeatable, parametrized by msg type URL), sendAuthorization, and
feeGrant -- combinable in any order via GrantSpec[]. That covers the
common auto-compounding batch (a staking grant, a bounded transfer grant,
and an unlimited feegrant, all in one signed transaction) without pulling
in a full generic amino-JSON encoder. It is deliberately not generalized
any further than that.
Two real findings from building this that are worth knowing
ethers.TypedDataEncodercannot compute this hash at all. It hardcodes the standard EIP-712 domain field types (verifyingContractasaddress) and throwsinvalid addresson Cosmos-EVM's domain, which declaresverifyingContractas a plainstring("cosmos", not a 0x address).@metamask/eth-sig-util-- MetaMask's own implementation -- respects whatever domain schema you give it, verified with a real sign+recover round trip. Public-key recovery itself (once you have the correct digest) is still done withethers.SigningKey, which is schema-agnostic pure ECDSA math and has no such limitation.- Signature byte format for the on-chain broadcast matters and is easy
to get backwards. A reference Go implementation that broadcast
successfully on mainnet explicitly bumps the recovery id from 0/1 to
27/28 before embedding the signature in the transaction, and never
converts it back. This library's
broadcastGrantsfollows that proven behavior -- it keeps the signature exactly aseth_signTypedData_v4returns it (v as 27/28) all the way into the transaction. A naive port that "normalizes" the recovery id back to 0/1 before broadcasting (matching go-ethereum's own internal convention for its recovery functions) would be broadcasting something different from what actually verified on mainnet -- worth checking explicitly if you port this further, since it's a one-byte difference that's easy to miss and easy to get backwards. - cosmos/evm's EIP-712 type generator deduplicates structurally
identical nested types. Two
genericAuthorizationgrants for different msg type URLs (e.g. one forMsgDelegate, one forMsgBeginRedelegate) share the exact same field schema ({msg: string}), so cosmos/evm's generator reuses the same generated type name for both rather than minting a new one -- confirmed empirically with aDRY_RUNdump of a reference Go implementation's typed-data output against a real KiiChain mainnet account (2026-09-14). This library'sbuildGrantsTypedDataimplements that dedup rule -- shape is judged purely by field schema, never by the actual data inside it. Seetest/fixtures/grantsFixture.ts'sDEDUP_FIXTURE_*exports for the captured example this is verified against.
Usage
import {
KII_MAINNET,
fetchAccount,
buildGrantsTypedData,
broadcastGrants,
GrantSpec,
} from 'cosmosevm-js-eip712';
const grants: GrantSpec[] = [
{ kind: 'genericAuthorization', msgTypeUrl: '/cosmos.staking.v1beta1.MsgDelegate' },
{ kind: 'genericAuthorization', msgTypeUrl: '/cosmos.staking.v1beta1.MsgBeginRedelegate' },
{
kind: 'sendAuthorization',
spendLimit: { denom: 'akii', amount: '1000000000000000' },
allowAddress: 'kii1...', // the ONLY address this transfer grant may send to
},
{ kind: 'feeGrant' },
];
const params = {
granterAddress: 'kii1...',
granteeAddress: 'kii1...', // the address you're granting permissions to
expirySeconds: Math.floor(Date.now() / 1000) + 365 * 24 * 60 * 60,
grants,
};
const { accountNumber, sequence } = await fetchAccount(KII_MAINNET.lcdUrl, params.granterAddress);
const signContext = { accountNumber, sequence, gasLimit: '400000', feeAmountAkii: '136000000000000' };
const typedData = buildGrantsTypedData(params, signContext, KII_MAINNET);
// Hand `typedData` to MetaMask, unmodified:
const signatureHex = await window.ethereum.request({
method: 'eth_signTypedData_v4',
params: [granterAddress, JSON.stringify(typedData)],
});
const result = await broadcastGrants(params, signContext, KII_MAINNET, signatureHex);broadcastGrants never trusts a caller-supplied public key: it recovers
the actual signer from the signature itself (the same ECDSA recovery an
EVM node uses to find a raw transaction's sender) and throws unless it
matches params.granterAddress.
GrantSpec kinds
{ kind: 'genericAuthorization', msgTypeUrl: string }-- repeatable. Grants authorization to send any single Cosmos message of that type (e.g.MsgBeginRedelegate,MsgDelegate,MsgWithdrawDelegatorReward). Multiple instances (differentmsgTypeUrls) dedupe onto one shared EIP-712 type behind the scenes -- there's no cost to adding more.{ kind: 'sendAuthorization', spendLimit, allowAddress? }-- a bank send authorization, capped atspendLimit. OmitallowAddressonly if you specifically want an unrestricted-recipient grant, which is a materially broader permission than a recipient-restricted one.{ kind: 'feeGrant' }-- an unlimitedBasicAllowancefeegrant, no expiry.
Not supported: more than one sendAuthorization or feeGrant per
call. A typical native (Keplr) auto-compounding flow never batches more
than one of either, so this isn't a gap in practice -- just an explicit
non-goal, to avoid building out dedup/counter logic for a case that
doesn't occur.
Revoking: RevokeSpec kinds
buildRevokesTypedData / broadcastRevokes withdraw grants issued
earlier. Same machinery, same signing flow, different messages
(MsgRevoke and MsgRevokeAllowance), and no expiry -- a revocation is
instantaneous, so RevokesParams has no expirySeconds.
{ kind: 'revokeAuthorization', msgTypeUrl: string }-- repeatable. Withdraws the authz grant for that message type. Note authz revokes bymsg_type_urlALONE: asendAuthorizationis withdrawn withmsgTypeUrl: '/cosmos.bank.v1beta1.MsgSend', and its spend limit and allow list play no part. That is whyRevokeSpecis not the mirror image ofGrantSpec.{ kind: 'revokeFeeGrant' }-- withdraws the feegrant allowance.
The messages are atomic, and the authz module errors on revoking an authorization that is not there -- so one stale entry fails the whole transaction and nothing is withdrawn. Read the granter's live grants immediately before building the params.
The EIP-712 field order for MsgRevoke is msg_type_url, granter,
grantee -- neither alphabetical nor the proto field order (where
msg_type_url is third). It was captured from a dry run of cosmos/evm's
own WrapTxToTypedData, the same way the grant shapes were, and
test/buildRevokesTypedData.test.ts compares against that dump byte for
byte. Do not "tidy" it.
What's NOT here
- Delegate (
MsgDelegate) as a directly-signed message -- not needed through this path at all. KiiChain's EVMStakingprecompile lets a plaineth_sendTransactiondelegate directly; only granting authz needs EIP-712, since there is no authz/feegrant precompile. (MsgDelegateas the target of agenericAuthorizationgrant -- i.e. authorizing someone else to delegate on your behalf -- is fully supported, same as any other msg type URL.) - Real browser MetaMask end-to-end test. The typedData construction is
verified against a real captured mainnet example; the sign+recover round
trip is verified with
@metamask/eth-sig-util's own reference signer (which real MetaMask is built on). Neither is the same as a real wallet popup in a real browser -- that's still open. - A dedicated Kii/Cosmos-EVM chain-agnostic config for other chains.
KII_MAINNETis the only providedChainConfig. - Any authz/feegrant kind beyond the
GrantSpec/RevokeSpecvariants above (e.g. stakingDenyauthorizations, periodic feegrant allowances). Add one only when a real caller needs it, following the same capture-and-verify process this library was built with.
Development
npm install
npm test
npm run build