@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
264 lines • 14.7 kB
JavaScript
import { DisconnectedDeviceDuringOperation, TransportStatusError, WrongDeviceForAccountPayout, WrongDeviceForAccountRefund, } from "@ledgerhq/errors";
import { createExchange, getExchangeErrorMessage, } from "@ledgerhq/hw-app-exchange";
import { getDefaultAccountName } from "@ledgerhq/live-wallet/accountName";
import { log } from "@ledgerhq/logs";
import BigNumber from "bignumber.js";
import invariant from "invariant";
import { Observable } from "rxjs";
import { secp256k1 } from "@noble/curves/secp256k1";
import { getCurrencyExchangeConfig } from "../";
import { getAccountCurrency, getMainAccount } from "../../account";
import { getAccountBridge } from "../../bridge";
import { TransactionRefusedOnDevice } from "../../errors";
import { handleHederaTrustedFlow } from "../../families/hedera/exchange";
import { withDevicePromise } from "../../hw/deviceAccess";
import { delay } from "../../promise";
import { convertTransportError } from "../error";
import { convertToAppExchangePartnerKey, getSwapProvider } from "../providers";
import { isAddressSanctioned } from "@ledgerhq/ledger-wallet-framework/sanction/index";
import { AddressesSanctionedError } from "@ledgerhq/ledger-wallet-framework/sanction/errors";
import { getCryptoCurrencyById } from "../../currencies";
const COMPLETE_EXCHANGE_LOG = "SWAP-CompleteExchange";
const LIFI_GAS_LIMIT_BUFFER_MULTIPLIER = 1.3;
const completeExchange = (input) => {
let { transaction } = input; // TODO build a tx from the data
const { deviceId, deviceModelId, exchange, provider, binaryPayload, signature, rateType, exchangeType, } = input;
const { fromAccount, fromParentAccount } = exchange;
const { toAccount, toParentAccount } = exchange;
return new Observable(o => {
let unsubscribed = false;
let ignoreTransportError = false;
let currentStep = "INIT";
const confirmExchange = async () => {
if (deviceId === undefined) {
throw new DisconnectedDeviceDuringOperation();
}
await withDevicePromise(deviceId, async (transport) => {
const providerConfig = await getSwapProvider(provider);
if (providerConfig.useInExchangeApp === false) {
throw new Error(`Unsupported provider type ${providerConfig.type}`);
}
const exchange = createExchange(transport, exchangeType, rateType, providerConfig.version);
const refundAccount = getMainAccount(fromAccount, fromParentAccount);
const payoutAccount = getMainAccount(toAccount, toParentAccount);
const accountBridge = getAccountBridge(refundAccount);
const payoutAccountBridge = getAccountBridge(payoutAccount);
const mainPayoutCurrency = getAccountCurrency(payoutAccount);
const payoutCurrency = getAccountCurrency(toAccount);
const refundCurrency = getAccountCurrency(fromAccount);
const mainRefundCurrency = getAccountCurrency(refundAccount);
const sanctionedAddresses = [];
for (const acc of [refundAccount, payoutAccount]) {
const isSanctioned = await isAddressSanctioned(acc.currency, acc.freshAddress);
if (isSanctioned)
sanctionedAddresses.push(acc.freshAddress);
}
if (sanctionedAddresses.length > 0) {
throw new AddressesSanctionedError("AddressesSanctionedError", {
addresses: sanctionedAddresses,
});
}
if (mainPayoutCurrency.type !== "CryptoCurrency")
throw new Error("This should be a cryptocurrency");
if (mainRefundCurrency.type !== "CryptoCurrency")
throw new Error("This should be a cryptocurrency");
const isDex = ["lifi", "thorswap"].includes(provider.toLowerCase());
// Thorswap/LiFi ERC20 token exception hack:
// - We remove subAccountId to prevent EVM calldata swap during prepareTransaction.
// - Set amount to 0 to ensure correct handling of the transaction
// (this is adjusted during prepareTransaction before signing the actual EVM transaction for tokens but we skip it).
// - Since it's an ERC20 token transaction (not ETH), amount is set to 0 ETH
// because no ETH is being sent, only tokens.
// - This workaround can't be applied earlier in the flow as the amount is used for display purposes and checks.
// We must set the amount to 0 at this stage to avoid issues during the transaction.
// - This ensures proper handling of Thorswap/LiFi ERC20-specific transactions.
if (isDex && transaction.subAccountId && transaction.family === "evm") {
const transactionFixed = {
...transaction,
subAccountId: undefined,
amount: BigNumber(0),
};
transaction = await accountBridge.prepareTransaction(refundAccount, transactionFixed);
}
else {
transaction = await accountBridge.prepareTransaction(refundAccount, transaction);
}
if (transaction.family === "bitcoin") {
const transactionFixed = {
...transaction,
rbf: true,
};
transaction = await accountBridge.prepareTransaction(refundAccount, transactionFixed);
}
if (unsubscribed)
return;
// The result of estimateGas is quite accurate in calculating how much gas the user will have to pay
// for after the execution of the transaction,
// but it does not represent how much gas the blockchain requires to successfully evaluate a transaction.
// This is due to gas refunds of storage slots that get set and cleaned in the same transaction,
// gas buffers in some implementations and other things.
// Please always add a buffer on top of that value, LiFi recommends 25-30%.
if (isDex &&
transaction.family === "evm" &&
transaction.gasLimit &&
BigNumber.isBigNumber(transaction.gasLimit)) {
const gasLimit = transaction.gasLimit
.times(LIFI_GAS_LIMIT_BUFFER_MULTIPLIER)
.integerValue(BigNumber.ROUND_UP);
const transactionFixed = {
...transaction,
fees: undefined, // to be recalculated
customGasLimit: gasLimit,
};
transaction = await accountBridge.prepareTransaction(refundAccount, transactionFixed);
}
const { errors, estimatedFees } = await accountBridge.getTransactionStatus(refundAccount, transaction);
if (unsubscribed)
return;
const errorsKeys = Object.keys(errors);
if (errorsKeys.length > 0)
throw errors[errorsKeys[0]]; // throw the first error
currentStep = "SET_PARTNER_KEY";
await exchange.setPartnerKey(convertToAppExchangePartnerKey(providerConfig));
if (unsubscribed)
return;
currentStep = "CHECK_PARTNER";
await exchange.checkPartner(providerConfig.signature);
if (unsubscribed)
return;
currentStep = "PROCESS_TRANSACTION";
const { payload, format } = exchange.transactionType === 3 /* ExchangeTypes.SwapNg */
? { payload: Buffer.from("." + binaryPayload), format: "jws" }
: { payload: Buffer.from(binaryPayload, "hex"), format: "raw" };
await exchange.processTransaction(payload, estimatedFees, format);
if (unsubscribed)
return;
const goodSign = convertSignature(signature, exchange.transactionType);
currentStep = "CHECK_TRANSACTION_SIGNATURE";
await exchange.checkTransactionSignature(goodSign);
if (unsubscribed)
return;
// Hedera swap payload is filled with user account address,
// but the device app requires the related public key for verification.
// Since this key is stored on-chain, we use the TrustedService
// to fetch a signed descriptor linking the address to its public key.
const hederaCurrency = getCryptoCurrencyById("hedera");
let hederaAccount = null;
if (payoutAccount.currency.family === hederaCurrency.family) {
hederaAccount = payoutAccount;
}
else if (refundAccount.currency.family === hederaCurrency.family) {
hederaAccount = refundAccount;
}
if (hederaAccount) {
invariant(deviceModelId, "hedera: deviceModelId is not available");
await handleHederaTrustedFlow({ exchange, hederaAccount, deviceModelId });
if (unsubscribed)
return;
}
const payoutAddressParameters = payoutAccountBridge.getSerializedAddressParameters(payoutAccount, mainPayoutCurrency.id);
if (unsubscribed)
return;
if (!payoutAddressParameters) {
throw new Error(`Family not supported: ${mainPayoutCurrency.family}`);
}
//-- CHECK_PAYOUT_ADDRESS
const { config: payoutAddressConfig, signature: payoutAddressConfigSignature } = await getCurrencyExchangeConfig(payoutCurrency);
try {
currentStep = "CHECK_PAYOUT_ADDRESS";
await exchange.validatePayoutOrAsset(payoutAddressConfig, payoutAddressConfigSignature, payoutAddressParameters);
}
catch (e) {
if (e instanceof TransportStatusError && e.statusCode === 0x6a83) {
throw new WrongDeviceForAccountPayout(getExchangeErrorMessage(e.statusCode, currentStep).errorMessage, {
accountName: getDefaultAccountName(payoutAccount),
});
}
throw convertTransportError(currentStep, e);
}
o.next({
type: "complete-exchange-requested",
estimatedFees: estimatedFees.toString(),
});
// Swap specific checks to confirm the refund address is correct.
if (unsubscribed)
return;
const refundAddressParameters = accountBridge.getSerializedAddressParameters(refundAccount, mainRefundCurrency.id);
if (unsubscribed)
return;
if (!refundAddressParameters) {
throw new Error(`Family not supported: ${mainRefundCurrency.family}`);
}
const { config: refundAddressConfig, signature: refundAddressConfigSignature } = await getCurrencyExchangeConfig(refundCurrency);
if (unsubscribed)
return;
try {
currentStep = "CHECK_REFUND_ADDRESS";
await exchange.checkRefundAddress(refundAddressConfig, refundAddressConfigSignature, refundAddressParameters);
log(COMPLETE_EXCHANGE_LOG, "checkrefund address");
}
catch (e) {
if (e instanceof TransportStatusError && e.statusCode === 0x6a83) {
log(COMPLETE_EXCHANGE_LOG, "transport error");
throw new WrongDeviceForAccountRefund(getExchangeErrorMessage(e.statusCode, currentStep).errorMessage, {
accountName: getDefaultAccountName(refundAccount),
});
}
throw convertTransportError(currentStep, e);
}
if (unsubscribed)
return;
ignoreTransportError = true;
currentStep = "SIGN_COIN_TRANSACTION";
await exchange.signCoinTransaction();
}).catch(e => {
if (ignoreTransportError)
return;
// During signature delegation, Exchange does not remap refusal errors from the coin app/OS.
// 0x6a84: user refused the proposal for an owned destination address.
// 0x5501: BOLOS/OS-level refusal (not an Exchange app error code).
if (e instanceof TransportStatusError &&
(e.statusCode === 0x6a84 || e.statusCode === 0x5501)) {
throw new TransactionRefusedOnDevice();
}
throw convertTransportError(currentStep, e);
});
await delay(3000);
if (unsubscribed)
return;
o.next({
type: "complete-exchange-result",
completeExchangeResult: transaction,
});
};
confirmExchange().then(() => {
o.complete();
unsubscribed = true;
}, e => {
o.next({
type: "complete-exchange-error",
error: e,
});
o.complete();
unsubscribed = true;
});
return () => {
unsubscribed = true;
};
});
};
function convertSignature(signature, exchangeType) {
return exchangeType === 3 /* ExchangeTypes.SwapNg */
? base64UrlDecode(signature)
: (() => {
const sig = secp256k1.Signature.fromCompact(Buffer.from(signature, "hex"));
return Buffer.from(sig.toDERRawBytes());
})();
}
function base64UrlDecode(base64Url) {
// React Native Hermes engine does not support Buffer.from(signature, "base64url")
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(base64, "base64");
}
export default completeExchange;
//# sourceMappingURL=completeExchange.js.map