@fluent-wallet/hw-app-conflux
Version:
Ledger Hardware Wallet Conflux Application API
536 lines (482 loc) • 14.5 kB
text/typescript
import { foreach, isLegacyVersion, splitMessage, splitPath } from "./utils";
import type Transport from "@ledgerhq/hw-transport";
import { sign, format } from "js-conflux-sdk";
import BIPPath from "bip32-path";
import type {
EIP712FieldDefinition,
EIP712ImplementationEntry,
} from "./eip712/types";
import { EIP712_P1, EIP712_P2, sendEIP712Payload } from "./eip712/transport";
import { encodeFieldDefinition, normalizeFieldValue } from "./eip712/codec";
import { prepareEIP712Payload, type EIP712TypedData } from "./eip712/typedData";
const remapTransactionRelatedErrors = (e) => {
if (e && e.statusCode === 0x6a80) {
throw new Error(
"Missing a parameter. Try enabling blind signature in the app"
);
}
return e;
};
const CLA = 0xe0;
const P1 = {
first: 0x00,
};
const P2 = {
more: 0x80,
last: 0x00,
};
const INS = {
GET_ADDRESS: 0x02,
SIGN_TX: 0x03,
SIGN_PERSONAL_MESSAGE: 0x04,
SIGN_EIP712: 0x0a,
EIP712_SEND_STRUCT_DEFINITION: 0x0b,
EIP712_SEND_STRUCT_IMPLEMENTATION: 0x0c,
};
const CHAINID = {
MAINNET: 1029,
TESTNET: 1,
};
/**
* Conflux API
*
* @param transport a transport for sending commands to a device
* @param scrambleKey a scramble key
*
* @example
* import Cfx from "@ledgerhq/hw-app-conflux";
* const cfx = new Cfx(transport)
*/
export default class Conflux {
transport: Transport;
chainId: number;
constructor(
transport: Transport,
chainId: number,
scrambleKey = "conflux_default_scramble_key"
) {
this.transport = transport;
this.chainId = chainId || CHAINID.MAINNET;
transport.decorateAppAPIMethods(
this,
[
"getAddress",
"signTransaction",
"getAppConfiguration",
"signPersonalMessage",
"signEIP712Message",
],
scrambleKey
);
}
/**
* get Conflux address for a given BIP 32 path.
* @param path a path in BIP 32 format
* @option boolDisplay optionally enable or not the display
* @option boolChaincode optionally enable or not the chaincode request
* @return an object with a publicKey, address and (optionally) chainCode
* @example
* cfx.getAddress("44'/503'/0'/0/0").then(o => o.publicKey)
* cfx.getAddress("44'/503'/0'/0/0",true).then(o => o.publicKey): show mainnet address
*/
getAddress(
path: string,
boolDisplay?: boolean,
boolChaincode?: boolean
): Promise<{
publicKey: string;
address: string;
chainCode?: string;
}> {
//path buffer
let buffer = this.derivationPathToBuffer(path);
//chainID buffer
if (boolDisplay) {
const chainIdBuffer = Buffer.alloc(4);
chainIdBuffer.writeUInt32BE(this.chainId);
buffer = Buffer.concat([buffer, chainIdBuffer]);
}
return this.transport
.send(
0xe0,
INS.GET_ADDRESS,
boolDisplay ? 0x01 : 0x00,
boolChaincode ? 0x01 : 0x00,
buffer
)
.then((response) => {
const publicKeyLength = response[0];
const publicKey = response
.slice(2, 1 + publicKeyLength)
.toString("hex"); // remove the prefix:04, because 04 means the uncompressed public key
const address = format.address(
`0x${sign["publicKeyToAddress"](
Buffer.from(publicKey, "hex")
).toString("hex")}`,
this.chainId
); //CIP-37 address
let chainCode;
if (boolChaincode) {
const chainCodeLength = response[1 + publicKeyLength];
chainCode = response
.slice(
1 + publicKeyLength + 1,
1 + publicKeyLength + 1 + chainCodeLength
)
.toString("hex");
}
return {
publicKey,
address,
chainCode,
};
});
}
private async _legacy_signTransaction(
path: string,
rawTxHex: string
): Promise<{
s: string;
v: string;
r: string;
}> {
const paths = BIPPath.fromString(path).toPathArray();
let offset = 0;
const rawTx = Buffer.from(rawTxHex, "hex");
const toSend: Buffer[] = [];
let response;
while (offset !== rawTx.length) {
const maxChunkSize = offset === 0 ? 150 - 1 - paths.length * 4 : 150;
const chunkSize =
offset + maxChunkSize > rawTx.length
? rawTx.length - offset
: maxChunkSize;
const buffer = Buffer.alloc(
offset === 0 ? 1 + paths.length * 4 + chunkSize : chunkSize
);
if (offset === 0) {
buffer[0] = paths.length;
paths.forEach((element, index) => {
buffer.writeUInt32BE(element, 1 + 4 * index);
});
rawTx.copy(buffer, 1 + 4 * paths.length, offset, offset + chunkSize);
} else {
rawTx.copy(buffer, 0, offset, offset + chunkSize);
}
toSend.push(buffer);
offset += chunkSize;
}
return foreach(toSend, (data, i) =>
this.transport
.send(0xe0, INS.SIGN_TX, i === 0 ? 0x00 : 0x80, 0x00, data)
.then((apduResponse) => {
response = apduResponse;
})
).then(
() => {
const response_byte: number = response.slice(0, 1)[0];
const v = response_byte.toString(16);
const r = response.slice(1, 1 + 32).toString("hex");
const s = response.slice(1 + 32, 1 + 32 + 32).toString("hex");
return {
v,
r,
s,
};
},
(e) => {
throw remapTransactionRelatedErrors(e);
}
);
}
private async _signTransaction(
path: string,
rawTxHex: string
): Promise<{
s: string;
v: string;
r: string;
}> {
const rawTx = Buffer.from(rawTxHex, "hex");
const derivationPathBuff = this.derivationPathToBuffer(path);
// send bip32
await this.transport.send(
CLA,
INS.SIGN_TX,
P1.first,
P2.more,
derivationPathBuff
);
const payloadChunks = splitMessage(rawTx, 255);
// send data chunks
if (payloadChunks.length > 1) {
for (let i = 0; i < payloadChunks.length - 1; i++) {
const chunk = payloadChunks[i];
await this.transport.send(CLA, INS.SIGN_TX, i + 1, P2.more, chunk);
}
}
const response = await this.transport.send(
CLA,
INS.SIGN_TX,
Math.max(payloadChunks.length - 1, 1),
P2.last,
payloadChunks[payloadChunks.length - 1]
);
const response_byte: number = response.subarray(0, 1)[0];
const v = response_byte.toString(16);
const r = response.subarray(1, 1 + 32).toString("hex");
const s = response.subarray(1 + 32, 1 + 32 + 32).toString("hex");
return {
v,
r,
s,
};
}
/**
* You can sign a transaction and retrieve v, r, s given the raw transaction and the BIP 32 path of the account to sign
* @example
cfx.signTransaction("44'/503'/0'/0/0", "e8018504e3b292008252089428ee52a8f3d6e5d15f8b131996950d7f296c7952872bd72a2487400080").then(result => ...)
*/
async signTransaction(
path: string,
rawTxHex: string
): Promise<{
s: string;
v: string;
r: string;
}> {
const { version } = await this._getAppConfiguration();
const isLegacy = isLegacyVersion(version);
if (isLegacy) return this._legacy_signTransaction(path, rawTxHex);
return this._signTransaction(path, rawTxHex);
}
private async _getAppConfiguration(): Promise<{
name: string;
version: string;
flags: number | Buffer;
}> {
const r = await this.transport.send(0xb0, 0x01, 0x00, 0x00);
let i = 0;
const format = r[i++];
if (format !== 1) {
throw new Error("getAppAndVersion: format not supported");
}
const nameLength = r[i++];
const name = r.slice(i, (i += nameLength)).toString("ascii");
const versionLength = r[i++];
const version = r.slice(i, (i += versionLength)).toString("ascii");
const flagLength = r[i++];
const flags = r.slice(i, (i += flagLength));
return {
name,
version,
flags,
};
}
async getAppConfiguration(): Promise<{
name: string;
version: string;
flags: number | Buffer;
}> {
return this._getAppConfiguration();
}
/**
* You can sign a message according to cfx_sign RPC call and retrieve v, r, s given the message and the BIP 32 path of the account to sign.
* @example cfx.signPersonalMessage("44'/503'/0'/0/0", Buffer.from("test").toString("hex"))
* @param path hdPath
* @param messageHex the hex string of the message
* @returns
*/
signPersonalMessage(
path: string,
messageHex: string
): Promise<{
v: number;
s: string;
r: string;
}> {
const message = Buffer.from(messageHex, "hex");
const pathBuffer = this.derivationPathToBuffer(path);
const messageChunks = splitMessage(message, 255);
// Firmware allows at most 0x20 personal-sign message chunks
// https://github.com/Conflux-Chain/app-conflux/blob/develop/docs/APDU.md#request-format-3
if (messageChunks.length > 0x20) {
throw new Error(
"Message too long: firmware allows at most 0x20 personal-sign chunks"
);
}
const chunks = [pathBuffer, ...messageChunks];
let response;
return foreach(chunks, (data, index) => {
const p1 = index === 0 ? 0x00 : index;
const p2 = index === chunks.length - 1 ? 0x00 : 0x80;
return this.transport
.send(0xe0, INS.SIGN_PERSONAL_MESSAGE, p1, p2, data)
.then((apduResponse) => {
response = apduResponse;
});
}).then(() => {
const v = response[0];
const r = response.slice(1, 1 + 32).toString("hex");
const s = response.slice(1 + 32, 1 + 32 + 32).toString("hex");
return {
v,
r,
s,
};
});
}
private pathToBuffer(originalPath: string) {
const path = originalPath
.split("/")
.map((value) =>
value.endsWith("'") || value.endsWith("h") ? value : value + "'"
)
.join("/");
const pathNums: number[] = BIPPath.fromString(path).toPathArray();
return this.serializePath(pathNums);
}
private serializePath(path: number[]) {
const buf = Buffer.alloc(1 + path.length * 4);
buf.writeUInt8(path.length, 0);
for (const [i, num] of path.entries()) {
buf.writeUInt32BE(num, 1 + i * 4);
}
return buf;
}
/**
* Encodes derivation path using legacy splitPath rules.
* This avoids auto-hardening and matches existing APIs.
*/
private derivationPathToBuffer(path: string): Buffer {
const paths = splitPath(path);
const buf = Buffer.alloc(1 + paths.length * 4);
buf[0] = paths.length;
paths.forEach((element, index) => {
buf.writeUInt32BE(element, 1 + 4 * index);
});
return buf;
}
private async _sendEIP712StructDefinition(
structName: string,
fields: EIP712FieldDefinition[]
): Promise<void> {
if (!structName) throw new Error("Struct name is required");
const nameBuf = Buffer.from(structName, "utf8");
if (nameBuf.length === 0 || nameBuf.length > 0xff) {
throw new Error("Struct name must be between 1 and 255 bytes");
}
// Definition APDU must be sent as a single complete chunk.
await this.transport.send(
CLA,
INS.EIP712_SEND_STRUCT_DEFINITION,
EIP712_P1.complete,
EIP712_P2.structName,
nameBuf
);
for (const field of fields) {
const payload = encodeFieldDefinition(field);
if (payload.length > 0xff) {
throw new Error(`Field definition for ${field.name} exceeds 255 bytes`);
}
await this.transport.send(
CLA,
INS.EIP712_SEND_STRUCT_DEFINITION,
EIP712_P1.complete,
EIP712_P2.structField,
payload
);
}
}
private async _sendEIP712StructImplementation(
entries: EIP712ImplementationEntry[]
): Promise<void> {
if (!entries.length) {
throw new Error("At least one implementation entry is required");
}
let rootDefined = false;
for (const entry of entries) {
if (entry.type === "root") {
rootDefined = true;
const rootNameBuf = Buffer.from(entry.name, "utf8");
if (rootNameBuf.length === 0 || rootNameBuf.length > 0xff) {
throw new Error("Root struct name must be between 1 and 255 bytes");
}
await this.transport.send(
CLA,
INS.EIP712_SEND_STRUCT_IMPLEMENTATION,
EIP712_P1.complete,
EIP712_P2.structName,
rootNameBuf
);
continue;
}
if (!rootDefined) {
throw new Error(
"Root struct must be set before sending arrays or fields"
);
}
if (entry.type === "array") {
if (entry.size < 0 || entry.size > 0xff) {
throw new Error("Array size must be in [0, 255]");
}
await sendEIP712Payload(
this.transport,
CLA,
INS.EIP712_SEND_STRUCT_IMPLEMENTATION,
EIP712_P2.array,
Buffer.from([entry.size])
);
continue;
}
const value = normalizeFieldValue(entry.value);
if (value.length > 0xffff) {
throw new Error("Field value exceeds maximum length (65535 bytes)");
}
const prefix = Buffer.alloc(2);
prefix.writeUInt16BE(value.length);
await sendEIP712Payload(
this.transport,
CLA,
INS.EIP712_SEND_STRUCT_IMPLEMENTATION,
EIP712_P2.structField,
Buffer.concat([prefix, value])
);
}
}
private async finalizeEIP712Signature(path: string): Promise<{
v: number;
r: string;
s: string;
}> {
const pathBuffer = this.derivationPathToBuffer(path);
const response = await this.transport.send(
CLA,
INS.SIGN_EIP712,
EIP712_P1.complete,
EIP712_P2.signFullImplementation,
pathBuffer
);
const v = response[0];
const r = response.subarray(1, 33).toString("hex");
const s = response.subarray(33, 65).toString("hex");
return { v, r, s };
}
async signEIP712Message(
path: string,
typedData: EIP712TypedData
): Promise<{
v: number;
r: string;
s: string;
}> {
if (typedData) {
const { definitions, implementation } = prepareEIP712Payload(typedData);
for (const def of definitions) {
await this._sendEIP712StructDefinition(def.name, def.fields);
}
await this._sendEIP712StructImplementation(implementation);
}
return this.finalizeEIP712Signature(path);
}
}