@ledgerhq/coin-multiversx
Version:
Ledger MultiversX Coin integration
388 lines (353 loc) • 12.5 kB
text/typescript
import type {
MultiversXAccount,
MultiversXOperation,
MultiversXOperationRaw,
Transaction,
} from "./types";
import invariant from "invariant";
import { parseCurrencyUnit } from "@ledgerhq/coin-framework/currencies";
import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies";
import { botTest, pickSiblings, genericTestDestination } from "@ledgerhq/coin-framework/bot/specs";
import type { AppSpec, TransactionTestInput } from "@ledgerhq/coin-framework/bot/types";
import { toOperationRaw } from "@ledgerhq/coin-framework/serialization";
import { DeviceModelId } from "@ledgerhq/devices";
import expect from "expect";
import {
acceptDelegateTransaction,
acceptEsdtTransferTransaction,
acceptMoveBalanceTransaction,
acceptUndelegateTransaction,
acceptWithdrawTransaction,
} from "./speculos-deviceActions";
import BigNumber from "bignumber.js";
import { MIN_DELEGATION_AMOUNT } from "./constants";
import { TokenAccount } from "@ledgerhq/types-live";
import sample from "lodash/sample";
const currency = getCryptoCurrencyById("elrond");
const minimalAmount = parseCurrencyUnit(currency.units[0], "0.001");
const maxAccounts = 6;
const MULTIVERSX_MIN_ACTIVATION_SAFE = new BigNumber(10000);
const UNCAPPED_PROVIDER = "erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqlhllllsr0pd0j";
function expectCorrectBalanceChange(input: TransactionTestInput<Transaction>) {
const { account, operation, accountBeforeTransaction } = input;
botTest("EGLD balance change is correct", () =>
expect(account.balance.toFixed()).toStrictEqual(
accountBeforeTransaction.balance.minus(operation.value).toFixed(),
),
);
}
function expectCorrectEsdtBalanceChange(input: TransactionTestInput<Transaction>) {
const { account, operation, accountBeforeTransaction, transaction } = input;
const { subAccountId } = transaction;
const subAccounts = account.subAccounts ?? [];
const subAccountsBefore = accountBeforeTransaction.subAccounts ?? [];
const tokenAccount = subAccounts.find(ta => ta.id === subAccountId);
const tokenAccountBefore = subAccountsBefore.find(ta => ta.id === subAccountId);
const subOperation = operation.subOperations?.find(sa => sa.id === operation.id);
if (tokenAccount && tokenAccountBefore && subOperation) {
botTest("ESDT balance change is correct", () =>
expect(tokenAccount.balance.toFixed()).toStrictEqual(
tokenAccountBefore.balance.minus(subOperation.value).toFixed(),
),
);
}
}
function expectCorrectOptimisticOperation(input: TransactionTestInput<Transaction>) {
const { operation, optimisticOperation, transaction } = input;
const opExpected: Partial<MultiversXOperationRaw> = toOperationRaw({
...optimisticOperation,
}) as MultiversXOperationRaw;
delete opExpected.value;
delete opExpected.fee;
delete opExpected.date;
delete opExpected.blockHash;
delete opExpected.blockHeight;
if (operation.type !== "OUT") {
delete opExpected.senders;
delete opExpected.recipients;
delete opExpected.contract;
}
botTest("optimistic operation matches id", () =>
expect(operation.id).toStrictEqual(optimisticOperation.id),
);
botTest("optimistic operation matches hash", () =>
expect(operation.hash).toStrictEqual(optimisticOperation.hash),
);
botTest("optimistic operation matches accountId", () =>
expect(operation.accountId).toStrictEqual(optimisticOperation.accountId),
);
// On ESDT transactions the fee can decrease when the transaction is executed
if (!transaction.subAccountId) {
botTest("optimistic operation matches fee", () =>
expect(operation.fee.toFixed()).toStrictEqual(optimisticOperation.fee.toFixed()),
);
}
botTest("optimistic operation matches type", () =>
expect(operation.type).toStrictEqual(optimisticOperation.type),
);
if (operation.type === "OUT") {
botTest("optimistic operation matches contract", () =>
expect(operation.contract).toStrictEqual(optimisticOperation.contract),
);
botTest("optimistic operation matches senders", () =>
expect(operation.senders).toStrictEqual(optimisticOperation.senders),
);
botTest("optimistic operation matches recipients", () =>
expect(operation.recipients).toStrictEqual(optimisticOperation.recipients),
);
if (!transaction.subAccountId) {
botTest("optimistic operation matches value", () =>
expect(operation.value.toFixed()).toStrictEqual(optimisticOperation.value.toFixed()),
);
}
}
botTest("optimistic operation matches transactionSequenceNumber", () =>
expect(operation.transactionSequenceNumber).toStrictEqual(
optimisticOperation.transactionSequenceNumber,
),
);
botTest("raw optimistic operation matches", () =>
expect(toOperationRaw(operation)).toMatchObject(opExpected),
);
}
function expectCorrectSpendableBalanceChange(input: TransactionTestInput<Transaction>) {
const { account, accountBeforeTransaction } = input;
const operation = input.operation as MultiversXOperation;
let value = operation.value;
if (operation.extra.amount) {
if (operation.type === "DELEGATE") {
value = value.plus(operation.extra.amount);
} else if (operation.type === "WITHDRAW_UNBONDED") {
value = value.minus(operation.extra.amount);
}
}
botTest("EGLD spendable balance change is correct", () =>
expect(account.spendableBalance.toFixed()).toStrictEqual(
accountBeforeTransaction.spendableBalance.minus(value).toFixed(),
),
);
}
function expectCorrectBalanceFeeChange(input: TransactionTestInput<Transaction>) {
const { account, operation, accountBeforeTransaction } = input;
botTest("Only change on balance is fees", () =>
expect(account.balance.toFixed()).toStrictEqual(
accountBeforeTransaction.balance.minus(operation.fee).toFixed(),
),
);
}
const multiversx: AppSpec<Transaction> = {
name: "MultiversX",
currency: getCryptoCurrencyById("elrond"),
appQuery: {
model: DeviceModelId.nanoS,
appName: "MultiversX",
},
genericDeviceAction: acceptMoveBalanceTransaction,
genericDeviceActionForSubAccountTransfers: acceptEsdtTransferTransaction,
testTimeout: 2 * 60 * 1000,
minViableAmount: minimalAmount,
transactionCheck: ({ maxSpendable }) => {
invariant(maxSpendable.gt(minimalAmount), "balance is too low");
},
test: input => {
expectCorrectOptimisticOperation(input);
},
mutations: [
{
name: "send 50%~",
feature: "send",
maxRun: 1,
deviceAction: acceptMoveBalanceTransaction,
transaction: ({ account, siblings, bridge }) => {
invariant(account.spendableBalance.gt(0), "balance is 0");
const sibling = pickSiblings(siblings, maxAccounts);
let amount = account.spendableBalance.div(1.9 + 0.2 * Math.random()).integerValue();
if (!sibling.used && amount.lt(MULTIVERSX_MIN_ACTIVATION_SAFE)) {
invariant(
account.spendableBalance.gt(MULTIVERSX_MIN_ACTIVATION_SAFE),
"send is too low to activate account",
);
amount = MULTIVERSX_MIN_ACTIVATION_SAFE;
}
return {
transaction: bridge.createTransaction(account),
updates: [
{
recipient: sibling.freshAddress,
},
{
amount,
},
],
};
},
testDestination: genericTestDestination,
test: input => {
expectCorrectBalanceChange(input);
},
},
{
name: "send max",
feature: "sendMax",
maxRun: 1,
deviceAction: acceptMoveBalanceTransaction,
transaction: ({ account, siblings, bridge }) => {
invariant(account.spendableBalance.gt(0), "balance is 0");
const sibling = pickSiblings(siblings, maxAccounts);
if (!sibling.used) {
invariant(
account.spendableBalance.gt(MULTIVERSX_MIN_ACTIVATION_SAFE),
"send is too low to activate account",
);
}
return {
transaction: bridge.createTransaction(account),
updates: [
{
recipient: sibling.freshAddress,
},
{
useAllAmount: true,
},
],
};
},
testDestination: genericTestDestination,
test: input => {
expectCorrectBalanceChange(input);
},
},
{
name: "move some ESDT",
feature: "tokens",
maxRun: 1,
deviceAction: acceptEsdtTransferTransaction,
transaction: ({ account, siblings, bridge }) => {
const esdtAccount: TokenAccount | undefined = sample(
(account.subAccounts || []).filter(a => a.balance.gt(0)),
);
invariant(esdtAccount, "no esdt account");
invariant(esdtAccount?.balance.gt(0), "esdt balance is 0");
const sibling = pickSiblings(siblings, 2);
const recipient = sibling.freshAddress;
const amount = esdtAccount?.balance.times(Math.random()).integerValue();
return {
transaction: bridge.createTransaction(account),
updates: [
{
subAccountId: esdtAccount?.id,
},
{
recipient,
},
{
amount,
},
],
};
},
test: input => {
expectCorrectEsdtBalanceChange(input);
expectCorrectBalanceFeeChange(input);
},
},
{
name: "delegate 1 EGLD",
feature: "staking",
maxRun: 1,
deviceAction: acceptDelegateTransaction,
transaction: ({ account, bridge }) => {
invariant(
account.spendableBalance.gt(MIN_DELEGATION_AMOUNT),
`spendable balance is less than minimum delegation amount`,
);
const amount = MIN_DELEGATION_AMOUNT;
return {
transaction: bridge.createTransaction(account),
updates: [
{
recipient: UNCAPPED_PROVIDER,
mode: "delegate",
amount,
},
],
};
},
test: input => {
expectCorrectSpendableBalanceChange(input);
expectCorrectBalanceFeeChange(input);
},
},
{
name: "unDelegate 1 EGLD",
feature: "staking",
maxRun: 1,
deviceAction: acceptUndelegateTransaction,
transaction: ({ account, bridge }) => {
const delegations = (account as MultiversXAccount)?.multiversxResources?.delegations;
invariant(delegations?.length, "account doesn't have any delegations");
invariant(
delegations.some(d => new BigNumber(d.userActiveStake).gt(0)),
"no active stake for account",
);
const amount = MIN_DELEGATION_AMOUNT;
return {
transaction: bridge.createTransaction(account),
updates: [
{
recipient: UNCAPPED_PROVIDER,
mode: "unDelegate",
amount,
},
],
};
},
test: input => {
expectCorrectSpendableBalanceChange(input);
expectCorrectBalanceFeeChange(input);
},
},
{
name: "withdraw all EGLD",
feature: "staking",
maxRun: 1,
deviceAction: acceptWithdrawTransaction,
transaction: ({ account, bridge }) => {
const delegations = (account as MultiversXAccount)?.multiversxResources?.delegations;
invariant(delegations?.length, "account doesn't have any delegations");
invariant(
// among all delegations
delegations.some(d =>
// among all undelegating amounts
d.userUndelegatedList?.some(
u =>
new BigNumber(u.amount).gt(0) && // the undelegation has a positive amount
new BigNumber(u.seconds).eq(0), // the undelegation period has ended
),
),
"no withdrawable stake for account",
);
return {
transaction: bridge.createTransaction(account),
updates: [
{
recipient: UNCAPPED_PROVIDER,
mode: "withdraw",
amount: new BigNumber(0),
},
],
};
},
test: input => {
expectCorrectSpendableBalanceChange(input);
expectCorrectBalanceFeeChange(input);
},
},
// TODO
// "reDelegateRewards"
// "claimRewards"
],
};
export default {
multiversx,
};