Clona el repositorio
// https
git clone <https://github.com/angelmc32/ledger-integration-tutorial.git>
// ssh
git clone [email protected]:angelmc32/ledger-integration-tutorial.git
Instala dependencias
// yarn
yarn install
// npm
npm install
Las dependencias son las siguientes:
"dependencies": {
// Necesario para habilitar Buffer en la app de React
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
// Paqueterías para integrar Ledger
"@ledgerhq/hw-app-eth": "^6.32.1",
"@ledgerhq/hw-transport-webhid": "^6.27.12",
"@ledgerhq/hw-transport-webusb": "^6.27.12",
// Necesario para conectar Ledger en la app de React
"buffer": "^6.0.3",
// Nos va a facilitar la comunicación con EVM
"ethers": "5.5.4",
// React
"react": "^18.2.0",
"react-dom": "^18.2.0",
// Toast para mejor UX
"react-hot-toast": "^2.4.0"
},
"devDependencies": {
// React
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
// Plugin de Compilador SWC
"@vitejs/plugin-react-swc": "^3.0.0",
// Tailwind
"autoprefixer": "^10.4.14",
"postcss": "^8.4.21",
"prettier": "^2.8.6",
"prettier-plugin-tailwindcss": "^0.2.5",
"tailwindcss": "^3.2.7",
// Vite
"vite": "^4.2.0"
},
"alias": {
"@ledgerhq/devices": "@ledgerhq/devices/lib-es"
}
Agrega un archivo .env en directorio raíz, y agrega la siguiente variable de ambiente
VITE_ALCHEMY_API_URL=https://eth-sepolia.g.alchemy.com/v2/DdYx5wS15WDafXvSOQ8BDFc85TdOaT9e
//
const ALCHEMY_RPC_URL = import.meta.env.VITE_ALCHEMY_API_URL;
const provider = new ethers.providers.JsonRpcProvider(ALCHEMY_RPC_URL);
const [addressState, setAddressState] = useState(null);
const [ethState, setEthState] = useState(null);
const [recipientState, setRecipientState] = useState(null);
const [showModal, setShowModal] = useState(false);
const [transferTxState, setTransferTxState] = useState({
to: null,
gasPrice: null,
gasLimit: null,
nonce: null,
chainId: 11155111,
data: null,
value: null,
});
const [isLoading, setIsLoading] = useState(false);
const [url, setUrl] = useState();
El formulario nos va a servir para definir la información de la transacción, que será enviada al Ledger para firmar, y posteriormente enviada a la EVM a través de nuestro Proveedor
Hay 2 botones que utilizaremos, a) Para abrir el modal donde se ejecuta la lógica para conectar nuestro Ledger a la aplicación. b) Para solicitar la firma de la transacción y su envío a la EVM
ConnectLedgerModal es el modal donde inicializamos nuestras variables de addressState y también ethState.
Cuando el usuario seleccione Ledger como cartera (única opción), se ejecutará una función asíncrona, que definiremos como connectLedger.
El primer paso es instanciar nuestro método de transporte entre la aplicación y el Ledger físico. Utilizaremos el método WebHID (Human Interface Device)
Con el transporte, utilizaremos la paquetería creada por Ledger para comunicarnos con la aplicación de Ethereum que está hospedada dentro del Ledger del usuario. Instanciamos el objeto Eth como eth, y lo guardamos dentro de nuestra variable ethState.
```jsx
const connectLedger = async () => {
const transport = await TransportWebHID.create();
const eth = new Eth(transport);
const { address } = await eth.getAddress("44'/60'/0'/0/0", false);
setAddressState(address);
setEthState(eth);
// Por hacer: calcular el precio de gas y el límite de gas
}
```
Obtenemos el precio del gas, y definimos el límite de gas a usar en la transacción.
Precaución: Esto puede impactar el precio final de la transacción, aquí no realizamos ninguna validación y establecemos un límite de gas alto, ya que es un tutorial. Para aplicaciones en producción, se sugiere investigar y crear una mejor lógica para calcular el límite de gas.
Guardamos los valores del gas en la variable de estado transferTxState.
```jsx
const connectLedger = async () => {
// Código anterior...
let gasPriceCalc = (
await provider.getGasPrice())._hex;
gasPriceCalc = parseInt(parseInt(gasPriceCalc, 16) * 1.15);
setTransferTxState((prevState) => ({
...prevState,
gasLimit: 32000,
gasPrice: gasPriceCalc,
});
);
}
```
Queremos darle feedback a nuestros usuarios, por lo que agregamos el toast. Para manejar errores, colocamos la lógica dentro de un bloque try-catch. Así quedaría nuestra función connectLedger:
```jsx
const connectLedger = async () => {
try {
const transport = await TransportWebHID.create();
const eth = new Eth(transport);
const { address } = await eth.getAddress("44'/60'/0'/0/0", false);
setAddressState(address);
setEthState(eth);
let gasPriceCalc = (await provider.getGasPrice())._hex;
gasPriceCalc = parseInt(parseInt(gasPriceCalc, 16) * 1.15);
setTransferTxState((prevState) => ({
...prevState,
gasLimit: 32000,
gasPrice: gasPriceCalc,
}));
toast.success("Ledger conectado");
setShowModal(false);
} catch (error) {
toast.error("Ocurrió un error, intenta de nuevo");
setShowModal(false);
}
};
```
Al conectar el Ledger, cargamos la información guardada en nuestras variables de estado en nuestro formulario, y estamos listos para crear una transacción
El usuario va a poder llenar la dirección donde recibirá el ETH, así como la cantidad a transferir (en ETH).
Bloqueamos el botón para evitar doble ejecución, y obtenemos el Nonce actual de la cartera de nuestro usuario. Para ello, utilizamos la variable de estado addressState, que contiene la llave pública o dirección de nuestro usuario.
Ahora podemos definir el objeto que contendrá los valores de la transacción, y la serializamos de tal manera que queda lista para ser firmada por el Ledger del usuario:
```jsx
const transactionTransfer = async () => {
setIsLoading(true);
const nonce = await provider.getTransactionCount(addressState, "latest");
const transaction = {
to: recipientState,
gasPrice: transferTxState.gasPrice,
gasLimit: ethers.utils.hexlify(32000),
nonce: nonce,
chainId: transferTxState.chainId,
data: "0x00",
value: ethers.utils.parseUnits(transferTxState.value, "ether")._hex,
};
let unsignedTx = ethers.utils.serializeTransaction(transaction).slice(2);
// No terminado, en proceso...
}
```
Solicitamos la firma del usuario a través de la aplicación de Ethereum instalada en el Ledger, modificamos algunos valores para que sean compatibles con nuestro Proveedor y volvemos a serializar , enviamos la transacción a la EVM
const transactionTransfer = async () => {
// Código anterior...
const signature = await ethState.signTransaction(
"44'/60'/0'/0/0",
unsignedTx,
null
);
// Modificamos las firmas para preparar nuestra firma para nuestro Proveedor
signature.r = "0x" + signature.r;
signature.s = "0x" + signature.s;
signature.v = parseInt("0x" + signature.v);
signature.from = addressState;
//Serializar de nuevo, esta vez incluyendo la firma
let signedTx = ethers.utils.serializeTransaction(transaction, signature);
// Enviamos la firma a la EVM
const hash = (await provider.sendTransaction(signedTx)).hash;
if (hash) {
toast.success("Se ha creado tu tx exitosamente");
setUrl("<https://sepolia.etherscan.io/tx/>" + hash);
setIsLoading(false);
}
De nuevo, colocaremos nuestra interacción asíncrona con el Ledger dentro de un bloque try-catch para darle una mejor UX a nuestra aplicación
Así quedaría nuestra función transactionTransfer:
const transactionTransfer = async () => {
setIsLoading(true);
const nonce = await provider.getTransactionCount(addressState, "latest");
const transaction = {
to: recipientState,
gasPrice: transferTxState.gasPrice,
gasLimit: ethers.utils.hexlify(32000),
nonce: nonce,
chainId: transferTxState.chainId,
data: "0x00",
value: ethers.utils.parseUnits(transferTxState.value, "ether")._hex,
};
//Serializing the transaction to pass it to Ledger Nano for signing
let unsignedTx = ethers.utils.serializeTransaction(transaction).slice(2);
try {
//Sign with the Ledger Nano (Sign what you see)
const signature = await ethState.signTransaction(
"44'/60'/0'/0/0",
unsignedTx,
null
);
//Parse the signature
signature.r = "0x" + signature.r;
signature.s = "0x" + signature.s;
signature.v = parseInt("0x" + signature.v);
signature.from = addressState;
//Serialize the same transaction as before, but adding the signature on it
let signedTx = ethers.utils.serializeTransaction(transaction, signature);
//Sending the transaction to the blockchain
const hash = (await provider.sendTransaction(signedTx)).hash;
if (hash) {
toast.success("Se ha creado tu tx exitosamente");
setUrl("<https://sepolia.etherscan.io/tx/>" + hash);
setIsLoading(false);
}
} catch (error) {
toast.error("Ocurrió un error, intenta de nuevo");
setIsLoading(false);
}
};
Ahora podemos probar nuestra aplicación, intentemos mandar una transacción. El flujo es el siguiente: