Skip to content

sdk: EIP-712 typed-data builder for Cosmos EVM signed Cosmos txs#204

Closed
trevormil wants to merge 12 commits intomainfrom
feat/eip712-typed-data
Closed

sdk: EIP-712 typed-data builder for Cosmos EVM signed Cosmos txs#204
trevormil wants to merge 12 commits intomainfrom
feat/eip712-typed-data

Conversation

@trevormil
Copy link
Copy Markdown
Collaborator

@trevormil trevormil commented May 5, 2026

Summary

  • Adds wrapTxToTypedData(signDoc, eip155ChainId) and supporting machinery so any eth_signTypedData_v4-capable wallet (MetaMask / Privy / Coinbase Smart Wallet) can sign Cosmos messages directly. Output is what the chain's cosmos/evm ante handler reconstructs during EIP-712 verification — drift = signing failures, so the implementation is a near-line-for-line port of the canonical Go reference at cosmos/evm/ethereum/eip712.
  • Coverage is exhaustive by construction: the type tree is built by walking the Amino-encoded message JSON, so every Msg* already in transactions/amino/registry.ts (cosmos.bank/staking/distribution/gov/authz/ibc, x/tokenization, x/gamm, x/managersplitter — 60+ types) works automatically.
  • Adds dropEmptyProtoSubMessages (proto-reflection walker) so the SDK's wire-side proto encoding agrees with the chain's nullable=true schema — empty sub-messages are stripped before serialization, which keeps the typed-data tree free of zero-field types that trip go-ethereum's apitypes.EncodeType bug. Pairs with the proto schema sweep in bitbadgeschain#90.
  • Adds an own hashTypedData() because ethers v6 hard-codes EIP712Domain.verifyingContract to address and salt to bytes32, which the canonical Cosmos EVM domain ("cosmos" + "0") violates. Native eth_signTypedData_v4 accepts the typed-data object fine; ethers does not.
  • Wired into createTransactionPayload so callers get an eip712 field alongside signDirect / legacyAmino / evmTx. EIP-155 chain id auto-derives from testnet flag (50024 / 50025); custom chains pass eip155ChainIdOverride.
  • Mirrors the optional requestId field on the SDK's locally-extracted ChallengeParams to match the upstream blockin SIWE-conformance PR (SIWE / EIP-4361 conformance fixes for SIWBB challenge construction Blockin-Labs/blockin#4). Pure type addition that keeps the SDK in sync as SIWBB becomes EIP-4361 conformant.

Refs bitbadges-autopilot#0359. Pair-merge with bitbadgeschain#90 and bitbadges-frontend#200.

Test plan

  • 21 unit specs covering domain, sanitize, message flattening, type generation, wrap, deterministic hashing, ecRecover round-trip, canonical EIP-712 type signature output.
  • 7 integration specs driving the full pipeline (proto Msg → Amino → StdSignDoc → EIP-712) for cosmos.bank.MsgSend, cosmos.staking.MsgDelegate, ibc MsgTransfer, tokenization.MsgTransferTokens, managersplitter.MsgCreateManagerSplitter, gamm.MsgSwapExactAmountIn, plus a multi-message tx.
  • Full SDK suite (2406 tests) passes — no regressions.
  • End-to-end against local devnet via bun run bootstrap:no-build from indexer: 25 bootstrap collections + a multi-msg gamm CreateBalancerPool + SwapExactAmountIn + TransferTokens tx all accepted by the chain (height 309, code 0, raw_log empty). Exercises 19+ msg types through the typed-data builder against the chain's reconstruction logic.

🤖 Generated with Claude Code

Public entry: wrapTxToTypedData(signDoc, eip155ChainId) takes an Amino
StdSignDoc and produces { domain, types, primaryType, message } that any
eth_signTypedData_v4 wallet (MetaMask / Privy / Coinbase Smart Wallet)
can sign directly. The chain's cosmos/evm v0.6.0 ante handler verifies
the resulting signature via ethsecp256k1.verifySignatureAsEIP712, so the
implementation is a near-line-for-line port of the canonical Go
reference at cosmos/evm/ethereum/eip712 to guarantee byte-exactness.

Coverage is exhaustive by construction: the type tree is built by
walking the Amino-encoded message JSON, so every Msg type with an
existing Amino converter (cosmos.bank, staking, distribution, gov,
authz, ibc, tokenization, gamm, managersplitter — 60+ types in
registry.ts) works automatically. Adding a new Msg type to the Amino
registry adds it to this builder for free.

Includes:
- domain.ts: canonical Cosmos EVM domain ({ name: 'Cosmos Web3', salt:
  '0', verifyingContract: 'cosmos', ... }).
- sanitize.ts: typedef name munging (`_.foo_bar` → `TypeFooBar`).
- message.ts: msgs[] flattening into msg0 / msg1 / ...
- types-builder.ts: recursive type-tree generation with reverse-alpha
  field ordering, dedup, empty-array sentinel.
- wrap.ts: top-level WrapTxToTypedData equivalent.
- hash.ts: own EIP-712 hasher that respects user-supplied EIP712Domain
  types — ethers v6 hard-codes verifyingContract to address and salt to
  bytes32, which the canonical Cosmos EVM domain (string + literal '0')
  doesn't satisfy. Native MetaMask / Privy accept the typed-data fine.
- build.ts: ergonomic helper that takes the same inputs as the existing
  Cosmos sign-doc builder and returns ready-to-sign typed-data.

Wired into createTransactionPayload: the returned TransactionPayload
now exposes an `eip712` field alongside signDirect / legacyAmino /
evmTx. EIP-155 chain id auto-derives from testnet flag (50024 mainnet,
50025 testnet); custom chains pass eip155ChainIdOverride.

Tests: 28 specs covering domain, sanitize, message flattening, type
generation (ordering, dedup, empty arrays, nested arrays), end-to-end
wrap, deterministic hashing, ecRecover round-trip, canonical type
signature output, and full pipeline drive for MsgSend, MsgDelegate,
MsgTransfer, MsgTransferTokens, MsgCreateManagerSplitter, MsgSwapExactAmountIn,
plus a multi-message tx.

Refs #0359.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

/** Returns the base struct type if `type` is a struct (or array of struct), otherwise null. */
function baseStructType(type: string): string | null {
const stripped = type.replace(/(\[\d*\])+$/g, '');
trevormil and others added 3 commits May 5, 2026 13:08
Keeps the SDK's local ChallengeParams type in sync with the upstream
blockin package after the SIWE / EIP-4361 conformance fixes added the
optional `requestId` field. The SDK only ships type definitions
extracted from blockin (no runtime dep), so this is a pure type
addition — no codepath changes.

Refs bitbadges-autopilot#0359.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the e2e gap on top of wrapTxToTypedData so callers can go from
a Cosmos message to a broadcast-ready tx with one method call. Three
additions, all backwards-compatible:

1. **`GenericEvmAdapter.signTypedData(typed)`** — routes through the
   wallet's `eth_signTypedData_v4` (EIP-1193) when available, otherwise
   ethers' `Signer.signTypedData`. Returns the 65-byte hex signature
   that wallets emit. Native MetaMask / Privy / Coinbase Smart Wallet
   accept the canonical Cosmos EVM domain (`verifyingContract: "cosmos"`,
   `salt: "0"`); ethers v6 hard-rejects it, so the adapter prefers the
   EIP-1193 path and warns explicitly when ethers can't sign the
   canonical domain.

   The interface (`WalletAdapter`, `BaseWalletAdapter`,
   `WalletAdapterInterface`) gains an optional `signTypedData?` method
   plus a `supportsSignTypedData()` capability flag, so callers can
   feature-detect.

2. **`recoverEvmPublicKey(typed, sig)`** — derives the 33-byte
   compressed pubkey + Ethereum address from a typed-data signature.
   Most EVM wallets don't expose `eth_getPublicKey`, so signing first +
   recovering is the canonical way to populate the SignerInfo. Pair
   with `stripRecoveryByte()` to convert wallet output (r||s||v) to
   the 64-byte form the chain's `ethsecp256k1.VerifySignature` expects.

3. **`buildEip712TxRaw` / `buildEip712TxBroadcastBody`** — assemble a
   legacyAmino TxRaw with an `ethsecp256k1.PubKey` SignerInfo (instead
   of the standard `secp256k1.PubKey`). The PubKey type is what routes
   chain-side verification through the EIP-712-aware code path; same
   Cosmos broadcast envelope otherwise (no `ExtensionOptionsWeb3Tx`
   needed — `cosmos/evm` v0.6.0 detects EIP-712 via the dual-path
   verifier inside ethsecp256k1).

   Adds `createSignerInfoEthsecp256k1` to `transactions/messages/transaction.ts`
   as the parallel of the existing secp256k1 builder.

4. **`BitBadgesSigningClient.signAndBroadcast({ mode: 'eip712' })`** —
   orchestrates the full flow: get account info, simulate fee, build
   typed-data, sign via adapter, recover pubkey, assemble TxRaw,
   broadcast through the existing /api/v0/broadcast proxy. Same
   sequence-mismatch retry logic as the Cosmos path. `mode` is now an
   explicit option on `SignAndBroadcastOptions`:
     - `cosmos`     — sign with adapter.signDirect (Keplr / Leap)
     - `precompile` — encode as EVM tx through the precompile
     - `eip712`     — sign as legacyAmino tx via eth_signTypedData_v4
   Default dispatch (cosmos vs evm precompile) unchanged.

Tests: 7 new specs covering pubkey recovery (round-trip through
ecRecover), recovery-byte stripping, TxRaw assembly with the right
PubKey type-url in AuthInfo, broadcast-body JSON shape, and end-to-end
build for cosmos.bank.MsgSend + tokenization.MsgTransferTokens. Full
SDK suite (2413 tests) clean.

Refs bitbadges-autopilot#0359.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ublic

`createProtoMsg` and `MessageGenerated` were defined but never re-exported
from the package root. Frontend EIP-712 path needs `createProtoMsg(msg)`
to wrap proto messages before passing them to `buildEip712TxBroadcastBody`,
which throws "createProtoMsg is not a function" at runtime since the
import resolves to undefined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
trevormil and others added 6 commits May 6, 2026 09:38
The SDK's `convertProtoMessagesToAmino` uses bufbuild's
`toJson({emitDefaultValues: true})` which emits every proto field
including zero-valued defaults. The chain's verifier reconstructs
amino JSON via Go's `MarshalAminoJSON` which prunes `omitempty`
defaults — so the SDK's typed-data tree had extra fields (empty
strings, false bools, empty arrays) that the chain's tree didn't.
The keccak diverged → "signature verification failed" on every
EIP-712-signed broadcast.

Two fixes:

1. `pruneAminoEmpties` — recursively strip empty strings, `false`,
   `[]`, `null`, `undefined`. Empty objects (`{}`) stay because
   gogoproto emits the struct value even when all inner fields drop.

2. Uint customtype coercion — bufbuild emits `""` for unset string
   fields. For fields tagged `(gogoproto.customtype) = "Uint"` in
   the proto IDL (cosmos-sdk math.Uint), the chain emits `"0"` for
   the zero value (non-nullable customtype, no omitempty). Coerce
   `""` → `"0"` for these field names before the prune so they
   survive and emit `"0"` like the chain.

The Uint name set is generated from
`grep '(gogoproto.customtype) = "Uint"' bitbadgeschain/proto/`
(50 unique names across all modules). If a future proto adds a new
Uint field, regenerate the set.

Verified programmatically: pruned SDK output is byte-identical to
the chain's canonical signDocBytes (sorted-JSON diff via comparator
script against actual chain log capture).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
go-ethereum's `apitypes.TypedData.EncodeType` always writes `(`, loops
the fields appending `,`, then `Truncate(len-1)` and writes `)`. The
truncate is intended to strip a trailing `,` between fields, but for
an empty fields list the loop never runs, so it strips the `(`
instead — producing `TypeName)` for empty struct types. The chain's
`apitypes.TypedDataAndHash` depends on this exact byte layout for
typeHash.

The SDK was emitting `TypeName()` for empties instead, so the
`encodeType` signature for our msgs (which contain 6 empty struct
types like `senderChecks`, `autoDeletionOptions`, `userPermissions`,
etc.) was 6 bytes longer than the chain's, producing a different
typeHash → different messageHash → different keccak → ecRecover
returns a wrong pubkey, which gets stuffed into SignerInfo, so chain
verification fails with "unauthorized" on every EIP-712 broadcast.

Pinpointed by feeding the SDK's typed-data tree to a Go program
using go-ethereum's `apitypes.TypedDataAndHash` directly — that
produced the chain's expected hash, while the SDK's own
`hashTypedData` produced a different value. Bisected to the
`encodeType` signature; signatures differed by exactly 6 chars
(the 6 `TypeName()` instances minus 6 `TypeName)` instances).

Refs bitbadges-autopilot#0381.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit made the SDK mirror go-ethereum's
`apitypes.TypedData.EncodeType` quirk (`TypeName)` for empty struct
types). That was the wrong direction: MetaMask's eth-sig-util
produces `TypeName()` per the EIP-712 spec, and MM is the signer.
SDK-side hashing must match what MM signs so `ecRecover` yields
the actual signer's pubkey for SignerInfo.

The chain-side bug (`apitypes.TypedDataAndHash` rendering empty
types as `TypeName)`) is fixed in our cosmos-evm fork — see
cosmos-evm-fork/ethereum/eip712/bbcorrect_hash.go. Both ends now
use the canonical eth-sig-util-style `TypeName()` form.

Verified programmatically: SDK's hashTypedData on the canonical
signDoc produces 0x623f05031539d73b3803f5125ae29cd1a5d2eb50819a7e1a58274ecc090846e4,
which matches the digest MetaMask produces and recovers the user's
correct EVM address.

Refs bitbadges-autopilot#0381.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the chain-side schema change in bitbadgeschain — see
that commit for the full rationale. Net effect on the TS side:
nested struct proto fields are now `optional` (compile to `?: T`
in protobuf-ts), so SDK callers that don't set them produce proto
wire bytes without the field, which the chain unmarshals as nil
and amino-omits from the canonical signDoc. No more empty struct
types in the typed-data tree → MetaMask's eth-sig-util digest
matches what the chain reconstructs → EIP-712 signatures verify.

Refs bitbadges-autopilot#0381.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ding

The chain's nullable=true schema change is necessary but not sufficient.
With nullable=true the chain's `MarshalAminoJSON` only omits a field
when the Go pointer is nil — but a non-nil pointer to an all-zero
struct still emits `field: {}` and reintroduces the empty-struct
type that triggers go-ethereum apitypes' encodeType bug.

The SDK class hierarchy auto-fills nested fields with `new SubType({})`
when the user passes an empty input (e.g. `collectionPermissions: {}`).
Those fresh empty instances flow through `.toProto()` into the
bufbuild proto, which then encodes the field on the wire as a
length-0 embedded message. Chain unmarshals to non-nil pointer →
amino emits `{}` → empty type in typed-data → eth-sig-util / apitypes
hash divergence → "signature verification failed".

Add `dropEmptyProtoSubMessages` — a bufbuild-reflection walker that
sets effectively-empty (`toBinary().length === 0` after recursive
descent) singular message-typed sub-fields to `undefined`. Apply at
two boundaries:

1. `convertProtoMessagesToAmino` — before producing the amino JSON
   that flows into the typed-data tree we sign.
2. `createAnyMessage` — before serializing the proto for the
   broadcast wire bytes the chain reconstructs from.

Both must be pruned identically so the SDK's typed-data and the
chain's reconstructed typed-data match byte-for-byte.

Verified end-to-end: with this prune, SDK `hashTypedData` and
go-ethereum `apitypes.TypedDataAndHash` produce the same digest on
the user's `MsgUniversalUpdateCollection` payload —
0x37c61d8e39c2d14c406595b997f790a0abeb8feda2bde50d275ebc2dc8c9717a.

Refs bitbadges-autopilot#0381.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
trevormil and others added 2 commits May 6, 2026 15:33
Replaces UINT_CUSTOMTYPE_FIELD_NAMES (a hand-curated 49-name allow-list
generated from grepping bitbadgeschain proto/) with a runtime walk over
the SDK class instance graph that aggregates `getNumberFieldNames()`
returns recursively.

The hardcoded set goes stale every time someone adds a Uint field to a
proto and forgets to update the SDK. The dynamic walk uses the same
infra each Msg class already exposes for NumberType conversion, so
adding a new Uint field anywhere in the class hierarchy auto-propagates.

`convertProtoMessagesToAmino` now takes an optional `sdkSourceMessages`
parameter — the class instance graph used to derive the set. The EIP-712
path threads SDK instances through `BuildEIP712Args.sdkSourceMessages`
so the pruner sees the original instance graph (post-toProto we'd only
have bufbuild protos, which don't expose getNumberFieldNames).

Non-EIP-712 callers that pass raw bufbuild protos get an empty Uint set
(no coercion), which is correct — those paths don't go through chain
EIP-712 verification, so the `""` vs `"0"` distinction is moot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ld time

Replaces the previous runtime walker (which only saw BitBadges-native SDK
classes implementing getNumberFieldNames) and the original hand-curated
allow-list (which went stale on every proto change) with a build-time
generator that mirrors the chain's own mechanism.

The chain's gogoproto.customtype detection is purely a protoc-time
parse: gogoproto reads `(gogoproto.customtype) = "Uint" | "Int" |
"LegacyDec" | …` from the .proto IDL and emits Go code where the field
type is `math.Uint` / `math.Int` / etc. — whose `MarshalJSON` always
produces a non-empty quoted decimal. We replay the same parse on the
same .proto sources to learn which fields the chain will emit as
"0" / "0.000000000000000000" rather than the proto3 default "".

scripts/generate-customtype-fields.ts: walks proto/ (286 .proto files
across BitBadges + Cosmos SDK + IBC), extracts customtype-tagged string
fields, splits them by zero-value family (Uint/Int → "0", Dec/LegacyDec
→ "0.000000000000000000"), emits two frozen Sets to
customtype-fields.generated.ts. Yield: 103 Uint/Int names, 37 Dec
names — vs. 49 names the original hand-curated list captured.

The generator runs as a `gen:customtypes` script chained into `build`,
so any proto regen automatically refreshes the sets. Output is
committed for cold installs.

API simplification: drops the optional `sdkSourceMessages` arg on
`buildEIP712TypedData` / `convertProtoMessagesToAmino` (runtime
walker is no longer needed). pruneAminoEmpties uses the static sets
directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@trevormil
Copy link
Copy Markdown
Collaborator Author

Closing in favor of Option 2 — see bitbadgeschain#91, bitbadgesjs#206, bitbadges-frontend#201.

After implementing both approaches end-to-end and comparing on a live local chain (signing real MsgCreateCollection / MsgUpdateUserApprovals via MetaMask, broadcasting, chain accepting), Option 2 ships the same EIP-712 functionality with far less chain-side complexity:

  • Option 1 (this PR): proto nullable=true sweep across ~128 fields, regenerated .pb.go, NormalizeNilPointers, IsBasicallyEmpty, ProtoEqualNullableAware, nil-guard sweep across keeper code (~3500 LoC delta).
  • Option 2: 14-line patch in signer/core/apitypes/types.go::EncodeType (a fork of cosmos/go-ethereum via go.mod replace) that fixes the actual root cause — go-ethereum's EncodeType emitting TypeName) for empty types instead of TypeName(). ~80 LoC chain delta (just codec/wiring).

Both options need the same v31 coordinated upgrade plan; Option 2 has a smaller validator review surface and trivial rollback.

The cherry-picked codec/wiring fixes from this PR live on in #91 (cosmos/evm crypto type registration, eip712.SetEncodingConfig, RegisterAminoConcretes split, ethsecp256k1 amino registration, the pre-existing test-bug fixes). The proto-sweep + nullable-aware helpers are abandoned.

@trevormil trevormil closed this May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants