UNPKG

@ledgerhq/coin-canton

Version:
390 lines (347 loc) 13 kB
import type { CryptoCurrency } from "@ledgerhq/ledger-wallet-framework/types"; import { generateMockKeyPair, verifySignature } from "../test/cantonTestUtils"; import { createMockCantonCurrency, setupMockCoinConfig } from "../test/fixtures"; import type { OnboardingPrepareResponse, Party } from "../types/gateway"; import { getBalance, getLedgerEnd, getOperations, getPartyById, getPartyByPubKey, prepareOnboarding, preparePreApprovalTransaction, prepareTapRequest, submit, submitOnboarding, submitPreApprovalTransaction, submitTapRequest, } from "./gateway"; const mockCurrency = createMockCantonCurrency(); /** * Party lookup can lag behind a successful onboarding submit on the gateway (CI / replication). * Duck-type 4xx errors: do not use `instanceof LedgerAPI4xx` — Jest can load a duplicate * `@ledgerhq/live-network/errors` module, which breaks instanceof and disables retries. */ function readErr(error: object): { name?: string; status?: number; message: string } { const rec = error as Record<string, unknown>; const out: { name?: string; status?: number; message: string } = { message: typeof rec.message === "string" ? rec.message : "", }; if (typeof rec.name === "string") out.name = rec.name; if (typeof rec.status === "number") out.status = rec.status; return out; } function isPartyNotYetVisibleError(error: unknown): boolean { if (!error || typeof error !== "object") return false; const e = readErr(error); const msg = e.message.toLowerCase(); const partyRelated = msg.includes("party") && (msg.includes("does not exist") || msg.includes("not found") || msg.includes("unknown") || msg.includes("no such")); if (partyRelated) { return true; } if (msg.includes("does not exist") && (msg.includes("party") || msg.includes("ldg::"))) { return true; } if ( e.name === "LedgerAPI4xx" && e.status !== undefined && e.status >= 400 && e.status < 500 && (e.status === 404 || msg.includes("party") || msg.includes("ldg::")) ) { return true; } return false; } async function getPartyByIdWhenVisible( currency: CryptoCurrency, partyId: string, opts?: { timeoutMs?: number; pollMs?: number }, ): Promise<Party> { const timeoutMs = opts?.timeoutMs ?? 100_000; const pollMs = opts?.pollMs ?? 2_500; const deadline = Date.now() + timeoutMs; let lastError: unknown; while (Date.now() < deadline) { try { return await getPartyById(currency, partyId); } catch (e) { lastError = e; if (!isPartyNotYetVisibleError(e)) throw e; await new Promise<void>(resolve => { setTimeout(resolve, pollMs); }); } } throw lastError ?? new Error("getPartyById timed out waiting for party to be visible"); } async function getPartyByPubKeyWhenVisible( currency: CryptoCurrency, publicKeyHex: string, opts?: { timeoutMs?: number; pollMs?: number }, ): Promise<Party> { const timeoutMs = opts?.timeoutMs ?? 100_000; const pollMs = opts?.pollMs ?? 2_500; const deadline = Date.now() + timeoutMs; let lastError: unknown; while (Date.now() < deadline) { try { return await getPartyByPubKey(currency, publicKeyHex); } catch (e) { lastError = e; if (!isPartyNotYetVisibleError(e)) throw e; await new Promise<void>(resolve => { setTimeout(resolve, pollMs); }); } } throw lastError ?? new Error("getPartyByPubKey timed out waiting for party to be visible"); } describe("gateway (devnet)", () => { let onboardedAccount: { keyPair: ReturnType<typeof generateMockKeyPair>; partyId: string; } | null = null; let prepareResponse: OnboardingPrepareResponse | null = null; beforeAll(async () => { setupMockCoinConfig(); const keyPair = generateMockKeyPair(); const pr = await prepareOnboarding(mockCurrency, keyPair.publicKeyHex); const signature = keyPair.sign(pr.transactions.combined_hash); const verification = verifySignature( keyPair.publicKeyHex, signature, pr.transactions.combined_hash, ); if (!verification.isValid) { throw new Error(verification.error ?? "invalid signature in onboarding beforeAll"); } const response = await submitOnboarding(mockCurrency, keyPair.publicKeyHex, pr, { signature, }); prepareResponse = pr; onboardedAccount = { keyPair, partyId: response.party.party_id }; }, 120_000); const getOnboardedAccount = () => { if (!onboardedAccount) { throw new Error("Shared onboarded account not available. Check beforeAll setup."); } return onboardedAccount; }; describe("prepareOnboarding", () => { it("should prepare onboarding", async () => { // GIVEN — fresh key; shared onboarded account comes from root beforeAll const keyPair = generateMockKeyPair(); // WHEN const response = await prepareOnboarding(mockCurrency, keyPair.publicKeyHex); // THEN expect(response).toHaveProperty("party_id"); expect(response).toHaveProperty("party_name"); expect(response).toHaveProperty("public_key_fingerprint"); expect(response).toHaveProperty("transactions"); expect(response.transactions).toHaveProperty("combined_hash"); expect(response.party_name).not.toBeUndefined(); expect(typeof response.party_name).toBe("string"); expect(response.public_key_fingerprint).toBe(keyPair.fingerprint); }); }); describe("submitOnboarding", () => { it("should have onboarded via shared beforeAll setup", () => { const { keyPair, partyId } = getOnboardedAccount(); expect(partyId.length).toBeGreaterThan(0); expect(prepareResponse).not.toBeNull(); expect(prepareResponse!.public_key_fingerprint).toBe(keyPair.fingerprint); }); const testIfPrepared = prepareResponse ? it.skip : it; testIfPrepared( "should not throw when already onboarded", async () => { // GIVEN const { keyPair } = getOnboardedAccount(); const signature = keyPair.sign(prepareResponse!.transactions.combined_hash); const verification = verifySignature( keyPair.publicKeyHex, signature, prepareResponse!.transactions.combined_hash, ); expect(verification.isValid).toBe(true); // WHEN const response = await submitOnboarding( mockCurrency, keyPair.publicKeyHex, prepareResponse!, { signature }, ); // THEN expect(response).toHaveProperty("party"); expect(response.party).toHaveProperty("party_id"); expect(response.party).toHaveProperty("public_key"); expect(response.party.public_key).toBe(keyPair.publicKeyHex); }, 30000, ); it("should handle PARTY_ALREADY_EXISTS error and return party_id and public_key", async () => { // GIVEN const { keyPair, partyId } = getOnboardedAccount(); const hashToSign = prepareResponse?.transactions?.combined_hash || ""; const signature = keyPair.sign(hashToSign); const verification = verifySignature(keyPair.publicKeyHex, signature, hashToSign); expect(verification.isValid).toBe(true); // WHEN const response = await submitOnboarding( mockCurrency, keyPair.publicKeyHex, prepareResponse!, { signature, }, ); // THEN expect(response).toHaveProperty("party"); expect(response.party).toHaveProperty("party_id"); expect(response.party).toHaveProperty("public_key"); expect(response.party.party_id).toBe(partyId); expect(response.party.public_key).toBe(keyPair.publicKeyHex); }, 30000); }); describe("getLedgerEnd", () => { it("should return ledger end", async () => { const end = await getLedgerEnd(mockCurrency); expect(end).toBeGreaterThanOrEqual(0); }); }); describe("getBalance", () => { it("should return user balance", async () => { const { partyId } = getOnboardedAccount(); const balance = await getBalance(mockCurrency, partyId); expect(balance.length).toBeGreaterThanOrEqual(0); if (balance.length > 0) { expect(balance[0].amount).toBeGreaterThanOrEqual(0); expect(balance[0]).toHaveProperty("instrument_id"); } }, 30000); }); describe("getPartyById", () => { it("should return party info", async () => { const { partyId, keyPair } = getOnboardedAccount(); let party: Party; try { party = await getPartyByIdWhenVisible(mockCurrency, partyId); } catch (e) { const exhaustedIdLookup = isPartyNotYetVisibleError(e) || (e instanceof Error && /timed out waiting for party/i.test(e.message)); if (!exhaustedIdLookup) throw e; party = await getPartyByPubKeyWhenVisible(mockCurrency, keyPair.publicKeyHex); } expect(party).not.toBeUndefined(); expect(party.party_id).toBe(partyId); }, 120_000); }); describe("getPartyByPubKey", () => { it("should return party info", async () => { const party = await getPartyByPubKey( mockCurrency, "c5cdb19624833f9a929a0125978c886ec4297320c14cea6bf667dc1d23a8e650", ); expect(party).not.toBeUndefined(); }); }); describe("getOperations", () => { it("should return user transactions", async () => { const { operations } = await getOperations( mockCurrency, "alice::1220f6efa949a0dcaab8bb1a066cf0ecbca370375e90552edd6d33c14be01082b000", {}, ); expect(operations.length).toBeGreaterThanOrEqual(0); }); }); describe("prepareTapRequest", () => { it.skip("should prepare tap request for onboarded party", async () => { // GIVEN const { partyId } = getOnboardedAccount(); const amount = "1000"; // WHEN const response = await prepareTapRequest(mockCurrency, { partyId, amount }); // THEN expect(response).toHaveProperty("serialized"); expect(response).toHaveProperty("hash"); expect(typeof response.serialized).toBe("string"); expect(typeof response.hash).toBe("string"); }); }); describe("submitTapRequest", () => { it.skip("should submit tap request with proper signature", async () => { // GIVEN const { keyPair, partyId } = getOnboardedAccount(); const { hash, serialized } = await prepareTapRequest(mockCurrency, { partyId, amount: "1000", }); const signature = keyPair.sign(hash); const verification = verifySignature(keyPair.publicKeyHex, signature, hash); expect(verification.isValid).toBe(true); // WHEN const response = await submitTapRequest(mockCurrency, { partyId, serialized, signature }); // THEN expect(response).toHaveProperty("submission_id"); expect(response).toHaveProperty("update_id"); expect(typeof response.submission_id).toBe("string"); expect(typeof response.update_id).toBe("string"); }); }); describe("preparePreApprovalTransaction", () => { it("should prepare pre-approval transaction for onboarded party", async () => { // GIVEN const { partyId } = getOnboardedAccount(); // WHEN const response = await preparePreApprovalTransaction(mockCurrency, partyId); // THEN expect(response).toHaveProperty("serialized"); expect(response).toHaveProperty("hash"); expect(typeof response.serialized).toBe("string"); expect(typeof response.hash).toBe("string"); }); }); describe("submitPreApprovalTransaction", () => { it("should submit pre-approval transaction with proper signature", async () => { // GIVEN const { keyPair, partyId } = getOnboardedAccount(); const preparedTransaction = await preparePreApprovalTransaction(mockCurrency, partyId); const preApprovalSignature = keyPair.sign(preparedTransaction.hash); const verification = verifySignature( keyPair.publicKeyHex, preApprovalSignature, preparedTransaction.hash, ); expect(verification.isValid).toBe(true); // WHEN const response = await submitPreApprovalTransaction( mockCurrency, partyId, preparedTransaction, preApprovalSignature, ); // THEN expect(response).toHaveProperty("isApproved"); expect(response).toHaveProperty("submissionId"); expect(response).toHaveProperty("updateId"); expect(response.isApproved).toBe(true); expect(typeof response.submissionId).toBe("string"); expect(typeof response.updateId).toBe("string"); }, 30000); }); describe("submit", () => { it("throws when submitting with a non-existent party", async () => { await expect( submit(mockCurrency, "fake-party-id", "garbage-serialized", "garbage-signature"), ).rejects.toThrow(/Invalid value for: path parameter party_id/); }); }); });