@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
335 lines (300 loc) • 12.5 kB
text/typescript
import { getAbandonSeedAddress } from "@ledgerhq/cryptoassets";
import {
TransportStatusError,
WrongDeviceForAccountPayout,
WrongDeviceForAccountRefund,
} from "@ledgerhq/errors";
import Exchange, {
decodePayloadProtobuf,
ExchangeTypes,
RateTypes,
} from "@ledgerhq/hw-app-exchange";
import network from "@ledgerhq/live-network/network";
import { log } from "@ledgerhq/logs";
import { BigNumber } from "bignumber.js";
import invariant from "invariant";
import { firstValueFrom, from, Observable } from "rxjs";
import { secp256k1 } from "@noble/curves/secp256k1";
import { getCurrencyExchangeConfig } from "../";
import { getAccountCurrency, getMainAccount } from "../../account";
import { getAccountBridge } from "../../bridge";
import { getEnv } from "@ledgerhq/live-env";
import {
SwapGenericAPIError,
SwapRateExpiredError,
TransactionRefusedOnDevice,
} from "../../errors";
import { withDevice } from "../../hw/deviceAccess";
import { delay } from "../../promise";
import { getSwapAPIBaseURL, getSwapUserIP } from "./";
import { mockInitSwap } from "./mock";
import type { InitSwapInput, SwapRequestEvent } from "./types";
import { convertToAppExchangePartnerKey, getSwapProvider } from "../providers";
import { getDefaultAccountName } from "@ledgerhq/live-wallet/accountName";
import { CEXProviderConfig } from "../providers/swap";
const withDevicePromise = (deviceId, fn) =>
firstValueFrom(withDevice(deviceId)(transport => from(fn(transport))));
// init a swap with the Exchange app
// throw if TransactionStatus have errors
// you get at the end a final Transaction to be done (it's not yet signed, nor broadcasted!) and a swapId
const initSwap = (input: InitSwapInput): Observable<SwapRequestEvent> => {
let swapId;
let { transaction } = input;
const { exchange, exchangeRate, deviceId } = input;
if (getEnv("MOCK")) return mockInitSwap(exchange, exchangeRate, transaction);
return new Observable(o => {
let unsubscribed = false;
const confirmSwap = async () => {
let ignoreTransportError;
let magnitudeAwareRate;
log("swap", `attempt to connect to ${deviceId}`);
await withDevicePromise(deviceId, async transport => {
const ratesFlag =
exchangeRate.tradeMethod === "fixed" ? RateTypes.Fixed : RateTypes.Floating;
const swap = new Exchange(transport, ExchangeTypes.Swap, ratesFlag);
// NB this id is crucial to prevent replay attacks, if it changes
// we need to start the flow again.
const deviceTransactionId = await swap.startNewTransaction();
if (unsubscribed) return;
const { provider, rateId } = exchangeRate;
const { fromParentAccount, fromAccount, toParentAccount, toAccount } = exchange;
const { amount } = transaction;
const refundCurrency = getAccountCurrency(fromAccount);
const unitFrom = getAccountCurrency(exchange.fromAccount).units[0];
const payoutCurrency = getAccountCurrency(toAccount);
const refundAccount = getMainAccount(fromAccount, fromParentAccount);
const payoutAccount = getMainAccount(toAccount, toParentAccount);
const apiAmount = new BigNumber(amount).div(new BigNumber(10).pow(unitFrom.magnitude));
// Request a swap, this locks the rates for fixed trade method only.
// NB Added the try/catch because of the API stability issues.
let res;
const swapProviderConfig = await getSwapProvider(provider);
const headers = {
EquipmentId: getEnv("USER_ID"),
...(getSwapUserIP() !== undefined ? getSwapUserIP() : {}),
};
const data = {
provider,
amountFrom: apiAmount.toString(),
amountFromInSmallestDenomination: amount.toNumber(),
from: refundCurrency.id,
to: payoutCurrency.id,
address: payoutAccount.freshAddress,
refundAddress: refundAccount.freshAddress,
deviceTransactionId,
...(rateId && ratesFlag === RateTypes.Fixed
? {
rateId,
}
: {}), // NB float rates dont need rate ids.
};
try {
res = await network({
method: "POST",
url: `${getSwapAPIBaseURL()}/swap`,
headers,
data,
});
if (unsubscribed || !res || !res.data) return;
} catch (e: any) {
if (e.msg.messageKey == "WRONG_OR_EXPIRED_RATE_ID") {
o.next({
type: "init-swap-error",
error: new SwapRateExpiredError(),
swapId,
});
}
o.next({
type: "init-swap-error",
error: new SwapGenericAPIError(),
swapId,
});
o.complete();
return;
}
const swapResult = res.data;
swapId = swapResult.swapId;
const accountBridge = getAccountBridge(refundAccount);
transaction = accountBridge.updateTransaction(transaction, {
recipient: swapResult.payinAddress,
});
if (refundCurrency.id === "ripple") {
transaction = accountBridge.updateTransaction(transaction, {
tag: new BigNumber(swapResult.payinExtraId).toNumber(),
});
invariant(transaction.tag, "Refusing to swap xrp without a destination tag");
} else if (refundCurrency.id === "stellar") {
transaction = accountBridge.updateTransaction(transaction, {
memoValue: swapResult.payinExtraId,
memoType: "MEMO_TEXT",
});
invariant(transaction.memoValue, "Refusing to swap xlm without a destination memo");
}
// Triplecheck we're not working with an abandonseed recipient anymore
invariant(
transaction.recipient !==
getAbandonSeedAddress(
refundCurrency.type === "TokenCurrency"
? refundCurrency.parentCurrency.id
: refundCurrency.id,
),
"Recipient address should never be the abandonseed address",
);
transaction = await accountBridge.prepareTransaction(refundAccount, transaction);
if (unsubscribed) return;
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
}
if (swapProviderConfig.useInExchangeApp === false) {
throw new Error(`Unsupported provider type ${swapProviderConfig.type}`);
}
// Prepare swap app to receive the tx to forward.
await swap.setPartnerKey(
convertToAppExchangePartnerKey(swapProviderConfig as CEXProviderConfig),
);
if (unsubscribed) return;
await swap.checkPartner((swapProviderConfig as CEXProviderConfig).signature!);
if (unsubscribed) return;
await swap.processTransaction(Buffer.from(swapResult.binaryPayload, "hex"), estimatedFees);
if (unsubscribed) return;
const goodSign = <Buffer>(() => {
const sig = secp256k1.Signature.fromCompact(Buffer.from(swapResult.signature, "hex"));
return Buffer.from(sig.toDERRawBytes());
})();
await swap.checkTransactionSignature(goodSign);
if (unsubscribed) return;
const mainPayoutCurrency = getAccountCurrency(payoutAccount);
invariant(mainPayoutCurrency.type === "CryptoCurrency", "This should be a cryptocurrency");
// FIXME: invariant not triggering typescriptp type guard
if (mainPayoutCurrency.type !== "CryptoCurrency") {
throw new Error("This should be a cryptocurrency");
}
const mainPayoutBridge = getAccountBridge(payoutAccount);
const payoutAddressParameters = mainPayoutBridge.getSerializedAddressParameters(
payoutAccount,
mainPayoutCurrency.id,
);
if (unsubscribed) return;
if (!payoutAddressParameters) {
throw new Error(`Family not supported: ${mainPayoutCurrency.family}`);
}
const { config: payoutAddressConfig, signature: payoutAddressConfigSignature } =
await getCurrencyExchangeConfig(payoutCurrency);
try {
await swap.validatePayoutOrAsset(
payoutAddressConfig,
payoutAddressConfigSignature,
payoutAddressParameters,
);
} catch (e) {
if (e instanceof TransportStatusError && e.statusCode === 0x6a83) {
throw new WrongDeviceForAccountPayout(undefined, {
accountName: getDefaultAccountName(payoutAccount),
});
}
throw e;
}
if (unsubscribed) return;
const mainRefundCurrency = getAccountCurrency(refundAccount);
invariant(mainRefundCurrency.type === "CryptoCurrency", "This should be a cryptocurrency");
// FIXME: invariant not triggering typescriptp type guard
if (mainRefundCurrency.type !== "CryptoCurrency") {
throw new Error("This should be a cryptocurrency");
}
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;
// NB Floating rates may change the original amountTo so we can pass an override
// to properly render the amount on the device confirmation steps. Although changelly
// made the calculation inside the binary payload, we still have to deal with it here
// to not break their other clients.
let amountExpectedTo;
if (swapResult.binaryPayload) {
const decodePayload = await decodePayloadProtobuf(swapResult.binaryPayload);
amountExpectedTo = new BigNumber(decodePayload.amountToWallet.toString());
magnitudeAwareRate = transaction.amount && amountExpectedTo.dividedBy(transaction.amount);
}
let amountExpectedFrom;
if (swapResult.binaryPayload) {
const decodePayload = await decodePayloadProtobuf(swapResult.binaryPayload);
amountExpectedFrom = new BigNumber(decodePayload.amountToProvider.toString());
if (data.amountFromInSmallestDenomination !== amountExpectedFrom.toNumber())
throw new Error("AmountFrom received from partner's payload mismatch user input");
}
o.next({
type: "init-swap-requested",
amountExpectedTo,
estimatedFees,
});
try {
await swap.checkRefundAddress(
refundAddressConfig,
refundAddressConfigSignature,
refundAddressParameters,
);
} catch (e) {
if (e instanceof TransportStatusError && e.statusCode === 0x6a83) {
throw new WrongDeviceForAccountRefund(undefined, {
accountName: getDefaultAccountName(refundAccount),
});
}
throw e;
}
if (unsubscribed) return;
ignoreTransportError = true;
await swap.signCoinTransaction();
}).catch(e => {
if (ignoreTransportError) return;
if (e instanceof TransportStatusError && e.statusCode === 0x6a84) {
throw new TransactionRefusedOnDevice("", { step: "SIGN_COIN_TRANSACTION" });
}
throw e;
});
if (!swapId) return;
log("swap", "awaiting device disconnection");
await delay(3000);
if (unsubscribed) return;
o.next({
type: "init-swap-result",
initSwapResult: {
transaction,
swapId,
magnitudeAwareRate,
},
});
};
confirmSwap().then(
() => {
o.complete();
unsubscribed = true;
},
e => {
o.next({
type: "init-swap-error",
error: e,
swapId,
});
o.complete();
unsubscribed = true;
},
);
return () => {
unsubscribed = true;
};
});
};
export default initSwap;