Skip to content

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.')
    }
  }
}