@ledgerhq/coin-filecoin
Version:
Ledger Filecoin Coin integration
132 lines (118 loc) • 4.39 kB
text/typescript
import type {
CraftedTransaction,
FeeEstimation,
TransactionIntent,
} from "@ledgerhq/coin-module-framework/api/index";
import { Message } from "iso-filecoin/message";
import { abiEncodeTransferParams, encodeTxnParams } from "../../erc20/tokenAccounts";
import { convertAddressFilToEth, validateAddress } from "../../network/addresses";
import { BroadcastBlockIncl } from "../../types";
import { fetchEstimatedFees } from "../../network/api";
import { getNextSequence } from "../account/getNextSequence";
// Inline method numbers from Filecoin spec (mirrors src/bridge/utils.ts Methods enum).
// logic/ must not import from bridge/, so we inline the relevant constants here.
const METHOD_TRANSFER = 0;
const METHOD_INVOKE_EVM = 3844450837;
// Serialisation format (symmetric with combine.ts):
// Output `transaction` field = JSON.stringify({ cbor, message }) where
// - cbor: base64-encoded CBOR of the Filecoin Message (what the signer signs)
// - message: the message fields in Lotus shape (what combine uses to rebuild
// the BroadcastTransactionRequest without re-deserialising CBOR — iso-filecoin
// v4 exposes Message.serialize() but no static deserialize equivalent).
export async function craftTransaction(
intent: TransactionIntent,
customFees?: FeeEstimation,
): Promise<CraftedTransaction> {
const senderValidation = validateAddress(intent.sender);
if (!senderValidation.isValid) {
throw new Error(`Invalid sender address: ${intent.sender}`);
}
const from = senderValidation.parsedAddress.toString();
const nonce = await getNextSequence(from);
let to: string;
let value: string;
let method: number;
let params: string | undefined;
const assetType = intent.asset.type;
if (assetType === "native") {
const recipientValidation = validateAddress(intent.recipient ?? "");
if (!recipientValidation.isValid) {
throw new Error(`Invalid recipient address: ${intent.recipient}`);
}
to = recipientValidation.parsedAddress.toString();
value = intent.amount.toString();
method = METHOD_TRANSFER;
} else if (assetType === "erc20") {
const asset = intent.asset as { type: "erc20"; assetReference: string };
const contractAddr = asset.assetReference.toLowerCase();
const contractValidation = validateAddress(contractAddr);
if (!contractValidation.isValid) {
throw new Error(`Invalid ERC-20 contract address: ${contractAddr}`);
}
to = contractValidation.parsedAddress.toString();
value = "0";
method = METHOD_INVOKE_EVM;
const recipientEth = intent.recipient?.startsWith("0x")
? intent.recipient
: (() => {
try {
return convertAddressFilToEth(intent.recipient ?? "");
} catch {
throw new Error(`Invalid token recipient: ${intent.recipient}`);
}
})();
const abiEncoded = abiEncodeTransferParams(recipientEth, intent.amount.toString());
params = encodeTxnParams(abiEncoded);
} else {
throw new Error(`Unsupported asset type: ${String(assetType)}`);
}
// Fetch gas estimates (or use custom fees if provided)
let gasFeeCap: string;
let gasLimit: number;
let gasPremium: string;
if (customFees?.parameters) {
gasFeeCap = String(customFees.parameters["gasFeeCap"] ?? "0");
gasLimit = Number(customFees.parameters["gasLimit"] ?? 0);
gasPremium = String(customFees.parameters["gasPremium"] ?? "0");
} else {
const fees = await fetchEstimatedFees({
from,
to,
methodNum: method,
blockIncl: BroadcastBlockIncl,
value,
...(params ? { params } : {}),
});
gasFeeCap = fees.gas_fee_cap;
gasLimit = fees.gas_limit;
gasPremium = fees.gas_premium;
}
const message = new Message({
to,
from,
nonce: Number(nonce),
value,
gasFeeCap,
gasLimit,
gasPremium,
method,
params,
version: 0,
});
const cbor = Buffer.from(message.serialize()).toString("base64");
const messageJson = {
version: message.version ?? 0,
to: message.to,
from: message.from,
nonce: message.nonce,
value: message.value,
gasLimit: message.gasLimit,
gasFeeCap: message.gasFeeCap,
gasPremium: message.gasPremium,
method: message.method,
params: message.params ?? "",
};
return {
transaction: JSON.stringify({ cbor, message: messageJson }),
};
}