@ledgerhq/coin-canton
Version:
477 lines (425 loc) • 14.4 kB
text/typescript
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;