diff --git a/package.json b/package.json index e161e2c..0329136 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@utxos/sdk", - "version": "0.2.1", + "version": "0.2.2", "description": "UTXOS SDK - Web3 infrastructure platform for UTXO blockchains", "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/src/sdk/wallet-developer-controlled/index.test.ts b/src/sdk/wallet-developer-controlled/index.test.ts index f878f73..d857c8f 100644 --- a/src/sdk/wallet-developer-controlled/index.test.ts +++ b/src/sdk/wallet-developer-controlled/index.test.ts @@ -511,14 +511,18 @@ describe("WalletDeveloperControlled", () => { }); describe("getProjectWallet", () => { - it("fetches wallet by ID from API", async () => { - const mockWalletInfo = { + it("fetches wallet by ID from API and normalizes flat response to nested chains shape", async () => { + const flatBackendResponse = { id: "test-wallet-id", projectId: "test-project-id", + key: mockEncryptedData, + pubKeyHash: "mock-cardano-pub-key-hash", + stakeCredentialHash: "mock-cardano-stake-hash", + tags: ["treasury"], }; mockAxiosInstance.get.mockResolvedValue({ status: 200, - data: mockWalletInfo, + data: flatBackendResponse, }); const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); @@ -527,7 +531,37 @@ describe("WalletDeveloperControlled", () => { expect(mockAxiosInstance.get).toHaveBeenCalledWith( "api/project-wallet/test-project-id/test-wallet-id", ); - expect(result).toEqual(mockWalletInfo); + expect(result).toEqual({ + id: "test-wallet-id", + projectId: "test-project-id", + key: mockEncryptedData, + tags: ["treasury"], + chains: { + cardano: { + pubKeyHash: "mock-cardano-pub-key-hash", + stakeCredentialHash: "mock-cardano-stake-hash", + }, + }, + }); + }); + + it("defaults tags to empty array when missing from backend", async () => { + mockAxiosInstance.get.mockResolvedValue({ + status: 200, + data: { + id: "wid", + projectId: "test-project-id", + key: mockEncryptedData, + pubKeyHash: "pkh", + stakeCredentialHash: null, + }, + }); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + const result = await wallet.getProjectWallet("wid"); + + expect(result.tags).toEqual([]); + expect(result.chains.cardano?.stakeCredentialHash).toBeNull(); }); it("throws if wallet not found", async () => { @@ -608,6 +642,97 @@ describe("WalletDeveloperControlled", () => { }); }); + describe("getProjectWalletsByTag", () => { + it("fetches wallets by tag and normalizes each flat response to nested chains shape", async () => { + const flatBackendResponse = [ + { + id: "wallet-1", + projectId: "test-project-id", + key: mockEncryptedData, + pubKeyHash: "pkh-1", + stakeCredentialHash: "sch-1", + tags: ["treasury"], + }, + { + id: "wallet-2", + projectId: "test-project-id", + key: mockEncryptedData, + pubKeyHash: "pkh-2", + stakeCredentialHash: null, + tags: ["treasury"], + }, + ]; + mockAxiosInstance.get.mockResolvedValue({ + status: 200, + data: flatBackendResponse, + }); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + const result = await wallet.getProjectWalletsByTag("treasury"); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "api/project-wallet/test-project-id/tag/treasury", + ); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: "wallet-1", + projectId: "test-project-id", + key: mockEncryptedData, + tags: ["treasury"], + chains: { + cardano: { pubKeyHash: "pkh-1", stakeCredentialHash: "sch-1" }, + }, + }); + expect(result[1]?.chains.cardano?.stakeCredentialHash).toBeNull(); + expect(result[0]?.chains.spark).toBeUndefined(); + }); + + it("URL-encodes tags with special characters", async () => { + mockAxiosInstance.get.mockResolvedValue({ status: 200, data: [] }); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await wallet.getProjectWalletsByTag("team/alpha beta"); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "api/project-wallet/test-project-id/tag/team%2Falpha%20beta", + ); + }); + + it("returns empty array when no wallets match", async () => { + mockAxiosInstance.get.mockResolvedValue({ status: 200, data: [] }); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + const result = await wallet.getProjectWalletsByTag("nonexistent"); + + expect(result).toEqual([]); + }); + + it("throws if tag is empty string", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await expect(wallet.getProjectWalletsByTag("")).rejects.toThrow( + "tag is required", + ); + }); + + it("throws if tag is whitespace only", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await expect(wallet.getProjectWalletsByTag(" ")).rejects.toThrow( + "tag is required", + ); + }); + + it("throws if API call fails", async () => { + mockAxiosInstance.get.mockResolvedValue({ status: 500 }); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await expect( + wallet.getProjectWalletsByTag("treasury"), + ).rejects.toThrow("Failed to get project wallets by tag"); + }); + }); + describe("getAllProjectWallets", () => { it("fetches all wallets across pages", async () => { const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); diff --git a/src/sdk/wallet-developer-controlled/index.ts b/src/sdk/wallet-developer-controlled/index.ts index fd64da8..cfdb9fb 100644 --- a/src/sdk/wallet-developer-controlled/index.ts +++ b/src/sdk/wallet-developer-controlled/index.ts @@ -261,7 +261,7 @@ export class WalletDeveloperControlled { instance.cardanoWallet = cardanoWallet; } - if ((chain === "spark" || !chain) && walletInfo.chains.spark && mnemonic) { + if ((chain === "spark" || !chain) && mnemonic) { const sparkNetwork = networkId === 1 ? "MAINNET" : "REGTEST"; const { wallet: sparkWallet } = await IssuerSparkWallet.initialize({ mnemonicOrSeed: mnemonic, @@ -274,6 +274,36 @@ export class WalletDeveloperControlled { return instance; } + /** + * Normalizes the backend's flat wallet response shape into the nested + * `MultiChainWalletInfo` shape consumed by the rest of the SDK. The + * backend's `ProjectWallet` table currently stores Cardano fields only at + * the top level (`pubKeyHash`, `stakeCredentialHash`); Spark public keys + * are not persisted, so `chains.spark` is omitted and re-derived from the + * mnemonic when needed. + */ + private normalizeWalletInfo(flat: { + id: string; + projectId: string; + key: string; + pubKeyHash: string; + stakeCredentialHash: string | null; + tags?: string[]; + }): MultiChainWalletInfo { + return { + id: flat.id, + projectId: flat.projectId, + key: flat.key, + tags: flat.tags ?? [], + chains: { + cardano: { + pubKeyHash: flat.pubKeyHash, + stakeCredentialHash: flat.stakeCredentialHash, + }, + }, + }; + } + /** * Retrieves wallet metadata by ID. * @@ -286,7 +316,7 @@ export class WalletDeveloperControlled { ); if (status === 200) { - return data as MultiChainWalletInfo; + return this.normalizeWalletInfo(data); } throw new Error("Project wallet not found"); @@ -373,6 +403,36 @@ export class WalletDeveloperControlled { return allWallets; } + + /** + * Retrieves all project wallets that have a given tag. + * + * @param tag - The tag to filter wallets by (case-sensitive, exact match) + * @returns Promise that resolves to an array of matching wallet info + * + * @example + * ```typescript + * const wallets = await sdk.wallet.getProjectWalletsByTag("treasury"); + * console.log(`Found ${wallets.length} treasury wallets`); + * ``` + */ + async getProjectWalletsByTag(tag: string): Promise { + if (!tag || tag.trim() === "") { + throw new Error("tag is required"); + } + + const { data, status } = await this.sdk.axiosInstance.get( + `api/project-wallet/${this.sdk.projectId}/tag/${encodeURIComponent(tag)}`, + ); + + if (status === 200) { + return (data as unknown[]).map((w) => + this.normalizeWalletInfo(w as Parameters[0]), + ); + } + + throw new Error("Failed to get project wallets by tag"); + } } export { CardanoWalletDeveloperControlled } from "./cardano";