Skip to content
7 changes: 7 additions & 0 deletions packages/bitbadgesjs-sdk/src/blockin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ export interface ChallengeParams<T extends NumberType> {
issuedAt?: string;
expirationDate?: string;
notBefore?: string;
/**
* Optional EIP-4361 §3 Request ID. Lets the relying party correlate
* this challenge with a server-side request. SIWE-aware libraries
* parse `Request ID: <value>` between Not Before and Resources.
* Mirrors the field added to the underlying `blockin` package.
*/
requestId?: string;
resources?: string[];
assetOwnershipRequirements?: AssetConditionGroup<T>;
}
Expand Down
60 changes: 60 additions & 0 deletions packages/bitbadgesjs-sdk/src/eip712/broadcast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Build a broadcast-ready TxRaw from an EIP-712 signature.
*
* Wire format is intentionally identical to a regular legacyAmino-mode
* Cosmos tx — the chain's `cosmos/evm` ante handler detects the
* EIP-712 case via the dual-path verifier inside
* `ethsecp256k1.PubKey.VerifySignature`, so no `ExtensionOptionsWeb3Tx`
* or other envelope is needed. The only differences from a standard
* Cosmos broadcast are:
*
* 1. `SignerInfo.publicKey` is wrapped as
* `cosmos.evm.crypto.v1.ethsecp256k1.PubKey` rather than the
* standard `cosmos.crypto.secp256k1.PubKey` — this is what
* routes verification through the EIP-712-aware code path.
* 2. The signature is the 64-byte `r || s` form (EVM wallets emit
* 65 bytes; the `v` recovery byte is stripped).
*/

import { generatePostBodyBroadcast } from '@/node-rest-api/broadcast.js';
import { TxRaw } from '@/proto/cosmos/tx/v1beta1/tx_pb.js';
import { createAuthInfo, createBodyWithMultipleMessages, createFee, createSignerInfoEthsecp256k1, LEGACY_AMINO } from '@/transactions/messages/transaction.js';
import { stripRecoveryByte } from './recover.js';

export interface BuildEip712TxRawArgs {
/** Proto messages (already wrapped via `createProtoMsg`) included in the tx. */
messages: any[];
/** 33-byte compressed pubkey of the EVM signer (recovered from the signature). */
compressedPubKey: Uint8Array;
/** Cosmos sequence at the time of signing. */
sequence: number;
/** Fee parameters that match what the user signed in the EIP-712 typed-data. */
fee: { amount: string; denom: string; gas: number };
memo?: string;
/** 65-byte hex signature returned by `eth_signTypedData_v4` / `Signer.signTypedData`. */
signatureHex: string;
}

export function buildEip712TxRaw(args: BuildEip712TxRawArgs): TxRaw {
const body = createBodyWithMultipleMessages(args.messages, args.memo ?? '');
const feeMessage = createFee(args.fee.amount, args.fee.denom, args.fee.gas);
const signerInfo = createSignerInfoEthsecp256k1(args.compressedPubKey, args.sequence, LEGACY_AMINO);
const authInfo = createAuthInfo(signerInfo, feeMessage);

const signature = stripRecoveryByte(args.signatureHex);
return new TxRaw({
bodyBytes: body.toBinary() as Uint8Array<ArrayBuffer>,
authInfoBytes: authInfo.toBinary() as Uint8Array<ArrayBuffer>,
signatures: [signature as Uint8Array<ArrayBuffer>]
});
}

/**
* Convenience wrapper around `buildEip712TxRaw` that returns the
* `application/json` POST body Cosmos LCD `/cosmos/tx/v1beta1/txs`
* (and our own `/api/v0/broadcast` proxy) accepts.
*/
export function buildEip712TxBroadcastBody(args: BuildEip712TxRawArgs): string {
const txRaw = buildEip712TxRaw(args);
return generatePostBodyBroadcast({ message: txRaw, path: TxRaw.typeName });
}
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