Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions packages/bitbadgesjs-sdk/src/eip712/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { EVMChainIDMainnet, EVMChainIDTestnet, MAINNET_CHAIN_DETAILS, TESTNET_CHAIN_DETAILS } from '../common/constants.js';
import { convertProtoMessagesToAmino, createStdFee } from '../transactions/messages/transaction.js';
import { makeSignDoc } from '../transactions/messages/signDoc.js';
import { createProtoMsg } from '../transactions/messages/utils.js';
import type { MessageGenerated } from '../transactions/messages/utils.js';
import { wrapTxToTypedData } from './wrap.js';
import type { EIP712TypedData } from './types.js';

/**
* High-level helper that mirrors the inputs `createTransactionWithMultipleMessages`
* takes and returns the EIP-712 typed-data ready for an EVM wallet.
*
* Same Amino encoding pipeline that powers `legacyAmino.signBytes` — so any
* Msg type with an Amino converter registered works automatically. Adding
* a new Msg type to the Amino registry adds it to this builder for free.
*/
export interface BuildEIP712Args {
/** Proto messages (raw bufbuild Message instances or pre-wrapped MessageGenerated). */
messages: any[];
/** Cosmos chain-id string, e.g. "bitbadges-1" or "bitbadges-2". */
cosmosChainId: string;
/** EIP-155 numeric chain id (50024 mainnet, 50025 testnet, 90123 local). */
eip155ChainId: number;
fee: { amount: string; denom: string; gas: number };
memo?: string;
sequence: number;
accountNumber: number;
}

export function buildEIP712TypedData(args: BuildEIP712Args): EIP712TypedData {
const generated: MessageGenerated[] = args.messages.map((m) =>
// Already a wrapped { message, path } envelope?
m && typeof m === 'object' && 'path' in m && 'message' in m ? (m as MessageGenerated) : createProtoMsg(m)
);
const aminoMsgs = convertProtoMessagesToAmino(generated);
const stdFee = createStdFee(args.fee.amount, args.fee.denom, args.fee.gas);
const signDoc = makeSignDoc(aminoMsgs, stdFee, args.cosmosChainId, args.memo ?? '', args.accountNumber, args.sequence);
return wrapTxToTypedData(signDoc as unknown as Record<string, unknown>, args.eip155ChainId);
}

/**
* Maps a Cosmos chain numeric id (1 = mainnet, 2 = testnet) to the
* corresponding EIP-155 numeric chain id used by the EVM module.
*
* Throws on unknown ids — callers using a chainIdOverride / local devnet
* must pass the EIP-155 chain id explicitly.
*/
export function eip155ChainIdFromCosmosChainId(cosmosChainNumeric: number): number {
if (cosmosChainNumeric === MAINNET_CHAIN_DETAILS.chainId) return Number(EVMChainIDMainnet);
if (cosmosChainNumeric === TESTNET_CHAIN_DETAILS.chainId) return Number(EVMChainIDTestnet);
throw new Error(
`eip712: unknown cosmos chain numeric id ${cosmosChainNumeric}; pass eip155ChainId explicitly for custom chains (e.g. 90123 for local devnet)`
);
}
23 changes: 23 additions & 0 deletions packages/bitbadgesjs-sdk/src/eip712/domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { EIP712Domain } from './types.js';

export const COSMOS_EVM_EIP712_DOMAIN_NAME = 'Cosmos Web3';
export const COSMOS_EVM_EIP712_DOMAIN_VERSION = '1.0.0';
export const COSMOS_EVM_EIP712_VERIFYING_CONTRACT = 'cosmos';
export const COSMOS_EVM_EIP712_SALT = '0';

/**
* Builds the EIP-712 domain for a Cosmos EVM chain.
*
* Mirrors `cosmos/evm/ethereum/eip712/domain.go::createEIP712Domain`.
* The chain's ante handler reconstructs this exact domain when verifying
* an EIP-712-signed Cosmos tx, so any deviation here will fail verification.
*/
export function createEIP712Domain(eip155ChainId: number | bigint): EIP712Domain {
return {
name: COSMOS_EVM_EIP712_DOMAIN_NAME,
version: COSMOS_EVM_EIP712_DOMAIN_VERSION,
chainId: typeof eip155ChainId === 'bigint' ? eip155ChainId : BigInt(eip155ChainId),
verifyingContract: COSMOS_EVM_EIP712_VERIFYING_CONTRACT,
salt: COSMOS_EVM_EIP712_SALT
};
}
144 changes: 144 additions & 0 deletions packages/bitbadgesjs-sdk/src/eip712/eip712-integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* Integration tests that drive the full pipeline for the actual proto
* `Msg*` types BitBadges users sign on-chain:
*
* 1. Construct a proto message instance (real wrapper class).
* 2. Run it through `convertProtoMessagesToAmino` (the same path
* `createTransactionPayload` uses to produce `legacyAmino.signBytes`).
* 3. Build an Amino StdSignDoc with `makeSignDoc`.
* 4. Wrap into EIP-712 typed-data.
* 5. Hash with our canonical hasher and round-trip through ecRecover.
*
* The point isn't field-by-field assertions — those live in
* `eip712.spec.ts`. The point is: every Msg type a user signs must make
* it through this pipeline without errors, with the expected type-name
* landing in the `Tx` schema, and produce a stable 32-byte digest.
*/

import { ethers } from 'ethers';
import { MsgSend } from '../proto/cosmos/bank/v1beta1/tx_pb.js';
import { MsgDelegate } from '../proto/cosmos/staking/v1beta1/tx_pb.js';
import { MsgTransfer } from '../proto/ibc/applications/transfer/v1/tx_pb.js';
import { MsgTransferTokens } from '../proto/tokenization/tx_pb.js';
import { MsgCreateManagerSplitter } from '../proto/managersplitter/tx_pb.js';
import { MsgSwapExactAmountIn } from '../proto/gamm/v1beta1/tx_pb.js';
import { convertProtoMessagesToAmino } from '../transactions/messages/transaction.js';
import { createProtoMsg } from '../transactions/messages/utils.js';
import { makeSignDoc } from '../transactions/messages/signDoc.js';
import { hashTypedData } from './hash.js';
import { wrapTxToTypedData } from './wrap.js';

const EIP155_CHAIN_ID = 50025;
const COSMOS_CHAIN_ID = 'bitbadges_50025-1';
const FEE = { amount: [{ amount: '1000', denom: 'ubadge' }], gas: '200000' };

function buildAndAssert(protoMsgs: any[], expectedTxFieldType: RegExp) {
const aminoMsgs = convertProtoMessagesToAmino(protoMsgs.map((m) => createProtoMsg(m)));
const signDoc = makeSignDoc(aminoMsgs, FEE, COSMOS_CHAIN_ID, '', 0, 0);
// makeSignDoc returns a JS object — wrapTxToTypedData operates on
// the same shape regardless of whether it came from JSON.parse or
// a fresh literal.
const typed = wrapTxToTypedData(signDoc as unknown as Record<string, unknown>, EIP155_CHAIN_ID);

// The msg0 entry on Tx should reference a TypeMsg... typedef.
const msg0Field = typed.types.Tx.find((t) => t.name === 'msg0');
expect(msg0Field).toBeDefined();
expect(msg0Field!.type).toMatch(expectedTxFieldType);

// Hash + sign-recover round-trip.
const digest = hashTypedData(typed);
expect(digest.length).toBe(32);

const wallet = ethers.Wallet.createRandom();
const sig = wallet.signingKey.sign(digest).serialized;
const recovered = ethers.recoverAddress(digest, sig);
expect(recovered.toLowerCase()).toBe(wallet.address.toLowerCase());

return typed;
}

describe('eip712 integration — real proto Msg pipeline', () => {
it('cosmos.bank MsgSend', () => {
const msg = new MsgSend({
fromAddress: 'bb1from',
toAddress: 'bb1to',
amount: [{ amount: '100', denom: 'ubadge' }]
});
buildAndAssert([msg], /^TypeMsgSend\d+$/);
});

it('cosmos.staking MsgDelegate', () => {
const msg = new MsgDelegate({
delegatorAddress: 'bb1delegator',
validatorAddress: 'bbvaloper1validator',
amount: { amount: '1000', denom: 'ubadge' }
});
buildAndAssert([msg], /^TypeMsgDelegate\d+$/);
});

it('ibc MsgTransfer', () => {
const msg = new MsgTransfer({
sourcePort: 'transfer',
sourceChannel: 'channel-0',
token: { amount: '100', denom: 'ubadge' },
sender: 'bb1sender',
receiver: 'osmo1receiver',
timeoutTimestamp: 1700000000000000000n,
memo: ''
});
buildAndAssert([msg], /^TypeMsgTransfer\d+$/);
});

// Note: BitBadges-specific modules (tokenization, managersplitter, gamm,
// poolmanager) register Amino names without the `Msg` prefix
// (e.g., `tokenization/TransferTokens`, not `tokenization/MsgTransferTokens`),
// following the same convention as Osmosis. The `Type` typedef in the
// EIP-712 schema reflects whatever the Amino name's last `/` segment is.
it('tokenization MsgTransferTokens', () => {
const msg = new MsgTransferTokens({
creator: 'bb1creator',
collectionId: '1',
transfers: []
});
buildAndAssert([msg], /^TypeTransferTokens\d+$/);
});

it('managersplitter MsgCreateManagerSplitter', () => {
const msg = new MsgCreateManagerSplitter({
admin: 'bb1admin'
});
buildAndAssert([msg], /^TypeCreateManagerSplitter\d+$/);
});

it('gamm MsgSwapExactAmountIn', () => {
const msg = new MsgSwapExactAmountIn({
sender: 'bb1sender',
routes: [],
tokenIn: { amount: '100', denom: 'ubadge' },
tokenOutMinAmount: '90'
});
buildAndAssert([msg], /^TypeSwapExactAmountIn\d+$/);
});

it('multi-message tx — MsgSend + MsgDelegate', () => {
const m1 = new MsgSend({
fromAddress: 'bb1from',
toAddress: 'bb1to',
amount: [{ amount: '50', denom: 'ubadge' }]
});
const m2 = new MsgDelegate({
delegatorAddress: 'bb1delegator',
validatorAddress: 'bbvaloper1validator',
amount: { amount: '1000', denom: 'ubadge' }
});
const aminoMsgs = convertProtoMessagesToAmino([createProtoMsg(m1), createProtoMsg(m2)]);
const signDoc = makeSignDoc(aminoMsgs, FEE, COSMOS_CHAIN_ID, '', 0, 0);
const typed = wrapTxToTypedData(signDoc as unknown as Record<string, unknown>, EIP155_CHAIN_ID);

expect(typed.types.Tx.find((t) => t.name === 'msg0')!.type).toMatch(/^TypeMsgSend\d+$/);
expect(typed.types.Tx.find((t) => t.name === 'msg1')!.type).toMatch(/^TypeMsgDelegate\d+$/);

const digest = hashTypedData(typed);
expect(digest.length).toBe(32);
});
});
Loading
Loading