Chain
Chain is the most basic interface of the signet.js library. It provides a standard set of methods that all chains must implement.
With this interface you will be able to interact with Sig Network smart contracts in a standard way to:
- getBalance
- deriveAddressAndPublicKey
- serializeTransaction
- deserializeTransaction
- prepareTransactionForSigning
- finalizeTransactionSigning
- broadcastTx
If you wanna have a look this is the chain interface with a brief description of each method:
import type { KeyDerivationPath, HashToSign, RSVSignature } from '@chains/types'
export abstract class Chain<TransactionRequest, UnsignedTransaction> {
/**
* Gets the native token balance and decimals for a given address
*
* @param address - The address to check
* @returns Promise resolving to an object containing:
* - balance: The balance as a bigint, in the chain's base units
* - decimals: The number of decimals used to format the balance
*/
abstract getBalance(address: string): Promise<{
balance: bigint
decimals: number
}>
/**
* Uses Sig Network Key Derivation Function to derive the address and public key. from a signer ID and string path.
*
* @param predecessor - The id/address of the account requesting signature
* @param path - The string path used to derive the key
* @returns Promise resolving to the derived address and public key
*/
abstract deriveAddressAndPublicKey(
predecessor: string,
path: KeyDerivationPath
): Promise<{
address: string
publicKey: string
}>
/**
* Serializes an unsigned transaction to a string format.
* This is useful for storing or transmitting the transaction.
*
* @param transaction - The unsigned transaction to serialize
* @returns The serialized transaction string
*/
abstract serializeTransaction(transaction: UnsignedTransaction): string
/**
* Deserializes a transaction string back into an unsigned transaction object.
* This reverses the serialization done by serializeTransaction().
*
* @param serialized - The serialized transaction string
* @returns The deserialized unsigned transaction
*/
abstract deserializeTransaction(serialized: string): UnsignedTransaction
/**
* Prepares a transaction for Sig Network MPC signing by creating the necessary payloads.
* This method handles chain-specific transaction preparation including:
* - Fee calculation
* - Nonce/sequence management
* - UTXO selection (for UTXO-based chains)
* - Transaction encoding
*
* @param transactionRequest - The transaction request containing parameters like recipient, amount, etc.
* @returns Promise resolving to an object containing:
* - transaction: The unsigned transaction
* - hashesToSign: Array of payloads to be signed by MPC. The order of these payloads must match
* the order of signatures provided to finalizeTransactionSigning()
*/
abstract prepareTransactionForSigning(
transactionRequest: TransactionRequest
): Promise<{
transaction: UnsignedTransaction
hashesToSign: HashToSign[]
}>
/**
* Adds Sig Network MPC-generated signatures to an unsigned transaction.
*
* @param params - Parameters for adding signatures
* @param params.transaction - The unsigned transaction to add signatures to
* @param params.rsvSignatures - Array of RSV signatures generated through MPC. Must be in the same order
* as the payloads returned by prepareTransactionForSigning()
* @returns The serialized signed transaction ready for broadcast
*/
abstract finalizeTransactionSigning(params: {
transaction: UnsignedTransaction
rsvSignatures: RSVSignature[]
}): string
/**
* Broadcasts a signed transaction to the network.
*
* @param txSerialized - The serialized signed transaction
* @returns Promise resolving to the transaction hash/ID
*/
abstract broadcastTx(txSerialized: string): Promise<string>
}
Implementing a New Chain
This guide explains how to implement support for a new blockchain network in the signet.js library.
Overview
To add support for a new blockchain, you need to implement the Chain
interface:
Step-by-Step Guide
1. Create the Chain Types
First, define the transaction types for your chain:
import type {
Address,
Hex,
TransactionRequest,
TypedDataDefinition,
SignableMessage,
} from 'viem'
export type EVMUnsignedTransaction = TransactionRequest & {
type: 'eip1559'
chainId: number
}
export interface EVMTransactionRequest
extends Omit<EVMUnsignedTransaction, 'chainId' | 'type'> {
from: Address
}
export type EVMMessage = SignableMessage
export type EVMTypedData = TypedDataDefinition
export interface UserOperationV7 {
sender: Hex
nonce: Hex
factory: Hex
factoryData: Hex
callData: Hex
callGasLimit: Hex
verificationGasLimit: Hex
preVerificationGas: Hex
maxFeePerGas: Hex
maxPriorityFeePerGas: Hex
paymaster: Hex
paymasterVerificationGasLimit: Hex
paymasterPostOpGasLimit: Hex
paymasterData: Hex
signature: Hex
}
export interface UserOperationV6 {
sender: Hex
nonce: Hex
initCode: Hex
callData: Hex
callGasLimit: Hex
verificationGasLimit: Hex
preVerificationGas: Hex
maxFeePerGas: Hex
maxPriorityFeePerGas: Hex
paymasterAndData: Hex
signature: Hex
}
2. Implement the Chain Interface
Create a new class that implements the Chain
interface:
import {
createPublicClient,
http,
parseTransaction,
type PublicClient,
hashMessage,
hashTypedData,
keccak256,
toBytes,
type Hex,
serializeTransaction,
type Signature,
numberToHex,
getAddress,
type Address,
type Hash,
concatHex,
encodeAbiParameters,
hexToBigInt,
concat,
pad,
isAddress,
} from 'viem'
import { Chain } from '@chains/Chain'
import type { BaseChainSignatureContract } from '@chains/ChainSignatureContract'
import type {
EVMTransactionRequest,
EVMUnsignedTransaction,
EVMMessage,
EVMTypedData,
UserOperationV6,
UserOperationV7,
} from '@chains/EVM/types'
import { fetchEVMFeeProperties } from '@chains/EVM/utils'
import type { HashToSign, RSVSignature, KeyDerivationPath } from '@chains/types'
/**
* Implementation of the Chain interface for EVM-compatible networks.
* Handles interactions with Ethereum Virtual Machine based blockchains like Ethereum, BSC, Polygon, etc.
*/
export class EVM extends Chain<EVMTransactionRequest, EVMUnsignedTransaction> {
private readonly client: PublicClient
private readonly contract: BaseChainSignatureContract
/**
* Creates a new EVM chain instance
* @param params - Configuration parameters
* @param params.rpcUrl - URL of the EVM JSON-RPC provider (e.g., Infura endpoint)
* @param params.contract - Instance of the chain signature contract for MPC operations
*/
constructor({
rpcUrl,
contract,
}: {
rpcUrl: string
contract: BaseChainSignatureContract
}) {
super()
this.contract = contract
this.client = createPublicClient({
transport: http(rpcUrl),
})
}
private async attachGasAndNonce(
transaction: EVMTransactionRequest
): Promise<EVMUnsignedTransaction> {
const fees = await fetchEVMFeeProperties(this.client, transaction)
const nonce = await this.client.getTransactionCount({
address: transaction.from,
})
const { from, ...rest } = transaction
return {
...fees,
nonce,
chainId: Number(await this.client.getChainId()),
type: 'eip1559',
...rest,
}
}
private transformRSVSignature(signature: RSVSignature): Signature {
return {
r: `0x${signature.r}`,
s: `0x${signature.s}`,
yParity: signature.v - 27,
}
}
private assembleSignature(signature: RSVSignature): Hex {
const { r, s, yParity } = this.transformRSVSignature(signature)
if (yParity === undefined) {
throw new Error('Missing yParity')
}
return concatHex([r, s, numberToHex(yParity + 27, { size: 1 })])
}
async deriveAddressAndPublicKey(
predecessor: string,
path: KeyDerivationPath
): Promise<{
address: string
publicKey: string
}> {
const uncompressedPubKey = await this.contract.getDerivedPublicKey({
path,
predecessor,
})
if (!uncompressedPubKey) {
throw new Error('Failed to get derived public key')
}
const publicKeyNoPrefix = uncompressedPubKey.startsWith('04')
? uncompressedPubKey.slice(2)
: uncompressedPubKey
const hash = keccak256(Buffer.from(publicKeyNoPrefix, 'hex'))
const address = getAddress(`0x${hash.slice(-40)}`)
return {
address,
publicKey: uncompressedPubKey,
}
}
async getBalance(
address: string
): Promise<{ balance: bigint; decimals: number }> {
const balance = await this.client.getBalance({
address: address as Address,
})
return {
balance,
decimals: 18,
}
}
serializeTransaction(transaction: EVMUnsignedTransaction): `0x${string}` {
return serializeTransaction(transaction)
}
deserializeTransaction(serialized: `0x${string}`): EVMUnsignedTransaction {
return parseTransaction(serialized) as EVMUnsignedTransaction
}
async prepareTransactionForSigning(
transactionRequest: EVMTransactionRequest
): Promise<{
transaction: EVMUnsignedTransaction
hashesToSign: HashToSign[]
}> {
const transaction = await this.attachGasAndNonce(transactionRequest)
const serializedTx = serializeTransaction(transaction)
const txHash = toBytes(keccak256(serializedTx))
return {
transaction,
hashesToSign: [Array.from(txHash)],
}
}
async prepareMessageForSigning(message: EVMMessage): Promise<{
hashToSign: HashToSign
}> {
return {
hashToSign: Array.from(toBytes(hashMessage(message))),
}
}
async prepareTypedDataForSigning(typedDataRequest: EVMTypedData): Promise<{
hashToSign: HashToSign
}> {
return {
hashToSign: Array.from(toBytes(hashTypedData(typedDataRequest))),
}
}
/**
* This implementation is a common step for Biconomy and Alchemy.
* Key differences between implementations:
* - Signature format: Biconomy omits 0x00 prefix when concatenating, Alchemy includes it
* - Version support: Biconomy only supports v6, Alchemy supports both v6 and v7
* - Validation: Biconomy uses modules for signature validation, Alchemy uses built-in validation
*/
async prepareUserOpForSigning(
userOp: UserOperationV7 | UserOperationV6,
entryPointAddress?: Address,
chainIdArgs?: number
): Promise<{
userOp: UserOperationV7 | UserOperationV6
hashToSign: HashToSign
}> {
const chainId = chainIdArgs ?? (await this.client.getChainId())
const entryPoint =
entryPointAddress || '0x0000000071727De22E5E9d8BAf0edAc6f37da032'
const encoded = encodeAbiParameters(
[{ type: 'bytes32' }, { type: 'address' }, { type: 'uint256' }],
[
keccak256(
encodeAbiParameters(
[
{ type: 'address' },
{ type: 'uint256' },
{ type: 'bytes32' },
{ type: 'bytes32' },
{ type: 'bytes32' },
{ type: 'uint256' },
{ type: 'bytes32' },
{ type: 'bytes32' },
],
[
userOp.sender,
hexToBigInt(userOp.nonce),
keccak256(
'factory' in userOp &&
'factoryData' in userOp &&
userOp.factory &&
userOp.factoryData
? concat([userOp.factory, userOp.factoryData])
: 'initCode' in userOp
? userOp.initCode
: '0x'
),
keccak256(userOp.callData),
concat([
pad(userOp.verificationGasLimit, { size: 16 }),
pad(userOp.callGasLimit, { size: 16 }),
]),
hexToBigInt(userOp.preVerificationGas),
concat([
pad(userOp.maxPriorityFeePerGas, { size: 16 }),
pad(userOp.maxFeePerGas, { size: 16 }),
]),
keccak256(
'paymaster' in userOp &&
userOp.paymaster &&
isAddress(userOp.paymaster)
? concat([
userOp.paymaster,
pad(userOp.paymasterVerificationGasLimit, { size: 16 }),
pad(userOp.paymasterPostOpGasLimit, { size: 16 }),
userOp.paymasterData,
])
: 'paymasterAndData' in userOp
? userOp.paymasterAndData
: '0x'
),
]
)
),
entryPoint,
BigInt(chainId),
]
)
const userOpHash = keccak256(encoded)
return {
userOp,
hashToSign: Array.from(toBytes(hashMessage({ raw: userOpHash }))),
}
}
finalizeTransactionSigning({
transaction,
rsvSignatures,
}: {
transaction: EVMUnsignedTransaction
rsvSignatures: RSVSignature[]
}): `0x02${string}` {
const signature = this.transformRSVSignature(rsvSignatures[0])
return serializeTransaction(transaction, signature)
}
finalizeMessageSigning({
rsvSignature,
}: {
rsvSignature: RSVSignature
}): Hex {
return this.assembleSignature(rsvSignature)
}
finalizeTypedDataSigning({
rsvSignature,
}: {
rsvSignature: RSVSignature
}): Hex {
return this.assembleSignature(rsvSignature)
}
finalizeUserOpSigning({
userOp,
rsvSignature,
}: {
userOp: UserOperationV7 | UserOperationV6
rsvSignature: RSVSignature
}): UserOperationV7 | UserOperationV6 {
const { r, s, yParity } = this.transformRSVSignature(rsvSignature)
if (yParity === undefined) {
throw new Error('Missing yParity')
}
return {
...userOp,
signature: concatHex([
'0x00', // Alchemy specific implementation. Biconomy doesn't include the 0x00 prefix.
r,
s,
numberToHex(Number(yParity + 27), { size: 1 }),
]),
}
}
async broadcastTx(txSerialized: `0x${string}`): Promise<Hash> {
try {
return await this.client.sendRawTransaction({
serializedTransaction: txSerialized,
})
} catch (error) {
console.error('Transaction broadcast failed:', error)
throw new Error('Failed to broadcast transaction.')
}
}
}