diff --git a/package.json b/package.json index d379ca4..521590e 100644 --- a/package.json +++ b/package.json @@ -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", @@ -29,4 +30,4 @@ "devDependencies": { "@changesets/cli": "^2.31.0" } -} \ No newline at end of file +} diff --git a/packages/sdk/README.md b/packages/sdk/README.md index b3af9f5..7be2ce6 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -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. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index ef7a474..bb17333 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -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" }, diff --git a/packages/sdk/scripts/regenerate-types-from-wasm.js b/packages/sdk/scripts/regenerate-types-from-wasm.js new file mode 100644 index 0000000..b90e1cc --- /dev/null +++ b/packages/sdk/scripts/regenerate-types-from-wasm.js @@ -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."); diff --git a/packages/sdk/src/__tests__/GasEstimator.test.ts b/packages/sdk/src/__tests__/GasEstimator.test.ts new file mode 100644 index 0000000..5b46ef6 --- /dev/null +++ b/packages/sdk/src/__tests__/GasEstimator.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest"; +import { + GasEstimator, + estimateSorobanGas, + type GasEstimatorRpc, +} from "../utils/GasEstimator"; + +function createRpc(overrides: Partial = {}): 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"); + }); +}); diff --git a/packages/sdk/src/generated/distributor/README.md b/packages/sdk/src/generated/distributor/README.md index fff439b..2c1e80e 100644 --- a/packages/sdk/src/generated/distributor/README.md +++ b/packages/sdk/src/generated/distributor/README.md @@ -1,41 +1,54 @@ -# distributor +# distributor JS -Auto-generated TypeScript bindings for the `distributor` Soroban smart contract. +JS library for interacting with [Soroban](https://soroban.stellar.org/) smart contract `distributor` via Soroban RPC. -> **Note:** This directory is generated by the Soroban CLI. Do not edit files inside `src/` by hand — they will be overwritten on the next regeneration. +This library was automatically generated by Soroban CLI using a command similar to: -## Regenerating bindings +```bash +soroban contract bindings ts \ + --rpc-url INSERT_RPC_URL_HERE \ + --network-passphrase "INSERT_NETWORK_PASSPHRASE_HERE" \ + --contract-id INSERT_CONTRACT_ID_HERE \ + --output-dir ./path/to/distributor +``` -Run the following command from the repo root, substituting the values for your target network: +The network passphrase and contract ID are exported from [index.ts](./src/index.ts) in the `networks` constant. If you are the one who generated this library and you know that this contract is also deployed to other networks, feel free to update `networks` with other valid options. This will help your contract consumers use this library more easily. -```bash -stellar contract bindings ts \ - --rpc-url https://soroban-testnet.stellar.org \ - --network-passphrase "Test SDF Network ; September 2015" \ - --contract-id \ - --output-dir packages/sdk/src/generated/distributor +# To publish or not to publish + +This library is suitable for publishing to NPM. You can publish it to NPM using the `npm publish` command. + +But you don't need to publish this library to NPM to use it. You can add it to your project's `package.json` using a file path: + +```json +"dependencies": { + "distributor": "./path/to/this/folder" +} ``` -**Where to get the values:** +However, we've actually encountered [frustration](https://github.com/stellar/soroban-example-dapp/pull/117#discussion_r1232873560) using local libraries with NPM in this way. Though it seems a bit messy, we suggest generating the library directly to your `node_modules` folder automatically after each install by using a `postinstall` script. We've had the least trouble with this approach. NPM will automatically remove what it sees as erroneous directories during the `install` step, and then regenerate them when it gets to your `postinstall` step, which will keep the library up-to-date with your contract. + +```json +"scripts": { + "postinstall": "soroban contract bindings ts --rpc-url INSERT_RPC_URL_HERE --network-passphrase \"INSERT_NETWORK_PASSPHRASE_HERE\" --id INSERT_CONTRACT_ID_HERE --name distributor" +} +``` -| Value | Source | -|-------|--------| -| `--contract-id` | `NEXT_PUBLIC_DISTRIBUTOR_CONTRACT_ID` in `apps/web/.env` (see `apps/web/.env.example`) | -| `--rpc-url` | `NEXT_PUBLIC_SOROBAN_RPC_URL` in `apps/web/.env` — testnet default: `https://soroban-testnet.stellar.org` | -| `--network-passphrase` | Testnet: `"Test SDF Network ; September 2015"` / Mainnet: `"Public Global Stellar Network ; September 2015"` | +Obviously you need to adjust the above command based on the actual command you used to generate the library. -Contract IDs for deployed instances are also recorded in `deployments/testnet.json` (if present). +# Use it -## Usage +Now that you have your library up-to-date and added to your project, you can import it in a file and see inline documentation for all of its exported methods: -This package is consumed internally by `@fundable/sdk`. You do not need to import it directly. See the [SDK README](../../../README.md) and the top-level [README](../../../../../README.md) for usage examples. +```js +import { Contract, networks } from "distributor" -```ts -import { DistributorClient } from '@fundable/sdk'; +const contract = new Contract({ + ...networks.futurenet, // for example; check which networks this library exports + rpcUrl: '...', // use your own, or find one for testing at https://soroban.stellar.org/docs/reference/rpc#public-rpc-providers +}) -const client = new DistributorClient({ - contractId: process.env.NEXT_PUBLIC_DISTRIBUTOR_CONTRACT_ID!, - networkPassphrase: 'Test SDF Network ; September 2015', - rpcUrl: 'https://soroban-testnet.stellar.org', -}); +contract.| ``` + +As long as your editor is configured to show JavaScript/TypeScript documentation, you can pause your typing at that `|` to get a list of all exports and inline-documentation for each. It exports a separate [async](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) function for each method in the smart contract, with documentation for each generated from the comments the contract's author included in the original source code. diff --git a/packages/sdk/src/generated/distributor/package.json b/packages/sdk/src/generated/distributor/package.json index 1abe540..5711475 100644 --- a/packages/sdk/src/generated/distributor/package.json +++ b/packages/sdk/src/generated/distributor/package.json @@ -8,7 +8,7 @@ "build": "tsc" }, "dependencies": { - "@stellar/stellar-sdk": "^14.1.1", + "@stellar/stellar-sdk": "^14.5.0", "buffer": "6.0.3" }, "devDependencies": { diff --git a/packages/sdk/src/generated/distributor/src/index.ts b/packages/sdk/src/generated/distributor/src/index.ts index 2972126..d160b54 100644 --- a/packages/sdk/src/generated/distributor/src/index.ts +++ b/packages/sdk/src/generated/distributor/src/index.ts @@ -1,5 +1,5 @@ import { Buffer } from "buffer"; -import { Address } from '@stellar/stellar-sdk'; +import { Address } from "@stellar/stellar-sdk"; import { AssembledTransaction, Client as ContractClient, @@ -7,7 +7,7 @@ import { MethodOptions, Result, Spec as ContractSpec, -} from '@stellar/stellar-sdk/contract'; +} from "@stellar/stellar-sdk/contract"; import type { u32, i32, @@ -18,14 +18,14 @@ import type { u256, i256, Option, - Typepoint, + Timepoint, Duration, -} from '@stellar/stellar-sdk/contract'; -export * from '@stellar/stellar-sdk' -export * as contract from '@stellar/stellar-sdk/contract' -export * as rpc from '@stellar/stellar-sdk/rpc' +} from "@stellar/stellar-sdk/contract"; +export * from "@stellar/stellar-sdk"; +export * as contract from "@stellar/stellar-sdk/contract"; +export * as rpc from "@stellar/stellar-sdk/rpc"; -if (typeof window !== 'undefined') { +if (typeof window !== "undefined") { //@ts-ignore Buffer exists window.Buffer = window.Buffer || Buffer; } @@ -59,202 +59,52 @@ export interface Client { /** * Construct and simulate a get_admin transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - get_admin: (options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise>> + get_admin: (options?: MethodOptions) => Promise>> /** * Construct and simulate a initialize transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - initialize: ({admin, protocol_fee_percent, fee_address}: {admin: string, protocol_fee_percent: u32, fee_address: string}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + initialize: ({admin, protocol_fee_percent, fee_address}: {admin: string, protocol_fee_percent: u32, fee_address: string}, options?: MethodOptions) => Promise> /** * Construct and simulate a get_user_stats transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - get_user_stats: ({user}: {user: string}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise>> + get_user_stats: ({user}: {user: string}, options?: MethodOptions) => Promise>> /** * Construct and simulate a get_token_stats transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - get_token_stats: ({token}: {token: string}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise>> + get_token_stats: ({token}: {token: string}, options?: MethodOptions) => Promise>> /** * Construct and simulate a distribute_equal transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - distribute_equal: ({sender, token, total_amount, recipients}: {sender: string, token: string, total_amount: i128, recipients: Array}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + distribute_equal: ({sender, token, total_amount, recipients}: {sender: string, token: string, total_amount: i128, recipients: Array}, options?: MethodOptions) => Promise> /** * Construct and simulate a set_protocol_fee transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - set_protocol_fee: ({admin, new_fee_percent}: {admin: string, new_fee_percent: u32}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + set_protocol_fee: ({admin, new_fee_percent}: {admin: string, new_fee_percent: u32}, options?: MethodOptions) => Promise> /** * Construct and simulate a distribute_weighted transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - distribute_weighted: ({sender, token, recipients, amounts}: {sender: string, token: string, recipients: Array, amounts: Array}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + distribute_weighted: ({sender, token, recipients, amounts}: {sender: string, token: string, recipients: Array, amounts: Array}, options?: MethodOptions) => Promise> /** * Construct and simulate a get_total_distributions transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - get_total_distributions: (options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + get_total_distributions: (options?: MethodOptions) => Promise> /** * Construct and simulate a get_distribution_history transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - get_distribution_history: ({start_id, limit}: {start_id: u64, limit: u64}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise>> + get_distribution_history: ({start_id, limit}: {start_id: u64, limit: u64}, options?: MethodOptions) => Promise>> /** * Construct and simulate a get_total_distributed_amount transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - get_total_distributed_amount: (options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + get_total_distributed_amount: (options?: MethodOptions) => Promise> } export class Client extends ContractClient { diff --git a/packages/sdk/src/generated/payment-stream/README.md b/packages/sdk/src/generated/payment-stream/README.md index bcb22db..17ad92b 100644 --- a/packages/sdk/src/generated/payment-stream/README.md +++ b/packages/sdk/src/generated/payment-stream/README.md @@ -1,41 +1,54 @@ -# payment-stream +# payment-stream JS -Auto-generated TypeScript bindings for the `payment-stream` Soroban smart contract. +JS library for interacting with [Soroban](https://soroban.stellar.org/) smart contract `payment-stream` via Soroban RPC. -> **Note:** This directory is generated by the Soroban CLI. Do not edit files inside `src/` by hand — they will be overwritten on the next regeneration. +This library was automatically generated by Soroban CLI using a command similar to: -## Regenerating bindings +```bash +soroban contract bindings ts \ + --rpc-url INSERT_RPC_URL_HERE \ + --network-passphrase "INSERT_NETWORK_PASSPHRASE_HERE" \ + --contract-id INSERT_CONTRACT_ID_HERE \ + --output-dir ./path/to/payment-stream +``` -Run the following command from the repo root, substituting the values for your target network: +The network passphrase and contract ID are exported from [index.ts](./src/index.ts) in the `networks` constant. If you are the one who generated this library and you know that this contract is also deployed to other networks, feel free to update `networks` with other valid options. This will help your contract consumers use this library more easily. -```bash -stellar contract bindings ts \ - --rpc-url https://soroban-testnet.stellar.org \ - --network-passphrase "Test SDF Network ; September 2015" \ - --contract-id \ - --output-dir packages/sdk/src/generated/payment-stream +# To publish or not to publish + +This library is suitable for publishing to NPM. You can publish it to NPM using the `npm publish` command. + +But you don't need to publish this library to NPM to use it. You can add it to your project's `package.json` using a file path: + +```json +"dependencies": { + "payment-stream": "./path/to/this/folder" +} ``` -**Where to get the values:** +However, we've actually encountered [frustration](https://github.com/stellar/soroban-example-dapp/pull/117#discussion_r1232873560) using local libraries with NPM in this way. Though it seems a bit messy, we suggest generating the library directly to your `node_modules` folder automatically after each install by using a `postinstall` script. We've had the least trouble with this approach. NPM will automatically remove what it sees as erroneous directories during the `install` step, and then regenerate them when it gets to your `postinstall` step, which will keep the library up-to-date with your contract. + +```json +"scripts": { + "postinstall": "soroban contract bindings ts --rpc-url INSERT_RPC_URL_HERE --network-passphrase \"INSERT_NETWORK_PASSPHRASE_HERE\" --id INSERT_CONTRACT_ID_HERE --name payment-stream" +} +``` -| Value | Source | -|-------|--------| -| `--contract-id` | `NEXT_PUBLIC_PAYMENT_STREAM_CONTRACT_ID` in `apps/web/.env` (see `apps/web/.env.example`) | -| `--rpc-url` | `NEXT_PUBLIC_SOROBAN_RPC_URL` in `apps/web/.env` — testnet default: `https://soroban-testnet.stellar.org` | -| `--network-passphrase` | Testnet: `"Test SDF Network ; September 2015"` / Mainnet: `"Public Global Stellar Network ; September 2015"` | +Obviously you need to adjust the above command based on the actual command you used to generate the library. -Contract IDs for deployed instances are also recorded in `deployments/testnet.json` (if present). +# Use it -## Usage +Now that you have your library up-to-date and added to your project, you can import it in a file and see inline documentation for all of its exported methods: -This package is consumed internally by `@fundable/sdk`. You do not need to import it directly. See the [SDK README](../../../README.md) and the top-level [README](../../../../../README.md) for usage examples. +```js +import { Contract, networks } from "payment-stream" -```ts -import { PaymentStreamClient } from '@fundable/sdk'; +const contract = new Contract({ + ...networks.futurenet, // for example; check which networks this library exports + rpcUrl: '...', // use your own, or find one for testing at https://soroban.stellar.org/docs/reference/rpc#public-rpc-providers +}) -const client = new PaymentStreamClient({ - contractId: process.env.NEXT_PUBLIC_PAYMENT_STREAM_CONTRACT_ID!, - networkPassphrase: 'Test SDF Network ; September 2015', - rpcUrl: 'https://soroban-testnet.stellar.org', -}); +contract.| ``` + +As long as your editor is configured to show JavaScript/TypeScript documentation, you can pause your typing at that `|` to get a list of all exports and inline-documentation for each. It exports a separate [async](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) function for each method in the smart contract, with documentation for each generated from the comments the contract's author included in the original source code. diff --git a/packages/sdk/src/generated/payment-stream/package.json b/packages/sdk/src/generated/payment-stream/package.json index 229567c..7f1dbcd 100644 --- a/packages/sdk/src/generated/payment-stream/package.json +++ b/packages/sdk/src/generated/payment-stream/package.json @@ -8,7 +8,7 @@ "build": "tsc" }, "dependencies": { - "@stellar/stellar-sdk": "^14.1.1", + "@stellar/stellar-sdk": "^14.5.0", "buffer": "6.0.3" }, "devDependencies": { diff --git a/packages/sdk/src/generated/payment-stream/src/index.ts b/packages/sdk/src/generated/payment-stream/src/index.ts index 6a4daaf..41d5ba5 100644 --- a/packages/sdk/src/generated/payment-stream/src/index.ts +++ b/packages/sdk/src/generated/payment-stream/src/index.ts @@ -1,5 +1,5 @@ import { Buffer } from "buffer"; -import { Address } from '@stellar/stellar-sdk'; +import { Address } from "@stellar/stellar-sdk"; import { AssembledTransaction, Client as ContractClient, @@ -7,7 +7,7 @@ import { MethodOptions, Result, Spec as ContractSpec, -} from '@stellar/stellar-sdk/contract'; +} from "@stellar/stellar-sdk/contract"; import type { u32, i32, @@ -18,14 +18,14 @@ import type { u256, i256, Option, - Typepoint, + Timepoint, Duration, -} from '@stellar/stellar-sdk/contract'; -export * from '@stellar/stellar-sdk' -export * as contract from '@stellar/stellar-sdk/contract' -export * as rpc from '@stellar/stellar-sdk/rpc' +} from "@stellar/stellar-sdk/contract"; +export * from "@stellar/stellar-sdk"; +export * as contract from "@stellar/stellar-sdk/contract"; +export * as rpc from "@stellar/stellar-sdk/rpc"; -if (typeof window !== 'undefined') { +if (typeof window !== "undefined") { //@ts-ignore Buffer exists window.Buffer = window.Buffer || Buffer; } @@ -159,400 +159,115 @@ export interface Client { * Construct and simulate a deposit transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Deposit tokens to an existing stream */ - deposit: ({stream_id, amount}: {stream_id: u64, amount: i128}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + deposit: ({stream_id, amount}: {stream_id: u64, amount: i128}, options?: MethodOptions) => Promise> /** * Construct and simulate a withdraw transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Withdraw from a stream */ - withdraw: ({stream_id, amount}: {stream_id: u64, amount: i128}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + withdraw: ({stream_id, amount}: {stream_id: u64, amount: i128}, options?: MethodOptions) => Promise> /** * Construct and simulate a get_stream transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Get stream details */ - get_stream: ({stream_id}: {stream_id: u64}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + get_stream: ({stream_id}: {stream_id: u64}, options?: MethodOptions) => Promise> /** * Construct and simulate a initialize transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Initialize the contract */ - initialize: ({admin, fee_collector, general_fee_rate}: {admin: string, fee_collector: string, general_fee_rate: u32}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + initialize: ({admin, fee_collector, general_fee_rate}: {admin: string, fee_collector: string, general_fee_rate: u32}, options?: MethodOptions) => Promise> /** * Construct and simulate a get_delegate transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Get the delegate for a stream */ - get_delegate: ({stream_id}: {stream_id: u64}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise>> + get_delegate: ({stream_id}: {stream_id: u64}, options?: MethodOptions) => Promise>> /** * Construct and simulate a pause_stream transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Pause a stream (sender only) */ - pause_stream: ({stream_id}: {stream_id: u64}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + pause_stream: ({stream_id}: {stream_id: u64}, options?: MethodOptions) => Promise> /** * Construct and simulate a set_delegate transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Set a delegate for withdrawal rights on a stream */ - set_delegate: ({stream_id, delegate}: {stream_id: u64, delegate: string}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + set_delegate: ({stream_id, delegate}: {stream_id: u64, delegate: string}, options?: MethodOptions) => Promise> /** * Construct and simulate a withdraw_max transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Withdraw the maximum available amount from a stream */ - withdraw_max: ({stream_id}: {stream_id: u64}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + withdraw_max: ({stream_id}: {stream_id: u64}, options?: MethodOptions) => Promise> /** * Construct and simulate a cancel_stream transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Cancel a stream */ - cancel_stream: ({stream_id}: {stream_id: u64}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + cancel_stream: ({stream_id}: {stream_id: u64}, options?: MethodOptions) => Promise> /** * Construct and simulate a create_stream transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Create a new payment stream */ - create_stream: ({sender, recipient, token, total_amount, initial_amount, start_time, end_time}: {sender: string, recipient: string, token: string, total_amount: i128, initial_amount: i128, start_time: u64, end_time: u64}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + create_stream: ({sender, recipient, token, total_amount, initial_amount, start_time, end_time}: {sender: string, recipient: string, token: string, total_amount: i128, initial_amount: i128, start_time: u64, end_time: u64}, options?: MethodOptions) => Promise> /** * Construct and simulate a resume_stream transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Resume a paused stream (sender only) */ - resume_stream: ({stream_id}: {stream_id: u64}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + resume_stream: ({stream_id}: {stream_id: u64}, options?: MethodOptions) => Promise> /** * Construct and simulate a revoke_delegate transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Revoke the delegate for a stream */ - revoke_delegate: ({stream_id}: {stream_id: u64}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + revoke_delegate: ({stream_id}: {stream_id: u64}, options?: MethodOptions) => Promise> /** * Construct and simulate a get_fee_collector transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Get the current fee collector */ - get_fee_collector: (options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + get_fee_collector: (options?: MethodOptions) => Promise> /** * Construct and simulate a set_fee_collector transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Set the fee collector address */ - set_fee_collector: ({new_fee_collector}: {new_fee_collector: string}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + set_fee_collector: ({new_fee_collector}: {new_fee_collector: string}, options?: MethodOptions) => Promise> /** * Construct and simulate a get_stream_metrics transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Get stream-specific metrics */ - get_stream_metrics: ({stream_id}: {stream_id: u64}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + get_stream_metrics: ({stream_id}: {stream_id: u64}, options?: MethodOptions) => Promise> /** * Construct and simulate a withdrawable_amount transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Calculate withdrawable amount for a stream */ - withdrawable_amount: ({stream_id}: {stream_id: u64}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + withdrawable_amount: ({stream_id}: {stream_id: u64}, options?: MethodOptions) => Promise> /** * Construct and simulate a get_protocol_metrics transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Get protocol-wide metrics */ - get_protocol_metrics: (options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + get_protocol_metrics: (options?: MethodOptions) => Promise> /** * Construct and simulate a get_protocol_fee_rate transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Get the current protocol fee rate */ - get_protocol_fee_rate: (options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + get_protocol_fee_rate: (options?: MethodOptions) => Promise> /** * Construct and simulate a set_protocol_fee_rate transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Set the protocol fee rate */ - set_protocol_fee_rate: ({new_fee_rate}: {new_fee_rate: u32}, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + set_protocol_fee_rate: ({new_fee_rate}: {new_fee_rate: u32}, options?: MethodOptions) => Promise> } export class Client extends ContractClient { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 3a21f8d..ef2ae82 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -37,6 +37,7 @@ export * from "./utils/networkDetection"; export * from "./utils/streamHistory"; export * from "./utils/BalanceWatcher"; export * from "./utils/transactions"; +export * from "./utils/GasEstimator"; // Export error handling utilities export { diff --git a/packages/sdk/src/utils/GasEstimator.ts b/packages/sdk/src/utils/GasEstimator.ts new file mode 100644 index 0000000..ad37f81 --- /dev/null +++ b/packages/sdk/src/utils/GasEstimator.ts @@ -0,0 +1,312 @@ +import { xdr } from "@stellar/stellar-sdk"; +import { Server, Api } from "@stellar/stellar-sdk/rpc"; + +const DEFAULT_BASE_FEE = "100"; +const DEFAULT_RESOURCE_BUFFER = 1.2; +const DEFAULT_CONGESTION_BUFFER = 1.1; +const DEFAULT_HIGH_CONGESTION_BUFFER = 1.35; + +type SimulatableTransaction = Parameters[0]; + +export interface GasEstimatorRpc { + simulateTransaction( + tx: SimulatableTransaction + ): ReturnType; + getFeeStats?: () => Promise; +} + +export interface GasEstimatorOptions { + /** Existing Soroban RPC server. When omitted, `rpcUrl` is required. */ + rpc?: GasEstimatorRpc; + /** Soroban RPC endpoint URL. */ + rpcUrl?: string; + /** Base Stellar inclusion fee in stroops. Defaults to 100. */ + baseFee?: string; + /** Multiplier applied to simulated Soroban resource limits. Defaults to 1.2. */ + resourceBuffer?: number; + /** Multiplier applied to fee recommendations under normal congestion. Defaults to 1.1. */ + congestionBuffer?: number; + /** Multiplier applied to fee recommendations under high/severe congestion. Defaults to 1.35. */ + highCongestionBuffer?: number; +} + +export type CongestionLevel = + | "low" + | "moderate" + | "high" + | "severe" + | "unknown"; + +export interface GasResourceLimits { + instructions: number; + readBytes: number; + writeBytes: number; + readEntries: number; + writeEntries: number; +} + +export interface GasPriceRecommendation { + /** Recommended inclusion fee in stroops before Soroban resource fees. */ + inclusionFee: string; + /** Network congestion estimate derived from RPC fee statistics. */ + congestionLevel: CongestionLevel; + /** Raw fee stats returned by the RPC server, when available. */ + feeStats?: unknown; +} + +export interface GasEstimate { + /** Minimum Soroban resource fee returned by simulation. */ + minResourceFee: string; + /** Buffered Soroban resource fee recommendation. */ + resourceFee: string; + /** Recommended inclusion fee based on recent network congestion. */ + inclusionFee: string; + /** Total recommended transaction fee in stroops. */ + recommendedFee: string; + /** Buffered resource limits derived from simulation results. */ + resourceLimits: GasResourceLimits; + /** Raw simulation response for advanced callers. */ + simulation: Api.SimulateTransactionSuccessResponse; + /** Congestion estimate used to choose the recommended fee. */ + congestionLevel: CongestionLevel; + /** Raw fee stats returned by the RPC server, when available. */ + feeStats?: unknown; +} + +/** + * Estimates Soroban transaction fees and resource limits from simulation data + * plus current RPC fee statistics when available. + */ +export class GasEstimator { + private readonly rpc: GasEstimatorRpc; + private readonly baseFee: string; + private readonly resourceBuffer: number; + private readonly congestionBuffer: number; + private readonly highCongestionBuffer: number; + + constructor(options: GasEstimatorOptions) { + if (!options.rpc && !options.rpcUrl) { + throw new Error("GasEstimator requires either rpc or rpcUrl"); + } + + this.rpc = options.rpc ?? new Server(options.rpcUrl!, { allowHttp: true }); + this.baseFee = options.baseFee ?? DEFAULT_BASE_FEE; + this.resourceBuffer = options.resourceBuffer ?? DEFAULT_RESOURCE_BUFFER; + this.congestionBuffer = + options.congestionBuffer ?? DEFAULT_CONGESTION_BUFFER; + this.highCongestionBuffer = + options.highCongestionBuffer ?? DEFAULT_HIGH_CONGESTION_BUFFER; + } + + async estimate(tx: SimulatableTransaction): Promise { + const simulation = await this.rpc.simulateTransaction(tx); + + if (Api.isSimulationError(simulation)) { + throw new Error(`Gas estimation simulation failed: ${simulation.error}`); + } + + const success = simulation as Api.SimulateTransactionSuccessResponse; + const minResourceFee = success.minResourceFee ?? "0"; + const gasPrice = await this.estimateGasPrice(); + const feeBuffer = + gasPrice.congestionLevel === "high" || + gasPrice.congestionLevel === "severe" + ? this.highCongestionBuffer + : this.congestionBuffer; + + const resourceFee = multiplyStroops(minResourceFee, this.resourceBuffer); + const inclusionFee = multiplyStroops(gasPrice.inclusionFee, feeBuffer); + const recommendedFee = addStroops(resourceFee, inclusionFee); + + return { + minResourceFee, + resourceFee, + inclusionFee, + recommendedFee, + resourceLimits: bufferResourceLimits( + extractResourceLimits(success), + this.resourceBuffer + ), + simulation: success, + congestionLevel: gasPrice.congestionLevel, + feeStats: gasPrice.feeStats, + }; + } + + async estimateGasPrice(): Promise { + const feeStats = await this.fetchFeeStats(); + const inclusionFeeStats = extractInclusionFeeStats(feeStats); + + if (!inclusionFeeStats) { + return { + inclusionFee: this.baseFee, + congestionLevel: "unknown", + feeStats, + }; + } + + const congestionLevel = classifyCongestion( + inclusionFeeStats, + Number(this.baseFee) + ); + const percentile = + congestionLevel === "severe" + ? inclusionFeeStats.p99 + : congestionLevel === "high" + ? inclusionFeeStats.p95 + : congestionLevel === "moderate" + ? inclusionFeeStats.p90 + : inclusionFeeStats.p50; + + return { + inclusionFee: String(Math.max(Number(this.baseFee), percentile)), + congestionLevel, + feeStats, + }; + } + + private async fetchFeeStats(): Promise { + if (!this.rpc.getFeeStats) return undefined; + + try { + return await this.rpc.getFeeStats(); + } catch { + return undefined; + } + } +} + +export async function estimateSorobanGas( + tx: SimulatableTransaction, + options: GasEstimatorOptions +): Promise { + return new GasEstimator(options).estimate(tx); +} + +function extractResourceLimits( + simulation: Api.SimulateTransactionSuccessResponse +): GasResourceLimits { + const transactionData = simulation.transactionData; + + if (!transactionData) { + return emptyResourceLimits(); + } + + try { + const data = + typeof transactionData === "string" + ? xdr.SorobanTransactionData.fromXDR(transactionData, "base64") + : transactionData; + const resources = (data as xdr.SorobanTransactionData).resources(); + + return { + instructions: resources.instructions(), + readBytes: resources.readBytes(), + writeBytes: resources.writeBytes(), + readEntries: resources.footprint().readOnly().length, + writeEntries: resources.footprint().readWrite().length, + }; + } catch { + return emptyResourceLimits(); + } +} + +function bufferResourceLimits( + limits: GasResourceLimits, + multiplier: number +): GasResourceLimits { + return { + instructions: Math.ceil(limits.instructions * multiplier), + readBytes: Math.ceil(limits.readBytes * multiplier), + writeBytes: Math.ceil(limits.writeBytes * multiplier), + readEntries: Math.ceil(limits.readEntries * multiplier), + writeEntries: Math.ceil(limits.writeEntries * multiplier), + }; +} + +function emptyResourceLimits(): GasResourceLimits { + return { + instructions: 0, + readBytes: 0, + writeBytes: 0, + readEntries: 0, + writeEntries: 0, + }; +} + +function extractInclusionFeeStats(feeStats: unknown): + | { + p50: number; + p90: number; + p95: number; + p99: number; + transactionCount: number; + } + | undefined { + if (!isRecord(feeStats)) return undefined; + + const stats = isRecord(feeStats.inclusionFee) + ? feeStats.inclusionFee + : isRecord(feeStats.sorobanInclusionFee) + ? feeStats.sorobanInclusionFee + : feeStats; + + const p50 = readNumber(stats.p50); + const p90 = readNumber(stats.p90); + const p95 = readNumber(stats.p95); + const p99 = readNumber(stats.p99); + + if ([p50, p90, p95, p99].some((value) => value === undefined)) { + return undefined; + } + + return { + p50: p50!, + p90: p90!, + p95: p95!, + p99: p99!, + transactionCount: readNumber(stats.transactionCount) ?? 0, + }; +} + +function classifyCongestion( + stats: { + p50: number; + p90: number; + p95: number; + p99: number; + transactionCount: number; + }, + baseFee: number +): CongestionLevel { + const safeBaseFee = Math.max(baseFee, 1); + const p95Ratio = stats.p95 / safeBaseFee; + + if (stats.p99 >= safeBaseFee * 20 || p95Ratio >= 10) return "severe"; + if (stats.p95 >= safeBaseFee * 5 || stats.transactionCount >= 500) + return "high"; + if (stats.p90 >= safeBaseFee * 2 || stats.transactionCount >= 100) + return "moderate"; + return "low"; +} + +function multiplyStroops(value: string, multiplier: number): string { + return String(Math.ceil(Number(value) * multiplier - 1e-9)); +} + +function addStroops(left: string, right: string): string { + return String(BigInt(left) + BigInt(right)); +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +}