Skip to content

Commit bb4966e

Browse files
authored
Merge pull request #127 from utxos-dev/feat/get-wallets-by-tag
feat: add getProjectWalletsByTag and normalize wallet shape
2 parents 4acba59 + 96c6d2d commit bb4966e

3 files changed

Lines changed: 192 additions & 7 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@utxos/sdk",
3-
"version": "0.2.1",
3+
"version": "0.2.2",
44
"description": "UTXOS SDK - Web3 infrastructure platform for UTXO blockchains",
55
"main": "./dist/index.cjs",
66
"module": "./dist/index.js",

src/sdk/wallet-developer-controlled/index.test.ts

Lines changed: 129 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -511,14 +511,18 @@ describe("WalletDeveloperControlled", () => {
511511
});
512512

513513
describe("getProjectWallet", () => {
514-
it("fetches wallet by ID from API", async () => {
515-
const mockWalletInfo = {
514+
it("fetches wallet by ID from API and normalizes flat response to nested chains shape", async () => {
515+
const flatBackendResponse = {
516516
id: "test-wallet-id",
517517
projectId: "test-project-id",
518+
key: mockEncryptedData,
519+
pubKeyHash: "mock-cardano-pub-key-hash",
520+
stakeCredentialHash: "mock-cardano-stake-hash",
521+
tags: ["treasury"],
518522
};
519523
mockAxiosInstance.get.mockResolvedValue({
520524
status: 200,
521-
data: mockWalletInfo,
525+
data: flatBackendResponse,
522526
});
523527
const wallet = new WalletDeveloperControlled({ sdk: mockSdk });
524528

@@ -527,7 +531,37 @@ describe("WalletDeveloperControlled", () => {
527531
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
528532
"api/project-wallet/test-project-id/test-wallet-id",
529533
);
530-
expect(result).toEqual(mockWalletInfo);
534+
expect(result).toEqual({
535+
id: "test-wallet-id",
536+
projectId: "test-project-id",
537+
key: mockEncryptedData,
538+
tags: ["treasury"],
539+
chains: {
540+
cardano: {
541+
pubKeyHash: "mock-cardano-pub-key-hash",
542+
stakeCredentialHash: "mock-cardano-stake-hash",
543+
},
544+
},
545+
});
546+
});
547+
548+
it("defaults tags to empty array when missing from backend", async () => {
549+
mockAxiosInstance.get.mockResolvedValue({
550+
status: 200,
551+
data: {
552+
id: "wid",
553+
projectId: "test-project-id",
554+
key: mockEncryptedData,
555+
pubKeyHash: "pkh",
556+
stakeCredentialHash: null,
557+
},
558+
});
559+
const wallet = new WalletDeveloperControlled({ sdk: mockSdk });
560+
561+
const result = await wallet.getProjectWallet("wid");
562+
563+
expect(result.tags).toEqual([]);
564+
expect(result.chains.cardano?.stakeCredentialHash).toBeNull();
531565
});
532566

533567
it("throws if wallet not found", async () => {
@@ -608,6 +642,97 @@ describe("WalletDeveloperControlled", () => {
608642
});
609643
});
610644

645+
describe("getProjectWalletsByTag", () => {
646+
it("fetches wallets by tag and normalizes each flat response to nested chains shape", async () => {
647+
const flatBackendResponse = [
648+
{
649+
id: "wallet-1",
650+
projectId: "test-project-id",
651+
key: mockEncryptedData,
652+
pubKeyHash: "pkh-1",
653+
stakeCredentialHash: "sch-1",
654+
tags: ["treasury"],
655+
},
656+
{
657+
id: "wallet-2",
658+
projectId: "test-project-id",
659+
key: mockEncryptedData,
660+
pubKeyHash: "pkh-2",
661+
stakeCredentialHash: null,
662+
tags: ["treasury"],
663+
},
664+
];
665+
mockAxiosInstance.get.mockResolvedValue({
666+
status: 200,
667+
data: flatBackendResponse,
668+
});
669+
const wallet = new WalletDeveloperControlled({ sdk: mockSdk });
670+
671+
const result = await wallet.getProjectWalletsByTag("treasury");
672+
673+
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
674+
"api/project-wallet/test-project-id/tag/treasury",
675+
);
676+
expect(result).toHaveLength(2);
677+
expect(result[0]).toEqual({
678+
id: "wallet-1",
679+
projectId: "test-project-id",
680+
key: mockEncryptedData,
681+
tags: ["treasury"],
682+
chains: {
683+
cardano: { pubKeyHash: "pkh-1", stakeCredentialHash: "sch-1" },
684+
},
685+
});
686+
expect(result[1]?.chains.cardano?.stakeCredentialHash).toBeNull();
687+
expect(result[0]?.chains.spark).toBeUndefined();
688+
});
689+
690+
it("URL-encodes tags with special characters", async () => {
691+
mockAxiosInstance.get.mockResolvedValue({ status: 200, data: [] });
692+
const wallet = new WalletDeveloperControlled({ sdk: mockSdk });
693+
694+
await wallet.getProjectWalletsByTag("team/alpha beta");
695+
696+
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
697+
"api/project-wallet/test-project-id/tag/team%2Falpha%20beta",
698+
);
699+
});
700+
701+
it("returns empty array when no wallets match", async () => {
702+
mockAxiosInstance.get.mockResolvedValue({ status: 200, data: [] });
703+
const wallet = new WalletDeveloperControlled({ sdk: mockSdk });
704+
705+
const result = await wallet.getProjectWalletsByTag("nonexistent");
706+
707+
expect(result).toEqual([]);
708+
});
709+
710+
it("throws if tag is empty string", async () => {
711+
const wallet = new WalletDeveloperControlled({ sdk: mockSdk });
712+
713+
await expect(wallet.getProjectWalletsByTag("")).rejects.toThrow(
714+
"tag is required",
715+
);
716+
});
717+
718+
it("throws if tag is whitespace only", async () => {
719+
const wallet = new WalletDeveloperControlled({ sdk: mockSdk });
720+
721+
await expect(wallet.getProjectWalletsByTag(" ")).rejects.toThrow(
722+
"tag is required",
723+
);
724+
});
725+
726+
it("throws if API call fails", async () => {
727+
mockAxiosInstance.get.mockResolvedValue({ status: 500 });
728+
const wallet = new WalletDeveloperControlled({ sdk: mockSdk });
729+
730+
await expect(
731+
wallet.getProjectWalletsByTag("treasury"),
732+
).rejects.toThrow("Failed to get project wallets by tag");
733+
});
734+
});
735+
611736
describe("getAllProjectWallets", () => {
612737
it("fetches all wallets across pages", async () => {
613738
const wallet = new WalletDeveloperControlled({ sdk: mockSdk });

src/sdk/wallet-developer-controlled/index.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ export class WalletDeveloperControlled {
261261
instance.cardanoWallet = cardanoWallet;
262262
}
263263

264-
if ((chain === "spark" || !chain) && walletInfo.chains.spark && mnemonic) {
264+
if ((chain === "spark" || !chain) && mnemonic) {
265265
const sparkNetwork = networkId === 1 ? "MAINNET" : "REGTEST";
266266
const { wallet: sparkWallet } = await IssuerSparkWallet.initialize({
267267
mnemonicOrSeed: mnemonic,
@@ -274,6 +274,36 @@ export class WalletDeveloperControlled {
274274
return instance;
275275
}
276276

277+
/**
278+
* Normalizes the backend's flat wallet response shape into the nested
279+
* `MultiChainWalletInfo` shape consumed by the rest of the SDK. The
280+
* backend's `ProjectWallet` table currently stores Cardano fields only at
281+
* the top level (`pubKeyHash`, `stakeCredentialHash`); Spark public keys
282+
* are not persisted, so `chains.spark` is omitted and re-derived from the
283+
* mnemonic when needed.
284+
*/
285+
private normalizeWalletInfo(flat: {
286+
id: string;
287+
projectId: string;
288+
key: string;
289+
pubKeyHash: string;
290+
stakeCredentialHash: string | null;
291+
tags?: string[];
292+
}): MultiChainWalletInfo {
293+
return {
294+
id: flat.id,
295+
projectId: flat.projectId,
296+
key: flat.key,
297+
tags: flat.tags ?? [],
298+
chains: {
299+
cardano: {
300+
pubKeyHash: flat.pubKeyHash,
301+
stakeCredentialHash: flat.stakeCredentialHash,
302+
},
303+
},
304+
};
305+
}
306+
277307
/**
278308
* Retrieves wallet metadata by ID.
279309
*
@@ -286,7 +316,7 @@ export class WalletDeveloperControlled {
286316
);
287317

288318
if (status === 200) {
289-
return data as MultiChainWalletInfo;
319+
return this.normalizeWalletInfo(data);
290320
}
291321

292322
throw new Error("Project wallet not found");
@@ -373,6 +403,36 @@ export class WalletDeveloperControlled {
373403

374404
return allWallets;
375405
}
406+
407+
/**
408+
* Retrieves all project wallets that have a given tag.
409+
*
410+
* @param tag - The tag to filter wallets by (case-sensitive, exact match)
411+
* @returns Promise that resolves to an array of matching wallet info
412+
*
413+
* @example
414+
* ```typescript
415+
* const wallets = await sdk.wallet.getProjectWalletsByTag("treasury");
416+
* console.log(`Found ${wallets.length} treasury wallets`);
417+
* ```
418+
*/
419+
async getProjectWalletsByTag(tag: string): Promise<MultiChainWalletInfo[]> {
420+
if (!tag || tag.trim() === "") {
421+
throw new Error("tag is required");
422+
}
423+
424+
const { data, status } = await this.sdk.axiosInstance.get(
425+
`api/project-wallet/${this.sdk.projectId}/tag/${encodeURIComponent(tag)}`,
426+
);
427+
428+
if (status === 200) {
429+
return (data as unknown[]).map((w) =>
430+
this.normalizeWalletInfo(w as Parameters<typeof this.normalizeWalletInfo>[0]),
431+
);
432+
}
433+
434+
throw new Error("Failed to get project wallets by tag");
435+
}
376436
}
377437

378438
export { CardanoWalletDeveloperControlled } from "./cardano";

0 commit comments

Comments
 (0)