@ledgerhq/coin-cardano
Version:
Ledger Cardano Coin integration
502 lines (447 loc) • 16.9 kB
text/typescript
import {
Transaction as TyphonTransaction,
address as TyphonAddress,
types as TyphonTypes,
utils as TyphonUtils,
} from "@stricahq/typhonjs";
import BigNumber from "bignumber.js";
import type { TokenAccount } from "@ledgerhq/types-live";
import {
getAccountStakeCredential,
getBaseAddress,
getTTL,
mergeTokens,
isProtocolParamsValid,
isTestnet,
} from "./logic";
import { decodeTokenAssetId, decodeTokenCurrencyId, getTokenAssetId } from "./buildSubAccounts";
import { getNetworkParameters } from "./networks";
import {
CardanoAccount,
CardanoOutput,
CardanoResources,
PaymentCredential,
Token,
Transaction,
} from "./types";
import { CARDANO_MAX_SUPPLY } from "./constants";
import { CardanoInvalidProtoParams } from "./errors";
function getTyphonInputFromUtxo(utxo: CardanoOutput): TyphonTypes.Input {
const address = TyphonUtils.getAddressFromHex(
Buffer.from(utxo.address, "hex"),
) as TyphonTypes.ShelleyAddress;
if (address.paymentCredential.type === TyphonTypes.HashType.ADDRESS) {
address.paymentCredential.bipPath = utxo.paymentCredential.path;
}
return {
txId: utxo.hash,
index: utxo.index,
amount: new BigNumber(utxo.amount),
tokens: utxo.tokens,
address: address,
};
}
function getRewardWithdrawalCertificate(account: CardanoAccount): TyphonTypes.Withdrawal | null {
if (
!account.cardanoResources.delegation ||
!account.cardanoResources.delegation.dRepHex ||
account.cardanoResources.delegation.rewards.isZero()
) {
return null;
}
if (!account.xpub) throw new Error("Account xpub is missing");
const stakeCredential = getAccountStakeCredential(account.xpub, account.index);
const stakeKeyHashCredential: TyphonTypes.HashCredential = {
hash: Buffer.from(stakeCredential.key, "hex"),
type: TyphonTypes.HashType.ADDRESS,
bipPath: stakeCredential.path,
};
const networkId = isTestnet(account.currency)
? TyphonTypes.NetworkId.TESTNET
: TyphonTypes.NetworkId.MAINNET;
const rewardAddress = new TyphonAddress.RewardAddress(networkId, stakeKeyHashCredential);
const rewardsWithdrawalCertificate: TyphonTypes.Withdrawal = {
rewardAccount: rewardAddress,
amount: account.cardanoResources.delegation.rewards,
};
return rewardsWithdrawalCertificate;
}
const buildSendTokenTransaction = async ({
account,
transaction,
tokenAccount,
typhonTx,
receiverAddress,
changeAddress,
}: {
account: CardanoAccount;
transaction: Transaction;
tokenAccount: TokenAccount;
typhonTx: TyphonTransaction;
receiverAddress: TyphonTypes.CardanoAddress;
changeAddress: TyphonTypes.CardanoAddress;
}): Promise<TyphonTransaction> => {
const cardanoResources = account.cardanoResources as CardanoResources;
const { assetId } = decodeTokenCurrencyId(tokenAccount.token.id);
const { policyId, assetName } = decodeTokenAssetId(assetId);
const transactionAmount = transaction.useAllAmount ? tokenAccount.balance : transaction.amount;
const tokensToSend = [
{
policyId,
assetName,
amount: transactionAmount,
},
];
const tokenUtxos: Array<CardanoOutput> = [];
const otherUtxos: Array<CardanoOutput> = [];
cardanoResources.utxos.forEach(u => {
if (u.tokens.some(t => getTokenAssetId(t) === assetId)) {
tokenUtxos.push(u);
} else {
otherUtxos.push(u);
}
});
const sortedTokenUtxo = tokenUtxos.sort((a, b) => {
const sendingTokenA = a.tokens.find(t => getTokenAssetId(t) === assetId) as Token;
const sendingTokenB = b.tokens.find(t => getTokenAssetId(t) === assetId) as Token;
const diff = sendingTokenB.amount.minus(sendingTokenA.amount);
return diff.eq(0) ? 0 : diff.lt(0) ? -1 : 1;
});
const sortedOtherUtxos = otherUtxos.sort((a, b) => {
const diff = b.amount.minus(a.amount);
return diff.eq(0) ? 0 : diff.lt(0) ? -1 : 1;
});
const totalAddedTokenAmount = new BigNumber(0);
const requiredMinAdaForTokens = TyphonUtils.calculateMinUtxoAmountBabbage(
{
address: receiverAddress,
amount: new BigNumber(CARDANO_MAX_SUPPLY),
tokens: tokensToSend,
},
new BigNumber(cardanoResources.protocolParams.utxoCostPerByte),
);
// Add enough utxo to cover token amount
for (let i = 0; i < sortedTokenUtxo.length; i++) {
const u = sortedTokenUtxo[i];
const sendingToken = u.tokens.find(t => getTokenAssetId(t) === assetId) as Token;
typhonTx.addInput(getTyphonInputFromUtxo(u));
totalAddedTokenAmount.plus(sendingToken.amount);
if (totalAddedTokenAmount.gte(transactionAmount)) break;
}
const requiredInputAmount = requiredMinAdaForTokens.plus(10e6);
// Add enough utxos to cover the transaction fees
for (let i = 0; i < sortedOtherUtxos.length; i++) {
const u = sortedOtherUtxos[i];
if (typhonTx.getInputAmount().ada.gte(requiredInputAmount)) {
break;
}
typhonTx.addInput(getTyphonInputFromUtxo(u));
}
const rewardsWithdrawalCertificate = getRewardWithdrawalCertificate(account);
if (rewardsWithdrawalCertificate) typhonTx.addWithdrawal(rewardsWithdrawalCertificate);
typhonTx.addOutput({
address: receiverAddress,
amount: requiredMinAdaForTokens,
tokens: tokensToSend,
});
return typhonTx.prepareTransaction({
inputs: [],
changeAddress,
});
};
const buildSendAdaTransaction = async ({
account,
transaction,
typhonTx,
receiverAddress,
changeAddress,
}: {
account: CardanoAccount;
transaction: Transaction;
typhonTx: TyphonTransaction;
receiverAddress: TyphonTypes.CardanoAddress;
changeAddress: TyphonTypes.CardanoAddress;
}): Promise<TyphonTransaction> => {
const cardanoResources = account.cardanoResources as CardanoResources;
const protocolParams = cardanoResources.protocolParams;
if (transaction.useAllAmount) {
// add all utxo as input
cardanoResources.utxos.forEach(u => typhonTx.addInput(getTyphonInputFromUtxo(u)));
const tokenBalance = mergeTokens(cardanoResources.utxos.map(u => u.tokens).flat());
// if account holds any tokens then add it to changeAddress,
// with minimum required ADA to spend those tokens
if (tokenBalance.length) {
const minAmountForChangeTokens = TyphonUtils.calculateMinUtxoAmountBabbage(
{
address: changeAddress,
amount: new BigNumber(CARDANO_MAX_SUPPLY),
tokens: tokenBalance,
},
new BigNumber(protocolParams.utxoCostPerByte),
);
typhonTx.addOutput({
address: changeAddress,
amount: minAmountForChangeTokens,
tokens: tokenBalance,
});
}
const rewardsWithdrawalCertificate = getRewardWithdrawalCertificate(account);
if (rewardsWithdrawalCertificate) typhonTx.addWithdrawal(rewardsWithdrawalCertificate);
return typhonTx.prepareTransaction({
inputs: [],
changeAddress: receiverAddress,
});
}
// sorting utxo from higher to lower ADA value
// to minimize the number of utxo use in transaction
const sortedUtxos = cardanoResources.utxos.sort((a, b) => {
const diff = b.amount.minus(a.amount);
return diff.eq(0) ? 0 : diff.lt(0) ? -1 : 1;
});
const transactionInputs: Array<TyphonTypes.Input> = [];
const usedUtxoAdaAmount = new BigNumber(0);
// Add 10 ADA as buffer for utxo selection to cover the transaction fees.
const requiredInputAmount = transaction.amount.plus(10e6);
for (let i = 0; i < sortedUtxos.length && usedUtxoAdaAmount.lte(requiredInputAmount); i++) {
const utxo = sortedUtxos[i];
const transactionInput = getTyphonInputFromUtxo(utxo);
transactionInputs.push(transactionInput);
usedUtxoAdaAmount.plus(transactionInput.amount);
}
const rewardsWithdrawalCertificate = getRewardWithdrawalCertificate(account);
if (rewardsWithdrawalCertificate) typhonTx.addWithdrawal(rewardsWithdrawalCertificate);
typhonTx.addOutput({
address: receiverAddress,
amount: transaction.amount,
tokens: [],
});
return typhonTx.prepareTransaction({
inputs: transactionInputs,
changeAddress,
});
};
const buildDelegateTransaction = async ({
account,
transaction,
typhonTx,
changeAddress,
}: {
account: CardanoAccount;
transaction: Transaction;
typhonTx: TyphonTransaction;
changeAddress: TyphonTypes.CardanoAddress;
}): Promise<TyphonTransaction> => {
const protocolParams = transaction.protocolParams;
if (!protocolParams) throw new Error("Missing protocol parameters"); // protocolParams will always be present
const cardanoResources = account.cardanoResources as CardanoResources;
const stakeCredential = getAccountStakeCredential(account.xpub as string, account.index);
const stakeKeyHashCredential: TyphonTypes.HashCredential = {
hash: Buffer.from(stakeCredential.key, "hex"),
type: TyphonTypes.HashType.ADDRESS,
bipPath: stakeCredential.path,
};
if (!cardanoResources.delegation || !cardanoResources.delegation.status) {
const stakeRegistrationCert: TyphonTypes.StakeKeyRegistrationCertificate = {
type: TyphonTypes.CertificateType.STAKE_KEY_REGISTRATION,
cert: {
stakeCredential: stakeKeyHashCredential,
deposit: new BigNumber(protocolParams.stakeKeyDeposit),
},
};
typhonTx.addCertificate(stakeRegistrationCert);
}
const delegationCert: TyphonTypes.StakeDelegationCertificate = {
type: TyphonTypes.CertificateType.STAKE_DELEGATION,
cert: {
stakeCredential: stakeKeyHashCredential,
poolHash: transaction.poolId as string,
},
};
typhonTx.addCertificate(delegationCert);
// sorting utxo from higher to lower ADA value
// to minimize the number of utxo use in transaction
const sortedUtxos = cardanoResources.utxos.sort((a, b) => {
const diff = b.amount.minus(a.amount);
return diff.eq(0) ? 0 : diff.lt(0) ? -1 : 1;
});
const transactionInputs: Array<TyphonTypes.Input> = [];
const usedUtxoAdaAmount = new BigNumber(0);
// Add 10 ADA as buffer for utxo selection to cover the transaction fees.
const requiredInputAmount = transaction.amount.plus(10e6);
for (let i = 0; i < sortedUtxos.length && usedUtxoAdaAmount.lte(requiredInputAmount); i++) {
const utxo = sortedUtxos[i];
const transactionInput = getTyphonInputFromUtxo(utxo);
transactionInputs.push(transactionInput);
usedUtxoAdaAmount.plus(transactionInput.amount);
}
const rewardsWithdrawalCertificate = getRewardWithdrawalCertificate(account);
if (rewardsWithdrawalCertificate) typhonTx.addWithdrawal(rewardsWithdrawalCertificate);
return typhonTx.prepareTransaction({
inputs: transactionInputs,
changeAddress,
});
};
const buildUndelegateTransaction = async ({
account,
transaction,
typhonTx,
changeAddress,
}: {
account: CardanoAccount;
transaction: Transaction;
typhonTx: TyphonTransaction;
changeAddress: TyphonTypes.CardanoAddress;
}): Promise<TyphonTransaction> => {
const cardanoResources = account.cardanoResources as CardanoResources;
const stakeCredential = getAccountStakeCredential(account.xpub as string, account.index);
const stakeKeyHashCredential: TyphonTypes.HashCredential = {
hash: Buffer.from(stakeCredential.key, "hex"),
type: TyphonTypes.HashType.ADDRESS,
bipPath: stakeCredential.path,
};
if (!cardanoResources.delegation || !cardanoResources.delegation.status) {
throw new Error("StakeKey is not registered");
}
const rewardsWithdrawalCertificate = getRewardWithdrawalCertificate(account);
if (rewardsWithdrawalCertificate) typhonTx.addWithdrawal(rewardsWithdrawalCertificate);
const stakeKeyDeRegistrationCertificate: TyphonTypes.StakeKeyDeRegistrationCertificate = {
type: TyphonTypes.CertificateType.STAKE_KEY_DE_REGISTRATION,
cert: {
stakeCredential: stakeKeyHashCredential,
deposit: new BigNumber(cardanoResources.delegation.deposit),
},
};
typhonTx.addCertificate(stakeKeyDeRegistrationCertificate);
// sorting utxo from higher to lower ADA value
// to minimize the number of utxo use in transaction
const sortedUtxos = cardanoResources.utxos.sort((a, b) => {
const diff = b.amount.minus(a.amount);
return diff.eq(0) ? 0 : diff.lt(0) ? -1 : 1;
});
const transactionInputs: Array<TyphonTypes.Input> = [];
const usedUtxoAdaAmount = new BigNumber(0);
// Add 10 ADA as buffer for utxo selection to cover the transaction fees.
const requiredInputAmount = transaction.amount.plus(10e6);
for (let i = 0; i < sortedUtxos.length && usedUtxoAdaAmount.lte(requiredInputAmount); i++) {
const utxo = sortedUtxos[i];
const transactionInput = getTyphonInputFromUtxo(utxo);
transactionInputs.push(transactionInput);
usedUtxoAdaAmount.plus(transactionInput.amount);
}
return typhonTx.prepareTransaction({
inputs: transactionInputs,
changeAddress,
});
};
/**
*
* @param {CardanoAccount} account
* @param {Transaction} transaction
*
* @returns {TyphonTransaction}
*/
export const buildTransaction = async (
account: CardanoAccount,
transaction: Transaction,
): Promise<TyphonTransaction> => {
const cardanoResources = account.cardanoResources as CardanoResources;
const { protocolParams } = transaction;
if (!protocolParams || !isProtocolParamsValid(protocolParams)) {
throw new CardanoInvalidProtoParams();
}
const typhonTx = new TyphonTransaction({
protocolParams: {
minFeeA: new BigNumber(protocolParams.minFeeA),
minFeeB: new BigNumber(protocolParams.minFeeB),
stakeKeyDeposit: new BigNumber(protocolParams.stakeKeyDeposit),
lovelacePerUtxoWord: new BigNumber(protocolParams.lovelacePerUtxoWord),
collateralPercent: new BigNumber(protocolParams.collateralPercent),
priceSteps: new BigNumber(protocolParams.priceSteps),
priceMem: new BigNumber(protocolParams.priceMem),
languageView: protocolParams.languageView,
maxTxSize: Number(protocolParams.maxTxSize),
maxValueSize: Number(protocolParams.maxValueSize),
utxoCostPerByte: new BigNumber(protocolParams.utxoCostPerByte),
minFeeRefScriptCostPerByte: new BigNumber(protocolParams.minFeeRefScriptCostPerByte),
},
});
const ttl = getTTL(account.currency.id);
typhonTx.setTTL(ttl);
// add ABSTAIN vote certificate when account has rewards but not the vote delegation
if (
account.cardanoResources.delegation &&
account.cardanoResources.delegation.rewards.gt(0) &&
!account.cardanoResources.delegation.dRepHex
) {
const stakeCred = getAccountStakeCredential(account.xpub as string, account.index);
const stakeCredential: TyphonTypes.HashCredential = {
hash: Buffer.from(stakeCred.key, "hex"),
type: TyphonTypes.HashType.ADDRESS,
bipPath: stakeCred.path,
};
const abstainDRep: TyphonTypes.DRep = {
type: TyphonTypes.DRepType.ABSTAIN,
key: undefined,
};
const voteCertificate: TyphonTypes.VoteDelegationCertificate = {
type: TyphonTypes.CertificateType.VOTE_DELEGATION,
cert: { stakeCredential, dRep: abstainDRep },
};
typhonTx.addCertificate(voteCertificate);
}
const metadata: Array<TyphonTypes.Metadata> = [];
if (transaction.memo) {
metadata.push({
label: 674,
data: new Map([["msg", [transaction.memo]]]),
});
}
if (metadata.length) {
typhonTx.setAuxiliaryData({ metadata });
}
const networkParams = getNetworkParameters(account.currency.id);
const unusedInternalCred = cardanoResources.internalCredentials.find(
cred => !cred.isUsed,
) as PaymentCredential;
const changeAddress = getBaseAddress({
networkId: networkParams.networkId,
paymentCred: unusedInternalCred,
stakeCred: getAccountStakeCredential(account.xpub as string, account.index),
});
if (transaction.mode === "send") {
const receiverAddress = TyphonUtils.getAddressFromString(transaction.recipient);
if (transaction.subAccountId) {
// Token Transaction
const tokenAccount = account.subAccounts
? account.subAccounts.find(a => {
return a.id === transaction.subAccountId;
})
: undefined;
if (!tokenAccount || tokenAccount.type !== "TokenAccount") {
throw new Error("TokenAccount not found");
}
return buildSendTokenTransaction({
account,
transaction,
tokenAccount,
typhonTx,
receiverAddress,
changeAddress,
});
}
// Normal ADA Transaction
return buildSendAdaTransaction({
account,
transaction,
typhonTx,
receiverAddress,
changeAddress,
});
} else if (transaction.mode === "delegate") {
return buildDelegateTransaction({ account, transaction, typhonTx, changeAddress });
} else if (transaction.mode === "undelegate") {
return buildUndelegateTransaction({ account, transaction, typhonTx, changeAddress });
} else {
throw new Error("Invalid transaction mode");
}
};