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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dev": "pnpm --filter @fundable/web dev",
"build": "pnpm --filter @fundable/web build",
"build:contracts": "cd contracts && cargo build --release",
"generate:sdk": "pnpm --filter @fundable/sdk generate",
"test:contracts": "cd contracts && cargo test",
"test:sdk": "pnpm --filter @fundable/sdk test",
"test:sdk:coverage": "pnpm --filter @fundable/sdk test:coverage",
Expand All @@ -29,4 +30,4 @@
"devDependencies": {
"@changesets/cli": "^2.31.0"
}
}
}
10 changes: 10 additions & 0 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ This SDK has a peer dependency on `@stellar/stellar-sdk`. You will need to have
pnpm add @stellar/stellar-sdk
```

## Regenerating Contract Bindings

Regenerate the SDK's generated contract clients from the current local contract WASM files:

```bash
pnpm --filter @fundable/sdk generate
```

The script builds the SDK contracts with `stellar contract build --optimize`, then runs `stellar contract bindings typescript --wasm` into `src/generated/payment-stream` and `src/generated/distributor`.

## Address Support

All SDK methods that accept address parameters now support both string addresses and `@stellar/stellar-sdk` `Address` objects. This provides better type safety and consistency with the underlying Stellar SDK.
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"lint": "eslint src --ext .ts,.tsx",
"format": "prettier --write \"src/**/*.{ts,tsx,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,json}\"",
"generate": "node scripts/regenerate-types.js",
"generate": "node scripts/regenerate-types-from-wasm.js",
"generate:wasm": "node scripts/regenerate-types-from-wasm.js",
"generate:testnet": "node scripts/regenerate-types.js --network=testnet",
"generate:mainnet": "node scripts/regenerate-types.js --network=mainnet"
},
Expand Down
190 changes: 190 additions & 0 deletions packages/sdk/scripts/regenerate-types-from-wasm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
#!/usr/bin/env node
/**
* Regenerates SDK contract bindings from the current local contract WASM files.
*
* The script builds the contracts with the Stellar CLI, then runs
* `stellar contract bindings typescript --wasm` for each SDK-generated client.
*
* Usage:
* node scripts/regenerate-types-from-wasm.js
* node scripts/regenerate-types-from-wasm.js --contract payment-stream
* node scripts/regenerate-types-from-wasm.js --skip-build
* node scripts/regenerate-types-from-wasm.js --no-optimize
*/

import { spawnSync } from "child_process";
import { existsSync, mkdirSync } from "fs";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "../../../");
const sdkRoot = resolve(__dirname, "..");
const contractsRoot = resolve(repoRoot, "contracts");
const wasmOutDir = resolve(sdkRoot, ".contract-wasm");

const args = process.argv.slice(2);
const selectedContract = readFlagValue("--contract");
const skipBuild = args.includes("--skip-build");
const optimize = !args.includes("--no-optimize");

const CONTRACTS = [
{
name: "payment-stream",
packageName: "payment-stream",
wasmFile: "payment_stream.wasm",
outputDir: resolve(sdkRoot, "src/generated/payment-stream"),
},
{
name: "distributor",
packageName: "distributor",
wasmFile: "distributor.wasm",
outputDir: resolve(sdkRoot, "src/generated/distributor"),
},
];

function readFlagValue(flag) {
const equalsArg = args.find((arg) => arg.startsWith(`${flag}=`));
if (equalsArg) {
return equalsArg.slice(flag.length + 1);
}

const flagIndex = args.indexOf(flag);
return flagIndex === -1 ? undefined : args[flagIndex + 1];
}

function quote(value) {
return value.includes(" ") ? `"${value}"` : value;
}

function run(command, commandArgs, label) {
console.log(`\n[regenerate-types:wasm] ${label}`);
console.log(` $ ${[command, ...commandArgs].map(quote).join(" ")}\n`);

const result = spawnSync(command, commandArgs, {
cwd: repoRoot,
stdio: "inherit",
});

if (result.status === 0) {
return;
}

if (result.error) {
throw result.error;
}

throw new Error(`${command} exited with status ${result.status}`);
}

function checkStellarCli() {
const result = spawnSync("stellar", ["--version"], { stdio: "pipe" });

if (result.status !== 0) {
console.error(
"[regenerate-types:wasm] ERROR: `stellar` CLI not found.\n" +
" Install it via: cargo install stellar-cli --features opt\n" +
" Docs: https://developers.stellar.org/docs/tools/developer-tools/cli/stellar-cli"
);
process.exit(1);
}
}

function selectedContracts() {
if (!selectedContract) {
return CONTRACTS;
}

const matches = CONTRACTS.filter(
(contract) =>
contract.name === selectedContract ||
contract.packageName === selectedContract
);

if (matches.length === 0) {
console.error(
`[regenerate-types:wasm] Unknown contract "${selectedContract}". ` +
`Expected one of: ${CONTRACTS.map((contract) => contract.name).join(", ")}.`
);
process.exit(1);
}

return matches;
}

checkStellarCli();
mkdirSync(wasmOutDir, { recursive: true });

const contracts = selectedContracts();

if (!skipBuild) {
for (const contract of contracts) {
const buildArgs = [
"contract",
"build",
"--manifest-path",
resolve(contractsRoot, "Cargo.toml"),
"--package",
contract.packageName,
"--out-dir",
wasmOutDir,
];

if (optimize) {
buildArgs.push("--optimize");
}

run(
"stellar",
buildArgs,
`Building ${contract.name}${optimize ? " with optimization" : ""}`
);
}
}

let hasError = false;

for (const contract of contracts) {
const wasmPath = resolve(wasmOutDir, contract.wasmFile);

if (!existsSync(wasmPath)) {
console.error(
`[regenerate-types:wasm] Missing WASM for ${contract.name}: ${wasmPath}\n` +
" Run without --skip-build, or build the contracts before regenerating."
);
hasError = true;
continue;
}

try {
run(
"stellar",
[
"contract",
"bindings",
"typescript",
"--wasm",
wasmPath,
"--output-dir",
contract.outputDir,
"--overwrite",
],
`Regenerating bindings for ${contract.name}`
);
console.log(
`[regenerate-types:wasm] ${contract.name} bindings written to ${contract.outputDir}`
);
} catch (err) {
console.error(
`[regenerate-types:wasm] Failed to regenerate ${contract.name}: ${err.message}`
);
hasError = true;
}
}

if (hasError) {
console.error("\n[regenerate-types:wasm] Completed with errors.");
process.exit(1);
}

console.log("\n[regenerate-types:wasm] All bindings regenerated successfully.");
128 changes: 128 additions & 0 deletions packages/sdk/src/__tests__/GasEstimator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from "vitest";
import {
GasEstimator,
estimateSorobanGas,
type GasEstimatorRpc,
} from "../utils/GasEstimator";

function createRpc(overrides: Partial<GasEstimatorRpc> = {}): GasEstimatorRpc {
return {
simulateTransaction: vi.fn().mockResolvedValue({
minResourceFee: "1000",
}),
getFeeStats: vi.fn().mockResolvedValue({
inclusionFee: {
p50: "100",
p90: "120",
p95: "140",
p99: "200",
transactionCount: "5",
},
}),
...overrides,
} as GasEstimatorRpc;
}

describe("GasEstimator", () => {
it("combines simulation resource fees with low congestion fee stats", async () => {
const rpc = createRpc();
const estimator = new GasEstimator({ rpc });

const estimate = await estimator.estimate({} as any);

expect(rpc.simulateTransaction).toHaveBeenCalled();
expect(rpc.getFeeStats).toHaveBeenCalled();
expect(estimate.minResourceFee).toBe("1000");
expect(estimate.resourceFee).toBe("1200");
expect(estimate.inclusionFee).toBe("110");
expect(estimate.recommendedFee).toBe("1310");
expect(estimate.congestionLevel).toBe("low");
});

it("uses higher percentiles and a larger buffer during high congestion", async () => {
const rpc = createRpc({
getFeeStats: vi.fn().mockResolvedValue({
inclusionFee: {
p50: "200",
p90: "450",
p95: "700",
p99: "1200",
transactionCount: "520",
},
}),
});
const estimator = new GasEstimator({ rpc });

const estimate = await estimator.estimate({} as any);

expect(estimate.congestionLevel).toBe("high");
expect(estimate.inclusionFee).toBe("945");
expect(estimate.recommendedFee).toBe("2145");
});

it("falls back to the configured base fee when fee stats are unavailable", async () => {
const rpc = createRpc({
getFeeStats: vi.fn().mockRejectedValue(new Error("method not found")),
});
const estimator = new GasEstimator({ rpc, baseFee: "250" });

const estimate = await estimator.estimate({} as any);

expect(estimate.congestionLevel).toBe("unknown");
expect(estimate.inclusionFee).toBe("275");
expect(estimate.recommendedFee).toBe("1475");
});

it("buffers resource limits parsed from Soroban transaction data", async () => {
const resources = {
instructions: () => 10,
readBytes: () => 20,
writeBytes: () => 30,
footprint: () => ({
readOnly: () => ["a", "b"],
readWrite: () => ["c"],
}),
};
const transactionData = {
resources: () => resources,
};
const rpc = createRpc({
simulateTransaction: vi.fn().mockResolvedValue({
minResourceFee: "1000",
transactionData,
}),
});
const estimator = new GasEstimator({ rpc, resourceBuffer: 1.5 });

const estimate = await estimator.estimate({} as any);

expect(estimate.resourceLimits).toEqual({
instructions: 15,
readBytes: 30,
writeBytes: 45,
readEntries: 3,
writeEntries: 2,
});
});

it("throws when simulation returns an error response", async () => {
const rpc = createRpc({
simulateTransaction: vi
.fn()
.mockResolvedValue({ error: "host function failed" }),
});
const estimator = new GasEstimator({ rpc });

await expect(estimator.estimate({} as any)).rejects.toThrow(
"Gas estimation simulation failed: host function failed"
);
});

it("provides a function helper for one-off estimates", async () => {
const rpc = createRpc();

const estimate = await estimateSorobanGas({} as any, { rpc });

expect(estimate.recommendedFee).toBe("1310");
});
});
Loading
Loading