Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
133 changes: 129 additions & 4 deletions src/sdk/wallet-developer-controlled/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 });
Expand Down
64 changes: 62 additions & 2 deletions src/sdk/wallet-developer-controlled/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
*
Expand All @@ -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");
Expand Down Expand Up @@ -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<MultiChainWalletInfo[]> {
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<typeof this.normalizeWalletInfo>[0]),
);
}

throw new Error("Failed to get project wallets by tag");
}
}

export { CardanoWalletDeveloperControlled } from "./cardano";
Expand Down
Loading