import { encodeAccountId, getSyncHash } from "@ledgerhq/ledger-wallet-framework/account/index"; import { GetAccountShape, mergeOps } from "@ledgerhq/ledger-wallet-framework/bridge/jsHelpers"; import { encodeOperationId } from "@ledgerhq/ledger-wallet-framework/operation"; import BigNumber from "bignumber.js"; import groupBy from "lodash/groupBy"; import { getAlpacaApi } from "./alpaca"; import { getBridgeApi } from "./bridge"; import { adaptCoreOperationToLiveOperation, cleanedOperation, extractBalance } from "./utils"; import { inferSubOperations } from "@ledgerhq/ledger-wallet-framework/serialization"; import { buildSubAccounts, mergeSubAccounts } from "./buildSubAccounts"; import type { Operation } from "@ledgerhq/coin-module-framework/api/types"; import type { OperationCommon } from "./types"; import type { Account, TokenAccount } from "@ledgerhq/types-live"; function isNftCoreOp(operation: Operation): boolean { return ( typeof operation.details?.ledgerOpType === "string" && ["NFT_IN", "NFT_OUT"].includes(operation.details?.ledgerOpType) ); } function isIncomingCoreOp(operation: Operation): boolean { const type = typeof operation.details?.ledgerOpType === "string" ? operation.details.ledgerOpType : operation.type; return type === "IN"; } function isInternalLiveOp(operation: OperationCommon): boolean { return !!operation.extra?.internal; } /** True when the op is a main-account (native) op, not a token/sub-account op */ function isNativeLiveOp(operation: OperationCommon): boolean { const assetReference = operation.extra?.assetReference; const assetOwner = operation.extra?.assetOwner; const hasAssetReference = typeof assetReference === "string" && assetReference.length > 0; const hasAssetOwner = typeof assetOwner === "string" && assetOwner.length > 0; // Native ops are those that do not have a non-empty asset reference/owner return !(hasAssetReference || hasAssetOwner); } /** * Parent recipients for token-only ops: use the token contract (assetReference), not the token transfer recipient. */ function getTokenContract(op: OperationCommon): string | undefined { const ref = op.extra?.assetReference; return typeof ref === "string" && ref.length > 0 ? ref : undefined; } /** Get the fee payer for this tx from the op (from API/extra). */ function getFeePayer(op: OperationCommon): string | undefined { const fp = op.extra?.feePayer; return typeof fp === "string" && fp.length > 0 ? fp : undefined; } /** Compare two addresses for equality, ignoring case. */ function isSameAddress(a: string, b: string): boolean { return a.toLowerCase() === b.toLowerCase(); } /** True when the native op is outbound with value equal to fee (fees-only). */ function isFeesOnlyNativeOp(op: OperationCommon): boolean { return op.type === "OUT" && op.value !== null && op.fee != null && op.value.eq(op.fee); } /** Emit one parent op per native op: FEES when fees-only, otherwise passthrough. */ function parentOpsFromNativeOps( nativeOps: OperationCommon[], accountId: string, subOperations: OperationCommon[], internalOperations: OperationCommon[], ): OperationCommon[] { const out: OperationCommon[] = []; for (const nativeOp of nativeOps) { // Native outgoing operation with value 0 (only fees) => output as single FEES op if (isFeesOnlyNativeOp(nativeOp)) { out.push( cleanedOperation({ id: encodeOperationId(accountId, nativeOp.hash, "FEES"), hash: nativeOp.hash, accountId, type: "FEES", value: nativeOp.fee, fee: nativeOp.fee, blockHash: nativeOp.blockHash, blockHeight: nativeOp.blockHeight, senders: nativeOp.senders, recipients: nativeOp.recipients, date: nativeOp.date, transactionSequenceNumber: nativeOp.transactionSequenceNumber, hasFailed: nativeOp.hasFailed, extra: nativeOp.extra, subOperations, internalOperations, }), ); } // Otherwise, don't transform the operation else { out.push( cleanedOperation({ ...nativeOp, subOperations, internalOperations, }), ); } } return out; } /** One synthetic FEES or NONE parent when the tx has no native ops (e.g. token-only). */ function syntheticParentForTokenOnlyTx( referenceOp: OperationCommon, accountId: string, address: string, subOperations: OperationCommon[], internalOperations: OperationCommon[], ): OperationCommon { // Parent operation is of type FEES if account has paid fees for the transaction, NONE otherwise. const feePayer = getFeePayer(referenceOp); const isFeePayer = feePayer !== undefined && isSameAddress(address, feePayer); const parentType = isFeePayer ? "FEES" : "NONE"; const parentValue = isFeePayer ? referenceOp.fee : new BigNumber(0); // In the case of smart contract interaction, the contract must be the recipient of the parent operation => this // is why we need to extract this information from the operation details. const contract = getTokenContract(referenceOp); const parentRecipients = contract !== undefined ? [contract] : referenceOp.recipients ?? []; const parentSenders = referenceOp.senders ?? []; return cleanedOperation({ id: encodeOperationId(accountId, referenceOp.hash, parentType), hash: referenceOp.hash, accountId, type: parentType, value: parentValue, fee: referenceOp.fee, blockHash: referenceOp.blockHash, blockHeight: referenceOp.blockHeight, senders: parentSenders, recipients: parentRecipients, date: referenceOp.date, transactionSequenceNumber: referenceOp.transactionSequenceNumber, hasFailed: referenceOp.hasFailed, extra: referenceOp.extra, subOperations, internalOperations, }); } /** Parent op(s) for a tx that has non-internal ops (native and/or token). */ function parentOpsForTxWithNonInternalOperations( hash: string, transactionOps: OperationCommon[], internalOperations: OperationCommon[], newSubAccounts: TokenAccount[], accountId: string, address: string, ): OperationCommon[] { const nativeOps = transactionOps.filter(isNativeLiveOp); // inferSubOperations returns types-live Operation[]; we use OperationCommon in this bridge // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- framework type vs bridge type const subOperations = inferSubOperations(hash, newSubAccounts) as OperationCommon[]; // If transaction has native ops, use them as parents if (nativeOps.length > 0) return parentOpsFromNativeOps(nativeOps, accountId, subOperations, internalOperations); // If transaction has no native ops, create a synthetic parent const firstOp = transactionOps[0]; return [ syntheticParentForTokenOnlyTx(firstOp, accountId, address, subOperations, internalOperations), ]; } /** * Parent + internal ops for a tx that has only internal ops (e.g. contract transfer from B to C). * This case happens when an address A calls a smart contract, that performs a transfer from B to C, seen from B or * C's perspective. In this case, the parent operation must be of type NONE, with A as the sender and the contract * as the recipient => the sender of the internal operation is used as the recipient of the synthetic parent operation. */ function parentOpsForTxWithOnlyInternalOperations( hash: string, internalOperations: OperationCommon[], newSubAccounts: TokenAccount[], accountId: string, ): OperationCommon[] { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- framework type vs bridge type const subOperations = inferSubOperations(hash, newSubAccounts) as OperationCommon[]; const firstInternal = internalOperations[0]; if (!firstInternal) return []; const out: OperationCommon[] = []; const feePayer = getFeePayer(firstInternal); if (feePayer != null) { out.push( cleanedOperation({ id: encodeOperationId(accountId, hash, "NONE"), hash, accountId, type: "NONE", value: new BigNumber(0), fee: firstInternal.fee, blockHash: firstInternal.blockHash, blockHeight: firstInternal.blockHeight, senders: [feePayer], recipients: firstInternal.senders, date: firstInternal.date, transactionSequenceNumber: firstInternal.transactionSequenceNumber, hasFailed: firstInternal.hasFailed, extra: firstInternal.extra, subOperations, internalOperations, }), ); } for (const internalOp of internalOperations) { out.push( cleanedOperation({ ...internalOp, subOperations, internalOperations, }), ); } return out; } /** * Emit parent operations per tx hash so the account has one top-level operation per transaction for normal transactions, * and two for self-sends (IN + OUT) or internal-only (NONE + IN). */ function buildParentOperations( newSubAccounts: TokenAccount[], newNonInternalOperations: OperationCommon[], newInternalOperations: OperationCommon[], accountId: string, address: string, ): OperationCommon[] { const nonInternalByHash = groupBy(newNonInternalOperations, "hash"); const internalByHash = groupBy(newInternalOperations, "hash"); const result: OperationCommon[] = []; // Inspect non-internal ops first to create parent ops for (const [hash, transactionOps] of Object.entries(nonInternalByHash)) { const internalOperations = internalByHash[hash] ?? []; result.push( ...parentOpsForTxWithNonInternalOperations( hash, transactionOps, internalOperations, newSubAccounts, accountId, address, ), ); } // If transaction only has internal ops, we must create a synthetic parent op as well for (const [hash, internalOperations] of Object.entries(internalByHash)) { if (hash in nonInternalByHash) continue; result.push( ...parentOpsForTxWithOnlyInternalOperations( hash, internalOperations, newSubAccounts, accountId, ), ); } return result; } export function genericGetAccountShape(network: string, kind: string): GetAccountShape { return async (info, syncConfig) => { const { address, initialAccount, currency, derivationMode } = info; const alpacaApi = getAlpacaApi(currency.id, kind); const bridgeApi = getBridgeApi(currency, network); const chainSpecificValidation = bridgeApi.getChainSpecificRules?.(); if (chainSpecificValidation) { chainSpecificValidation.getAccountShape(address); } const accountId = encodeAccountId({ type: "js", version: "2", currencyId: currency.id, xpubOrAddress: address, derivationMode, }); const blockInfo = await alpacaApi.lastBlock(); const balanceRes = await alpacaApi.getBalance(address); const nativeAsset = extractBalance(balanceRes, "native"); const allTokenAssetsBalances = balanceRes.filter(b => b.asset.type !== "native"); const nativeBalance = BigInt(nativeAsset?.value ?? "0"); const spendableBalance = BigInt(nativeBalance - BigInt(nativeAsset?.locked ?? "0")); // Normalize pre-alpaca operations to the new accountId to keep UI rendering consistent const oldOps = ((initialAccount?.operations || []) as OperationCommon[]).map(op => op.accountId === accountId ? op : { ...op, accountId, id: encodeOperationId(accountId, op.hash, op.type) }, ); const cursor = oldOps[0]?.extra?.pagingToken || ""; const syncHash = await getSyncHash(currency.id, syncConfig.blacklistedTokenIds); const syncFromScratch = !initialAccount?.blockHeight || initialAccount?.syncHash !== syncHash; // Calculate minHeight for pagination const minHeight = syncFromScratch ? 0 : (oldOps[0]?.blockHeight ?? 0) + 1; const paginationCursor = cursor && !syncFromScratch ? cursor : undefined; const { items: newCoreOps } = await alpacaApi.listOperations(address, { minHeight, cursor: paginationCursor, order: "desc", }); const newOps = newCoreOps .filter(op => !isNftCoreOp(op) && (!isIncomingCoreOp(op) || !op.tx.failed)) .map(op => adaptCoreOperationToLiveOperation(accountId, op)) as OperationCommon[]; const newAssetOperations = newOps.filter( operation => operation?.extra?.assetReference && operation?.extra?.assetOwner && !["OPT_IN", "OPT_OUT"].includes(operation.type), ); const newInternalOperations: OperationCommon[] = []; const newNonInternalOperations: OperationCommon[] = []; for (const op of newOps) { if (isInternalLiveOp(op)) newInternalOperations.push(op); else newNonInternalOperations.push(op); } const newSubAccounts = await buildSubAccounts({ accountId, allTokenAssetsBalances, syncConfig, operations: newAssetOperations, getTokenFromAsset: bridgeApi.getTokenFromAsset, }); const subAccounts = syncFromScratch ? newSubAccounts : mergeSubAccounts(initialAccount?.subAccounts ?? [], newSubAccounts); const newOpsWithSubs = buildParentOperations( newSubAccounts, newNonInternalOperations, newInternalOperations, accountId, address, ); // Try to refresh known pending and broadcasted operations (if not already updated) // Useful for integrations without explorers const operationsToRefresh = initialAccount?.pendingOperations.filter( pendingOp => pendingOp.hash && // operation has been broadcasted !newOpsWithSubs.some(newOp => pendingOp.hash === newOp.hash), // operation is not confirmed yet ); const confirmedOperations = alpacaApi.refreshOperations && operationsToRefresh?.length ? await alpacaApi.refreshOperations(operationsToRefresh) : []; const newOperations = [...confirmedOperations, ...newOpsWithSubs]; const operations = mergeOps(syncFromScratch ? [] : oldOps, newOperations) as OperationCommon[]; const res: Partial = { id: accountId, xpub: address, blockHeight: operations.length === 0 ? 0 : blockInfo.height || initialAccount?.blockHeight, balance: new BigNumber(nativeBalance.toString()), spendableBalance: new BigNumber(spendableBalance.toString()), operations, subAccounts, operationsCount: operations.length, syncHash, }; return res; }; }