diff --git a/examples/developer-controlled-wallet/index.ts b/examples/developer-controlled-wallet/index.ts new file mode 100644 index 0000000..0c53e4b --- /dev/null +++ b/examples/developer-controlled-wallet/index.ts @@ -0,0 +1,75 @@ +import { Web3Sdk } from "@utxos/sdk"; + +/** + * Example: Developer-Controlled Wallet + * + * This example demonstrates wallet management with multi-chain support (Spark and Cardano). + * Use sdk.wallet.* for wallet operations. + * Use sdk.tokenization.spark.* for token operations (see tokenization example). + */ + +async function main() { + // Initialize SDK + const sdk = new Web3Sdk({ + projectId: "your-project-id", + apiKey: "your-api-key", + network: "testnet", // or "mainnet" + appUrl: "https://your-app.com", + privateKey: "your-private-key", // Required for developer-controlled wallets + }); + + // === CREATE WALLET === + + console.log("Creating developer-controlled wallet..."); + + // Create wallet with both Spark and Cardano chains (shared mnemonic) + const { info, sparkIssuerWallet, cardanoWallet } = await sdk.wallet.createWallet({ + tags: ["treasury"], + }); + + console.log("Wallet created:", info.id); + + // === LIST WALLETS === + + console.log("\nListing project wallets (paginated)..."); + const { data: wallets, pagination } = await sdk.wallet.getProjectWallets(); + console.log(`Found ${wallets.length} wallets on page ${pagination.page} of ${pagination.totalPages}`); + console.log(`Total wallets: ${pagination.totalCount}`); + + // Or get all wallets at once + const allWallets = await sdk.wallet.getAllProjectWallets(); + console.log(`All wallets: ${allWallets.length}`); + + // === GET WALLET BY CHAIN === + + console.log("\nGetting wallet for specific chain..."); + + // Get Cardano wallet + const { cardanoWallet: cardano } = await sdk.wallet.getWallet(info.id, "cardano"); + const addresses = cardano!.getAddresses(); + console.log("Cardano base address:", addresses.baseAddressBech32); + + // Get Spark wallet info + const sparkWalletInfo = await sdk.wallet.sparkIssuer.getWallet(info.id); + console.log("Spark wallet public key:", sparkWalletInfo.publicKey); + + // === LOAD EXISTING WALLET === + + console.log("\nLoading existing wallet..."); + const { info: existingInfo, sparkWallet, cardanoWallet: existingCardano } = + await sdk.wallet.initWallet("existing-wallet-id"); + + console.log("Loaded wallet:", existingInfo.id); + + // === LIST BY TAG === + + console.log("\nListing wallets by tag..."); + const sparkWallets = await sdk.wallet.sparkIssuer.getByTag("treasury"); + const cardanoWallets = await sdk.wallet.cardano.getWalletsByTag("treasury"); + + console.log(`Found ${sparkWallets.length} Spark wallets with 'treasury' tag`); + console.log(`Found ${cardanoWallets.length} Cardano wallets with 'treasury' tag`); +} + +// Run example +main().catch(console.error); \ No newline at end of file diff --git a/package.json b/package.json index 3fa55ea..affe03f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@utxos/sdk", - "version": "0.0.78", + "version": "0.1.0", "description": "UTXOS SDK - Web3 infrastructure platform for UTXO blockchains", "main": "./dist/index.cjs", "browser": "./dist/index.js", @@ -28,7 +28,7 @@ "scripts": { "build:sdk": "tsup src/index.ts --format esm,cjs --dts", "dev": "tsup src/index.ts --format esm,cjs --watch --dts", - "test": "jest" + "test": "npx jest" }, "devDependencies": { "@types/base32-encoding": "^1.0.2", @@ -41,6 +41,8 @@ "typescript": "^5.3.3" }, "dependencies": { + "@utxos/api-contracts": "^0.0.1", + "@buildonspark/issuer-sdk": "^0.1.5", "@buildonspark/spark-sdk": "0.5.0", "@meshsdk/bitcoin": "1.9.0-beta.89", "@meshsdk/common": "1.9.0-beta.89", diff --git a/src/functions/client/derive-wallet.test.ts b/src/functions/client/derive-wallet.test.ts new file mode 100644 index 0000000..f7c2b89 --- /dev/null +++ b/src/functions/client/derive-wallet.test.ts @@ -0,0 +1,287 @@ +import { crypto } from "../crypto"; +import { encryptWithCipher } from "../crypto"; +import { spiltKeyIntoShards } from "../key-shard"; + +// Mock external wallet SDKs +jest.mock("@meshsdk/wallet", () => ({ + MeshWallet: jest.fn().mockImplementation(() => ({ + init: jest.fn().mockResolvedValue(undefined), + getUsedAddresses: jest.fn().mockResolvedValue(["addr_test1..."]), + })), +})); + +jest.mock("@meshsdk/bitcoin", () => ({ + EmbeddedWallet: jest.fn().mockImplementation(() => ({ + getAddress: jest.fn().mockResolvedValue("bc1q..."), + })), +})); + +jest.mock("@buildonspark/spark-sdk", () => ({ + SparkWallet: { + initialize: jest.fn().mockResolvedValue({ + wallet: { + getAddress: jest.fn().mockResolvedValue("spark1..."), + }, + }), + }, +})); + +// Import after mocks +import { clientDeriveWallet } from "./derive-wallet"; +import { MeshWallet } from "@meshsdk/wallet"; +import { EmbeddedWallet } from "@meshsdk/bitcoin"; +import { SparkWallet } from "@buildonspark/spark-sdk"; + +async function deriveKeyFromPassword(password: string): Promise { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(password), + "PBKDF2", + false, + ["deriveKey"], + ); + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: encoder.encode("static-salt-for-test"), + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +describe("clientDeriveWallet", () => { + const testMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("derives wallet from encrypted device shard and custodial shard", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const deviceKey = await deriveKeyFromPassword("device-password"); + + // Encrypt the device shard (shard 1) + const encryptedDeviceShard = await encryptWithCipher({ + data: shards[0]!, + key: deviceKey, + }); + + // Custodial shard is shard 2 (auth shard) + const custodialShard = shards[1]!; + + const result = await clientDeriveWallet( + encryptedDeviceShard, + deviceKey, + custodialShard, + 0, // testnet + ); + + expect(result).toHaveProperty("bitcoinWallet"); + expect(result).toHaveProperty("cardanoWallet"); + expect(result).toHaveProperty("sparkWallet"); + expect(result).toHaveProperty("key"); + }); + + it("returns the reconstructed mnemonic as key", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const deviceKey = await deriveKeyFromPassword("device-password"); + + const encryptedDeviceShard = await encryptWithCipher({ + data: shards[0]!, + key: deviceKey, + }); + + const result = await clientDeriveWallet( + encryptedDeviceShard, + deviceKey, + shards[1]!, + 0, + ); + + expect(result.key).toBe(testMnemonic); + }); + + it("creates wallets with testnet configuration when networkId is 0", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const deviceKey = await deriveKeyFromPassword("device-password"); + + const encryptedDeviceShard = await encryptWithCipher({ + data: shards[0]!, + key: deviceKey, + }); + + await clientDeriveWallet(encryptedDeviceShard, deviceKey, shards[1]!, 0); + + expect(EmbeddedWallet).toHaveBeenCalledWith( + expect.objectContaining({ network: "Testnet" }), + ); + expect(MeshWallet).toHaveBeenCalledWith( + expect.objectContaining({ networkId: 0 }), + ); + expect(SparkWallet.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ network: "REGTEST" }), + }), + ); + }); + + it("creates wallets with mainnet configuration when networkId is 1", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const deviceKey = await deriveKeyFromPassword("device-password"); + + const encryptedDeviceShard = await encryptWithCipher({ + data: shards[0]!, + key: deviceKey, + }); + + await clientDeriveWallet(encryptedDeviceShard, deviceKey, shards[1]!, 1); + + expect(EmbeddedWallet).toHaveBeenCalledWith( + expect.objectContaining({ network: "Mainnet" }), + ); + expect(MeshWallet).toHaveBeenCalledWith( + expect.objectContaining({ networkId: 1 }), + ); + expect(SparkWallet.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ network: "MAINNET" }), + }), + ); + }); + + it("passes bitcoinProvider when provided", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const deviceKey = await deriveKeyFromPassword("device-password"); + const mockProvider = { getUtxos: jest.fn() }; + + const encryptedDeviceShard = await encryptWithCipher({ + data: shards[0]!, + key: deviceKey, + }); + + await clientDeriveWallet( + encryptedDeviceShard, + deviceKey, + shards[1]!, + 0, + mockProvider as any, + ); + + expect(EmbeddedWallet).toHaveBeenCalledWith( + expect.objectContaining({ provider: mockProvider }), + ); + }); + + it("fails with wrong decryption key", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const correctKey = await deriveKeyFromPassword("correct-password"); + const wrongKey = await deriveKeyFromPassword("wrong-password"); + + const encryptedDeviceShard = await encryptWithCipher({ + data: shards[0]!, + key: correctKey, + }); + + await expect( + clientDeriveWallet(encryptedDeviceShard, wrongKey, shards[1]!, 0), + ).rejects.toThrow(); + }); + + it("fails with corrupted encrypted shard", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const deviceKey = await deriveKeyFromPassword("device-password"); + + const encryptedDeviceShard = await encryptWithCipher({ + data: shards[0]!, + key: deviceKey, + }); + + // Corrupt the encrypted data + const parsed = JSON.parse(encryptedDeviceShard); + parsed.ciphertext = "corrupted" + parsed.ciphertext; + + await expect( + clientDeriveWallet(JSON.stringify(parsed), deviceKey, shards[1]!, 0), + ).rejects.toThrow(); + }); + + it("fails with invalid JSON in encrypted shard", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const deviceKey = await deriveKeyFromPassword("device-password"); + + await expect( + clientDeriveWallet("not-valid-json", deviceKey, shards[1]!, 0), + ).rejects.toThrow(); + }); + + it("works with shards from different positions", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const deviceKey = await deriveKeyFromPassword("device-password"); + + // Use shard 2 as device shard and shard 3 as custodial + const encryptedDeviceShard = await encryptWithCipher({ + data: shards[1]!, + key: deviceKey, + }); + + const result = await clientDeriveWallet( + encryptedDeviceShard, + deviceKey, + shards[2]!, + 0, + ); + + expect(result.key).toBe(testMnemonic); + }); + + it("initializes cardano wallet", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const deviceKey = await deriveKeyFromPassword("device-password"); + + const encryptedDeviceShard = await encryptWithCipher({ + data: shards[0]!, + key: deviceKey, + }); + + const result = await clientDeriveWallet( + encryptedDeviceShard, + deviceKey, + shards[1]!, + 0, + ); + + expect(result.cardanoWallet.init).toHaveBeenCalled(); + }); +}); + +describe("clientDeriveWallet with 24-word mnemonic", () => { + const mnemonic24 = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + + it("derives wallet from 24-word mnemonic shards", async () => { + const shards = await spiltKeyIntoShards(mnemonic24); + const deviceKey = await deriveKeyFromPassword("device-password"); + + const encryptedDeviceShard = await encryptWithCipher({ + data: shards[0]!, + key: deviceKey, + }); + + const result = await clientDeriveWallet( + encryptedDeviceShard, + deviceKey, + shards[1]!, + 0, + ); + + expect(result.key).toBe(mnemonic24); + expect(result.key.split(" ").length).toBe(24); + }); +}); diff --git a/src/functions/client/generate-wallet.test.ts b/src/functions/client/generate-wallet.test.ts new file mode 100644 index 0000000..6fde73d --- /dev/null +++ b/src/functions/client/generate-wallet.test.ts @@ -0,0 +1,301 @@ +import { crypto } from "../crypto"; + +// Mock external wallet SDKs +jest.mock("@meshsdk/wallet", () => ({ + MeshWallet: jest.fn().mockImplementation(() => ({ + init: jest.fn().mockResolvedValue(undefined), + getAddresses: jest.fn().mockResolvedValue({ + baseAddressBech32: + "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp", + }), + })), +})); + +jest.mock("@meshsdk/common", () => ({ + generateMnemonic: jest.fn().mockResolvedValue( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + ), +})); + +jest.mock("@meshsdk/core-cst", () => ({ + deserializeBech32Address: jest.fn().mockReturnValue({ + pubKeyHash: "mock-pub-key-hash", + stakeCredentialHash: "mock-stake-credential-hash", + }), +})); + +jest.mock("@meshsdk/bitcoin", () => ({ + EmbeddedWallet: jest.fn().mockImplementation(() => ({ + getPublicKey: jest.fn().mockReturnValue("mock-bitcoin-pub-key-hash"), + getAddress: jest.fn().mockResolvedValue("bc1q..."), + })), +})); + +jest.mock("@buildonspark/spark-sdk", () => ({ + SparkWallet: { + initialize: jest.fn().mockResolvedValue({ + wallet: { + getIdentityPublicKey: jest.fn().mockResolvedValue("mock-spark-pub-key"), + getStaticDepositAddress: jest + .fn() + .mockResolvedValue("mock-spark-deposit-address"), + }, + }), + }, +})); + +// Import after mocks +import { clientGenerateWallet } from "./generate-wallet"; +import { generateMnemonic } from "@meshsdk/common"; +import { MeshWallet } from "@meshsdk/wallet"; +import { EmbeddedWallet } from "@meshsdk/bitcoin"; +import { SparkWallet } from "@buildonspark/spark-sdk"; +import { decryptWithCipher } from "../crypto"; +import { hexToBytes, bytesToString } from "../convertors"; +import { shamirCombine } from "../key-shard"; + +async function deriveKeyFromPassword(password: string): Promise { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(password), + "PBKDF2", + false, + ["deriveKey"], + ); + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: encoder.encode("static-salt-for-test"), + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +describe("clientGenerateWallet", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("generates a wallet and returns all required fields", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + const result = await clientGenerateWallet(deviceKey, recoveryKey); + + expect(result).toHaveProperty("encryptedDeviceShard"); + expect(result).toHaveProperty("authShard"); + expect(result).toHaveProperty("encryptedRecoveryShard"); + expect(result).toHaveProperty("bitcoinMainnetPubKeyHash"); + expect(result).toHaveProperty("bitcoinTestnetPubKeyHash"); + expect(result).toHaveProperty("cardanoPubKeyHash"); + expect(result).toHaveProperty("cardanoStakeCredentialHash"); + expect(result).toHaveProperty("sparkMainnetPubKeyHash"); + expect(result).toHaveProperty("sparkRegtestPubKeyHash"); + expect(result).toHaveProperty("sparkMainnetStaticDepositAddress"); + expect(result).toHaveProperty("sparkRegtestStaticDepositAddress"); + }); + + it("calls generateMnemonic with 256 bits", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + await clientGenerateWallet(deviceKey, recoveryKey); + + expect(generateMnemonic).toHaveBeenCalledWith(256); + }); + + it("returns encrypted device shard as JSON string", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + const result = await clientGenerateWallet(deviceKey, recoveryKey); + + expect(typeof result.encryptedDeviceShard).toBe("string"); + const parsed = JSON.parse(result.encryptedDeviceShard); + expect(parsed).toHaveProperty("iv"); + expect(parsed).toHaveProperty("ciphertext"); + }); + + it("returns encrypted recovery shard as JSON string", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + const result = await clientGenerateWallet(deviceKey, recoveryKey); + + expect(typeof result.encryptedRecoveryShard).toBe("string"); + const parsed = JSON.parse(result.encryptedRecoveryShard); + expect(parsed).toHaveProperty("iv"); + expect(parsed).toHaveProperty("ciphertext"); + }); + + it("returns authShard as hex string", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + const result = await clientGenerateWallet(deviceKey, recoveryKey); + + expect(typeof result.authShard).toBe("string"); + expect(result.authShard).toMatch(/^[0-9a-f]+$/); + }); + + it("device shard can be decrypted with device key", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + const result = await clientGenerateWallet(deviceKey, recoveryKey); + + const decrypted = await decryptWithCipher({ + encryptedDataJSON: result.encryptedDeviceShard, + key: deviceKey, + }); + + expect(typeof decrypted).toBe("string"); + expect(decrypted).toMatch(/^[0-9a-f]+$/); // Should be hex-encoded shard + }); + + it("recovery shard can be decrypted with recovery key", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + const result = await clientGenerateWallet(deviceKey, recoveryKey); + + const decrypted = await decryptWithCipher({ + encryptedDataJSON: result.encryptedRecoveryShard, + key: recoveryKey, + }); + + expect(typeof decrypted).toBe("string"); + expect(decrypted).toMatch(/^[0-9a-f]+$/); // Should be hex-encoded shard + }); + + it("device shard cannot be decrypted with wrong key", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + const wrongKey = await deriveKeyFromPassword("wrong-password"); + + const result = await clientGenerateWallet(deviceKey, recoveryKey); + + await expect( + decryptWithCipher({ + encryptedDataJSON: result.encryptedDeviceShard, + key: wrongKey, + }), + ).rejects.toThrow(); + }); + + it("creates EmbeddedWallet for both testnet and mainnet", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + await clientGenerateWallet(deviceKey, recoveryKey); + + expect(EmbeddedWallet).toHaveBeenCalledTimes(2); + expect(EmbeddedWallet).toHaveBeenCalledWith( + expect.objectContaining({ network: "Testnet" }), + ); + expect(EmbeddedWallet).toHaveBeenCalledWith( + expect.objectContaining({ network: "Mainnet" }), + ); + }); + + it("creates MeshWallet with networkId 1", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + await clientGenerateWallet(deviceKey, recoveryKey); + + expect(MeshWallet).toHaveBeenCalledWith( + expect.objectContaining({ networkId: 1 }), + ); + }); + + it("initializes SparkWallet for both mainnet and regtest", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + await clientGenerateWallet(deviceKey, recoveryKey); + + expect(SparkWallet.initialize).toHaveBeenCalledTimes(2); + expect(SparkWallet.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ network: "MAINNET" }), + }), + ); + expect(SparkWallet.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ network: "REGTEST" }), + }), + ); + }); + + it("returns bitcoin pub key hashes", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + const result = await clientGenerateWallet(deviceKey, recoveryKey); + + expect(result.bitcoinMainnetPubKeyHash).toBe("mock-bitcoin-pub-key-hash"); + expect(result.bitcoinTestnetPubKeyHash).toBe("mock-bitcoin-pub-key-hash"); + }); + + it("returns cardano key hashes", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + const result = await clientGenerateWallet(deviceKey, recoveryKey); + + expect(result.cardanoPubKeyHash).toBe("mock-pub-key-hash"); + expect(result.cardanoStakeCredentialHash).toBe( + "mock-stake-credential-hash", + ); + }); + + it("returns spark pub key hashes and deposit addresses", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + const result = await clientGenerateWallet(deviceKey, recoveryKey); + + expect(result.sparkMainnetPubKeyHash).toBe("mock-spark-pub-key"); + expect(result.sparkRegtestPubKeyHash).toBe("mock-spark-pub-key"); + expect(result.sparkMainnetStaticDepositAddress).toBe( + "mock-spark-deposit-address", + ); + expect(result.sparkRegtestStaticDepositAddress).toBe( + "mock-spark-deposit-address", + ); + }); + + it("2 of 3 shards can reconstruct the mnemonic", async () => { + const deviceKey = await deriveKeyFromPassword("device-password"); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + + const result = await clientGenerateWallet(deviceKey, recoveryKey); + + // Decrypt device shard (shard 1) + const shard1 = await decryptWithCipher({ + encryptedDataJSON: result.encryptedDeviceShard, + key: deviceKey, + }); + + // Auth shard is shard 2 (unencrypted hex) + const shard2 = result.authShard; + + // Combine shards 1 and 2 + const share1 = hexToBytes(shard1); + const share2 = hexToBytes(shard2); + const reconstructed = await shamirCombine([share1, share2]); + const mnemonic = bytesToString(reconstructed); + + // Should match the mock mnemonic + expect(mnemonic).toBe( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + ); + }); +}); diff --git a/src/functions/client/recovery.test.ts b/src/functions/client/recovery.test.ts new file mode 100644 index 0000000..bdbdb8f --- /dev/null +++ b/src/functions/client/recovery.test.ts @@ -0,0 +1,385 @@ +import { crypto } from "../crypto"; +import { encryptWithCipher, decryptWithCipher } from "../crypto"; +import { spiltKeyIntoShards } from "../key-shard"; +import { hexToBytes, bytesToString } from "../convertors"; +import { shamirCombine } from "../key-shard"; + +// Mock external wallet SDKs +jest.mock("@meshsdk/wallet", () => ({ + MeshWallet: jest.fn().mockImplementation(() => ({ + init: jest.fn().mockResolvedValue(undefined), + })), +})); + +jest.mock("@meshsdk/bitcoin", () => ({ + EmbeddedWallet: jest.fn().mockImplementation(() => ({})), +})); + +jest.mock("@buildonspark/spark-sdk", () => ({ + SparkWallet: { + initialize: jest.fn().mockResolvedValue({ + wallet: {}, + }), + }, +})); + +// Import after mocks +import { clientRecovery } from "./recovery"; + +async function deriveKeyFromPassword(password: string): Promise { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(password), + "PBKDF2", + false, + ["deriveKey"], + ); + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: encoder.encode("static-salt-for-test"), + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +describe("clientRecovery", () => { + const testMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("recovers wallet from auth shard and encrypted recovery shard", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + const newDeviceKey = await deriveKeyFromPassword("new-device-password"); + + // Auth shard is shard 2 + const authShard = shards[1]!; + + // Recovery shard is shard 3, encrypted + const encryptedRecoveryShard = await encryptWithCipher({ + data: shards[2]!, + key: recoveryKey, + }); + + const result = await clientRecovery( + authShard, + encryptedRecoveryShard, + recoveryKey, + newDeviceKey, + ); + + expect(result).toHaveProperty("deviceShard"); + expect(result).toHaveProperty("authShard"); + expect(result).toHaveProperty("fullKey"); + }); + + it("returns the original mnemonic as fullKey", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + const newDeviceKey = await deriveKeyFromPassword("new-device-password"); + + const encryptedRecoveryShard = await encryptWithCipher({ + data: shards[2]!, + key: recoveryKey, + }); + + const result = await clientRecovery( + shards[1]!, + encryptedRecoveryShard, + recoveryKey, + newDeviceKey, + ); + + expect(result.fullKey).toBe(testMnemonic); + }); + + it("returns new device shard encrypted with new key", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + const newDeviceKey = await deriveKeyFromPassword("new-device-password"); + + const encryptedRecoveryShard = await encryptWithCipher({ + data: shards[2]!, + key: recoveryKey, + }); + + const result = await clientRecovery( + shards[1]!, + encryptedRecoveryShard, + recoveryKey, + newDeviceKey, + ); + + // Device shard should be decryptable with new device key + expect(typeof result.deviceShard).toBe("string"); + const decrypted = await decryptWithCipher({ + encryptedDataJSON: result.deviceShard, + key: newDeviceKey, + }); + expect(decrypted).toMatch(/^[0-9a-f]+$/); // hex-encoded shard + }); + + it("returns new auth shard as hex string", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + const newDeviceKey = await deriveKeyFromPassword("new-device-password"); + + const encryptedRecoveryShard = await encryptWithCipher({ + data: shards[2]!, + key: recoveryKey, + }); + + const result = await clientRecovery( + shards[1]!, + encryptedRecoveryShard, + recoveryKey, + newDeviceKey, + ); + + expect(typeof result.authShard).toBe("string"); + expect(result.authShard).toMatch(/^[0-9a-f]+$/); + }); + + it("new shards can reconstruct the original mnemonic", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + const newDeviceKey = await deriveKeyFromPassword("new-device-password"); + + const encryptedRecoveryShard = await encryptWithCipher({ + data: shards[2]!, + key: recoveryKey, + }); + + const result = await clientRecovery( + shards[1]!, + encryptedRecoveryShard, + recoveryKey, + newDeviceKey, + ); + + // Decrypt new device shard + const newShard1 = await decryptWithCipher({ + encryptedDataJSON: result.deviceShard, + key: newDeviceKey, + }); + const newShard2 = result.authShard; + + // Combine new shards + const share1 = hexToBytes(newShard1); + const share2 = hexToBytes(newShard2); + const reconstructed = await shamirCombine([share1, share2]); + const mnemonic = bytesToString(reconstructed); + + expect(mnemonic).toBe(testMnemonic); + }); + + it("device shard cannot be decrypted with wrong key", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + const newDeviceKey = await deriveKeyFromPassword("new-device-password"); + const wrongKey = await deriveKeyFromPassword("wrong-password"); + + const encryptedRecoveryShard = await encryptWithCipher({ + data: shards[2]!, + key: recoveryKey, + }); + + const result = await clientRecovery( + shards[1]!, + encryptedRecoveryShard, + recoveryKey, + newDeviceKey, + ); + + await expect( + decryptWithCipher({ + encryptedDataJSON: result.deviceShard, + key: wrongKey, + }), + ).rejects.toThrow(); + }); + + it("throws with wrong recovery key", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + const shards = await spiltKeyIntoShards(testMnemonic); + const correctRecoveryKey = await deriveKeyFromPassword("recovery-password"); + const wrongRecoveryKey = await deriveKeyFromPassword("wrong-recovery"); + const newDeviceKey = await deriveKeyFromPassword("new-device-password"); + + const encryptedRecoveryShard = await encryptWithCipher({ + data: shards[2]!, + key: correctRecoveryKey, + }); + + await expect( + clientRecovery( + shards[1]!, + encryptedRecoveryShard, + wrongRecoveryKey, + newDeviceKey, + ), + ).rejects.toThrow("Invalid recovery answer"); + consoleSpy.mockRestore(); + }); + + it("throws with corrupted recovery shard", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + const shards = await spiltKeyIntoShards(testMnemonic); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + const newDeviceKey = await deriveKeyFromPassword("new-device-password"); + + const encryptedRecoveryShard = await encryptWithCipher({ + data: shards[2]!, + key: recoveryKey, + }); + + // Corrupt the encrypted data + const parsed = JSON.parse(encryptedRecoveryShard); + parsed.ciphertext = "corrupted" + parsed.ciphertext; + + await expect( + clientRecovery( + shards[1]!, + JSON.stringify(parsed), + recoveryKey, + newDeviceKey, + ), + ).rejects.toThrow("Invalid recovery answer"); + consoleSpy.mockRestore(); + }); + + it("throws with invalid auth shard", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + const shards = await spiltKeyIntoShards(testMnemonic); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + const newDeviceKey = await deriveKeyFromPassword("new-device-password"); + + const encryptedRecoveryShard = await encryptWithCipher({ + data: shards[2]!, + key: recoveryKey, + }); + + await expect( + clientRecovery( + "invalid-hex!@#", + encryptedRecoveryShard, + recoveryKey, + newDeviceKey, + ), + ).rejects.toThrow("Invalid recovery answer"); + consoleSpy.mockRestore(); + }); + + it("throws with invalid JSON in recovery shard", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + const shards = await spiltKeyIntoShards(testMnemonic); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + const newDeviceKey = await deriveKeyFromPassword("new-device-password"); + + await expect( + clientRecovery(shards[1]!, "not-valid-json", recoveryKey, newDeviceKey), + ).rejects.toThrow("Invalid recovery answer"); + consoleSpy.mockRestore(); + }); +}); + +describe("clientRecovery with 24-word mnemonic", () => { + const mnemonic24 = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + + it("recovers 24-word mnemonic", async () => { + const shards = await spiltKeyIntoShards(mnemonic24); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + const newDeviceKey = await deriveKeyFromPassword("new-device-password"); + + const encryptedRecoveryShard = await encryptWithCipher({ + data: shards[2]!, + key: recoveryKey, + }); + + const result = await clientRecovery( + shards[1]!, + encryptedRecoveryShard, + recoveryKey, + newDeviceKey, + ); + + expect(result.fullKey).toBe(mnemonic24); + expect(result.fullKey.split(" ").length).toBe(24); + }); +}); + +describe("clientRecovery generates new shards", () => { + const testMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + it("generates different shards than original", async () => { + const originalShards = await spiltKeyIntoShards(testMnemonic); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + const newDeviceKey = await deriveKeyFromPassword("new-device-password"); + + const encryptedRecoveryShard = await encryptWithCipher({ + data: originalShards[2]!, + key: recoveryKey, + }); + + const result = await clientRecovery( + originalShards[1]!, + encryptedRecoveryShard, + recoveryKey, + newDeviceKey, + ); + + // New auth shard should be different from original (due to re-splitting) + // Note: There's a tiny probability they could be the same, but it's negligible + expect(result.authShard).not.toBe(originalShards[1]); + }); + + it("recovery can be performed multiple times", async () => { + const shards = await spiltKeyIntoShards(testMnemonic); + const recoveryKey = await deriveKeyFromPassword("recovery-password"); + const newDeviceKey1 = await deriveKeyFromPassword("new-device-1"); + const newDeviceKey2 = await deriveKeyFromPassword("new-device-2"); + + const encryptedRecoveryShard = await encryptWithCipher({ + data: shards[2]!, + key: recoveryKey, + }); + + const result1 = await clientRecovery( + shards[1]!, + encryptedRecoveryShard, + recoveryKey, + newDeviceKey1, + ); + + const result2 = await clientRecovery( + shards[1]!, + encryptedRecoveryShard, + recoveryKey, + newDeviceKey2, + ); + + // Both should recover the same mnemonic + expect(result1.fullKey).toBe(testMnemonic); + expect(result2.fullKey).toBe(testMnemonic); + + // But device shards should be encrypted with different keys + await expect( + decryptWithCipher({ + encryptedDataJSON: result1.deviceShard, + key: newDeviceKey2, + }), + ).rejects.toThrow(); + }); +}); diff --git a/src/functions/convertors.test.ts b/src/functions/convertors.test.ts new file mode 100644 index 0000000..e63ba40 --- /dev/null +++ b/src/functions/convertors.test.ts @@ -0,0 +1,146 @@ +import { + stringToBytes, + bytesToString, + bytesToHex, + hexToBytes, +} from "./convertors"; + +describe("stringToBytes", () => { + it("converts ASCII string to bytes", () => { + const bytes = stringToBytes("hello"); + expect(bytes).toBeInstanceOf(Uint8Array); + expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]); + }); + + it("converts empty string to empty array", () => { + const bytes = stringToBytes(""); + expect(bytes.length).toBe(0); + }); + + it("converts unicode string to bytes", () => { + const bytes = stringToBytes("日本"); + expect(bytes.length).toBeGreaterThan(2); // UTF-8 multibyte + }); + + it("handles special characters", () => { + const bytes = stringToBytes("!@#$%"); + expect(bytes.length).toBe(5); + }); +}); + +describe("bytesToString", () => { + it("converts bytes back to ASCII string", () => { + const bytes = new Uint8Array([104, 101, 108, 108, 111]); + expect(bytesToString(bytes)).toBe("hello"); + }); + + it("converts empty array to empty string", () => { + const bytes = new Uint8Array([]); + expect(bytesToString(bytes)).toBe(""); + }); + + it("roundtrip: string -> bytes -> string", () => { + const original = "Hello, World! 123"; + const bytes = stringToBytes(original); + const result = bytesToString(bytes); + expect(result).toBe(original); + }); + + it("handles unicode roundtrip", () => { + const original = "日本語テスト"; + const bytes = stringToBytes(original); + const result = bytesToString(bytes); + expect(result).toBe(original); + }); +}); + +describe("bytesToHex", () => { + it("converts bytes to hex string", () => { + const bytes = new Uint8Array([0, 15, 16, 255]); + expect(bytesToHex(bytes)).toBe("000f10ff"); + }); + + it("converts empty array to empty string", () => { + const bytes = new Uint8Array([]); + expect(bytesToHex(bytes)).toBe(""); + }); + + it("pads single digit hex values", () => { + const bytes = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + expect(bytesToHex(bytes)).toBe("00010203040506070809"); + }); + + it("handles large values correctly", () => { + const bytes = new Uint8Array([255, 254, 253]); + expect(bytesToHex(bytes)).toBe("fffefd"); + }); + + it("produces lowercase hex", () => { + const bytes = new Uint8Array([171, 205, 239]); + expect(bytesToHex(bytes)).toBe("abcdef"); + expect(bytesToHex(bytes)).not.toMatch(/[A-F]/); + }); +}); + +describe("hexToBytes", () => { + it("converts hex string to bytes", () => { + const bytes = hexToBytes("000f10ff"); + expect(Array.from(bytes)).toEqual([0, 15, 16, 255]); + }); + + it("converts empty string to empty array", () => { + const bytes = hexToBytes(""); + expect(bytes.length).toBe(0); + }); + + it("handles lowercase hex", () => { + const bytes = hexToBytes("abcdef"); + expect(Array.from(bytes)).toEqual([171, 205, 239]); + }); + + it("handles uppercase hex", () => { + const bytes = hexToBytes("ABCDEF"); + expect(Array.from(bytes)).toEqual([171, 205, 239]); + }); + + it("handles mixed case hex", () => { + const bytes = hexToBytes("AbCdEf"); + expect(Array.from(bytes)).toEqual([171, 205, 239]); + }); + + it("roundtrip: bytes -> hex -> bytes", () => { + const original = new Uint8Array([0, 127, 128, 255, 1, 2, 3]); + const hex = bytesToHex(original); + const result = hexToBytes(hex); + expect(Array.from(result)).toEqual(Array.from(original)); + }); +}); + +describe("full roundtrip: string -> bytes -> hex -> bytes -> string", () => { + it("preserves ASCII string through full conversion cycle", () => { + const original = "hello world"; + const bytes1 = stringToBytes(original); + const hex = bytesToHex(bytes1); + const bytes2 = hexToBytes(hex); + const result = bytesToString(bytes2); + expect(result).toBe(original); + }); + + it("preserves unicode string through full conversion cycle", () => { + const original = "こんにちは"; + const bytes1 = stringToBytes(original); + const hex = bytesToHex(bytes1); + const bytes2 = hexToBytes(hex); + const result = bytesToString(bytes2); + expect(result).toBe(original); + }); + + it("handles large data", () => { + const original = "x".repeat(10000); + const bytes1 = stringToBytes(original); + const hex = bytesToHex(bytes1); + const bytes2 = hexToBytes(hex); + const result = bytesToString(bytes2); + expect(result).toBe(original); + }); +}); diff --git a/src/functions/crypto/encryption.test.ts b/src/functions/crypto/encryption.test.ts index 66ff248..7e2f379 100644 --- a/src/functions/crypto/encryption.test.ts +++ b/src/functions/crypto/encryption.test.ts @@ -1,3 +1,4 @@ +import { crypto } from "."; import { decryptWithCipher, decryptWithPrivateKey, @@ -9,12 +10,41 @@ import { const data = "solution solution solution solution solution solution solution solution solution solution solution solution solution solution solution solution solution solution solution solution solution solution solution solution"; +async function deriveKeyFromPassword( + password: string, + usages: KeyUsage[] = ["encrypt", "decrypt"], +): Promise { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(password), + "PBKDF2", + false, + ["deriveKey"], + ); + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: encoder.encode("static-salt-for-test"), + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + usages, + ); +} + describe("with cipher", () => { - const key = "01234567890123456789"; + const keyString = "01234567890123456789"; - it("decrypt - 12 IV length", async () => { - const encryptedDataJSON = - '{"iv":"/bs1AzciZ1bDqT5W","ciphertext":"mh5pgH8ErqqH2KLLEBqqr8Pwm+mUuh9HhaAHslSD8ho6zk7mXccc9NUQAW8rb9UajCq8LYyANuiorjYD5N0hd2Lbe2n1x8AGRZrogyRKW6uhoFD1/FW6ofjgGP/kQRQSW2ZdJaDMbCxwYSdzxmaRunk6JRfybhfRU6kIxPMu41jhhRC3LbwZ+NnfBJFrg859hbuQgMQm8mqOUgOxcK8kKH54shOpGuLT4YBXhx33dZ//wT5VXrQ8kwIKttNk5h9MNKCacpRZSqU3pGlZ5oxucNEGos0IKTTXfbmwYx14uiERcXd32OP2"}'; + it("encrypt and decrypt", async () => { + const key = await deriveKeyFromPassword(keyString); + const encryptedDataJSON = await encryptWithCipher({ + data, + key, + }); const decrypted = await decryptWithCipher({ encryptedDataJSON: encryptedDataJSON, @@ -23,19 +53,92 @@ describe("with cipher", () => { expect(data).toBe(decrypted); }); - it("encrypt and decrypt", async () => { + + it("encrypts empty string", async () => { + const key = await deriveKeyFromPassword(keyString); const encryptedDataJSON = await encryptWithCipher({ - data, + data: "", + key, + }); + const decrypted = await decryptWithCipher({ + encryptedDataJSON, key, }); - console.log("encryptedDataJSON", encryptedDataJSON); + expect(decrypted).toBe(""); + }); + it("encrypts unicode characters", async () => { + const key = await deriveKeyFromPassword(keyString); + const unicodeData = "Hello 日本語 🎉"; + const encryptedDataJSON = await encryptWithCipher({ + data: unicodeData, + key, + }); const decrypted = await decryptWithCipher({ - encryptedDataJSON: encryptedDataJSON, + encryptedDataJSON, key, }); + expect(decrypted).toBe(unicodeData); + }); - expect(data).toBe(decrypted); + it("produces different ciphertext for same input (due to random IV)", async () => { + const key = await deriveKeyFromPassword(keyString); + const encrypted1 = await encryptWithCipher({ data, key }); + const encrypted2 = await encryptWithCipher({ data, key }); + expect(encrypted1).not.toBe(encrypted2); + }); + + it("fails to decrypt with wrong key", async () => { + const key1 = await deriveKeyFromPassword("password1"); + const key2 = await deriveKeyFromPassword("password2"); + const encryptedDataJSON = await encryptWithCipher({ + data, + key: key1, + }); + await expect( + decryptWithCipher({ encryptedDataJSON, key: key2 }), + ).rejects.toThrow(); + }); + + it("fails to decrypt corrupted ciphertext", async () => { + const key = await deriveKeyFromPassword(keyString); + const encryptedDataJSON = await encryptWithCipher({ data, key }); + const parsed = JSON.parse(encryptedDataJSON); + parsed.ciphertext = "corrupted" + parsed.ciphertext; + await expect( + decryptWithCipher({ encryptedDataJSON: JSON.stringify(parsed), key }), + ).rejects.toThrow(); + }); + + it("fails to decrypt with corrupted IV", async () => { + const key = await deriveKeyFromPassword(keyString); + const encryptedDataJSON = await encryptWithCipher({ data, key }); + const parsed = JSON.parse(encryptedDataJSON); + parsed.iv = "AAAAAAAAAAAAAAAAAAAAAA=="; // Different IV + await expect( + decryptWithCipher({ encryptedDataJSON: JSON.stringify(parsed), key }), + ).rejects.toThrow(); + }); + + it("fails to decrypt invalid JSON", async () => { + const key = await deriveKeyFromPassword(keyString); + await expect( + decryptWithCipher({ encryptedDataJSON: "not valid json", key }), + ).rejects.toThrow(); + }); + + it("handles large data", async () => { + const key = await deriveKeyFromPassword(keyString); + const largeData = "x".repeat(100000); + const encryptedDataJSON = await encryptWithCipher({ + data: largeData, + key, + }); + const decrypted = await decryptWithCipher({ + encryptedDataJSON, + key, + }); + expect(decrypted).toBe(largeData); }); }); @@ -52,4 +155,145 @@ describe("with keypair", () => { expect(data).toBe(decrypted); }); + + it("generates unique keypairs", async () => { + const keyPair1 = await generateKeyPair(); + const keyPair2 = await generateKeyPair(); + expect(keyPair1.publicKey).not.toBe(keyPair2.publicKey); + expect(keyPair1.privateKey).not.toBe(keyPair2.privateKey); + }); + + it("encrypts empty string", async () => { + const { publicKey, privateKey } = await generateKeyPair(); + const encryptedDataJSON = await encryptWithPublicKey({ + publicKey, + data: "", + }); + const decrypted = await decryptWithPrivateKey({ + privateKey, + encryptedDataJSON, + }); + expect(decrypted).toBe(""); + }); + + it("encrypts unicode characters", async () => { + const { publicKey, privateKey } = await generateKeyPair(); + const unicodeData = "Hello 世界 🌍"; + const encryptedDataJSON = await encryptWithPublicKey({ + publicKey, + data: unicodeData, + }); + const decrypted = await decryptWithPrivateKey({ + privateKey, + encryptedDataJSON, + }); + expect(decrypted).toBe(unicodeData); + }); + + it("produces different ciphertext for same input (ephemeral key)", async () => { + const { publicKey, privateKey } = await generateKeyPair(); + const encrypted1 = await encryptWithPublicKey({ publicKey, data }); + const encrypted2 = await encryptWithPublicKey({ publicKey, data }); + expect(encrypted1).not.toBe(encrypted2); + // Both should decrypt to same value + const decrypted1 = await decryptWithPrivateKey({ + privateKey, + encryptedDataJSON: encrypted1, + }); + const decrypted2 = await decryptWithPrivateKey({ + privateKey, + encryptedDataJSON: encrypted2, + }); + expect(decrypted1).toBe(data); + expect(decrypted2).toBe(data); + }); + + it("fails to decrypt with wrong private key", async () => { + const keyPair1 = await generateKeyPair(); + const keyPair2 = await generateKeyPair(); + const encryptedDataJSON = await encryptWithPublicKey({ + publicKey: keyPair1.publicKey, + data, + }); + await expect( + decryptWithPrivateKey({ + privateKey: keyPair2.privateKey, + encryptedDataJSON, + }), + ).rejects.toThrow(); + }); + + it("fails to decrypt with invalid private key format", async () => { + const { publicKey } = await generateKeyPair(); + const encryptedDataJSON = await encryptWithPublicKey({ publicKey, data }); + await expect( + decryptWithPrivateKey({ + privateKey: "not-a-valid-key", + encryptedDataJSON, + }), + ).rejects.toThrow(); + }); + + it("fails to encrypt with invalid public key format", async () => { + await expect( + encryptWithPublicKey({ + publicKey: "not-a-valid-key", + data, + }), + ).rejects.toThrow(); + }); + + it("fails to decrypt corrupted ciphertext", async () => { + const { publicKey, privateKey } = await generateKeyPair(); + const encryptedDataJSON = await encryptWithPublicKey({ publicKey, data }); + const parsed = JSON.parse(encryptedDataJSON); + parsed.ciphertext = "corrupted" + parsed.ciphertext; + await expect( + decryptWithPrivateKey({ + privateKey, + encryptedDataJSON: JSON.stringify(parsed), + }), + ).rejects.toThrow(); + }); + + it("fails to decrypt invalid JSON", async () => { + const { privateKey } = await generateKeyPair(); + await expect( + decryptWithPrivateKey({ + privateKey, + encryptedDataJSON: "not valid json", + }), + ).rejects.toThrow(); + }); + + it("handles large data", async () => { + const { publicKey, privateKey } = await generateKeyPair(); + const largeData = "y".repeat(50000); + const encryptedDataJSON = await encryptWithPublicKey({ + publicKey, + data: largeData, + }); + const decrypted = await decryptWithPrivateKey({ + privateKey, + encryptedDataJSON, + }); + expect(decrypted).toBe(largeData); + }); +}); + +describe("generateKeyPair", () => { + it("returns base64 encoded keys", async () => { + const { publicKey, privateKey } = await generateKeyPair(); + // Base64 strings should not throw when decoded + expect(() => Buffer.from(publicKey, "base64")).not.toThrow(); + expect(() => Buffer.from(privateKey, "base64")).not.toThrow(); + }); + + it("generates keys of expected format (SPKI for public, PKCS8 for private)", async () => { + const { publicKey, privateKey } = await generateKeyPair(); + // SPKI encoded P-256 public keys are typically 91 bytes -> ~122 base64 chars + // PKCS8 encoded P-256 private keys are typically 138 bytes -> ~184 base64 chars + expect(publicKey.length).toBeGreaterThan(100); + expect(privateKey.length).toBeGreaterThan(150); + }); }); diff --git a/src/functions/crypto/hash.test.ts b/src/functions/crypto/hash.test.ts new file mode 100644 index 0000000..c11c368 --- /dev/null +++ b/src/functions/crypto/hash.test.ts @@ -0,0 +1,102 @@ +import { generateHash, hashData } from "./hash"; + +describe("generateHash", () => { + it("generates a hex string of default size (64 bytes = 128 hex chars)", async () => { + const hash = await generateHash({}); + expect(typeof hash).toBe("string"); + expect(hash).toMatch(/^[0-9a-f]+$/); + expect(hash.length).toBe(128); // 64 bytes = 128 hex characters + }); + + it("generates a hex string of custom size", async () => { + const hash = await generateHash({ size: 32 }); + expect(hash.length).toBe(64); // 32 bytes = 64 hex characters + }); + + it("generates a hex string of small size", async () => { + const hash = await generateHash({ size: 8 }); + expect(hash.length).toBe(16); // 8 bytes = 16 hex characters + }); + + it("generates unique hashes on each call", async () => { + const hash1 = await generateHash({}); + const hash2 = await generateHash({}); + expect(hash1).not.toBe(hash2); + }); +}); + +describe("hashData", () => { + it("hashes data with sha256 by default", async () => { + const hash = await hashData({ data: "hello world" }); + expect(typeof hash).toBe("string"); + expect(hash).toMatch(/^[0-9a-f]+$/); + expect(hash.length).toBe(64); // SHA-256 = 32 bytes = 64 hex characters + }); + + it("produces consistent hash for same input", async () => { + const hash1 = await hashData({ data: "test data" }); + const hash2 = await hashData({ data: "test data" }); + expect(hash1).toBe(hash2); + }); + + it("produces different hash for different input", async () => { + const hash1 = await hashData({ data: "data1" }); + const hash2 = await hashData({ data: "data2" }); + expect(hash1).not.toBe(hash2); + }); + + it("hashes data with a private key (HMAC)", async () => { + const hash = await hashData({ + data: "hello world", + privateKey: "secret-key", + }); + expect(typeof hash).toBe("string"); + expect(hash.length).toBe(64); + }); + + it("produces different hash with different private keys", async () => { + const hash1 = await hashData({ data: "test", privateKey: "key1" }); + const hash2 = await hashData({ data: "test", privateKey: "key2" }); + expect(hash1).not.toBe(hash2); + }); + + it("produces consistent HMAC hash for same input and key", async () => { + const hash1 = await hashData({ data: "test", privateKey: "secret" }); + const hash2 = await hashData({ data: "test", privateKey: "secret" }); + expect(hash1).toBe(hash2); + }); + + it("hashes data with sha512 algorithm", async () => { + const hash = await hashData({ + data: "hello world", + algorithm: "sha512", + }); + expect(hash.length).toBe(128); // SHA-512 = 64 bytes = 128 hex characters + }); + + it("hashes data with sha1 algorithm", async () => { + const hash = await hashData({ + data: "hello world", + algorithm: "sha1", + }); + expect(hash.length).toBe(40); // SHA-1 = 20 bytes = 40 hex characters + }); + + it("handles empty string input", async () => { + const hash = await hashData({ data: "" }); + expect(typeof hash).toBe("string"); + expect(hash.length).toBe(64); + }); + + it("handles special characters", async () => { + const hash = await hashData({ data: "Hello! @#$%^&*() 日本語" }); + expect(typeof hash).toBe("string"); + expect(hash.length).toBe(64); + }); + + it("handles numeric data converted to string", async () => { + const hash = await hashData({ data: "12345" }); + expect(typeof hash).toBe("string"); + expect(hash.length).toBe(64); + }); +}); diff --git a/src/functions/key-shard/combine-shards-build-wallet.test.ts b/src/functions/key-shard/combine-shards-build-wallet.test.ts new file mode 100644 index 0000000..d417a71 --- /dev/null +++ b/src/functions/key-shard/combine-shards-build-wallet.test.ts @@ -0,0 +1,267 @@ +import { spiltKeyIntoShards } from "./spilt-key-into-shards"; +import { hexToBytes, bytesToString } from "../convertors"; +import { shamirCombine } from "./shamir-secret-sharing"; + +// Mock external wallet SDKs +jest.mock("@meshsdk/wallet", () => ({ + MeshWallet: jest.fn().mockImplementation(() => ({ + init: jest.fn().mockResolvedValue(undefined), + getUsedAddresses: jest.fn().mockResolvedValue(["addr_test1..."]), + })), +})); + +jest.mock("@meshsdk/bitcoin", () => ({ + EmbeddedWallet: jest.fn().mockImplementation(() => ({ + getAddress: jest.fn().mockResolvedValue("bc1q..."), + })), +})); + +jest.mock("@buildonspark/spark-sdk", () => ({ + SparkWallet: { + initialize: jest.fn().mockResolvedValue({ + wallet: { + getAddress: jest.fn().mockResolvedValue("spark1..."), + }, + }), + }, +})); + +// Import after mocks are set up +import { combineShardsBuildWallet } from "./combine-shards-build-wallet"; +import { MeshWallet } from "@meshsdk/wallet"; +import { EmbeddedWallet } from "@meshsdk/bitcoin"; +import { SparkWallet } from "@buildonspark/spark-sdk"; + +describe("combineShardsBuildWallet", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("reconstructs mnemonic from 2 shards and creates wallets", async () => { + const originalMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const shards = await spiltKeyIntoShards(originalMnemonic); + + const result = await combineShardsBuildWallet(0, shards[0]!, shards[1]!); + + expect(result.key).toBe(originalMnemonic); + expect(result.bitcoinWallet).toBeDefined(); + expect(result.cardanoWallet).toBeDefined(); + expect(result.sparkWallet).toBeDefined(); + }); + + it("works with any 2-of-3 shard combination", async () => { + const mnemonic = + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"; + const shards = await spiltKeyIntoShards(mnemonic); + + // Test all combinations + const result1 = await combineShardsBuildWallet(0, shards[0]!, shards[1]!); + const result2 = await combineShardsBuildWallet(0, shards[0]!, shards[2]!); + const result3 = await combineShardsBuildWallet(0, shards[1]!, shards[2]!); + + expect(result1.key).toBe(mnemonic); + expect(result2.key).toBe(mnemonic); + expect(result3.key).toBe(mnemonic); + }); + + it("initializes EmbeddedWallet with correct network for testnet", async () => { + const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const shards = await spiltKeyIntoShards(mnemonic); + + await combineShardsBuildWallet(0, shards[0]!, shards[1]!); + + expect(EmbeddedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + network: "Testnet", + key: expect.objectContaining({ + type: "mnemonic", + words: mnemonic.split(" "), + }), + }), + ); + }); + + it("initializes EmbeddedWallet with correct network for mainnet", async () => { + const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const shards = await spiltKeyIntoShards(mnemonic); + + await combineShardsBuildWallet(1, shards[0]!, shards[1]!); + + expect(EmbeddedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + network: "Mainnet", + key: expect.objectContaining({ + type: "mnemonic", + words: mnemonic.split(" "), + }), + }), + ); + }); + + it("initializes MeshWallet with correct networkId for testnet", async () => { + const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const shards = await spiltKeyIntoShards(mnemonic); + + await combineShardsBuildWallet(0, shards[0]!, shards[1]!); + + expect(MeshWallet).toHaveBeenCalledWith( + expect.objectContaining({ + networkId: 0, + key: expect.objectContaining({ + type: "mnemonic", + words: mnemonic.split(" "), + }), + }), + ); + }); + + it("initializes MeshWallet with correct networkId for mainnet", async () => { + const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const shards = await spiltKeyIntoShards(mnemonic); + + await combineShardsBuildWallet(1, shards[0]!, shards[1]!); + + expect(MeshWallet).toHaveBeenCalledWith( + expect.objectContaining({ + networkId: 1, + key: expect.objectContaining({ + type: "mnemonic", + words: mnemonic.split(" "), + }), + }), + ); + }); + + it("initializes SparkWallet with REGTEST for testnet", async () => { + const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const shards = await spiltKeyIntoShards(mnemonic); + + await combineShardsBuildWallet(0, shards[0]!, shards[1]!); + + expect(SparkWallet.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + mnemonicOrSeed: mnemonic, + options: expect.objectContaining({ + network: "REGTEST", + }), + }), + ); + }); + + it("initializes SparkWallet with MAINNET for mainnet", async () => { + const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const shards = await spiltKeyIntoShards(mnemonic); + + await combineShardsBuildWallet(1, shards[0]!, shards[1]!); + + expect(SparkWallet.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + mnemonicOrSeed: mnemonic, + options: expect.objectContaining({ + network: "MAINNET", + }), + }), + ); + }); + + it("calls cardanoWallet.init()", async () => { + const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const shards = await spiltKeyIntoShards(mnemonic); + + const result = await combineShardsBuildWallet(0, shards[0]!, shards[1]!); + + expect(result.cardanoWallet.init).toHaveBeenCalled(); + }); + + it("passes bitcoinProvider to EmbeddedWallet when provided", async () => { + const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const shards = await spiltKeyIntoShards(mnemonic); + const mockProvider = { getUtxos: jest.fn() }; + + await combineShardsBuildWallet( + 0, + shards[0]!, + shards[1]!, + mockProvider as any, + ); + + expect(EmbeddedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + }), + ); + }); + + it("handles 24-word mnemonic", async () => { + const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + const shards = await spiltKeyIntoShards(mnemonic); + + const result = await combineShardsBuildWallet(0, shards[0]!, shards[2]!); + + expect(result.key).toBe(mnemonic); + expect(result.key.split(" ").length).toBe(24); + }); +}); + +describe("combineShardsBuildWallet error cases", () => { + it("fails with invalid hex shards", async () => { + await expect( + combineShardsBuildWallet(0, "invalid-hex!", "also-invalid!"), + ).rejects.toThrow(); + }); + + it("fails with mismatched shard lengths", async () => { + const mnemonic1 = "short"; + const mnemonic2 = "much longer mnemonic phrase here"; + const shards1 = await spiltKeyIntoShards(mnemonic1); + const shards2 = await spiltKeyIntoShards(mnemonic2); + + await expect( + combineShardsBuildWallet(0, shards1[0]!, shards2[1]!), + ).rejects.toThrow("all shares must have the same byte length"); + }); + + it("fails with shards from different secrets of same length", async () => { + // Both mnemonics have same byte length but different content + const mnemonic1 = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const mnemonic2 = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon actor"; + const shards1 = await spiltKeyIntoShards(mnemonic1); + const shards2 = await spiltKeyIntoShards(mnemonic2); + + // This won't throw but will produce garbage - testing the operation completes + // (In real use, the resulting wallet would be unusable) + const result = await combineShardsBuildWallet(0, shards1[0]!, shards2[1]!); + + // Result won't match either original mnemonic (demonstrates corruption) + expect(result.key).not.toBe(mnemonic1); + expect(result.key).not.toBe(mnemonic2); + }); +}); + +describe("shard reconstruction without wallet creation", () => { + // These tests verify the core shard logic independently of wallet mocks + it("shamirCombine correctly reconstructs from hex shards", async () => { + const originalMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const shards = await spiltKeyIntoShards(originalMnemonic); + + const share1 = hexToBytes(shards[0]!); + const share2 = hexToBytes(shards[1]!); + const reconstructed = await shamirCombine([share1, share2]); + const result = bytesToString(reconstructed); + + expect(result).toBe(originalMnemonic); + }); +}); diff --git a/src/functions/key-shard/shamir-secret-sharing.test.ts b/src/functions/key-shard/shamir-secret-sharing.test.ts new file mode 100644 index 0000000..e243145 --- /dev/null +++ b/src/functions/key-shard/shamir-secret-sharing.test.ts @@ -0,0 +1,226 @@ +import { shamirSplit, shamirCombine } from "./shamir-secret-sharing"; + +describe("shamirSplit", () => { + it("splits a secret into the requested number of shares", async () => { + const secret = new Uint8Array([1, 2, 3, 4, 5]); + const shares = await shamirSplit(secret, 3, 2); + expect(shares.length).toBe(3); + }); + + it("each share has length = secret.length + 1", async () => { + const secret = new Uint8Array([1, 2, 3, 4, 5]); + const shares = await shamirSplit(secret, 3, 2); + for (const share of shares) { + expect(share.length).toBe(6); // 5 + 1 for x-coordinate + } + }); + + it("produces different shares for same secret (randomized)", async () => { + const secret = new Uint8Array([1, 2, 3, 4, 5]); + const shares1 = await shamirSplit(secret, 3, 2); + const shares2 = await shamirSplit(secret, 3, 2); + // At least one share should be different (extremely unlikely to be same) + const allSame = shares1.every((s1, i) => { + const s2 = shares2[i]!; + return s1.every((b, j) => b === s2[j]); + }); + expect(allSame).toBe(false); + }); + + it("works with different threshold values", async () => { + const secret = new Uint8Array([10, 20, 30]); + const shares = await shamirSplit(secret, 5, 3); + expect(shares.length).toBe(5); + }); + + it("throws if secret is empty", async () => { + const secret = new Uint8Array([]); + await expect(shamirSplit(secret, 3, 2)).rejects.toThrow( + "secret cannot be empty", + ); + }); + + it("throws if shares < 2", async () => { + const secret = new Uint8Array([1, 2, 3]); + await expect(shamirSplit(secret, 1, 1)).rejects.toThrow( + "shares must be at least 2 and at most 255", + ); + }); + + it("throws if shares > 255", async () => { + const secret = new Uint8Array([1, 2, 3]); + await expect(shamirSplit(secret, 256, 2)).rejects.toThrow( + "shares must be at least 2 and at most 255", + ); + }); + + it("throws if threshold < 2", async () => { + const secret = new Uint8Array([1, 2, 3]); + await expect(shamirSplit(secret, 3, 1)).rejects.toThrow( + "threshold must be at least 2 and at most 255", + ); + }); + + it("throws if threshold > 255", async () => { + const secret = new Uint8Array([1, 2, 3]); + await expect(shamirSplit(secret, 3, 256)).rejects.toThrow( + "threshold must be at least 2 and at most 255", + ); + }); + + it("throws if shares < threshold", async () => { + const secret = new Uint8Array([1, 2, 3]); + await expect(shamirSplit(secret, 2, 3)).rejects.toThrow( + "shares cannot be less than threshold", + ); + }); + + it("throws if secret is not Uint8Array", async () => { + await expect( + shamirSplit([1, 2, 3] as unknown as Uint8Array, 3, 2), + ).rejects.toThrow("secret must be a Uint8Array"); + }); +}); + +describe("shamirCombine", () => { + it("reconstructs secret from threshold number of shares", async () => { + const secret = new Uint8Array([1, 2, 3, 4, 5]); + const shares = await shamirSplit(secret, 3, 2); + const reconstructed = await shamirCombine([shares[0]!, shares[1]!]); + expect(Array.from(reconstructed)).toEqual(Array.from(secret)); + }); + + it("reconstructs secret from any 2 shares in 2-of-3 scheme", async () => { + const secret = new Uint8Array([10, 20, 30, 40, 50]); + const shares = await shamirSplit(secret, 3, 2); + + // Try all combinations of 2 shares + const combo1 = await shamirCombine([shares[0]!, shares[1]!]); + const combo2 = await shamirCombine([shares[0]!, shares[2]!]); + const combo3 = await shamirCombine([shares[1]!, shares[2]!]); + + expect(Array.from(combo1)).toEqual(Array.from(secret)); + expect(Array.from(combo2)).toEqual(Array.from(secret)); + expect(Array.from(combo3)).toEqual(Array.from(secret)); + }); + + it("reconstructs secret from all 3 shares", async () => { + const secret = new Uint8Array([1, 2, 3]); + const shares = await shamirSplit(secret, 3, 2); + const reconstructed = await shamirCombine(shares); + expect(Array.from(reconstructed)).toEqual(Array.from(secret)); + }); + + it("reconstructs secret in 3-of-5 scheme", async () => { + const secret = new Uint8Array([11, 22, 33, 44]); + const shares = await shamirSplit(secret, 5, 3); + + // Need at least 3 shares + const reconstructed = await shamirCombine([ + shares[0]!, + shares[2]!, + shares[4]!, + ]); + expect(Array.from(reconstructed)).toEqual(Array.from(secret)); + }); + + it("handles single-byte secret", async () => { + const secret = new Uint8Array([42]); + const shares = await shamirSplit(secret, 3, 2); + const reconstructed = await shamirCombine([shares[0]!, shares[1]!]); + expect(Array.from(reconstructed)).toEqual([42]); + }); + + it("handles large secret", async () => { + const secret = new Uint8Array(256); + for (let i = 0; i < 256; i++) { + secret[i] = i; + } + const shares = await shamirSplit(secret, 3, 2); + const reconstructed = await shamirCombine([shares[0]!, shares[2]!]); + expect(Array.from(reconstructed)).toEqual(Array.from(secret)); + }); + + it("throws if shares array is empty", async () => { + await expect(shamirCombine([])).rejects.toThrow( + "shares must have at least 2 and at most 255 elements", + ); + }); + + it("throws if only 1 share provided", async () => { + const secret = new Uint8Array([1, 2, 3]); + const shares = await shamirSplit(secret, 3, 2); + await expect(shamirCombine([shares[0]!])).rejects.toThrow( + "shares must have at least 2 and at most 255 elements", + ); + }); + + it("throws if shares have different lengths", async () => { + const share1 = new Uint8Array([1, 2, 3, 10]); + const share2 = new Uint8Array([4, 5, 20]); + await expect(shamirCombine([share1, share2])).rejects.toThrow( + "all shares must have the same byte length", + ); + }); + + it("throws if share is too short (< 2 bytes)", async () => { + const share1 = new Uint8Array([1]); + const share2 = new Uint8Array([2]); + await expect(shamirCombine([share1, share2])).rejects.toThrow( + "each share must be at least 2 bytes", + ); + }); + + it("throws if duplicate x-coordinates", async () => { + // Create two shares with same x-coordinate (last byte) + const share1 = new Uint8Array([1, 2, 3, 10]); + const share2 = new Uint8Array([4, 5, 6, 10]); // Same x-coordinate + await expect(shamirCombine([share1, share2])).rejects.toThrow( + "shares must contain unique values but a duplicate was found", + ); + }); + + it("throws if shares is not an Array", async () => { + await expect( + shamirCombine("not an array" as unknown as Uint8Array[]), + ).rejects.toThrow("shares must be an Array"); + }); + + it("throws if share is not Uint8Array", async () => { + await expect( + shamirCombine([[1, 2, 3, 10], [4, 5, 6, 20]] as unknown as Uint8Array[]), + ).rejects.toThrow("each share must be a Uint8Array"); + }); +}); + +describe("shamirSplit and shamirCombine roundtrip", () => { + it("preserves secret through split and combine", async () => { + const originalSecret = new TextEncoder().encode("hello world secret"); + const shares = await shamirSplit(originalSecret, 5, 3); + const reconstructed = await shamirCombine([ + shares[1]!, + shares[3]!, + shares[4]!, + ]); + const decoded = new TextDecoder().decode(reconstructed); + expect(decoded).toBe("hello world secret"); + }); + + it("preserves mnemonic through split and combine", async () => { + const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const secret = new TextEncoder().encode(mnemonic); + const shares = await shamirSplit(secret, 3, 2); + const reconstructed = await shamirCombine([shares[0]!, shares[2]!]); + const decoded = new TextDecoder().decode(reconstructed); + expect(decoded).toBe(mnemonic); + }); + + it("handles unicode content", async () => { + const unicodeSecret = new TextEncoder().encode("Hello 世界 🎉 привет"); + const shares = await shamirSplit(unicodeSecret, 3, 2); + const reconstructed = await shamirCombine([shares[1]!, shares[2]!]); + const decoded = new TextDecoder().decode(reconstructed); + expect(decoded).toBe("Hello 世界 🎉 привет"); + }); +}); diff --git a/src/functions/key-shard/spilt-key-into-shards.test.ts b/src/functions/key-shard/spilt-key-into-shards.test.ts new file mode 100644 index 0000000..c24e5b5 --- /dev/null +++ b/src/functions/key-shard/spilt-key-into-shards.test.ts @@ -0,0 +1,165 @@ +import { spiltKeyIntoShards } from "./spilt-key-into-shards"; +import { hexToBytes, bytesToString } from "../convertors"; +import { shamirCombine } from "./shamir-secret-sharing"; + +describe("spiltKeyIntoShards", () => { + it("splits a mnemonic into 3 hex-encoded shards", async () => { + const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const shards = await spiltKeyIntoShards(mnemonic); + + expect(shards.length).toBe(3); + expect(typeof shards[0]).toBe("string"); + expect(typeof shards[1]).toBe("string"); + expect(typeof shards[2]).toBe("string"); + }); + + it("produces hex-encoded shards", async () => { + const key = "test secret key"; + const shards = await spiltKeyIntoShards(key); + + // Each shard should be valid hex (only 0-9, a-f characters) + for (const shard of shards) { + expect(shard).toMatch(/^[0-9a-f]+$/); + } + }); + + it("produces unique shards", async () => { + const key = "some secret"; + const shards = await spiltKeyIntoShards(key); + + expect(shards[0]).not.toBe(shards[1]); + expect(shards[0]).not.toBe(shards[2]); + expect(shards[1]).not.toBe(shards[2]); + }); + + it("produces shards of consistent length", async () => { + const key = "test key 12345"; + const shards = await spiltKeyIntoShards(key); + + // All shards should have the same length (key bytes + 1 for x-coordinate, then hex-encoded) + expect(shards[0]!.length).toBe(shards[1]!.length); + expect(shards[1]!.length).toBe(shards[2]!.length); + }); + + it("shard length is (key_length + 1) * 2 due to hex encoding", async () => { + const key = "hello"; // 5 characters = 5 bytes + const shards = await spiltKeyIntoShards(key); + + // Each shard: 5 bytes + 1 x-coordinate byte = 6 bytes = 12 hex chars + expect(shards[0]!.length).toBe(12); + }); + + it("handles empty string", async () => { + // Note: shamirSplit throws on empty secret, but spiltKeyIntoShards converts string to bytes first + // An empty string becomes an empty Uint8Array, which should throw + await expect(spiltKeyIntoShards("")).rejects.toThrow("secret cannot be empty"); + }); + + it("handles unicode strings", async () => { + const unicodeKey = "Hello 世界 🎉"; + const shards = await spiltKeyIntoShards(unicodeKey); + + expect(shards.length).toBe(3); + // Unicode characters take multiple bytes in UTF-8 + expect(shards[0]!.length).toBeGreaterThan(20); + }); + + it("handles long mnemonics", async () => { + const longMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + const shards = await spiltKeyIntoShards(longMnemonic); + + expect(shards.length).toBe(3); + }); + + it("produces different shards on each call (due to randomization)", async () => { + const key = "same key"; + const shards1 = await spiltKeyIntoShards(key); + const shards2 = await spiltKeyIntoShards(key); + + // At least one shard should differ between calls + const allSame = + shards1[0] === shards2[0] && + shards1[1] === shards2[1] && + shards1[2] === shards2[2]; + expect(allSame).toBe(false); + }); +}); + +describe("spiltKeyIntoShards 2-of-3 reconstruction", () => { + it("can reconstruct original key from shards[0] and shards[1]", async () => { + const originalKey = "my secret mnemonic phrase"; + const shards = await spiltKeyIntoShards(originalKey); + + const share1 = hexToBytes(shards[0]!); + const share2 = hexToBytes(shards[1]!); + const reconstructed = await shamirCombine([share1, share2]); + const result = bytesToString(reconstructed); + + expect(result).toBe(originalKey); + }); + + it("can reconstruct original key from shards[0] and shards[2]", async () => { + const originalKey = "my secret mnemonic phrase"; + const shards = await spiltKeyIntoShards(originalKey); + + const share1 = hexToBytes(shards[0]!); + const share3 = hexToBytes(shards[2]!); + const reconstructed = await shamirCombine([share1, share3]); + const result = bytesToString(reconstructed); + + expect(result).toBe(originalKey); + }); + + it("can reconstruct original key from shards[1] and shards[2]", async () => { + const originalKey = "my secret mnemonic phrase"; + const shards = await spiltKeyIntoShards(originalKey); + + const share2 = hexToBytes(shards[1]!); + const share3 = hexToBytes(shards[2]!); + const reconstructed = await shamirCombine([share2, share3]); + const result = bytesToString(reconstructed); + + expect(result).toBe(originalKey); + }); + + it("can reconstruct from all 3 shards", async () => { + const originalKey = "test key"; + const shards = await spiltKeyIntoShards(originalKey); + + const share1 = hexToBytes(shards[0]!); + const share2 = hexToBytes(shards[1]!); + const share3 = hexToBytes(shards[2]!); + const reconstructed = await shamirCombine([share1, share2, share3]); + const result = bytesToString(reconstructed); + + expect(result).toBe(originalKey); + }); + + it("preserves 24-word mnemonic through split and combine", async () => { + const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + const shards = await spiltKeyIntoShards(mnemonic); + + const share1 = hexToBytes(shards[0]!); + const share2 = hexToBytes(shards[2]!); + const reconstructed = await shamirCombine([share1, share2]); + const result = bytesToString(reconstructed); + + expect(result).toBe(mnemonic); + }); + + it("preserves 12-word mnemonic through split and combine", async () => { + const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const shards = await spiltKeyIntoShards(mnemonic); + + const share1 = hexToBytes(shards[1]!); + const share2 = hexToBytes(shards[2]!); + const reconstructed = await shamirCombine([share1, share2]); + const result = bytesToString(reconstructed); + + expect(result).toBe(mnemonic); + }); +}); diff --git a/src/index.ts b/src/index.ts index 5542709..6f989d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,20 @@ export * from "./wallet-user-controlled"; // Re-export Spark utilities to avoid installing full SDK in apps export { isValidSparkAddress, + decodeSparkAddress, + getNetworkFromSparkAddress, type Bech32mTokenIdentifier, + encodeBech32mTokenIdentifier, + decodeBech32mTokenIdentifier, + getNetworkFromBech32mTokenIdentifier, SparkWallet, } from "@buildonspark/spark-sdk"; + +// Re-export our own Spark utilities +export { + extractIdentityPublicKey, + getSparkAddressFromPubkey, + convertLegacyToNewFormat, +} from "./chains/spark/utils"; + +export { type IssuerTokenMetadata } from "@buildonspark/issuer-sdk"; diff --git a/src/sdk/index.ts b/src/sdk/index.ts index e9dac87..241fc23 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -3,6 +3,7 @@ import { WalletDeveloperControlled } from "./wallet-developer-controlled/"; import { Web3Project } from "../types"; import { IFetcher, ISubmitter } from "@meshsdk/common"; import { Sponsorship } from "./sponsorship"; +import { Tokenization } from "./tokenization"; export const meshUniversalStaticUtxo = { mainnet: { @@ -93,6 +94,7 @@ export class Web3Sdk { project: Web3Project | undefined; wallet: WalletDeveloperControlled; sponsorship: Sponsorship; + tokenization: Tokenization; constructor({ appUrl, @@ -130,6 +132,9 @@ export class Web3Sdk { this.sponsorship = new Sponsorship({ sdk: this, }); + this.tokenization = new Tokenization({ + sdk: this, + }); } async getProject() { @@ -151,3 +156,4 @@ export class Web3Sdk { } export * from "./sponsorship"; +export * from "./wallet-developer-controlled"; diff --git a/src/sdk/sponsorship/index.ts b/src/sdk/sponsorship/index.ts index 16d7f27..8dd3e63 100644 --- a/src/sdk/sponsorship/index.ts +++ b/src/sdk/sponsorship/index.ts @@ -1,5 +1,5 @@ import { Web3Sdk } from ".."; -import { UTxO } from "@meshsdk/common"; +import { Asset, UTxO } from "@meshsdk/common"; import { MeshTxBuilder } from "@meshsdk/transaction"; import { meshUniversalStaticUtxo } from "../index"; import { SponsorshipTxParserPostRequestBody } from "../../types"; @@ -36,9 +36,7 @@ export class Sponsorship { private readonly sdk: Web3Sdk; constructor({ sdk }: { sdk: Web3Sdk }) { - { - this.sdk = sdk; - } + this.sdk = sdk; } /** @@ -214,11 +212,13 @@ export class Sponsorship { if (!isUtxoUsed) { selectedUtxo = _selectedUtxo; - await this.dbAppendUtxosUsed( - config, - selectedUtxo.input.txHash, - selectedUtxo.input.outputIndex, - ); + if (selectedUtxo) { + await this.dbAppendUtxosUsed( + config, + selectedUtxo.input.txHash, + selectedUtxo.input.outputIndex, + ); + } } } @@ -339,9 +339,8 @@ export class Sponsorship { } private async getSponsorWallet(projectWalletId: string) { - const networkId = this.sdk.network === "mainnet" ? 1 : 0; - const wallet = await this.sdk.wallet.getWallet(projectWalletId, networkId); - return wallet.wallet; + const walletResult = await this.sdk.wallet.cardano.getWallet(projectWalletId); + return walletResult.wallet; } /** @@ -443,7 +442,7 @@ export class Sponsorship { // Add balance from UTXOs that are not the exact sponsor amount for (const utxo of utxosAsInput) { const lovelaceAmount = utxo.output.amount.find( - (amount) => amount.unit === "lovelace", + (amount: Asset) => amount.unit === "lovelace", ); if (lovelaceAmount) { totalBalance += parseInt(lovelaceAmount.quantity); @@ -453,7 +452,7 @@ export class Sponsorship { // Add balance from UTXOs that were pending for too long for (const utxo of utxosNotSpentAfterDuration) { const lovelaceAmount = utxo.output.amount.find( - (amount) => amount.unit === "lovelace", + (amount: Asset) => amount.unit === "lovelace", ); if (lovelaceAmount) { totalBalance += parseInt(lovelaceAmount.quantity); diff --git a/src/sdk/tokenization/cardano.ts b/src/sdk/tokenization/cardano.ts new file mode 100644 index 0000000..b522712 --- /dev/null +++ b/src/sdk/tokenization/cardano.ts @@ -0,0 +1,324 @@ +import { Web3Sdk } from ".."; +import { decryptWithPrivateKey } from "../../functions"; +import { MultiChainWalletInfo } from "../../types"; +import { MeshWallet } from "@meshsdk/wallet"; +import { + TokenizationTransaction, + TokenizationFrozenAddress, + TokenizationPaginationInfo, + TokenizationPolicy, + CreateTokenParams, + MintTokensParams, + TransferTokensParams, + BurnTokensParams, + FreezeTokensParams, + UnfreezeTokensParams, + ListTransactionsParams, + ListFrozenAddressesParams, + ListTokenizationPoliciesParams, +} from "../../types/cardano/tokenization"; + +export type { + CreateTokenParams, + MintTokensParams, + TransferTokensParams, + BurnTokensParams, + FreezeTokensParams, + UnfreezeTokensParams, + ListTransactionsParams, + ListFrozenAddressesParams, + ListTokenizationPoliciesParams, + TokenizationTransaction, + TokenizationFrozenAddress, + TokenizationPaginationInfo, + TokenizationPolicy, +}; + +export class TokenizationCardano { + private readonly sdk: Web3Sdk; + private wallet: MeshWallet | null = null; + private walletInfo: MultiChainWalletInfo | null = null; + + constructor({ sdk }: { sdk: Web3Sdk }) { + this.sdk = sdk; + } + + setWallet(wallet: MeshWallet, walletInfo: MultiChainWalletInfo): void { + this.wallet = wallet; + this.walletInfo = walletInfo; + } + + getWalletId(): string | null { + return this.walletInfo?.id ?? null; + } + + private async initWalletByWalletId(walletId: string): Promise { + if (this.sdk.privateKey === undefined) { + throw new Error("Private key not found - required to decrypt wallet"); + } + + const networkParam = this.sdk.network === "mainnet" ? "mainnet" : "testnet"; + const { data, status } = await this.sdk.axiosInstance.get( + `api/project-wallet/${this.sdk.projectId}/${walletId}?chain=cardano&network=${networkParam}`, + ); + + if (status !== 200) { + throw new Error("Failed to get Cardano wallet"); + } + + const walletInfo = data as MultiChainWalletInfo; + + const mnemonic = await decryptWithPrivateKey({ + privateKey: this.sdk.privateKey, + encryptedDataJSON: walletInfo.key, + }); + + const networkId = this.sdk.network === "mainnet" ? 1 : 0; + const wallet = new MeshWallet({ + networkId, + fetcher: undefined, + submitter: undefined, + key: { + type: "mnemonic", + words: mnemonic.split(" "), + }, + }); + + this.wallet = wallet; + this.walletInfo = walletInfo; + } + + async initWallet(tokenId: string): Promise { + const policy = await this.getTokenizationPolicy(tokenId); + await this.initWalletByWalletId(policy.walletId); + return policy; + } + + async createToken(params: CreateTokenParams): Promise<{ + txId: string; + tokenId: string; + walletId: string; + }> { + if (!this.wallet || !this.walletInfo) { + throw new Error("No wallet loaded. Use createWallet({ enableTokenization: true }) first."); + } + + // TODO: Implement CIP113 token creation + // 1. Create minting policy script + // 2. Build and submit token creation transaction + // 3. Return token ID (policy ID + asset name) + throw new Error("Cardano token creation not yet implemented"); + } + + async mintTokens(params: MintTokensParams): Promise { + if (!this.wallet || !this.walletInfo) { + throw new Error("No wallet loaded. Call initWallet(tokenId) first."); + } + + // TODO: Implement CIP113 minting + throw new Error("Cardano token minting not yet implemented"); + } + + async getTokenBalance(): Promise<{ balance: string }> { + if (!this.wallet) { + throw new Error("No wallet loaded. Call initWallet(tokenId) first."); + } + + // TODO: Query UTxOs for token balance + throw new Error("Cardano token balance query not yet implemented"); + } + + async getTokenMetadata() { + if (!this.wallet) { + throw new Error("No wallet loaded. Call initWallet(tokenId) first."); + } + + // TODO: Query on-chain metadata (CIP25/CIP68) + throw new Error("Cardano token metadata query not yet implemented"); + } + + async transferTokens(params: TransferTokensParams): Promise { + if (!this.wallet || !this.walletInfo) { + throw new Error("No wallet loaded. Call initWallet(tokenId) first."); + } + + // TODO: Build and submit transfer transaction + throw new Error("Cardano token transfer not yet implemented"); + } + + async burnTokens(params: BurnTokensParams): Promise { + if (!this.wallet || !this.walletInfo) { + throw new Error("No wallet loaded. Call initWallet(tokenId) first."); + } + + // TODO: Build and submit burn transaction + throw new Error("Cardano token burning not yet implemented"); + } + + async freezeTokens(params: FreezeTokensParams): Promise<{ + txId?: string; + address: string; + }> { + if (!this.wallet || !this.walletInfo) { + throw new Error("No wallet loaded. Call initWallet(tokenId) first."); + } + + // TODO: Implement freeze logic (on-chain or off-chain) + throw new Error("Cardano token freezing not yet implemented"); + } + + async unfreezeTokens(params: UnfreezeTokensParams): Promise<{ + txId?: string; + address: string; + }> { + if (!this.wallet || !this.walletInfo) { + throw new Error("No wallet loaded. Call initWallet(tokenId) first."); + } + + // TODO: Implement unfreeze logic + throw new Error("Cardano token unfreezing not yet implemented"); + } + + async getFrozenAddresses(params?: ListFrozenAddressesParams): Promise<{ + frozenAddresses: TokenizationFrozenAddress[]; + pagination: TokenizationPaginationInfo; + }> { + if (!this.wallet) { + throw new Error("No wallet loaded. Call initWallet(tokenId) first."); + } + + // TODO: Get token ID from wallet metadata + const tokenId = ""; + + const { includeUnfrozen = false, page = 1, limit = 15 } = params || {}; + + const { data, status } = await this.sdk.axiosInstance.get( + `api/tokenization/frozen-addresses`, + { + params: { + tokenId, + projectId: this.sdk.projectId, + includeUnfrozen, + page, + limit, + }, + } + ); + + if (status === 200) { + return data as { + frozenAddresses: TokenizationFrozenAddress[]; + pagination: TokenizationPaginationInfo; + }; + } + + throw new Error("Failed to get frozen addresses"); + } + + async getTransactions(params?: ListTransactionsParams): Promise<{ + transactions: TokenizationTransaction[]; + pagination: TokenizationPaginationInfo; + }> { + if (!this.wallet) { + throw new Error("No wallet loaded. Call initWallet(tokenId) first."); + } + + // TODO: Get token ID from wallet metadata + const tokenId = ""; + + const { type, page = 1, limit = 50 } = params || {}; + + const { data, status } = await this.sdk.axiosInstance.get( + `api/tokenization/transactions`, + { + params: { + tokenId, + projectId: this.sdk.projectId, + ...(type && { type }), + page, + limit, + }, + } + ); + + if (status === 200) { + return data as { + transactions: TokenizationTransaction[]; + pagination: TokenizationPaginationInfo; + }; + } + + throw new Error("Failed to get transactions"); + } + + async getTokenizationPolicies(params?: ListTokenizationPoliciesParams): Promise<{ + tokens: TokenizationPolicy[]; + pagination: TokenizationPaginationInfo; + }> { + const { tokenId, page = 1, limit = 15 } = params || {}; + + const { data, status } = await this.sdk.axiosInstance.get( + `api/tokenization/policies`, + { + params: { + projectId: this.sdk.projectId, + chain: "cardano", + ...(tokenId && { tokenId }), + page, + limit, + }, + } + ); + + if (status === 200) { + return data as { + tokens: TokenizationPolicy[]; + pagination: TokenizationPaginationInfo; + }; + } + + throw new Error("Failed to get tokenization policies"); + } + + async getTokenizationPolicy(tokenId: string): Promise { + const { tokens } = await this.getTokenizationPolicies({ tokenId, limit: 1 }); + const policy = tokens[0]; + + if (!policy) { + throw new Error("Tokenization policy not found"); + } + + return policy; + } + + private async logTransaction(params: { + tokenId: string; + walletInfo: MultiChainWalletInfo; + type: "create" | "mint" | "burn" | "transfer" | "freeze" | "unfreeze"; + txHash?: string; + amount?: string; + fromAddress?: string; + toAddress?: string; + status?: string; + metadata?: Record; + }): Promise { + try { + await this.sdk.axiosInstance.post("/api/tokenization/transactions", { + tokenId: params.tokenId, + projectId: this.sdk.projectId, + projectWalletId: params.walletInfo.id, + type: params.type, + chain: "cardano", + network: this.sdk.network, + txHash: params.txHash, + amount: params.amount, + fromAddress: params.fromAddress, + toAddress: params.toAddress, + status: params.status || "success", + metadata: params.metadata, + }); + } catch (error) { + console.warn(`Failed to log ${params.type} transaction:`, error); + } + } +} diff --git a/src/sdk/tokenization/index.ts b/src/sdk/tokenization/index.ts new file mode 100644 index 0000000..19448e8 --- /dev/null +++ b/src/sdk/tokenization/index.ts @@ -0,0 +1,15 @@ +import { Web3Sdk } from ".."; +import { TokenizationSpark } from "./spark"; +import { TokenizationCardano } from "./cardano"; + +export class Tokenization { + private readonly sdk: Web3Sdk; + spark: TokenizationSpark; + cardano: TokenizationCardano; + + constructor({ sdk }: { sdk: Web3Sdk }) { + this.sdk = sdk; + this.spark = new TokenizationSpark({ sdk: this.sdk }); + this.cardano = new TokenizationCardano({ sdk: this.sdk }); + } +} diff --git a/src/sdk/tokenization/spark.ts b/src/sdk/tokenization/spark.ts new file mode 100644 index 0000000..219cc88 --- /dev/null +++ b/src/sdk/tokenization/spark.ts @@ -0,0 +1,678 @@ +import { Web3Sdk } from ".."; +import { decryptWithPrivateKey } from "../../functions"; +import { MultiChainWalletInfo } from "../../types"; +import { IssuerSparkWallet } from "@buildonspark/issuer-sdk"; +import { v4 as uuidv4 } from "uuid"; +import { + Bech32mTokenIdentifier, + decodeBech32mTokenIdentifier, + encodeBech32mTokenIdentifier, +} from "@buildonspark/spark-sdk"; +import { extractIdentityPublicKey } from "../../chains/spark/utils"; +import { SparkFreezeResult } from "../../types/spark/dev-wallet"; +import { + TokenizationTransaction, + TokenizationFrozenAddress, + TokenizationPaginationInfo, + TokenizationPolicy, + InitTokenizationParams, + CreateTokenParams, + MintTokensParams, + TransferTokensParams, + BurnTokensParams, + FreezeTokensParams, + UnfreezeTokensParams, + ListTransactionsParams, + ListFrozenAddressesParams, + ListTokenizationPoliciesParams, +} from "../../types/spark/tokenization"; + +export type { + InitTokenizationParams, + CreateTokenParams, + MintTokensParams, + TransferTokensParams, + BurnTokensParams, + FreezeTokensParams, + UnfreezeTokensParams, + ListTransactionsParams, + ListFrozenAddressesParams, + ListTokenizationPoliciesParams, + TokenizationTransaction, + TokenizationFrozenAddress, + TokenizationPaginationInfo, + TokenizationPolicy, +}; + +/** + * The `TokenizationSpark` class provides methods for token operations on Spark network. + * + * @example + * ```typescript + * const sdk = new Web3Sdk({ ... }); + * + * // Create token (wallet is created automatically) + * const { tokenId, walletId } = await sdk.tokenization.spark.createToken({ + * tokenName: "MyToken", + * tokenTicker: "MTK", + * decimals: 8, + * isFreezable: true, + * }); + * + * // Load existing token by token ID + * const policy = await sdk.tokenization.spark.initTokenization({ tokenId: "btknrt1..." }); + * + * // Perform token operations + * await sdk.tokenization.spark.mintTokens({ amount: BigInt("1000000") }); + * const balance = await sdk.tokenization.spark.getTokenBalance(); + * ``` + */ +export class TokenizationSpark { + private readonly sdk: Web3Sdk; + private wallet: IssuerSparkWallet | null = null; + private walletInfo: MultiChainWalletInfo | null = null; + private walletNetwork: "MAINNET" | "REGTEST" = "MAINNET"; + + constructor({ sdk }: { sdk: Web3Sdk }) { + this.sdk = sdk; + } + + /** + * Gets the current wallet ID if one is loaded. + */ + getWalletId(): string | null { + return this.walletInfo?.id ?? null; + } + + /** + * Clears the currently loaded wallet state. + * Call this before creating a new token if you want to start fresh. + */ + clearWallet(): void { + this.wallet = null; + this.walletInfo = null; + } + + /** + * Internal method to initialize the wallet by wallet ID. + */ + private async initWalletByWalletId(walletId: string): Promise { + const networkParam = this.sdk.network === "mainnet" ? "mainnet" : "regtest"; + const { data, status } = await this.sdk.axiosInstance.get( + `api/project-wallet/${this.sdk.projectId}/${walletId}?chain=spark&network=${networkParam}`, + ); + + if (status !== 200) { + throw new Error("Failed to get Spark wallet"); + } + + const walletProject = data as MultiChainWalletInfo; + this.walletNetwork = this.sdk.network === "mainnet" ? "MAINNET" : "REGTEST"; + + if (this.sdk.privateKey === undefined) { + throw new Error("Private key not found - required to decrypt wallet"); + } + + const mnemonic = await decryptWithPrivateKey({ + privateKey: this.sdk.privateKey, + encryptedDataJSON: walletProject.key, + }); + + const { wallet } = await IssuerSparkWallet.initialize({ + mnemonicOrSeed: mnemonic, + options: { + network: this.walletNetwork, + }, + }); + + this.wallet = wallet; + this.walletInfo = walletProject; + } + + /** + * Initializes the tokenization by token ID. + * + * @param params - { tokenId } - the token ID to load + * @returns The tokenization policy + * + * @example + * ```typescript + * // Load existing token by token ID + * const policy = await sdk.tokenization.spark.initTokenization({ tokenId: "btknrt1..." }); + * + * // Then perform operations + * await sdk.tokenization.spark.mintTokens({ amount: BigInt(1000) }); + * ``` + */ + async initTokenization(params: InitTokenizationParams): Promise { + const normalizedTokenId = this.normalizeTokenId(params.tokenId); + const policy = await this.getTokenizationPolicy(normalizedTokenId); + await this.initWalletByWalletId(policy.walletId); + return policy; + } + + /** + * Normalizes token ID to hex format. + * If bech32m encoded (btkn1.., btknrt1.., etc), decodes to hex. Otherwise returns as-is. + */ + private normalizeTokenId(tokenId: string): string { + const network = this.sdk.network === "mainnet" ? "MAINNET" : "REGTEST"; + try { + const decoded = decodeBech32mTokenIdentifier( + tokenId as Bech32mTokenIdentifier, + network, + ); + return Buffer.from(decoded.tokenIdentifier).toString("hex"); + } catch { + return tokenId; + } + } + + /** + * Creates a new token on the Spark network. + * Automatically creates a new wallet if none is loaded. + * + * @param params - Token creation parameters + * @returns Object containing txId, tokenId, and walletId + * + * @example + * ```typescript + * // Simple one-step token creation (creates wallet automatically) + * const { tokenId, walletId } = await sdk.tokenization.spark.createToken({ + * tokenName: "MyToken", + * tokenTicker: "MTK", + * decimals: 8, + * isFreezable: true, + * }); + * + * // Or load existing wallet first + * await sdk.tokenization.spark.initTokenization({ tokenId: "existing-token-id" }); + * const { tokenId } = await sdk.tokenization.spark.createToken({ + * tokenName: "MyToken", + * tokenTicker: "MTK", + * decimals: 8, + * isFreezable: true, + * }); + * ``` + */ + async createToken(params: CreateTokenParams): Promise<{ + txId: string; + tokenId: string; + walletId: string; + }> { + if (!this.wallet || !this.walletInfo) { + const { info, sparkIssuerWallet } = await this.sdk.wallet.createWallet({ + tags: ["tokenization", "spark"], + }); + + this.walletNetwork = + this.sdk.network === "mainnet" ? "MAINNET" : "REGTEST"; + this.walletInfo = info; + this.wallet = sparkIssuerWallet; + } + + const txId = await this.wallet.createToken({ + tokenName: params.tokenName, + tokenTicker: params.tokenTicker, + decimals: params.decimals, + maxSupply: params.maxSupply, + isFreezable: params.isFreezable, + }); + + const tokenMetadata = await this.wallet.getIssuerTokenMetadata(); + const tokenIdHex = Buffer.from(tokenMetadata.rawTokenIdentifier).toString( + "hex", + ); + const tokenId = encodeBech32mTokenIdentifier({ + tokenIdentifier: tokenMetadata.rawTokenIdentifier, + network: this.walletNetwork, + }); + + // Save tokenization policy to database + try { + await this.sdk.axiosInstance.post("/api/tokenization/tokens", { + tokenId: tokenIdHex, + projectId: this.sdk.projectId, + walletId: this.walletInfo.id, + chain: "spark", + network: this.walletNetwork.toLowerCase(), + }); + + // Log the create transaction + await this.logTransaction({ + txId, + tokenId: tokenIdHex, + walletInfo: this.walletInfo, + type: "create", + }); + } catch (saveError) { + console.warn("Failed to save token to database:", saveError); + } + + return { txId, tokenId, walletId: this.walletInfo.id }; + } + + /** + * Mints tokens from the issuer wallet. + * Requires initTokenization() to be called first. + * + * @param params - Mint parameters including amount + * @returns Transaction ID of the mint operation + */ + async mintTokens(params: MintTokensParams): Promise { + if (!this.wallet || !this.walletInfo) { + throw new Error("No wallet loaded. Call initTokenization(tokenId) first."); + } + + const txHash = await this.wallet.mintTokens(params.amount); + + const tokenMetadata = await this.wallet.getIssuerTokenMetadata(); + const tokenId = Buffer.from(tokenMetadata.rawTokenIdentifier).toString( + "hex", + ); + + await this.logTransaction({ + txId: txHash, + tokenId, + walletInfo: this.walletInfo, + type: "mint", + amount: params.amount.toString(), + }); + + return txHash; + } + + /** + * Gets the token balance for an issuer wallet. + * Requires initTokenization() to be called first. + * + * @returns Balance information + */ + async getTokenBalance(): Promise<{ balance: string }> { + if (!this.wallet) { + throw new Error("No wallet loaded. Call initTokenization(tokenId) first."); + } + const result = await this.wallet.getIssuerTokenBalance(); + return { balance: result.balance.toString() }; + } + + /** + * Gets metadata for the token created by an issuer wallet. + * Requires initTokenization() to be called first. + * + * @returns Token metadata + */ + async getTokenMetadata() { + if (!this.wallet) { + throw new Error("No wallet loaded. Call initTokenization(tokenId) first."); + } + return await this.wallet.getIssuerTokenMetadata(); + } + + /** + * Transfers tokens from the issuer wallet to another address. + * Requires initTokenization() to be called first. + * + * @param params - Transfer parameters + * @returns Transaction ID of the transfer + */ + async transferTokens(params: TransferTokensParams): Promise { + if (!this.wallet || !this.walletInfo) { + throw new Error("No wallet loaded. Call initTokenization(tokenId) first."); + } + + const tokenMetadata = await this.wallet.getIssuerTokenMetadata(); + const tokenId = Buffer.from(tokenMetadata.rawTokenIdentifier).toString( + "hex", + ); + const bech32mTokenId = encodeBech32mTokenIdentifier({ + tokenIdentifier: tokenMetadata.rawTokenIdentifier, + network: this.walletNetwork, + }); + const txHash = await this.wallet.transferTokens({ + tokenIdentifier: bech32mTokenId, + tokenAmount: params.amount, + receiverSparkAddress: params.toAddress, + }); + + const issuerAddress = await this.wallet.getSparkAddress(); + await this.logTransaction({ + txId: txHash, + tokenId, + walletInfo: this.walletInfo, + type: "transfer", + amount: params.amount.toString(), + fromAddress: issuerAddress, + toAddress: params.toAddress, + }); + + return txHash; + } + + /** + * Burns tokens permanently from circulation. + * Requires initTokenization() to be called first. + * + * @param params - Burn parameters + * @returns Transaction ID of the burn operation + */ + async burnTokens(params: BurnTokensParams): Promise { + if (!this.wallet || !this.walletInfo) { + throw new Error("No wallet loaded. Call initTokenization(tokenId) first."); + } + + const txHash = await this.wallet.burnTokens(params.amount); + + const tokenMetadata = await this.wallet.getIssuerTokenMetadata(); + const tokenId = Buffer.from(tokenMetadata.rawTokenIdentifier).toString( + "hex", + ); + + await this.logTransaction({ + txId: txHash, + tokenId, + walletInfo: this.walletInfo, + type: "burn", + amount: params.amount.toString(), + }); + + return txHash; + } + + /** + * Freezes tokens at a specific Spark address for compliance purposes. + * Requires initTokenization() to be called first. + * + * @param params - Freeze parameters + * @returns Freeze operation results + */ + async freezeTokens(params: FreezeTokensParams): Promise { + if (!this.wallet || !this.walletInfo) { + throw new Error("No wallet loaded. Call initTokenization(tokenId) first."); + } + + const result = await this.wallet.freezeTokens(params.address); + + const tokenMetadata = await this.wallet.getIssuerTokenMetadata(); + const tokenId = Buffer.from(tokenMetadata.rawTokenIdentifier).toString( + "hex", + ); + + try { + const publicKeyHash = extractIdentityPublicKey(params.address); + + if (!publicKeyHash) { + throw new Error( + `Failed to extract public key hash from Spark address: ${params.address}`, + ); + } + + // Update frozen addresses table + await this.sdk.axiosInstance.post("/api/tokenization/frozen-addresses", { + tokenId, + projectId: this.sdk.projectId, + projectWalletId: this.walletInfo.id, + chain: "spark", + network: this.walletNetwork.toLowerCase(), + publicKeyHash, + isFrozen: true, + freezeReason: params.freezeReason || "Frozen by issuer", + frozenAt: new Date().toISOString(), + }); + + // Log the freeze transaction + await this.logTransaction({ + txId: uuidv4(), + tokenId, + walletInfo: this.walletInfo, + type: "freeze", + fromAddress: "Issuer Wallet", + toAddress: params.address, + amount: result.impactedTokenAmount.toString(), + }); + } catch (saveError) { + console.warn("Failed to save freeze operation:", saveError); + } + + return { + impactedOutputIds: result.impactedOutputIds, + impactedTokenAmount: result.impactedTokenAmount.toString(), + }; + } + + /** + * Unfreezes tokens at a specific Spark address. + * Requires initTokenization() to be called first. + * + * @param params - Unfreeze parameters + * @returns Unfreeze operation results + */ + async unfreezeTokens( + params: UnfreezeTokensParams, + ): Promise { + if (!this.wallet || !this.walletInfo) { + throw new Error("No wallet loaded. Call initTokenization(tokenId) first."); + } + + const result = await this.wallet.unfreezeTokens(params.address); + + const tokenMetadata = await this.wallet.getIssuerTokenMetadata(); + const tokenId = Buffer.from(tokenMetadata.rawTokenIdentifier).toString( + "hex", + ); + + try { + const publicKeyHash = extractIdentityPublicKey(params.address); + + if (!publicKeyHash) { + throw new Error( + `Failed to extract public key hash from Spark address: ${params.address}`, + ); + } + + // Update frozen addresses table + await this.sdk.axiosInstance.put("/api/tokenization/frozen-addresses", { + tokenId, + publicKeyHash, + projectId: this.sdk.projectId, + projectWalletId: this.walletInfo.id, + }); + + // Log the unfreeze transaction + await this.logTransaction({ + txId: uuidv4(), + tokenId, + walletInfo: this.walletInfo, + type: "unfreeze", + fromAddress: "Issuer Wallet", + toAddress: params.address, + amount: result.impactedTokenAmount.toString(), + }); + } catch (saveError) { + console.warn("Failed to save unfreeze operation:", saveError); + } + + return { + impactedOutputIds: result.impactedOutputIds, + impactedTokenAmount: result.impactedTokenAmount.toString(), + }; + } + + /** + * Lists frozen addresses for a token from the database. + * Requires initTokenization() to be called first. + * + * @param params - Query parameters including pagination + * @returns List of frozen addresses with pagination info + */ + async getFrozenAddresses(params?: ListFrozenAddressesParams): Promise<{ + frozenAddresses: TokenizationFrozenAddress[]; + pagination: TokenizationPaginationInfo; + }> { + if (!this.wallet) { + throw new Error("No wallet loaded. Call initTokenization(tokenId) first."); + } + + const { includeUnfrozen = false, page = 1, limit = 15 } = params || {}; + + const tokenMetadata = await this.wallet.getIssuerTokenMetadata(); + const tokenId = Buffer.from(tokenMetadata.rawTokenIdentifier).toString( + "hex", + ); + + const { data, status } = await this.sdk.axiosInstance.get( + `api/tokenization/frozen-addresses`, + { + params: { + tokenId, + projectId: this.sdk.projectId, + includeUnfrozen, + page, + limit, + }, + }, + ); + + if (status === 200) { + return data as { + frozenAddresses: TokenizationFrozenAddress[]; + pagination: TokenizationPaginationInfo; + }; + } + + throw new Error("Failed to get frozen addresses"); + } + + /** + * Lists token transactions from the database. + * Requires initTokenization() to be called first. + * + * @param params - Query parameters including type filter and pagination + * @returns List of transactions with pagination info + */ + async getTransactions(params?: ListTransactionsParams): Promise<{ + transactions: TokenizationTransaction[]; + pagination: TokenizationPaginationInfo; + }> { + if (!this.wallet) { + throw new Error("No wallet loaded. Call initTokenization(tokenId) first."); + } + + const { type, page = 1, limit = 50 } = params || {}; + + const tokenMetadata = await this.wallet.getIssuerTokenMetadata(); + const tokenId = Buffer.from(tokenMetadata.rawTokenIdentifier).toString( + "hex", + ); + + const { data, status } = await this.sdk.axiosInstance.get( + `api/tokenization/transactions`, + { + params: { + tokenId, + projectId: this.sdk.projectId, + ...(type && { type }), + page, + limit, + }, + }, + ); + + if (status === 200) { + return data as { + transactions: TokenizationTransaction[]; + pagination: TokenizationPaginationInfo; + }; + } + + throw new Error("Failed to get transactions"); + } + + /** + * Lists tokenization policies for the current project from the database. + * + * @param params - Optional filter and pagination parameters + * @returns List of tokenization policies with pagination info + */ + async getTokenizationPolicies( + params?: ListTokenizationPoliciesParams, + ): Promise<{ + tokens: TokenizationPolicy[]; + pagination: TokenizationPaginationInfo; + }> { + const { tokenId, page = 1, limit = 15 } = params || {}; + + const { data, status } = await this.sdk.axiosInstance.get( + `api/tokenization/policies`, + { + params: { + projectId: this.sdk.projectId, + ...(tokenId && { tokenId }), + page, + limit, + }, + }, + ); + + if (status === 200) { + return data as { + tokens: TokenizationPolicy[]; + pagination: TokenizationPaginationInfo; + }; + } + + throw new Error("Failed to get tokenization policies"); + } + + /** + * Gets a single tokenization policy by token ID. + * + * @param tokenId - The token ID to look up + * @returns The tokenization policy + */ + async getTokenizationPolicy(tokenId: string): Promise { + const { tokens } = await this.getTokenizationPolicies({ + tokenId, + limit: 1, + }); + const policy = tokens[0]; + + if (!policy) { + throw new Error("Tokenization policy not found"); + } + + return policy; + } + + /** + * Internal helper to log token transactions to the database + */ + private async logTransaction(params: { + txId: string; + tokenId: string; + walletInfo: MultiChainWalletInfo; + type: "create" | "mint" | "burn" | "transfer" | "freeze" | "unfreeze"; + amount?: string; + fromAddress?: string; + toAddress?: string; + status?: string; + }): Promise { + try { + await this.sdk.axiosInstance.post("/api/tokenization/transactions", { + txId: params.txId, + tokenId: params.tokenId, + projectId: this.sdk.projectId, + projectWalletId: params.walletInfo.id, + type: params.type, + chain: "spark", + network: this.walletNetwork.toLowerCase(), + amount: params.amount, + fromAddress: params.fromAddress, + toAddress: params.toAddress, + status: params.status || "confirmed", + }); + } catch (error) { + console.warn(`Failed to log ${params.type} transaction:`, error); + } + } +} diff --git a/src/sdk/wallet-developer-controlled/cardano.ts b/src/sdk/wallet-developer-controlled/cardano.ts new file mode 100644 index 0000000..878afb2 --- /dev/null +++ b/src/sdk/wallet-developer-controlled/cardano.ts @@ -0,0 +1,143 @@ +import { Web3Sdk } from ".."; +import { MeshWallet } from "@meshsdk/wallet"; +import { decryptWithPrivateKey } from "../../functions"; +import { MultiChainWalletInfo, TokenCreationParams } from "../../types"; + +/** + * CardanoWalletDeveloperControlled - Manages Cardano-specific developer-controlled wallets. + * + * Provides wallet management operations for Cardano wallets. + * + * @example + * ```typescript + * // List all Cardano wallets + * const wallets = await sdk.wallet.cardano.getWallets(); + * + * // Get wallets by tag + * const treasuryWallets = await sdk.wallet.cardano.getWalletsByTag("treasury"); + * + * // Get a specific wallet with initialized MeshWallet + * const { info, wallet } = await sdk.wallet.cardano.getWallet("wallet-id"); + * const addresses = wallet.getAddresses(); + * ``` + */ +export class CardanoWalletDeveloperControlled { + readonly sdk: Web3Sdk; + + constructor({ + sdk, + }: { + sdk: Web3Sdk; + }) { + this.sdk = sdk; + } + + /** + * Retrieves all Cardano wallets for the project. + * + * @returns Promise resolving to array of wallet information + * + * @example + * ```typescript + * const wallets = await sdk.wallet.cardano.getWallets(); + * console.log(`Found ${wallets.length} Cardano wallets`); + * ``` + */ + async getWallets(): Promise { + const { data, status } = await this.sdk.axiosInstance.get( + `api/project-wallet/${this.sdk.projectId}/cardano`, + ); + + if (status === 200) { + return data as MultiChainWalletInfo[]; + } + + throw new Error("Failed to get Cardano wallets"); + } + + /** + * Retrieves a specific Cardano wallet by ID and initializes a MeshWallet instance. + * + * @param walletId - The wallet ID to retrieve + * @param decryptKey - If true, returns the decrypted mnemonic in wallet info + * @returns Promise resolving to wallet info and initialized MeshWallet + * + * @example + * ```typescript + * const { info, wallet } = await sdk.wallet.cardano.getWallet("wallet-id"); + * const addresses = wallet.getAddresses(); + * console.log("Base address:", addresses.baseAddressBech32); + * ``` + */ + async getWallet( + walletId: string, + decryptKey = false, + ): Promise<{ + info: MultiChainWalletInfo; + wallet: MeshWallet; + }> { + if (this.sdk.privateKey === undefined) { + throw new Error("Private key not found"); + } + + const { data, status } = await this.sdk.axiosInstance.get( + `api/project-wallet/${this.sdk.projectId}/${walletId}?chain=cardano`, + ); + + if (status === 200) { + const web3Wallet = data as MultiChainWalletInfo; + + const mnemonic = await decryptWithPrivateKey({ + privateKey: this.sdk.privateKey, + encryptedDataJSON: web3Wallet.key, + }); + + if (decryptKey) { + web3Wallet.key = mnemonic; + } + + const networkId = this.sdk.network === "mainnet" ? 1 : 0; + const wallet = new MeshWallet({ + networkId: networkId, + key: { + type: "mnemonic", + words: mnemonic.split(" "), + }, + fetcher: this.sdk.providerFetcher, + submitter: this.sdk.providerSubmitter, + }); + await wallet.init(); + + return { info: web3Wallet, wallet: wallet }; + } + + throw new Error("Failed to get Cardano wallet"); + } + + /** + * Gets Cardano wallets filtered by tag. + * + * @param tag - The tag to filter by + * @returns Promise resolving to array of matching wallet information + * + * @example + * ```typescript + * const wallets = await sdk.wallet.cardano.getWalletsByTag("treasury"); + * ``` + */ + async getWalletsByTag(tag: string): Promise { + if (this.sdk.privateKey === undefined) { + throw new Error("Private key not found"); + } + + const { data, status } = await this.sdk.axiosInstance.get( + `api/project-wallet/${this.sdk.projectId}/cardano/tag/${tag}`, + ); + + if (status === 200) { + return data as MultiChainWalletInfo[]; + } + + throw new Error("Failed to get Cardano wallets by tag"); + } +} diff --git a/src/sdk/wallet-developer-controlled/index.test.ts b/src/sdk/wallet-developer-controlled/index.test.ts new file mode 100644 index 0000000..7be78f5 --- /dev/null +++ b/src/sdk/wallet-developer-controlled/index.test.ts @@ -0,0 +1,690 @@ +// Mock uuid before imports +jest.mock("uuid", () => ({ + v4: jest.fn().mockReturnValue("mock-wallet-id-uuid"), +})); + +// Mock external wallet SDKs +jest.mock("@meshsdk/wallet", () => ({ + MeshWallet: Object.assign( + jest.fn().mockImplementation(() => ({ + init: jest.fn().mockResolvedValue(undefined), + getAddresses: jest.fn().mockReturnValue({ + baseAddressBech32: + "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp", + }), + })), + { + brew: jest.fn().mockReturnValue([ + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "about", + ]), + }, + ), +})); + +jest.mock("@meshsdk/core-cst", () => ({ + deserializeBech32Address: jest.fn().mockReturnValue({ + pubKeyHash: "mock-cardano-pub-key-hash", + stakeCredentialHash: "mock-cardano-stake-hash", + }), +})); + +jest.mock("@buildonspark/issuer-sdk", () => ({ + IssuerSparkWallet: { + initialize: jest.fn().mockResolvedValue({ + wallet: { + getIdentityPublicKey: jest.fn().mockResolvedValue("mock-spark-pub-key"), + getSparkAddress: jest.fn().mockResolvedValue("mock-spark-address"), + }, + }), + }, +})); + +// Mock encryption functions +const mockEncryptedData = JSON.stringify({ + ephemeralPublicKey: "mock-ephemeral-key", + iv: "mock-iv", + ciphertext: "mock-ciphertext", +}); + +jest.mock("../../functions", () => ({ + encryptWithPublicKey: jest.fn().mockResolvedValue(mockEncryptedData), + decryptWithPrivateKey: jest.fn().mockResolvedValue( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ), +})); + +// Import after mocks +import { WalletDeveloperControlled } from "./index"; +import { Web3Sdk } from "../index"; +import { MeshWallet } from "@meshsdk/wallet"; +import { IssuerSparkWallet } from "@buildonspark/issuer-sdk"; +import { encryptWithPublicKey, decryptWithPrivateKey } from "../../functions"; +import { v4 as uuidv4 } from "uuid"; + +// Mock axios +jest.mock("axios", () => ({ + create: jest.fn().mockReturnValue({ + get: jest.fn(), + post: jest.fn(), + }), +})); + +// Helper to create mock SDK with configurable properties +function createMockSdk(overrides: { + network?: "mainnet" | "testnet"; + privateKey?: string | undefined; + axiosInstance?: { get: jest.Mock; post: jest.Mock }; +}) { + const axiosInstance = overrides.axiosInstance || { + get: jest.fn(), + post: jest.fn(), + }; + return { + projectId: "test-project-id", + apiKey: "test-api-key", + network: overrides.network ?? "testnet", + privateKey: overrides.privateKey ?? "mock-private-key", + axiosInstance, + providerFetcher: undefined, + providerSubmitter: undefined, + getProject: jest.fn().mockResolvedValue({ + id: "test-project-id", + publicKey: "mock-public-key", + }), + } as unknown as Web3Sdk; +} + +describe("WalletDeveloperControlled", () => { + let mockAxiosInstance: { + get: jest.Mock; + post: jest.Mock; + }; + let mockSdk: Web3Sdk; + + beforeEach(() => { + jest.clearAllMocks(); + + mockAxiosInstance = { + get: jest.fn(), + post: jest.fn(), + }; + + mockSdk = createMockSdk({ axiosInstance: mockAxiosInstance }); + }); + + describe("constructor", () => { + it("creates instance with sdk reference", () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + expect(wallet.sdk).toBe(mockSdk); + expect(wallet.cardano).toBeDefined(); + expect(wallet.sparkIssuer).toBeDefined(); + }); + }); + + describe("createWallet", () => { + beforeEach(() => { + mockAxiosInstance.post.mockResolvedValue({ status: 200, data: {} }); + }); + + it("creates a new multi-chain wallet", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + const result = await wallet.createWallet(); + + expect(result).toHaveProperty("info"); + expect(result).toHaveProperty("sparkIssuerWallet"); + expect(result).toHaveProperty("cardanoWallet"); + }); + + it("generates a new mnemonic using MeshWallet.brew", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await wallet.createWallet(); + + expect(MeshWallet.brew).toHaveBeenCalled(); + }); + + it("encrypts mnemonic with project public key", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await wallet.createWallet(); + + expect(encryptWithPublicKey).toHaveBeenCalledWith({ + publicKey: "mock-public-key", + data: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + }); + }); + + it("creates wallet with testnet configuration", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await wallet.createWallet(); + + expect(MeshWallet).toHaveBeenCalledWith( + expect.objectContaining({ networkId: 0 }), + ); + }); + + it("creates wallet with mainnet configuration", async () => { + const mainnetSdk = createMockSdk({ + network: "mainnet", + axiosInstance: mockAxiosInstance, + }); + const wallet = new WalletDeveloperControlled({ sdk: mainnetSdk }); + + await wallet.createWallet(); + + expect(MeshWallet).toHaveBeenCalledWith( + expect.objectContaining({ networkId: 1 }), + ); + }); + + it("initializes Spark wallets for both mainnet and regtest", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await wallet.createWallet(); + + expect(IssuerSparkWallet.initialize).toHaveBeenCalledTimes(2); + expect(IssuerSparkWallet.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + options: { network: "MAINNET" }, + }), + ); + expect(IssuerSparkWallet.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + options: { network: "REGTEST" }, + }), + ); + }); + + it("posts wallet data to API", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await wallet.createWallet(); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + "api/project-wallet", + expect.objectContaining({ + id: "mock-wallet-id-uuid", + projectId: "test-project-id", + key: mockEncryptedData, + }), + ); + }); + + it("includes tags in wallet data when provided", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await wallet.createWallet({ tags: ["treasury", "main"] }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + "api/project-wallet", + expect.objectContaining({ + tags: ["treasury", "main"], + }), + ); + }); + + it("includes chain information in wallet data", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await wallet.createWallet(); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + "api/project-wallet", + expect.objectContaining({ + chains: { + cardano: { + pubKeyHash: "mock-cardano-pub-key-hash", + stakeCredentialHash: "mock-cardano-stake-hash", + }, + spark: { + mainnetPublicKey: "mock-spark-pub-key", + regtestPublicKey: "mock-spark-pub-key", + }, + }, + }), + ); + }); + + it("returns regtest spark wallet for testnet", async () => { + const testnetSdk = createMockSdk({ + network: "testnet", + axiosInstance: mockAxiosInstance, + }); + const wallet = new WalletDeveloperControlled({ sdk: testnetSdk }); + + const result = await wallet.createWallet(); + + expect(result.sparkIssuerWallet).toBeDefined(); + }); + + it("throws if project public key not found", async () => { + (mockSdk.getProject as jest.Mock).mockResolvedValue({ + id: "test-project-id", + publicKey: null, + }); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await expect(wallet.createWallet()).rejects.toThrow( + "Project public key not found", + ); + }); + + it("throws if API call fails", async () => { + mockAxiosInstance.post.mockResolvedValue({ status: 500 }); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await expect(wallet.createWallet()).rejects.toThrow( + "Failed to create wallet", + ); + }); + }); + + describe("initWallet", () => { + const mockWalletInfo = { + id: "test-wallet-id", + projectId: "test-project-id", + tags: ["test"], + key: mockEncryptedData, + chains: { + cardano: { + pubKeyHash: "mock-cardano-pub-key-hash", + stakeCredentialHash: "mock-cardano-stake-hash", + }, + spark: { + mainnetPublicKey: "mock-spark-pub-key", + regtestPublicKey: "mock-spark-pub-key", + }, + }, + }; + + beforeEach(() => { + mockAxiosInstance.get.mockResolvedValue({ + status: 200, + data: mockWalletInfo, + }); + }); + + it("loads existing wallet by ID", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + const result = await wallet.initWallet("test-wallet-id"); + + expect(result).toHaveProperty("info"); + expect(result).toHaveProperty("sparkWallet"); + expect(result).toHaveProperty("cardanoWallet"); + }); + + it("calls API to get wallet info", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await wallet.initWallet("test-wallet-id"); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "api/project-wallet/test-project-id/test-wallet-id", + ); + }); + + it("decrypts mnemonic with private key", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await wallet.initWallet("test-wallet-id"); + + expect(decryptWithPrivateKey).toHaveBeenCalledWith({ + privateKey: "mock-private-key", + encryptedDataJSON: mockEncryptedData, + }); + }); + + it("creates MeshWallet with decrypted mnemonic", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await wallet.initWallet("test-wallet-id"); + + expect(MeshWallet).toHaveBeenCalledWith( + expect.objectContaining({ + key: { + type: "mnemonic", + words: [ + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "about", + ], + }, + }), + ); + }); + + it("initializes Spark wallet with correct network for testnet", async () => { + const testnetSdk = createMockSdk({ + network: "testnet", + axiosInstance: mockAxiosInstance, + }); + const wallet = new WalletDeveloperControlled({ sdk: testnetSdk }); + + await wallet.initWallet("test-wallet-id"); + + expect(IssuerSparkWallet.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + options: { network: "REGTEST" }, + }), + ); + }); + + it("initializes Spark wallet with correct network for mainnet", async () => { + const mainnetSdk = createMockSdk({ + network: "mainnet", + axiosInstance: mockAxiosInstance, + }); + const wallet = new WalletDeveloperControlled({ sdk: mainnetSdk }); + + await wallet.initWallet("test-wallet-id"); + + expect(IssuerSparkWallet.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + options: { network: "MAINNET" }, + }), + ); + }); + + it("throws if private key not provided", async () => { + // Need to create SDK without privateKey AND ensure getProject is called + const noPrivateKeyAxios = { + get: jest.fn().mockResolvedValue({ + status: 200, + data: { + id: "test-wallet-id", + projectId: "test-project-id", + tags: [], + key: mockEncryptedData, + chains: { + cardano: { pubKeyHash: "mock", stakeCredentialHash: "mock" }, + spark: { mainnetPublicKey: "mock", regtestPublicKey: "mock" }, + }, + }, + }), + post: jest.fn(), + }; + const noPrivateKeySdk = { + projectId: "test-project-id", + apiKey: "test-api-key", + network: "testnet" as const, + privateKey: undefined, // No private key + axiosInstance: noPrivateKeyAxios, + providerFetcher: undefined, + providerSubmitter: undefined, + getProject: jest.fn().mockResolvedValue({ + id: "test-project-id", + publicKey: "mock-public-key", + }), + } as unknown as Web3Sdk; + const wallet = new WalletDeveloperControlled({ sdk: noPrivateKeySdk }); + + await expect(wallet.initWallet("test-wallet-id")).rejects.toThrow( + "Private key required to load developer-controlled wallet", + ); + }); + }); + + describe("getWallet", () => { + const mockWalletInfo = { + id: "test-wallet-id", + projectId: "test-project-id", + tags: [], + key: mockEncryptedData, + chains: { + cardano: { + pubKeyHash: "mock-cardano-pub-key-hash", + stakeCredentialHash: "mock-cardano-stake-hash", + }, + spark: { + mainnetPublicKey: "mock-spark-pub-key", + regtestPublicKey: "mock-spark-pub-key", + }, + }, + }; + + beforeEach(() => { + mockAxiosInstance.get.mockResolvedValue({ + status: 200, + data: mockWalletInfo, + }); + }); + + it("returns wallet instance for cardano chain", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + const result = await wallet.getWallet("test-wallet-id", "cardano"); + + expect(result).toHaveProperty("info"); + expect(result).toHaveProperty("cardanoWallet"); + }); + + it("returns wallet instance for spark chain", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + const result = await wallet.getWallet("test-wallet-id", "spark"); + + expect(result).toHaveProperty("info"); + expect(result).toHaveProperty("sparkIssuerWallet"); + }); + + it("decrypts mnemonic when private key available", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await wallet.getWallet("test-wallet-id", "cardano"); + + expect(decryptWithPrivateKey).toHaveBeenCalled(); + }); + + it("returns info only when no private key", async () => { + const noPrivateKeySdk = { + projectId: "test-project-id", + apiKey: "test-api-key", + network: "testnet" as const, + privateKey: undefined, // No private key + axiosInstance: mockAxiosInstance, + providerFetcher: undefined, + providerSubmitter: undefined, + getProject: jest.fn().mockResolvedValue({ + id: "test-project-id", + publicKey: "mock-public-key", + }), + } as unknown as Web3Sdk; + const wallet = new WalletDeveloperControlled({ sdk: noPrivateKeySdk }); + + const result = await wallet.getWallet("test-wallet-id", "cardano"); + + expect(result.info).toBeDefined(); + expect(result.cardanoWallet).toBeUndefined(); + }); + }); + + describe("getProjectWallet", () => { + it("fetches wallet by ID from API", async () => { + const mockWalletInfo = { + id: "test-wallet-id", + projectId: "test-project-id", + }; + mockAxiosInstance.get.mockResolvedValue({ + status: 200, + data: mockWalletInfo, + }); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + const result = await wallet.getProjectWallet("test-wallet-id"); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "api/project-wallet/test-project-id/test-wallet-id", + ); + expect(result).toEqual(mockWalletInfo); + }); + + it("throws if wallet not found", async () => { + mockAxiosInstance.get.mockResolvedValue({ status: 404 }); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await expect(wallet.getProjectWallet("nonexistent")).rejects.toThrow( + "Project wallet not found", + ); + }); + }); + + describe("getProjectWallets", () => { + it("fetches wallets with pagination", async () => { + const mockWallets = [ + { id: "wallet-1", projectId: "test-project-id" }, + { id: "wallet-2", projectId: "test-project-id" }, + ]; + const mockResponse = { + data: mockWallets, + pagination: { page: 1, pageSize: 10, totalCount: 2, totalPages: 1 }, + }; + mockAxiosInstance.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + const result = await wallet.getProjectWallets(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "api/project-wallet/test-project-id?page=1", + ); + expect(result.data).toEqual(mockWallets); + expect(result.pagination.totalCount).toBe(2); + }); + + it("fetches specific page", async () => { + const mockResponse = { + data: [{ id: "wallet-3", projectId: "test-project-id" }], + pagination: { page: 2, pageSize: 10, totalCount: 4, totalPages: 2 }, + }; + mockAxiosInstance.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + const result = await wallet.getProjectWallets({ page: 2 }); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "api/project-wallet/test-project-id?page=2", + ); + expect(result.pagination.page).toBe(2); + }); + + it("returns empty array when no wallets", async () => { + const mockResponse = { + data: [], + pagination: { page: 1, pageSize: 10, totalCount: 0, totalPages: 0 }, + }; + mockAxiosInstance.get.mockResolvedValue({ status: 200, data: mockResponse }); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + const result = await wallet.getProjectWallets(); + + expect(result.data).toEqual([]); + expect(result.pagination.totalCount).toBe(0); + }); + + it("throws if API call fails", async () => { + mockAxiosInstance.get.mockResolvedValue({ status: 500 }); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await expect(wallet.getProjectWallets()).rejects.toThrow( + "Failed to get project wallets", + ); + }); + }); + + describe("getAllProjectWallets", () => { + it("fetches all wallets across pages", async () => { + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + // First page + mockAxiosInstance.get.mockResolvedValueOnce({ + status: 200, + data: { + data: [{ id: "wallet-1" }, { id: "wallet-2" }, { id: "wallet-3" }], + pagination: { page: 1, pageSize: 10, totalCount: 5, totalPages: 2 }, + }, + }); + // Second page + mockAxiosInstance.get.mockResolvedValueOnce({ + status: 200, + data: { + data: [{ id: "wallet-4" }, { id: "wallet-5" }], + pagination: { page: 2, pageSize: 10, totalCount: 5, totalPages: 2 }, + }, + }); + + const result = await wallet.getAllProjectWallets(); + + expect(result).toHaveLength(5); + expect(mockAxiosInstance.get).toHaveBeenCalledTimes(2); + }); + }); +}); + +describe("WalletDeveloperControlled error scenarios", () => { + let mockSdk: Web3Sdk; + let mockAxiosInstance: { get: jest.Mock; post: jest.Mock }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockAxiosInstance = { + get: jest.fn(), + post: jest.fn(), + }; + + mockSdk = { + projectId: "test-project-id", + apiKey: "test-api-key", + network: "testnet", + privateKey: "mock-private-key", + axiosInstance: mockAxiosInstance, + getProject: jest.fn().mockResolvedValue({ + id: "test-project-id", + publicKey: "mock-public-key", + }), + } as unknown as Web3Sdk; + }); + + it("handles network errors gracefully", async () => { + mockAxiosInstance.post.mockRejectedValue(new Error("Network error")); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await expect(wallet.createWallet()).rejects.toThrow("Network error"); + }); + + it("handles API timeout", async () => { + mockAxiosInstance.get.mockRejectedValue(new Error("timeout")); + const wallet = new WalletDeveloperControlled({ sdk: mockSdk }); + + await expect(wallet.getProjectWallets()).rejects.toThrow("timeout"); + }); +}); diff --git a/src/sdk/wallet-developer-controlled/index.ts b/src/sdk/wallet-developer-controlled/index.ts index d70e2bc..ffd5f87 100644 --- a/src/sdk/wallet-developer-controlled/index.ts +++ b/src/sdk/wallet-developer-controlled/index.ts @@ -1,180 +1,375 @@ import { Web3Sdk } from ".."; +import { + MultiChainWalletInfo, + MultiChainWalletInstance, + SupportedChain, +} from "../../types/core/multi-chain"; +import { CardanoWalletDeveloperControlled } from "./cardano"; +import { SparkIssuerWalletDeveloperControlled } from "./spark-issuer"; import { MeshWallet } from "@meshsdk/wallet"; -import { decryptWithPrivateKey, encryptWithPublicKey } from "../../functions"; -import { Web3ProjectWallet } from "../../types"; +import { IssuerSparkWallet } from "@buildonspark/issuer-sdk"; import { deserializeBech32Address } from "@meshsdk/core-cst"; +import { encryptWithPublicKey, decryptWithPrivateKey } from "../../functions"; import { v4 as uuidv4 } from "uuid"; /** * The `WalletDeveloperControlled` class provides functionality for managing developer-controlled wallets - * within a Web3 project. It allows for creating wallets, retrieving wallet information, and accessing - * specific wallets using their identifiers. + * within a Web3 project. Supports multi-chain wallets with a shared mnemonic for Spark and Cardano. + * + * @example + * ```typescript + * // Create a new multi-chain wallet + * const { info, sparkIssuerWallet, cardanoWallet } = await sdk.wallet.createWallet({ + * tags: ["treasury"], + * }); + * + * // Load an existing wallet by ID + * const { info, sparkWallet, cardanoWallet } = await sdk.wallet.initWallet("wallet-id"); + * + * // Get a wallet for a specific chain + * const { cardanoWallet } = await sdk.wallet.getWallet("wallet-id", "cardano"); + * const { sparkIssuerWallet } = await sdk.wallet.getWallet("wallet-id", "spark"); + * const sparkAddress = await sparkIssuerWallet.getSparkAddress(); + * + * // List all project wallets + * const wallets = await sdk.wallet.getProjectWallets(); + * ``` */ export class WalletDeveloperControlled { readonly sdk: Web3Sdk; + cardano: CardanoWalletDeveloperControlled; + sparkIssuer: SparkIssuerWalletDeveloperControlled; constructor({ sdk }: { sdk: Web3Sdk }) { - { - this.sdk = sdk; - } + this.sdk = sdk; + this.cardano = new CardanoWalletDeveloperControlled({ sdk }); + this.sparkIssuer = new SparkIssuerWalletDeveloperControlled({ sdk }); } /** - * Creates a new wallet associated with the current project. - * This method generates a new wallet encrypts it with the project's public key, and registers the wallet with the backend service. + * Creates a new developer-controlled wallet with both Spark and Cardano chains using shared mnemonic. * - * @param {Object} [options] - Optional parameters for wallet creation. - * @param {string} [options.tag] - An optional tag to associate with the wallet. + * @param options - Wallet creation options + * @param options.tags - Optional tags for the wallet + * @returns Promise that resolves to wallet info and chain wallet instances * - * @returns {Promise} A promise that resolves to the created wallet instance. + * @example + * ```typescript + * // Create wallet + * const { info } = await sdk.wallet.createWallet({ tags: ["tokenization"] }); * - * @throws {Error} If the project's public key is not found. - * @throws {Error} If the wallet creation request to the backend fails. + * // For tokenization, use initWallet then createToken + * await sdk.tokenization.spark.initWallet({ walletId: info.id }); + * await sdk.tokenization.spark.createToken({ + * tokenName: "MyToken", + * tokenTicker: "MTK", + * decimals: 8, + * isFreezable: true + * }); + * ``` */ - async createWallet({ - tags, - }: { tags?: string[] } = {}): Promise { + async createWallet( + options: { + tags?: string[]; + } = {}, + ): Promise<{ + info: MultiChainWalletInfo; + sparkIssuerWallet: IssuerSparkWallet; + cardanoWallet: MeshWallet; + }> { const project = await this.sdk.getProject(); - if (!project.publicKey) { throw new Error("Project public key not found"); } + const networkId = this.sdk.network === "mainnet" ? 1 : 0; + const walletId = uuidv4(); const mnemonic = MeshWallet.brew() as string[]; - const encryptedMnemonic = await encryptWithPublicKey({ + const encryptedKey = await encryptWithPublicKey({ publicKey: project.publicKey, data: mnemonic.join(" "), }); - const _wallet = new MeshWallet({ - networkId: 1, - key: { - type: "mnemonic", - words: mnemonic, - }, + const cardanoWallet = new MeshWallet({ + networkId: networkId, + key: { type: "mnemonic", words: mnemonic }, fetcher: this.sdk.providerFetcher, submitter: this.sdk.providerSubmitter, }); - await _wallet.init(); + await cardanoWallet.init(); - const addresses = await _wallet.getAddresses(); - const baseAddressBech32 = addresses.baseAddressBech32!; + const [{ wallet: sparkMainnetWallet }, { wallet: sparkRegtestWallet }] = + await Promise.all([ + IssuerSparkWallet.initialize({ + mnemonicOrSeed: mnemonic.join(" "), + options: { network: "MAINNET" }, + }), + IssuerSparkWallet.initialize({ + mnemonicOrSeed: mnemonic.join(" "), + options: { network: "REGTEST" }, + }), + ]); - const { pubKeyHash, stakeCredentialHash } = - deserializeBech32Address(baseAddressBech32); + const addresses = cardanoWallet.getAddresses(); + const { pubKeyHash, stakeCredentialHash } = deserializeBech32Address( + addresses.baseAddressBech32!, + ); + const [mainnetPublicKey, regtestPublicKey] = await Promise.all([ + sparkMainnetWallet.getIdentityPublicKey(), + sparkRegtestWallet.getIdentityPublicKey(), + ]); - // create wallet + const sparkWallet = + networkId === 1 ? sparkMainnetWallet : sparkRegtestWallet; - const web3Wallet: Web3ProjectWallet = { - id: uuidv4(), - key: encryptedMnemonic, - tags: tags || [], + const walletData: MultiChainWalletInfo = { + id: walletId, projectId: this.sdk.projectId, - pubKeyHash: pubKeyHash, - stakeCredentialHash: stakeCredentialHash, + tags: options.tags || [], + key: encryptedKey, + chains: { + cardano: { pubKeyHash, stakeCredentialHash }, + spark: { mainnetPublicKey, regtestPublicKey }, + }, }; - const { data, status } = await this.sdk.axiosInstance.post( + const { status } = await this.sdk.axiosInstance.post( `api/project-wallet`, - web3Wallet, + walletData, ); if (status === 200) { - return data as Web3ProjectWallet; + return { + info: walletData, + sparkIssuerWallet: sparkWallet, + cardanoWallet: cardanoWallet, + }; } throw new Error("Failed to create wallet"); } /** - * Retrieves a list of wallets associated with the current project. + * Loads an existing developer-controlled wallet by ID and returns both chain wallet instances. + * + * @param walletId - The wallet ID to load + * @returns Promise that resolves to wallet info and initialized wallet instances + * + * @example + * ```typescript + * const { info, sparkWallet, cardanoWallet } = await sdk.wallet.initWallet("wallet-id"); * - * @returns {Promise} A promise that resolves to an array of wallets, - * each containing the wallet's `id`, `address`, `networkId`, and `tag`. + * // Get Spark wallet address + * const sparkAddress = await sparkWallet.getSparkAddress(); * - * @throws {Error} Throws an error if the request to fetch wallets fails. + * // Get Cardano wallet addresses + * const addresses = cardanoWallet.getAddresses(); + * ``` */ - async getWallets(): Promise { - const { data, status } = await this.sdk.axiosInstance.get( - `api/project-wallet/${this.sdk.projectId}`, - ); - - if (status === 200) { - return data as Web3ProjectWallet[]; + async initWallet(walletId: string): Promise<{ + info: MultiChainWalletInfo; + sparkWallet: IssuerSparkWallet; + cardanoWallet: MeshWallet; + }> { + if (!this.sdk.privateKey) { + throw new Error( + "Private key required to load developer-controlled wallet", + ); } - throw new Error("Failed to get wallets"); + const walletInfo = await this.getProjectWallet(walletId); + const effectiveNetworkId = this.sdk.network === "mainnet" ? 1 : 0; + const sharedMnemonic = await decryptWithPrivateKey({ + privateKey: this.sdk.privateKey, + encryptedDataJSON: walletInfo.key, + }); + + const cardanoWallet = new MeshWallet({ + networkId: effectiveNetworkId, + key: { type: "mnemonic", words: sharedMnemonic.split(" ") }, + fetcher: this.sdk.providerFetcher, + submitter: this.sdk.providerSubmitter, + }); + await cardanoWallet.init(); + + const sparkNetwork = effectiveNetworkId === 1 ? "MAINNET" : "REGTEST"; + const { wallet: sparkWallet } = await IssuerSparkWallet.initialize({ + mnemonicOrSeed: sharedMnemonic, + options: { network: sparkNetwork }, + }); + + return { + info: walletInfo, + sparkWallet, + cardanoWallet, + }; } /** - * Retrieves a wallet by its ID and decrypts the key with the project's private key. + * Retrieves a multi-chain wallet for a specific chain. + * + * @param walletId - The unique identifier of the wallet + * @param chain - The chain to load ("spark" or "cardano") + * @returns Promise that resolves to multi-chain wallet instance * - * @param walletId - The unique identifier of the wallet to retrieve. - * @param networkId - The network ID associated with the wallet (0 or 1). - * @param decryptKey - A boolean indicating whether to decrypt the wallet key (default: false). + * @example + * ```typescript + * // Load Spark wallet + * const { sparkIssuerWallet } = await sdk.wallet.getWallet("wallet-id", "spark"); * - * @returns A promise that resolves to an initialized `MeshWallet` instance. - * @throws Will throw an error if the private key is not found or if the wallet retrieval fails. + * // Load Cardano wallet + * const { cardanoWallet } = await sdk.wallet.getWallet("wallet-id", "cardano"); + * ``` */ async getWallet( - walletId: string, - networkId: 0 | 1, - decryptKey = false, - ): Promise<{ - info: Web3ProjectWallet; - wallet: MeshWallet; - }> { - if (this.sdk.privateKey === undefined) { - throw new Error("Private key not found"); - } - - const { data, status } = await this.sdk.axiosInstance.get( - `api/project-wallet/${this.sdk.projectId}/${walletId}`, - ); + projectWalletId: string, + chain: SupportedChain, + ): Promise { + const walletInfo = await this.getProjectWallet(projectWalletId); - if (status === 200) { - const web3Wallet = data as Web3ProjectWallet; + const instance: MultiChainWalletInstance = { + info: walletInfo, + }; - const mnemonic = await decryptWithPrivateKey({ + let mnemonic: string | null = null; + if (this.sdk.privateKey) { + mnemonic = await decryptWithPrivateKey({ privateKey: this.sdk.privateKey, - encryptedDataJSON: web3Wallet.key, + encryptedDataJSON: walletInfo.key, }); + } - if (decryptKey) { - web3Wallet.key = mnemonic; - } + const networkId = this.sdk.network === "mainnet" ? 1 : 0; - const wallet = new MeshWallet({ + if ( + (chain === "cardano" || !chain) && + walletInfo.chains.cardano && + mnemonic + ) { + const cardanoWallet = new MeshWallet({ networkId: networkId, - key: { - type: "mnemonic", - words: mnemonic.split(" "), - }, + key: { type: "mnemonic", words: mnemonic.split(" ") }, fetcher: this.sdk.providerFetcher, submitter: this.sdk.providerSubmitter, }); - await wallet.init(); + await cardanoWallet.init(); - return { info: web3Wallet, wallet: wallet }; + instance.cardanoWallet = cardanoWallet; } - throw new Error("Failed to get wallet"); + if ((chain === "spark" || !chain) && walletInfo.chains.spark && mnemonic) { + const sparkNetwork = networkId === 1 ? "MAINNET" : "REGTEST"; + const { wallet: sparkWallet } = await IssuerSparkWallet.initialize({ + mnemonicOrSeed: mnemonic, + options: { network: sparkNetwork }, + }); + + instance.sparkIssuerWallet = sparkWallet; + } + + return instance; } - async getWalletsByTag(tag: string): Promise { - if (this.sdk.privateKey === undefined) { - throw new Error("Private key not found"); + /** + * Retrieves wallet metadata by ID. + * + * @param walletId - The unique identifier of the wallet + * @returns Promise that resolves to wallet info + */ + async getProjectWallet(walletId: string): Promise { + const { data, status } = await this.sdk.axiosInstance.get( + `api/project-wallet/${this.sdk.projectId}/${walletId}`, + ); + + if (status === 200) { + return data as MultiChainWalletInfo; } + throw new Error("Project wallet not found"); + } + + /** + * Retrieves wallets for the project with pagination support. + * + * @param options - Pagination options + * @param options.page - Page number (default: 1) + * @param options.pageSize - Number of wallets per page (default: 10) + * @returns Promise that resolves to paginated wallet response + * + * @example + * ```typescript + * // Get first page of wallets + * const { data, pagination } = await sdk.wallet.getProjectWallets(); + * + * // Get specific page + * const { data, pagination } = await sdk.wallet.getProjectWallets({ page: 2 }); + * + * // Iterate through all pages + * let page = 1; + * let allWallets: MultiChainWalletInfo[] = []; + * while (true) { + * const { data, pagination } = await sdk.wallet.getProjectWallets({ page }); + * allWallets.push(...data); + * if (page >= pagination.totalPages) break; + * page++; + * } + * ``` + */ + async getProjectWallets(options?: { page?: number }): Promise<{ + data: MultiChainWalletInfo[]; + pagination: { + page: number; + pageSize: number; + totalCount: number; + totalPages: number; + }; + }> { + const page = options?.page ?? 1; const { data, status } = await this.sdk.axiosInstance.get( - `api/project-wallet/${this.sdk.projectId}/tag/${tag}`, + `api/project-wallet/${this.sdk.projectId}?page=${page}`, ); if (status === 200) { - const web3Wallets = data as Web3ProjectWallet[]; - return web3Wallets; + return data as { + data: MultiChainWalletInfo[]; + pagination: { + page: number; + pageSize: number; + totalCount: number; + totalPages: number; + }; + }; + } + + throw new Error("Failed to get project wallets"); + } + + /** + * Retrieves all wallets for the project (fetches all pages). + * + * @returns Promise that resolves to array of all wallet info + * + * @example + * ```typescript + * const wallets = await sdk.wallet.getAllProjectWallets(); + * console.log(`Found ${wallets.length} wallets`); + * ``` + */ + async getAllProjectWallets(): Promise { + const allWallets: MultiChainWalletInfo[] = []; + let page = 1; + + while (true) { + const { data, pagination } = await this.getProjectWallets({ page }); + allWallets.push(...data); + + if (page >= pagination.totalPages) break; + page++; } - throw new Error("Failed to get wallet"); + return allWallets; } } + +export { CardanoWalletDeveloperControlled } from "./cardano"; +export { SparkIssuerWalletDeveloperControlled } from "./spark-issuer"; diff --git a/src/sdk/wallet-developer-controlled/spark-issuer.ts b/src/sdk/wallet-developer-controlled/spark-issuer.ts new file mode 100644 index 0000000..a1be8aa --- /dev/null +++ b/src/sdk/wallet-developer-controlled/spark-issuer.ts @@ -0,0 +1,100 @@ +import { Web3Sdk } from ".."; +import { MultiChainWalletInfo } from "../../types"; + +/** + * SparkIssuerWalletDeveloperControlled - Developer-controlled Spark issuer wallet management + * + * Provides wallet management operations for Spark issuer wallets. + * Token operations (create, mint, transfer, burn, freeze) are handled by TokenizationSpark. + * + * @example + * ```typescript + * // List all Spark wallets + * const wallets = await sdk.wallet.sparkIssuer.list(); + * + * // Get wallets by tag + * const tokenizationWallets = await sdk.wallet.sparkIssuer.getByTag("tokenization"); + * + * // Get wallet info by ID + * const walletInfo = await sdk.wallet.sparkIssuer.get("wallet-id"); + * ``` + */ +export class SparkIssuerWalletDeveloperControlled { + readonly sdk: Web3Sdk; + + constructor({ sdk }: { sdk: Web3Sdk }) { + this.sdk = sdk; + } + + /** + * Gets wallet info by ID. + * + * @param walletId The wallet ID to retrieve + * @returns Promise resolving to wallet info + * + * @example + * ```typescript + * const walletInfo = await sdk.wallet.sparkIssuer.get("existing-wallet-id"); + * ``` + */ + async getWallet(walletId: string): Promise { + const networkParam = this.sdk.network === "mainnet" ? "mainnet" : "regtest"; + const { data, status } = await this.sdk.axiosInstance.get( + `api/project-wallet/${this.sdk.projectId}/${walletId}?chain=spark&network=${networkParam}`, + ); + + if (status === 200) { + return data as MultiChainWalletInfo; + } + + throw new Error("Failed to get Spark wallet"); + } + + /** + * Lists all Spark wallets for the current project. + * Returns basic wallet information for selection/management purposes. + * + * @returns Promise resolving to array of wallet information + * + * @example + * ```typescript + * const wallets = await sdk.wallet.sparkIssuer.list(); + * console.log(`Found ${wallets.length} Spark wallets:`); + * wallets.forEach(w => console.log(`- ${w.id}: tags=[${w.tags.join(', ')}]`)); + * ``` + */ + async list(): Promise { + const { data, status } = await this.sdk.axiosInstance.get( + `api/project-wallet/${this.sdk.projectId}/spark`, + ); + + if (status === 200) { + return data as MultiChainWalletInfo[]; + } + + throw new Error("Failed to get Spark wallets"); + } + + /** + * Gets Spark wallets filtered by tag. + * + * @param tag The tag to filter by + * @returns Promise resolving to array of matching wallet information + * + * @example + * ```typescript + * const wallets = await sdk.wallet.sparkIssuer.getByTag("tokenization"); + * ``` + */ + async getByTag(tag: string): Promise { + const { data, status } = await this.sdk.axiosInstance.get( + `api/project-wallet/${this.sdk.projectId}/spark/tag/${tag}`, + ); + + if (status === 200) { + return data as MultiChainWalletInfo[]; + } + + throw new Error("Failed to get Spark wallets by tag"); + } +} \ No newline at end of file diff --git a/src/types/cardano/dev-wallet.ts b/src/types/cardano/dev-wallet.ts new file mode 100644 index 0000000..e4d9e20 --- /dev/null +++ b/src/types/cardano/dev-wallet.ts @@ -0,0 +1,76 @@ +/** + * Cardano-specific transaction result + */ +export interface CardanoTransactionResult { + transactionId: string; +} + +/** + * Parameters for querying Cardano token balance + */ +export interface CardanoTokenBalanceParams { + tokenId: string; + address: string; +} + +/** + * Result for Cardano token balance queries + */ +export interface CardanoTokenBalanceResult { + balance: string; +} + +/** + * Parameters for transferring Cardano tokens + */ +export interface CardanoTransferTokensParams { + tokenId: string; + amount: string; + toAddress: string; +} + +/** + * Individual recipient for batch Cardano operations + */ +export interface CardanoBatchRecipient { + address: string; + amount: string; +} + +/** + * Parameters for batch transferring Cardano tokens + */ +export interface CardanoBatchTransferParams { + tokenId: string; + recipients: CardanoBatchRecipient[]; +} + +/** + * Parameters for freezing Cardano tokens (CIP-113 compliance) + */ +export interface CardanoFreezeTokensParams { + address: string; + reason?: string; +} + +/** + * Parameters for unfreezing Cardano tokens (CIP-113 compliance) + */ +export interface CardanoUnfreezeTokensParams { + address: string; +} + +/** + * Result for Cardano freeze/unfreeze operations (CIP-113) + */ +export interface CardanoFreezeResult { + transactionId: string; + impactedTokens: string[]; +} + +/** + * Parameters for burning Cardano tokens + */ +export interface CardanoBurnTokensParams { + amount: string; +} \ No newline at end of file diff --git a/src/types/cardano/index.ts b/src/types/cardano/index.ts new file mode 100644 index 0000000..5e078c9 --- /dev/null +++ b/src/types/cardano/index.ts @@ -0,0 +1 @@ +export * from "./dev-wallet"; \ No newline at end of file diff --git a/src/types/cardano/tokenization.ts b/src/types/cardano/tokenization.ts new file mode 100644 index 0000000..90acdf0 --- /dev/null +++ b/src/types/cardano/tokenization.ts @@ -0,0 +1,94 @@ +export type TokenizationTransaction = { + id: string; + tokenId: string; + type: "create" | "mint" | "burn" | "transfer" | "freeze" | "unfreeze"; + chain: string; + network: string; + txHash?: string; + amount?: string; + fromAddress?: string; + toAddress?: string; + status: string; + metadata?: Record; + createdAt: string; +}; + +export type TokenizationFrozenAddress = { + id: string; + address: string; + publicKeyHash: string; + stakeKeyHash?: string; + chain: string; + network: string; + isFrozen: boolean; + freezeReason?: string; + frozenAt: string; + unfrozenAt?: string | null; + createdAt: string; +}; + +export type TokenizationPaginationInfo = { + currentPage: number; + totalPages: number; + totalCount: number; + hasNextPage: boolean; + hasPreviousPage: boolean; +}; + +export type TokenizationPolicy = { + tokenId: string; + projectId: string; + walletId: string; + chain: string; + network: string; + isActive: boolean; + createdAt: string; +}; + +export type CreateTokenParams = { + tokenName: string; + tokenTicker: string; + decimals: number; + maxSupply?: bigint; + isFreezable: boolean; +}; + +export type MintTokensParams = { + amount: bigint; +}; + +export type TransferTokensParams = { + amount: bigint; + toAddress: string; +}; + +export type BurnTokensParams = { + amount: bigint; +}; + +export type FreezeTokensParams = { + address: string; + freezeReason?: string; +}; + +export type UnfreezeTokensParams = { + address: string; +}; + +export type ListTransactionsParams = { + type?: "create" | "mint" | "burn" | "transfer" | "freeze" | "unfreeze"; + page?: number; + limit?: number; +}; + +export type ListFrozenAddressesParams = { + includeUnfrozen?: boolean; + page?: number; + limit?: number; +}; + +export type ListTokenizationPoliciesParams = { + tokenId?: string; + page?: number; + limit?: number; +}; diff --git a/src/types/core/index.ts b/src/types/core/index.ts index 1e3174b..6cd20ef 100644 --- a/src/types/core/index.ts +++ b/src/types/core/index.ts @@ -27,15 +27,6 @@ export type Web3ProjectBranding = { appleEnabled?: boolean; }; -export type Web3ProjectWallet = { - id: string; - key: string; - tags: string[]; - projectId: string; - pubKeyHash: string; - stakeCredentialHash: string; -}; - export type Web3JWTBody = { /** User's ID */ sub: string; @@ -66,3 +57,5 @@ export type SponsorshipTxParserPostRequestBody = { sponsorUtxo: string; network: "mainnet" | "testnet"; }; + +export * from "./multi-chain"; diff --git a/src/types/core/multi-chain.ts b/src/types/core/multi-chain.ts new file mode 100644 index 0000000..19a1cc1 --- /dev/null +++ b/src/types/core/multi-chain.ts @@ -0,0 +1,43 @@ +import { MeshWallet } from "@meshsdk/wallet"; +import { IssuerSparkWallet } from "@buildonspark/issuer-sdk"; +import { + MultiChainWalletInfo as ContractWalletInfo, + validateMultiChainWalletInfo, + isValidMultiChainWalletInfo, +} from "@utxos/api-contracts"; + +/** + * Standardized network ID type (0 = testnet, 1 = mainnet) + */ +export type NetworkId = 0 | 1; + +/** + * Multi-chain wallet creation options + */ +export interface MultiChainWalletOptions { + tags?: string[]; + networkId?: NetworkId; +} + +/** + * Multi-chain wallet information - one wallet per project with all chain keys + * Type is imported from shared API contracts to ensure SDK/API compatibility. + */ +export type MultiChainWalletInfo = ContractWalletInfo; + +// Re-export validation utilities for runtime checking +export { validateMultiChainWalletInfo, isValidMultiChainWalletInfo }; + +/** + * Multi-chain wallet instance with initialized wallet objects + */ +export interface MultiChainWalletInstance { + info: MultiChainWalletInfo; + cardanoWallet?: MeshWallet; + sparkIssuerWallet?: IssuerSparkWallet; +} + +/** + * Supported chain types for wallet operations + */ +export type SupportedChain = "cardano" | "spark"; diff --git a/src/types/index.ts b/src/types/index.ts index fc71847..3b93ff7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,3 +2,4 @@ export * from "./core"; export * from "./user"; export * from "./window"; export * from "./spark"; +export * from "./cardano"; diff --git a/src/types/spark/dev-wallet.ts b/src/types/spark/dev-wallet.ts new file mode 100644 index 0000000..9023531 --- /dev/null +++ b/src/types/spark/dev-wallet.ts @@ -0,0 +1,47 @@ +/** + * Developer-controlled Spark wallet types + * + * Most types are re-exported from @buildonspark/spark-sdk and @buildonspark/issuer-sdk. + * This file only contains types specific to our wrapper layer. + */ + +// Re-export SDK types for convenience +export type { Bech32mTokenIdentifier } from "@buildonspark/spark-sdk"; +export type { IssuerTokenMetadata } from "@buildonspark/issuer-sdk"; + +/** + * Parameters for freezing tokens at a specific address. + * Extends SDK freeze with optional reason for database tracking. + */ +export interface SparkFreezeTokensParams { + address: string; + /** Optional reason for freezing - stored in database for compliance tracking */ + freezeReason?: string; +} + +/** + * Parameters for unfreezing tokens at a specific address. + */ +export interface SparkUnfreezeTokensParams { + address: string; +} + +/** + * Result for freeze/unfreeze operations. + * Matches SDK return type but with string amounts for serialization. + */ +export interface SparkFreezeResult { + impactedOutputIds: string[]; + impactedTokenAmount: string; +} + +/** + * Information about a frozen address stored in database. + */ +export interface SparkFrozenAddressInfo { + address: string; + frozenTokenAmount: string; + freezeTransactionId?: string; + freezeReason?: string; + frozenAt: string; +} diff --git a/src/types/spark/index.ts b/src/types/spark/index.ts index 4746aa7..b89c35d 100644 --- a/src/types/spark/index.ts +++ b/src/types/spark/index.ts @@ -46,6 +46,74 @@ export interface TokenMetadata { isFreezable: boolean | null; } +/** + * Token transaction participant info + * Used in TokenTransaction for from/to fields + */ +export interface TokenTransactionParticipant { + type: string; + identifier: string; + pubkey: string; +} + +/** + * Individual token transaction from Sparkscan API + * @see https://docs.sparkscan.io/api/tokens#get-token-transactions + */ +export interface TokenTransaction { + id: string; + type: string; + status: string; + createdAt: string; + updatedAt: string; + from: TokenTransactionParticipant; + to: TokenTransactionParticipant; + amount: number; + valueUsd?: number; + tokenMetadata?: TokenMetadata; + multiIoDetails?: any; +} + +/** + * Token transactions response from Sparkscan API + * @see https://docs.sparkscan.io/api/tokens#get-token-transactions + */ +export interface TokenTransactionsResponse { + meta: { + totalItems: number; + limit: number; + offset: number; + }; + data: TokenTransaction[]; +} + +/** + * Token balance information for an address + */ +export interface AddressTokenBalance { + tokenIdentifier: string; + tokenAddress: string; + name: string; + ticker: string; + decimals: number; + balance: number; + valueUsd?: number; + issuerPublicKey: string; + maxSupply: number | null; + isFreezable: boolean | null; +} + +/** + * Address tokens response from Sparkscan API + * @see https://docs.sparkscan.io/api/address#get-address-tokens + */ +export interface AddressTokensResponse { + address: string; + pubkey: string; + totalValueUsd: number; + tokens: AddressTokenBalance[]; +} + export interface TransactionOutput { address: string; pubkey: string; @@ -165,6 +233,23 @@ export interface LatestTxidResponse { [address: string]: string | null; } +export interface TokenCreationParams { + tokenName: string; + tokenTicker: string; + decimals: number; + maxSupply?: string; + isFreezable: boolean; +} + +export interface MintTokenParams { + tokenIdentifier: string; + amount: bigint; + recipientAddress: string; +} + +export * from "./dev-wallet"; +export * from "./tokenization"; + // Types copied from @buildonspark/spark-sdk since they are currently private // Source: https://github.com/buildonspark/spark-sdk export enum TransferDirection { diff --git a/src/types/spark/tokenization.ts b/src/types/spark/tokenization.ts new file mode 100644 index 0000000..da4f217 --- /dev/null +++ b/src/types/spark/tokenization.ts @@ -0,0 +1,144 @@ +/** + * Token transaction record from database + */ +export type TokenizationTransaction = { + id: string; + tokenId: string; + type: "create" | "mint" | "burn" | "transfer" | "freeze" | "unfreeze"; + chain: string; + network: string; + txHash?: string; + amount?: string; + fromAddress?: string; + toAddress?: string; + status: string; + metadata?: Record; + createdAt: string; +}; + +/** + * Frozen address information from database + */ +export type TokenizationFrozenAddress = { + id: string; + address: string; + publicKeyHash: string; + stakeKeyHash?: string; + chain: string; + network: string; + freezeReason?: string; + frozenAt: string; + createdAt: string; +}; + +/** + * Pagination info for list responses + */ +export type TokenizationPaginationInfo = { + currentPage: number; + totalPages: number; + totalCount: number; + hasNextPage: boolean; + hasPreviousPage: boolean; +}; + +/** + * Tokenization policy from database + */ +export type TokenizationPolicy = { + tokenId: string; + projectId: string; + walletId: string; + chain: string; + network: string; + isActive: boolean; + createdAt: string; +}; + +/** + * Parameters for initializing tokenization by token ID. + */ +export type InitTokenizationParams = { tokenId: string }; + +/** + * Parameters for creating a new token. + * Automatically creates a new wallet if none is loaded. + */ +export type CreateTokenParams = { + tokenName: string; + tokenTicker: string; + decimals: number; + maxSupply?: bigint; + isFreezable: boolean; +}; + +/** + * Parameters for minting tokens. + * Requires initTokenization() to be called first. + */ +export type MintTokensParams = { + amount: bigint; +}; + +/** + * Parameters for transferring tokens. + * Requires initTokenization() to be called first. + */ +export type TransferTokensParams = { + amount: bigint; + toAddress: string; +}; + +/** + * Parameters for burning tokens. + * Requires initTokenization() to be called first. + */ +export type BurnTokensParams = { + amount: bigint; +}; + +/** + * Parameters for freezing tokens. + * Requires initTokenization() to be called first. + */ +export type FreezeTokensParams = { + address: string; + freezeReason?: string; +}; + +/** + * Parameters for unfreezing tokens. + * Requires initTokenization() to be called first. + */ +export type UnfreezeTokensParams = { + address: string; +}; + +/** + * Parameters for listing transactions. + * Requires initTokenization() to be called first. + */ +export type ListTransactionsParams = { + type?: "create" | "mint" | "burn" | "transfer" | "freeze" | "unfreeze"; + page?: number; + limit?: number; +}; + +/** + * Parameters for listing frozen addresses. + * Requires initTokenization() to be called first. + */ +export type ListFrozenAddressesParams = { + includeUnfrozen?: boolean; + page?: number; + limit?: number; +}; + +/** + * Parameters for listing tokenization policies. + */ +export type ListTokenizationPoliciesParams = { + tokenId?: string; + page?: number; + limit?: number; +};