跳转到主要内容

在 Injective 上使用 Ledger 签名交易

本文档的目标是解释如何使用 Ledger 在 Injective 上签名交易并将其广播到链上。该实现与 Cosmos SDK 原生链的默认方法不同,因为 Injective 定义了使用 Ethereum 的 ECDSA secp256k1 曲线作为密钥的自定义账户类型。

实现

为了理解我们应该如何实现,让我们先了解一些概念,以便更容易理解我们将采取的方法。

背景

派生路径是一段数据,告诉分层确定性(HD)钱包如何在密钥树中派生特定密钥。派生路径作为标准使用,并作为 BIP32 的一部分与 HD 钱包一起引入。分层确定性钱包是一个术语,用于描述使用种子派生许多公钥和私钥的钱包。 派生路径看起来像这样 m/purpose'/coin_type'/account'/change/address_index 序列中的每个部分都起作用,每个部分都会改变私钥、公钥和地址。我们不会深入探讨 HD 路径每个部分的确切含义,而只是简要解释 coin_type。每个区块链都有一个代表它的数字,即 coin_type。Bitcoin 是 0,Ethereum 是 60,Cosmos 是 118

Injective 特定上下文

Injective 使用与 Ethereum 相同的 coin_type,即 60。这意味着要使用 Ledger 在 Injective 上签名交易,我们必须使用 Ledger 上的 Ethereum 应用程序 Ledger 限制为一个 coin_type 只能安装一个应用程序。由于我们必须使用 Ethereum 应用程序在 Injective 上签名交易,我们必须探索可用的选项来获取有效签名。可用选项之一是用于哈希和签名类型化结构数据的 EIP712 程序。Ledger 暴露了我们将使用的 signEIP712HashedMessage 签名 EIP712 类型数据后,我们将使用正常的 Cosmos-SDK 方法打包和广播交易。有一些小的差异,其中之一是使用 SIGN_MODE_LEGACY_AMINO_JSON 模式并将 Web3Exension 附加到 Cosmos 交易,我们将在本文档中解释它们。

EIP712 类型数据

EIP 712 是用于哈希和签名类型化结构数据的标准。对于每个 EIP712 类型数据,用户传递的每个值(需要签名的)都有一个类型表示,解释该特定值的确切类型。除了用户想要签名的值及其类型(EIP712 typedData 的 PrimaryType)之外,每个 EIP712 类型数据都应包含一个 EIP712Domain,提供有关交易来源的上下文。

交易流程

实现本身包含几个步骤,即:
  1. 准备交易以使用 Ledger 上的 Ethereum 应用程序签名,
  2. 在 Ledger 上准备和签名交易,
  3. 准备交易以进行广播,
  4. 广播交易。
我们将深入每个步骤并详细说明我们需要采取的操作,以使交易签名并广播到链上。

准备交易(用于签名)

如上所述,交易需要使用 Ledger 上的 Ethereum 应用程序签名。这意味着一旦用户到达签名阶段,必须提示用户切换(或打开)Ledger 上的 Ethereum 应用程序。 我们知道每个 Cosmos 交易都由消息组成,这些消息表示用户想要在链上执行的指令。如果我们想将资金从一个地址发送到另一个地址,我们将把 MsgSend 消息打包到交易中并广播到链上。 知道这一点,Injective 团队对这些消息进行了抽象,以简化它们打包到交易中的方式。每个消息都接受实例化消息所需的特定参数集。完成后,抽象暴露了几个方便的方法,我们可以根据选择的签名/广播方法使用。例如,消息暴露了 toDirectSign 方法,该方法返回消息的类型和 proto 表示,然后可以使用默认的 Cosmos 方法打包交易,使用 privateKey 签名并广播到链上。 对于这个特定实现,重要的是 toEip712TypestoEip712 方法。在消息实例上调用第一个方法会给出 EIP712 类型数据的消息类型,第二个方法给出 EIP712 数据的消息值。当我们组合这两个方法时,我们可以生成有效的 EIP712 类型数据,可以传递给签名过程。 所以,让我们看一个使用这些方法的快速代码片段,以及如何从消息生成 EIP712 typedData:
import {
  MsgSend,
} from "@injectivelabs/sdk-ts/core/modules";
import {
  getEip712TypedDataV2,
  type Eip712ConvertTxArgs,
  type Eip712ConvertFeeArgs,
} from "@injectivelabs/sdk-ts/core/tx";
import { EvmChainId } from "@injectivelabs/ts-types";
import { toChainFormat, getDefaultStdFee } from "@injectivelabs/utils";

/** 稍后会详细介绍这两个接口 */
const txArgs: Eip712ConvertTxArgs = {
  accountNumber: accountDetails.accountNumber.toString(),
  sequence: accountDetails.sequence.toString(),
  timeoutHeight: timeoutHeight.toFixed(),
  chainId: chainId,
};
const txFeeArgs: Eip712ConvertFeeArgs = getDefaultStdFee();
const injectiveAddress = "inj14au322k9munkmx5wrchz9q30juf5wjgz2cfqku";
const amount = {
  denom: "inj",
  amount: toChainFormat(0.01).toFixed(),
};
const evmChainId = EvmChainId.Mainnet;

const msg = MsgSend.fromJSON({
  amount,
  srcInjectiveAddress: injectiveAddress,
  dstInjectiveAddress: injectiveAddress,
});

/** 可用于签名的 EIP712 TypedData **/
const eip712TypedData = getEip712TypedDataV2({
  msgs: msg,
  tx: txArgs,
  evmChainId,
  fee: txFeeArgs,
});

return eip712TypedData;

在 Ledger 上准备签名过程

现在我们有了 eip712TypedData,我们需要使用 Ledger 签名它。首先,我们需要根据用户在浏览器上的支持获取 Ledger 的 transport,并使用 @ledgerhq/hw-app-eth 创建一个 Ledger 实例,该实例将使用 Ledger 设备上的 Ethereum 应用程序执行用户的操作(确认交易)。从步骤 1 获取 eip712TypedData 后,我们可以使用 EthereumApp 上的 signEIP712HashedMessage 签名此 typedData 并返回签名。
import { TypedDataUtils } from 'eth-sig-util'
import { bufferToHex, addHexPrefix } from 'ethereumjs-util'
import EthereumApp from '@ledgerhq/hw-app-eth'

const domainHash = (message: any) =>
  TypedDataUtils.hashStruct('EIP712Domain', message.domain, message.types, true)

const messageHash = (message: any) =>
  TypedDataUtils.hashStruct(
    message.primaryType,
    message.message,
    message.types,
    true,
  )

const transport = /* 从 Ledger 获取 transport */
const ledger = new EthereumApp(transport)
const derivationPath = /* 获取地址的派生路径 */

/* 来自步骤 1 的 eip712TypedData */
const object = JSON.parse(eip712TypedData)

const result = await ledger.signEIP712HashedMessage(
  derivationPath,
  bufferToHex(domainHash(object)),
  bufferToHex(messageHash(object)),
)
const combined = `${result.r}${result.s}${result.v.toString(16)}`
const signature = combined.startsWith('0x') ? combined : `0x${combined}`

return signature;

准备交易以进行广播

现在我们有了签名,我们可以使用默认的 cosmos 方法准备交易。
import {
  SIGN_AMINO,
  createTransaction,
  createTxRawEIP712,
  createWeb3Extension,
} from "@injectivelabs/sdk-ts/core/tx";
import {
  BaseAccount,
} from "@injectivelabs/sdk-ts/core/accounts";
import {
  ChainRestAuthApi,
  ChainRestTendermintApi,
} from "@injectivelabs/sdk-ts/client/chain";
import { ChainId, EvmChainId } from "@injectivelabs/ts-types";
import {
  toBigNumber,
  DEFAULT_BLOCK_TIMEOUT_HEIGHT,
} from "@injectivelabs/utils";

const msg: MsgSend; /* 来自步骤 1 */

const chainId = ChainId.Mainnet;
const evmChainId = EvmChainId.Mainnet;

/** 账户详情 **/
const chainRestAuthApi = new ChainRestAuthApi(lcdEndpoint);
const accountDetailsResponse = await chainRestAuthApi.fetchAccount(
  injectiveAddress
);
const baseAccount = BaseAccount.fromRestApi(accountDetailsResponse);
const accountDetails = baseAccount.toAccountDetails();

/** 区块详情 */
const chainRestTendermintApi = new ChainRestTendermintApi(lcdEndpoint);
const latestBlock = await chainRestTendermintApi.fetchLatestBlock();
const latestHeight = latestBlock.header.height;
const timeoutHeight = toBigNumber(latestHeight).plus(
  DEFAULT_BLOCK_TIMEOUT_HEIGHT
);

const { txRaw } = createTransaction({
  message: msgs,
  memo: "",
  signMode: SIGN_AMINO,
  fee: getDefaultStdFee(),
  pubKey: publicKeyBase64,
  sequence: baseAccount.sequence,
  timeoutHeight: timeoutHeight.toNumber(),
  accountNumber: baseAccount.accountNumber,
  chainId,
});
const web3Extension = createWeb3Extension({
  evmChainId,
});
const txRawEip712 = createTxRawEIP712(txRaw, web3Extension);

/** 附加签名 */
const signatureBuff = Buffer.from(signature.replace("0x", ""), "hex");
txRawEip712.signatures = [signatureBuff];

return txRawEip712;

广播交易

现在我们已将交易打包到 TxRaw 中,我们可以使用默认的 cosmos 方法将其广播到节点。

代码库

让我们看一个包含上述所有步骤的示例代码库
import {
  TxRestApi,
  SIGN_AMINO,
  createTransaction,
  createTxRawEIP712,
  createWeb3Extension,
  getEip712TypedDataV2,
  type Eip712ConvertTxArgs,
  type Eip712ConvertFeeArgs
} from '@injectivelabs/sdk-ts/core/tx'
import {
  MsgSend,
} from '@injectivelabs/sdk-ts/core/modules'
import {
  BaseAccount,
} from '@injectivelabs/sdk-ts/core/accounts'
import {
  ChainRestAuthApi,
  ChainRestTendermintApi,
} from '@injectivelabs/sdk-ts/client/chain'
import { TypedDataUtils } from 'eth-sig-util'
import EthereumApp from '@ledgerhq/hw-app-eth'
import { bufferToHex, addHexPrefix } from 'ethereumjs-util'
import { EvmChainId, ChainId } from '@injectivelabs/ts-types'
import { toChainFormat, DEFAULT_BLOCK_TIMEOUT_HEIGHT, getDefaultStdFee } from '@injectivelabs/utils'

const domainHash = (message: any) =>
TypedDataUtils.hashStruct('EIP712Domain', message.domain, message.types, true)

const messageHash = (message: any) =>
  TypedDataUtils.hashStruct(
    message.primaryType,
    message.message,
    message.types,
    true,
  )

const signTransaction = async (eip712TypedData: any) => {
  const transport = /* 从 Ledger 获取 transport */
  const ledger = new EthereumApp(transport)
  const derivationPath = /* 获取地址的派生路径 */

  /* 来自步骤 1 的 eip712TypedData */
  const result = await ledger.signEIP712HashedMessage(
    derivationPath,
    bufferToHex(domainHash(eip712TypedData)),
    bufferToHex(messageHash(eip712TypedData)),
  )
  const combined = `${result.r}${result.s}${result.v.toString(16)}`
  const signature = combined.startsWith('0x') ? combined : `0x${combined}`

  return signature;
}

const getAccountDetails = (address: string): BaseAccount => {
  const chainRestAuthApi = new ChainRestAuthApi(
    lcdEndpoint,
  )
  const accountDetailsResponse = await chainRestAuthApi.fetchAccount(
    address,
  )
  const baseAccount = BaseAccount.fromRestApi(accountDetailsResponse)
  const accountDetails = baseAccount.toAccountDetails()

  return accountDetails
}

const getTimeoutHeight = () => {
  const chainRestTendermintApi = new ChainRestTendermintApi(
    lcdEndpoint,
  )
  const latestBlock = await chainRestTendermintApi.fetchLatestBlock()
  const latestHeight = latestBlock.header.height
  const timeoutHeight = latestHeight + DEFAULT_BLOCK_TIMEOUT_HEIGHT

  return timeoutHeight
}

const address = 'inj14au322k9munkmx5wrchz9q30juf5wjgz2cfqku'
const chainId = ChainId.Mainnet
const evmChainId = EvmChainId.Mainnet
const accountDetails = getAccountDetails()
const timeoutHeight = getTimeoutHeight

const txArgs: Eip712ConvertTxArgs = {
  accountNumber: accountDetails.accountNumber.toString(),
  sequence: accountDetails.sequence.toString(),
  timeoutHeight: timeoutHeight.toString(),
  chainId: chainId,
}
const txFeeArgs: Eip712ConvertFeeArgs = getDefaultStdFee()
const injectiveAddress = 'inj14au322k9munkmx5wrchz9q30juf5wjgz2cfqku'
const amount = {
  amount: toChainFormat(0.01).toFixed(),
  denom: "inj",
};

const msg = MsgSend.fromJSON({
  amount,
  srcInjectiveAddress: injectiveAddress,
  dstInjectiveAddress: injectiveAddress,
});

/** 可用于签名的 EIP712 TypedData **/
const eip712TypedData = getEip712TypedDataV2({
  msgs: msg,
  tx: txArgs,
  evmChainId,
  fee: txFeeArgs
})

/** 在 Ethereum 上签名 */
const signature = await signTransaction(eip712TypedData)

/** 准备交易以进行客户端广播 */
const { txRaw } = createTransaction({
  message: msg,
  memo: '',
  signMode: SIGN_AMINO,
  fee: getDefaultStdFee(),
  pubKey: publicKeyBase64,
  sequence: accountDetails.sequence,
  timeoutHeight: timeoutHeight.toNumber(),
  accountNumber: accountDetails.accountNumber,
  chainId: chainId,
})
const web3Extension = createWeb3Extension({
  evmChainId,
})
const txRawEip712 = createTxRawEIP712(txRaw, web3Extension)

/** 附加签名 */
const signatureBuff = Buffer.from(signature.replace('0x', ''), 'hex')
txRawEip712.signatures = [signatureBuff]

/** 广播交易 **/
const txRestApi = new TxRestApi(lcdEndpoint)
const response = await txRestApi.broadcast(txRawEip712)

if (response.code !== 0) {
  throw new Error(`Transaction failed: ${response.rawLog}`)
}

return response.txhash