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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

87 changes: 87 additions & 0 deletions src/batch.ts
Original file line number Diff line number Diff line change
@@ -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<BatchOperation> {
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<BatchFailureMode, number> {
return this.operationsSnapshot.reduce(
(acc, item) => {
acc[item.mode] += 1;
return acc;
},
{ abort: 0, continue: 0 } as Record<BatchFailureMode, number>
);
}

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();
}
}
36 changes: 35 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
CreateGroupParams,
GroupStatus,
} from "./types";
import { WalletAdapter } from "./wallets";
import { BatchBuilder } from "./batch";

/**
* SoroSave SDK Client
Expand All @@ -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<StellarSdk.Transaction> {
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<StellarSdk.Transaction> {
const account = await this.server.getAccount(sourcePublicKey);
return batch.buildTransaction(account, this.networkPassphrase);
}

// ─── Group Lifecycle ────────────────────────────────────────────
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
131 changes: 131 additions & 0 deletions src/wallets.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
getAddress(): Promise<string>;
signTransaction(
transaction: StellarSdk.Transaction,
networkPassphrase: string
): Promise<StellarSdk.Transaction>;
signAuthEntry?(
authEntry: string,
networkPassphrase: string
): Promise<string>;
}

type GlobalRuntime = {
freighterApi?: {
isConnected: () => Promise<boolean>;
getAddress: () => Promise<string>;
signTransaction?: (args: {
xdr: string;
network: string;
}) => Promise<string | { signedTxXdr?: string; signedXdr?: string }>;
};
};

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<boolean> {
const api = this.resolveApi();
if (!api) {
return false;
}

try {
return await api.isConnected();
} catch {
return false;
}
}

async getAddress(): Promise<string> {
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<StellarSdk.Transaction> {
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<string> {
throw new Error(
"FreighterAdapter only supports transaction signing for now."
);
}
}
25 changes: 25 additions & 0 deletions tests/batch.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading