diff --git a/package-lock.json b/package-lock.json index a4efe85..eaa7afa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,11 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@stellar/stellar-sdk": "^13.0.0" + "@stellar/stellar-sdk": "^13.0.0", + "commander": "^11.0.0" + }, + "bin": { + "sorosave": "dist/cli.js" }, "devDependencies": { "@types/node": "^20.0.0", @@ -1213,6 +1217,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", diff --git a/src/batch.ts b/src/batch.ts new file mode 100644 index 0000000..417d765 --- /dev/null +++ b/src/batch.ts @@ -0,0 +1,87 @@ +import * as StellarSdk from "@stellar/stellar-sdk"; + +export type BatchFailureMode = "abort" | "continue"; + +export interface BatchOperation { + op: StellarSdk.xdr.Operation; + order: number; + mode: BatchFailureMode; +} + +export interface BatchOperationOptions { + order?: number; + mode?: BatchFailureMode; +} + +export class BatchBuilder { + private operations: BatchOperation[] = []; + private insertCursor = 0; + + addOperation( + op: StellarSdk.xdr.Operation, + options: BatchOperationOptions = {} + ): this { + this.operations.push({ + op, + order: options.order ?? this.insertCursor, + mode: options.mode ?? "abort", + }); + + this.insertCursor += 1; + return this; + } + + clear(): this { + this.operations = []; + this.insertCursor = 0; + return this; + } + + get size(): number { + return this.operations.length; + } + + get operationsSnapshot(): ReadonlyArray { + return [...this.operations].sort((a, b) => { + if (a.order === b.order) { + return 0; + } + return a.order - b.order; + }); + } + + toOperationList(): BatchOperation[] { + return [...this.operationsSnapshot]; + } + + getFailureModeSummary(): Record { + return this.operationsSnapshot.reduce( + (acc, item) => { + acc[item.mode] += 1; + return acc; + }, + { abort: 0, continue: 0 } as Record + ); + } + + buildTransaction( + account: StellarSdk.Account, + networkPassphrase: string, + timeout = 30 + ): StellarSdk.Transaction { + if (this.operations.length === 0) { + throw new Error("BatchBuilder has no operations."); + } + + const txBuilder = new StellarSdk.TransactionBuilder(account, { + fee: `${this.operations.length * 100}`, + networkPassphrase, + }).setTimeout(timeout); + + for (const item of this.operationsSnapshot) { + txBuilder.addOperation(item.op); + } + + return txBuilder.build(); + } +} diff --git a/src/client.ts b/src/client.ts index 76a4780..89e0789 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6,6 +6,8 @@ import { CreateGroupParams, GroupStatus, } from "./types"; +import { WalletAdapter } from "./wallets"; +import { BatchBuilder } from "./batch"; /** * SoroSave SDK Client @@ -17,11 +19,43 @@ export class SoroSaveClient { private server: StellarSdk.rpc.Server; private contractId: string; private networkPassphrase: string; + private walletAdapter?: WalletAdapter; - constructor(config: SoroSaveConfig) { + constructor(config: SoroSaveConfig, walletAdapter?: WalletAdapter) { this.server = new StellarSdk.rpc.Server(config.rpcUrl); this.contractId = config.contractId; this.networkPassphrase = config.networkPassphrase; + this.walletAdapter = walletAdapter; + } + + setWalletAdapter(walletAdapter: WalletAdapter): this { + this.walletAdapter = walletAdapter; + return this; + } + + async buildAndSignTransaction( + operation: StellarSdk.xdr.Operation, + sourcePublicKey: string + ): Promise { + const tx = await this.buildTransaction(operation, sourcePublicKey); + + if (!this.walletAdapter) { + throw new Error("Wallet adapter is not configured."); + } + + return this.walletAdapter.signTransaction(tx, this.networkPassphrase); + } + + createBatchBuilder(): BatchBuilder { + return new BatchBuilder(); + } + + async buildBatchTransaction( + sourcePublicKey: string, + batch: BatchBuilder + ): Promise { + const account = await this.server.getAccount(sourcePublicKey); + return batch.buildTransaction(account, this.networkPassphrase); } // ─── Group Lifecycle ──────────────────────────────────────────── diff --git a/src/index.ts b/src/index.ts index c1dd9a5..d9a9ac7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ export { SoroSaveClient } from "./client"; +export { WalletAdapter, FreighterAdapter, WalletCapabilities } from "./wallets"; +export { BatchBuilder, type BatchOperation, type BatchOperationOptions, type BatchFailureMode } from "./batch"; export { GroupStatus, type SavingsGroup, diff --git a/src/wallets.ts b/src/wallets.ts new file mode 100644 index 0000000..ed184e8 --- /dev/null +++ b/src/wallets.ts @@ -0,0 +1,131 @@ +import * as StellarSdk from "@stellar/stellar-sdk"; + +export interface WalletCapabilities { + supportsSignTransaction: boolean; + supportsSignAuth: boolean; + networkSupport: string[]; +} + +export interface WalletAdapter { + name: string; + capabilities: WalletCapabilities; + isAvailable(): Promise; + getAddress(): Promise; + signTransaction( + transaction: StellarSdk.Transaction, + networkPassphrase: string + ): Promise; + signAuthEntry?( + authEntry: string, + networkPassphrase: string + ): Promise; +} + +type GlobalRuntime = { + freighterApi?: { + isConnected: () => Promise; + getAddress: () => Promise; + signTransaction?: (args: { + xdr: string; + network: string; + }) => Promise; + }; +}; + +function extractSignedXdr(payload: string | { signedTxXdr?: string; signedXdr?: string }): string { + if (typeof payload === "string") { + return payload; + } + + if (payload.signedTxXdr && typeof payload.signedTxXdr === "string") { + return payload.signedTxXdr; + } + + if (payload.signedXdr && typeof payload.signedXdr === "string") { + return payload.signedXdr; + } + + throw new Error("Freighter API returned unexpected sign payload."); +} + +export class FreighterAdapter implements WalletAdapter { + name = "freighter"; + capabilities: WalletCapabilities = { + supportsSignTransaction: true, + supportsSignAuth: false, + networkSupport: ["testnet", "public"], + }; + + private resolveApi() { + return (globalThis as unknown as GlobalRuntime).freighterApi; + } + + async isAvailable(): Promise { + const api = this.resolveApi(); + if (!api) { + return false; + } + + try { + return await api.isConnected(); + } catch { + return false; + } + } + + async getAddress(): Promise { + const api = this.resolveApi(); + if (!api) { + throw new Error("Freighter API is unavailable in this runtime."); + } + + return api.getAddress(); + } + + async signTransaction( + transaction: StellarSdk.Transaction, + networkPassphrase: string + ): Promise { + const api = this.resolveApi(); + if (!api) { + throw new Error("Freighter API is unavailable in this runtime."); + } + + if (typeof api.signTransaction !== "function") { + throw new Error("Freighter API does not expose signTransaction in this environment."); + } + + const response = await api.signTransaction({ + xdr: transaction.toXDR(), + network: networkPassphrase, + }); + + const signedXdr = extractSignedXdr(response); + + const builderAny = StellarSdk.TransactionBuilder as unknown as { + fromXDR?: (xdr: string, networkPassphrase: string) => StellarSdk.Transaction; + }; + if (typeof builderAny.fromXDR === "function") { + return builderAny.fromXDR( + signedXdr, + networkPassphrase + ) as StellarSdk.Transaction; + } + + const txAny = transaction.constructor as { + fromXDR?: (xdr: string, networkPassphrase: string) => StellarSdk.Transaction; + }; + + if (typeof txAny.fromXDR !== "function") { + throw new Error("Unable to parse signed XDR in this SDK/runtime."); + } + + return txAny.fromXDR(signedXdr, networkPassphrase); + } + + async signAuthEntry(authEntry: string): Promise { + throw new Error( + "FreighterAdapter only supports transaction signing for now." + ); + } +} diff --git a/tests/batch.test.ts b/tests/batch.test.ts new file mode 100644 index 0000000..130cdd4 --- /dev/null +++ b/tests/batch.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import * as StellarSdk from '@stellar/stellar-sdk'; +import { BatchBuilder } from '../src/batch'; + +describe('BatchBuilder', () => { + it('keeps deterministic ordering by order field', () => { + const builder = new BatchBuilder(); + + builder.addOperation({} as unknown as StellarSdk.xdr.Operation, { order: 2, mode: 'abort' }); + builder.addOperation({} as unknown as StellarSdk.xdr.Operation, { order: 1, mode: 'continue' }); + builder.addOperation({} as unknown as StellarSdk.xdr.Operation, { order: 2, mode: 'abort' }); + + const list = builder.operationsSnapshot; + + expect(list[0].order).toBe(1); + expect(list[1].order).toBe(2); + expect(list[2].order).toBe(2); + expect(builder.getFailureModeSummary()).toEqual({ abort: 2, continue: 1 }); + }); + + it('buildBatchTransaction requires at least one operation', () => { + const builder = new BatchBuilder(); + expect(builder.size).toBe(0); + }); +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts new file mode 100644 index 0000000..7d313ce --- /dev/null +++ b/tests/integration.test.ts @@ -0,0 +1,209 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { describe, expect, it, vi } from 'vitest'; +import { SoroSaveClient } from '../src/client'; +import { WalletAdapter } from '../src/wallets'; + +describe('integration suite', () => { + it('covers full SoroSave lifecycle with mocked Soroban server and wallet adapter', async () => { + const contractId = 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; + const sourcePublicKey = StellarSdk.Keypair.random().publicKey(); + const sourceAccount = new StellarSdk.Account(sourcePublicKey, '1000'); + + const simulateQueue: Array = [ + { + result: { + retval: StellarSdk.nativeToScVal({ + id: 1n, + name: 'test-group', + admin: sourcePublicKey, + token: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + contribution_amount: '100', + cycle_length: 14, + max_members: 5, + members: [sourcePublicKey], + payout_order: [sourcePublicKey], + current_round: 1, + total_rounds: 1, + status: 'Active', + created_at: 1719500000, + }), + }, + }, + { + result: { + retval: StellarSdk.nativeToScVal({ + round_number: 1, + recipient: sourcePublicKey, + contributions: { + [sourcePublicKey]: true, + }, + total_contributed: '100', + is_complete: true, + deadline: 1719500400, + }), + }, + }, + { + result: { + retval: StellarSdk.nativeToScVal([1n]), + }, + }, + ]; + + const simulateTransactionMock = vi.fn().mockImplementation(() => { + const next = simulateQueue.shift(); + if (!next) { + throw new Error('simulateQueue exhausted before test completed'); + } + return Promise.resolve(next); + }); + + const buildTxTemplate = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase: StellarSdk.Networks.TESTNET, + }) + .addOperation(StellarSdk.Operation.setOptions({})) + .setTimeout(30) + .build(); + + const signTransaction = vi + .fn() + .mockImplementation(async (tx: StellarSdk.Transaction) => tx); + const walletAdapter: WalletAdapter = { + name: 'mock-wallet', + capabilities: { + supportsSignTransaction: true, + supportsSignAuth: false, + networkSupport: ['testnet'], + }, + isAvailable: vi.fn().mockResolvedValue(true), + getAddress: vi.fn().mockResolvedValue(sourcePublicKey), + signTransaction, + signAuthEntry: vi.fn(), + }; + + const client = new SoroSaveClient( + { + contractId, + rpcUrl: 'https://127.0.0.1:12345', + networkPassphrase: StellarSdk.Networks.TESTNET, + }, + walletAdapter + ); + + // Use deterministic mocks and bypass network-bound assemble logic for this test. + (client as any).server = { + getAccount: vi.fn().mockResolvedValue(sourceAccount), + simulateTransaction: simulateTransactionMock, + } as never; + const buildTransactionSpy = vi + .spyOn(client as any, 'buildTransaction') + .mockResolvedValue(buildTxTemplate); + + const groupParams = { + admin: sourcePublicKey, + name: 'test-group', + token: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + contributionAmount: BigInt(100), + cycleLength: 14, + maxMembers: 5, + }; + + // 1) Lifecycle tx creation path. + await client.createGroup(groupParams, sourcePublicKey); + await client.joinGroup(sourcePublicKey, 1, sourcePublicKey); + await client.startGroup(sourcePublicKey, 1, sourcePublicKey); + await client.contribute(sourcePublicKey, 1, sourcePublicKey); + await client.distributePayout(1, sourcePublicKey); + + const buildAndSignSpy = vi.spyOn(client, 'buildAndSignTransaction'); + const contract = new StellarSdk.Contract(contractId); + const rawOperation = contract.call( + 'create_group', + new StellarSdk.Address(groupParams.admin).toScVal(), + StellarSdk.nativeToScVal(groupParams.name, { type: 'string' }), + new StellarSdk.Address(groupParams.token).toScVal(), + StellarSdk.nativeToScVal(groupParams.contributionAmount, { type: 'i128' }), + StellarSdk.nativeToScVal(groupParams.cycleLength, { type: 'u64' }), + StellarSdk.nativeToScVal(groupParams.maxMembers, { type: 'u32' }) + ); + + await client.buildAndSignTransaction(rawOperation, sourcePublicKey); + + const batch = client.createBatchBuilder(); + batch + .addOperation( + contract.call( + 'join_group', + new StellarSdk.Address(sourcePublicKey).toScVal(), + StellarSdk.nativeToScVal(1, { type: 'u64' }) + ) + ) + .addOperation( + contract.call( + 'contribute', + new StellarSdk.Address(sourcePublicKey).toScVal(), + StellarSdk.nativeToScVal(1, { type: 'u64' }) + ), + { + mode: 'continue', + } + ); + + const batchTx = await client.buildBatchTransaction(sourcePublicKey, batch); + expect(batch.size).toBe(2); + expect(batch.getFailureModeSummary()).toEqual({ abort: 1, continue: 1 }); + expect(batchTx).toBeDefined(); + + expect(buildTransactionSpy).toHaveBeenCalledTimes(6); + expect(signTransaction).toHaveBeenCalledTimes(1); + expect(buildAndSignSpy).toHaveBeenCalledTimes(1); + + // 2) Query path -> parse typed objects from simulation payloads. + const group = await client.getGroup(1); + expect(group.id).toBe(1); + expect(group.name).toBe('test-group'); + expect(group.status).toBe('Active'); + + const round = await client.getRoundStatus(1, 1); + expect(round.roundNumber).toBe(1); + expect(round.totalContributed).toBe(BigInt(100)); + expect(round.isComplete).toBe(true); + + const memberGroups = await client.getMemberGroups(sourcePublicKey); + expect(memberGroups).toEqual([1n]); + expect(simulateTransactionMock).toHaveBeenCalledTimes(3); + }); + + it.skipIf( + !process.env.LOCAL_SOROBAN_RPC_URL, + 'set LOCAL_SOROBAN_RPC_URL to run true contract lifecycle against a local Soroban node' + )( + 'runs real Soroban lifecycle against local node when env is available', + async () => { + const sourcePublicKey = StellarSdk.Keypair.random().publicKey(); + const localUrl = process.env.LOCAL_SOROBAN_RPC_URL!; + + const client = new SoroSaveClient({ + contractId: process.env.LOCAL_SOROBAN_CONTRACT_ID!, + rpcUrl: localUrl, + networkPassphrase: StellarSdk.Networks.TESTNET, + }); + + const groupParams = { + admin: sourcePublicKey, + name: 'local-int-test', + token: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + contributionAmount: BigInt(100), + cycleLength: 14, + maxMembers: 4, + }; + + const createTx = await client.createGroup(groupParams, sourcePublicKey); + const joinTx = await client.joinGroup(sourcePublicKey, 1, sourcePublicKey); + + expect(createTx).toBeDefined(); + expect(joinTx).toBeDefined(); + } + ); +});