UNPKG

@ledgerhq/coin-canton

Version:
477 lines (425 loc) 14.4 kB
import { getEnv } from "@ledgerhq/live-env"; import network from "@ledgerhq/live-network"; import { makeLRUCache, minutes } from "@ledgerhq/live-network/cache"; import type { LiveNetworkRequest, LiveNetworkResponse } from "@ledgerhq/live-network/network"; import type { CryptoCurrency } from "@ledgerhq/ledger-wallet-framework/types"; import coinConfig from "../config"; import { TopologyChangeError } from "../types/errors"; import type { GetBalanceResponse, GetTransferPreApprovalResponse, OnboardingPrepareRequest, OnboardingPrepareResponse, OnboardingSubmitRequest, OnboardingSubmitResponse, OperationView, Party, PrepareTapResponse, PrepareTransferInstructionRequest, PrepareTransferRequest, PrepareTransferResponse, SubmitTapRequestRequest, SubmitTapRequestResponse, SubmitTransactionRequest, SubmitTransactionResponse, TapRequest, TransferProposal, } from "../types/gateway"; import { TransactionType } from "../types/gateway"; import { PreApprovalResult, PrepareTransactionRequest, PrepareTransactionResponse, } from "../types/onboard"; import type { CantonSignature } from "../types/signer"; export const SEPARATOR = "____"; export const DEFAULT_TAP_REQUEST_AMOUNT = "100000000000000000000000000000000000000"; export const getKey = (id: string, adminId: string) => `${id}${SEPARATOR}${adminId}`; const getGatewayUrl = (currency: CryptoCurrency) => coinConfig.getCoinConfig(currency.id).gatewayUrl; const getNodeId = (currency: CryptoCurrency) => { const overrideNodeId = getEnv("CANTON_NODE_ID_OVERRIDE"); if (overrideNodeId) { return overrideNodeId; } return coinConfig.getCoinConfig(currency.id).nodeId || "ledger-live-devnet"; }; export const getNetworkType = (currency: CryptoCurrency) => coinConfig.getCoinConfig(currency.id).networkType; export function isGatewayEnabled(currency: CryptoCurrency): boolean { return coinConfig.getCoinConfig(currency.id).useGateway === true; } export const isPartyNotFound = (error: unknown): boolean => { if (error instanceof Error) { const errorMessage = error.message.toLowerCase().replace(/_/g, " "); return errorMessage.includes("party") && errorMessage.includes("not found"); } return false; }; export const isPartyAlreadyExists = (error: unknown): boolean => { if (error instanceof Error) { const errorMessage = error.message.toLowerCase().replace(/_/g, " "); return errorMessage.includes("party") && errorMessage.includes("already exists"); } return false; }; const gatewayNetwork = async <T, U = unknown>( req: LiveNetworkRequest<U>, ): Promise<LiveNetworkResponse<T>> => { const API_KEY = getEnv("CANTON_API_KEY"); try { return await network<T, U>({ ...req, headers: { ...(req.headers || {}), ...(API_KEY ? { "X-Ledger-Canton-Api-Key": API_KEY } : {}), }, }); } catch (error) { if (isPartyNotFound(error)) { throw new TopologyChangeError("Topology change detected. Re-onboarding required."); } throw error; } }; /* * Parties * @see https://canton-gateway.api.live.ledger.com/docs/openapi/redoc/index.html#tag/Parties */ export async function getPartyById(currency: CryptoCurrency, partyId: string): Promise<Party> { return await getParty(currency, partyId, "party-id"); } export async function getPartyByPubKey(currency: CryptoCurrency, pubKey: string): Promise<Party> { return await getParty(currency, pubKey, "public-key"); } async function getParty( currency: CryptoCurrency, identifier: string, by: "party-id" | "public-key", ): Promise<Party> { const { data } = await gatewayNetwork<Party>({ method: "GET", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/party/${identifier}?by=${by}`, }); return data; } export async function prepareOnboarding( currency: CryptoCurrency, pubKey: string, ): Promise<OnboardingPrepareResponse> { const gatewayUrl = getGatewayUrl(currency); const nodeId = getNodeId(currency); const fullUrl = `${gatewayUrl}/v1/node/${nodeId}/onboarding/prepare`; const { data } = await gatewayNetwork<OnboardingPrepareResponse, OnboardingPrepareRequest>({ method: "POST", url: fullUrl, data: { public_key: pubKey, public_key_type: "ed25519", }, }); return data; } export async function isTopologyChangeRequired(currency: CryptoCurrency, pubKey: string) { try { const response = await prepareOnboarding(currency, pubKey); // if response is not undefined (we have a transaction to sign) topology change is required if (response) { return true; } return false; } catch (error) { if (isPartyAlreadyExists(error)) { return false; } throw error; } } const getIsTopologyChangeRequiredCacheKey = (currency: CryptoCurrency, pubKey: string): string => { const nodeId = getNodeId(currency); return `${pubKey}_${nodeId}`; }; export const isTopologyChangeRequiredCached = makeLRUCache( isTopologyChangeRequired, getIsTopologyChangeRequiredCacheKey, minutes(10), ); export function clearIsTopologyChangeRequiredCache(currency: CryptoCurrency, pubKey: string): void { const cacheKey = getIsTopologyChangeRequiredCacheKey(currency, pubKey); isTopologyChangeRequiredCached.clear(cacheKey); } export type InstrumentInfo = { id: string; admin: string; }; export type InstrumentsResponse = InstrumentInfo[]; export async function getEnabledInstruments(currency: CryptoCurrency): Promise<Set<string>> { try { const { data } = await gatewayNetwork<InstrumentsResponse>({ method: "GET", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/instruments`, }); return new Set(data.map(({ id, admin }) => getKey(id, admin))); } catch (error) { // If API fails, return empty array (fail-safe: only native instrument will work) console.error("Failed to fetch enabled instruments:", error); return new Set(); } } const getEnabledInstrumentsCacheKey = (currency: CryptoCurrency): string => { const nodeId = getNodeId(currency); return `instruments_${nodeId}`; }; export const getEnabledInstrumentsCached = makeLRUCache( getEnabledInstruments, getEnabledInstrumentsCacheKey, minutes(15), ); export function clearEnabledInstrumentsCache(currency: CryptoCurrency): void { const cacheKey = getEnabledInstrumentsCacheKey(currency); getEnabledInstrumentsCached.clear(cacheKey); } export async function submitOnboarding( currency: CryptoCurrency, publicKey: string, prepareResponse: OnboardingPrepareResponse, { signature, applicationSignature }: CantonSignature, ) { try { const { data } = await gatewayNetwork<OnboardingSubmitResponse, OnboardingSubmitRequest>({ method: "POST", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/onboarding/submit`, data: { prepare_request: { public_key: publicKey, public_key_type: "ed25519", }, prepare_response: prepareResponse, signature, ...(applicationSignature ? { application_signature: applicationSignature } : {}), }, }); return data; } catch (error) { if (isPartyAlreadyExists(error)) { // If party already exists, use party_id from prepare response // The network layer strips custom properties from errors, so we can't extract partyId from error return { party: { party_id: prepareResponse.party_id, public_key: publicKey, }, }; } throw error; } } /* * State * @see https://canton-gateway.api.live.ledger.com/docs/openapi/redoc/index.html#tag/State */ export async function getBalance(currency: CryptoCurrency, partyId: string) { const { data } = await gatewayNetwork<GetBalanceResponse>({ method: "GET", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/party/${partyId}/balance`, }); return Array.isArray(data) ? data : (data.balances ?? []); } export async function getOperations( currency: CryptoCurrency, partyId: string, options?: { cursor?: number | undefined; minOffset?: number | undefined; maxOffset?: number | undefined; limit?: number | undefined; }, ): Promise<{ next: number; operations: OperationView[]; }> { const { data } = await gatewayNetwork<{ next: number; operations: OperationView[]; }>({ method: "GET", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/party/${partyId}/operations`, params: options, }); return data; } export async function getPendingTransferProposals(currency: CryptoCurrency, partyId: string) { const { data } = await gatewayNetwork<TransferProposal[]>({ method: "GET", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/party/${partyId}/transfer-proposals?timestamp=${Date.now()}`, }); return data; } export async function getTransferPreApproval(currency: CryptoCurrency, partyId: string) { const { data } = await gatewayNetwork<GetTransferPreApprovalResponse>({ method: "GET", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/party/${partyId}/transfer-preapproval`, }); return data; } export async function getLedgerEnd(currency: CryptoCurrency): Promise<number> { const { data } = await gatewayNetwork<number>({ method: "GET", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/ledger-end`, }); return data; } /* * Transaction * @see https://canton-gateway.api.live.ledger.com/docs/openapi/redoc/index.html#tag/Transaction */ export async function prepare( currency: CryptoCurrency, partyId: string, params: PrepareTransferRequest | PrepareTransferInstructionRequest, ) { const { data } = await gatewayNetwork< PrepareTransferResponse, PrepareTransferRequest | PrepareTransferInstructionRequest >({ method: "POST", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/party/${partyId}/transaction/prepare`, data: params, }); return data; } export async function prepareTransferRequest( currency: CryptoCurrency, partyId: string, params: PrepareTransferRequest, ) { return prepare(currency, partyId, params); } export async function prepareTransferInstruction( currency: CryptoCurrency, partyId: string, params: PrepareTransferInstructionRequest, ) { return prepare(currency, partyId, params); } export async function preparePreApprovalTransaction(currency: CryptoCurrency, partyId: string) { const { data } = await gatewayNetwork<PrepareTransactionResponse, PrepareTransactionRequest>({ method: "POST", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/party/${partyId}/transaction/prepare`, data: { type: TransactionType.TRANSFER_PRE_APPROVAL_PROPOSAL, receiver: partyId, }, }); return data; } export async function prepareTapRequest(currency: CryptoCurrency, { partyId, amount }: TapRequest) { // Default to 1.0 in fixed-point representation (1 * 10^38) const fixedPointAmount = amount ?? DEFAULT_TAP_REQUEST_AMOUNT; const { data } = await gatewayNetwork<PrepareTapResponse, { amount: string; type: string }>({ method: "POST", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/party/${partyId}/transaction/prepare`, data: { amount: fixedPointAmount.toString(), type: TransactionType.TAP_REQUEST, }, }); return data; } export async function submit( currency: CryptoCurrency, partyId: string, serialized: string, signature: string, ) { const { data } = await gatewayNetwork<SubmitTransactionResponse, SubmitTransactionRequest>({ method: "POST", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/party/${partyId}/transaction/submit`, data: { serialized, signature, }, }); return data; } export async function submitTransferInstruction( currency: CryptoCurrency, partyId: string, serialized: string, signature: string, ) { return submit(currency, partyId, serialized, signature); } export async function submitPreApprovalTransaction( currency: CryptoCurrency, partyId: string, { serialized }: PrepareTransactionResponse, signature: string, ) { const { data } = await gatewayNetwork<SubmitTransactionResponse, SubmitTransactionRequest>({ method: "POST", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/party/${partyId}/transaction/submit`, data: { serialized, signature, }, }); return { isApproved: true, submissionId: data.submission_id, updateId: data.update_id, } satisfies PreApprovalResult; } export async function submitTapRequest( currency: CryptoCurrency, { partyId, serialized, signature }: SubmitTapRequestRequest, ) { const { data } = await gatewayNetwork< SubmitTapRequestResponse, Omit<SubmitTapRequestRequest, "partyId"> >({ method: "POST", url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/party/${partyId}/transaction/submit`, data: { serialized, signature, }, }); return data; } // CAL API types export type CalToken = { id: string; name: string; ticker: string; network: string; contract_address: string; token_identifier: string; }; /** * Fetch Canton tokens from CAL service and create a map of id -> token_identifier */ async function getCalTokens(currency: CryptoCurrency): Promise<Map<string, string>> { const calUrl = getEnv("CAL_SERVICE_URL"); const { data: calTokens } = await gatewayNetwork<CalToken[]>({ method: "GET", url: `${calUrl}/v1/tokens?network=${currency.id}&output=id,name,ticker,network,contract_address,token_identifier,units,standard`, }); // Map id -> token_identifier const tokenIdentifierMap = new Map<string, string>(); for (const token of calTokens) { tokenIdentifierMap.set(token.id, token.token_identifier); } return tokenIdentifierMap; } const getCalTokensCacheKey = (currency: CryptoCurrency): string => currency.id; export const getCalTokensCached = makeLRUCache(getCalTokens, getCalTokensCacheKey, minutes(30)); // Re-export types that are needed by other modules export type { TransferProposal, OnboardingPrepareResponse, PrepareTransferResponse, OperationView, } from "../types/gateway"; // Type alias for backward compatibility export type OperationInfo = OperationView;