@ledgerhq/coin-algorand
Version:
Ledger Algorand Coin integration
163 lines (147 loc) • 5.3 kB
text/typescript
import {
Balance,
FeeEstimation,
TransactionIntent,
TransactionValidation,
} from "@ledgerhq/coin-module-framework/api/types";
import {
InvalidAddress,
RecipientRequired,
InvalidAddressBecauseDestinationIsAlsoSource,
AmountRequired,
NotEnoughBalance,
NotEnoughBalanceInParentAccount,
NotEnoughBalanceBecauseDestinationNotCreated,
} from "@ledgerhq/errors";
import { isValidAddress } from "algosdk";
import { AlgorandASANotOptInInRecipient, AlgorandMemoExceededSizeError } from "../errors";
import { getAccount } from "../network";
import type { AlgorandMemo } from "../types";
import { ALGORAND_MIN_ACCOUNT_BALANCE } from "./common";
import { validateMemo } from "./validateMemo";
/**
* Validate a transaction intent for Algorand
* @param intent - The transaction intent
* @param balances - Current account balances
* @param customFees - Optional custom fees
* @returns Validation result with errors, warnings, and amounts
*/
export async function validateIntent(
intent: TransactionIntent<AlgorandMemo>,
balances: Balance[],
customFees?: FeeEstimation,
): Promise<TransactionValidation> {
const errors: Record<string, Error> = {};
const warnings: Record<string, Error> = {};
const fees = customFees?.value ?? 0n;
let amount = intent.amount;
// Validate recipient
if (!intent.recipient) {
errors.recipient = new RecipientRequired();
} else if (!isValidAddress(intent.recipient)) {
errors.recipient = new InvalidAddress();
} else if (intent.sender === intent.recipient) {
errors.recipient = new InvalidAddressBecauseDestinationIsAlsoSource();
}
// Get native balance
const nativeBalance = balances.find(b => b.asset.type === "native");
const balance = nativeBalance?.value ?? 0n;
const locked = nativeBalance?.locked ?? 0n;
// Check for token transfer
const isTokenTransfer = intent.asset.type !== "native";
let tokenBalance: Balance | undefined;
if (isTokenTransfer) {
const intentAssetRef = (intent.asset as { assetReference?: string }).assetReference;
tokenBalance = balances.find(b => {
if (b.asset.type !== "asa") return false;
const balanceAssetRef = (b.asset as { assetReference?: string }).assetReference;
return balanceAssetRef === intentAssetRef;
});
if (!tokenBalance) {
errors.amount = new NotEnoughBalance();
}
}
// Validate amount
if (amount <= 0n && !intent.useAllAmount) {
errors.amount = new AmountRequired();
}
// Handle useAllAmount
if (intent.useAllAmount) {
if (isTokenTransfer && tokenBalance) {
amount = tokenBalance.value;
} else {
const spendable = balance - locked - fees;
amount = spendable > 0n ? spendable : 0n;
}
}
// Calculate total spent
const totalSpent = isTokenTransfer ? amount : amount + fees;
// Check balance
if (!errors.amount) {
if (isTokenTransfer) {
// Check token balance
if (tokenBalance && amount > tokenBalance.value) {
errors.amount = new NotEnoughBalance();
}
// Check native balance for fees
if (fees > balance - locked) {
errors.amount = new NotEnoughBalanceInParentAccount();
}
} else {
// Check native balance
const spendable = balance - locked;
if (totalSpent > spendable) {
errors.amount = new NotEnoughBalance();
}
}
}
// Validate recipient account (fetch once for both ASA opt-in and native minimum balance checks)
if (!errors.recipient && intent.recipient) {
try {
const recipientAccount = await getAccount(intent.recipient);
if (isTokenTransfer) {
// Check if recipient has opted in to the ASA token
const intentAssetRef = (intent.asset as { assetReference?: string }).assetReference;
if (intentAssetRef) {
const hasOptedIn = recipientAccount.assets.map(a => a.assetId).includes(intentAssetRef);
if (!hasOptedIn) {
errors.recipient = new AlgorandASANotOptInInRecipient();
}
}
} else if (amount > 0n) {
// Check minimum balance requirement for native transfers
const recipientBalance = BigInt(recipientAccount.balance.toString());
if (recipientBalance === 0n && amount < ALGORAND_MIN_ACCOUNT_BALANCE) {
errors.amount = new NotEnoughBalanceBecauseDestinationNotCreated("", {
minimalAmount: "0.1 ALGO",
});
}
}
} catch {
// Handle account fetch error
if (isTokenTransfer) {
// If we can't fetch the account, assume it doesn't exist and hasn't opted in
errors.recipient = new AlgorandASANotOptInInRecipient();
} else if (amount > 0n) {
// Account doesn't exist yet, need minimum balance for native transfer
if (amount < ALGORAND_MIN_ACCOUNT_BALANCE) {
errors.amount = new NotEnoughBalanceBecauseDestinationNotCreated("", {
minimalAmount: "0.1 ALGO",
});
}
}
}
}
// Validate memo
const memoValue = intent.memo?.type === "string" ? intent.memo.value : undefined;
if (memoValue && !validateMemo(memoValue)) {
errors.transaction = new AlgorandMemoExceededSizeError();
}
return {
errors,
warnings,
estimatedFees: fees,
amount,
totalSpent,
};
}