From b1ee9a78da2bd5d7d2346c7e4c5e9d9f9a199548 Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 30 Jun 2026 10:59:19 -0400 Subject: [PATCH 1/5] Add rpc.Server.queryContract for one-line read-only contract calls --- docs/reference/contracts-client.md | 4 +- docs/reference/network-rpc.md | 104 +++++++++++++----- src/contract/client.ts | 10 +- src/rpc/server.ts | 75 +++++++++++++ .../server/soroban/query_contract.test.ts | 92 ++++++++++++++++ 5 files changed, 254 insertions(+), 31 deletions(-) create mode 100644 test/unit/server/soroban/query_contract.test.ts diff --git a/docs/reference/contracts-client.md b/docs/reference/contracts-client.md index f9d14b89a..ce6bdb63d 100644 --- a/docs/reference/contracts-client.md +++ b/docs/reference/contracts-client.md @@ -770,7 +770,7 @@ txFromJSON(json: string): AssembledTransaction; - **`json`** — `string` (required) -**Source:** [src/contract/client.ts:222](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L222) +**Source:** [src/contract/client.ts:224](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L224) ### `client.txFromXDR(xdrBase64)` @@ -782,7 +782,7 @@ txFromXDR(xdrBase64: string): AssembledTransaction; - **`xdrBase64`** — `string` (required) -**Source:** [src/contract/client.ts:235](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L235) +**Source:** [src/contract/client.ts:237](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L237) ## contract.DEFAULT_TIMEOUT diff --git a/docs/reference/network-rpc.md b/docs/reference/network-rpc.md index 8e0905cec..ab4d1b786 100644 --- a/docs/reference/network-rpc.md +++ b/docs/reference/network-rpc.md @@ -135,6 +135,7 @@ class Server { getVersionInfo(): Promise; pollTransaction(hash: string, opts?: PollingOptions): Promise; prepareTransaction(tx: Transaction | FeeBumpTransaction): Promise; + queryContract(contractId: string, method: string, args: Record = {}, networkPassphrase?: string): Promise; requestAirdrop(address: string | Pick, friendbotUrl?: string): Promise; sendTransaction(transaction: Transaction | FeeBumpTransaction): Promise; simulateTransaction(tx: Transaction | FeeBumpTransaction, addlResources?: ResourceLeeway, authMode?: SimulationAuthMode): Promise; @@ -202,7 +203,7 @@ _getEvents(request: GetEventsRequest): Promise; - **`request`** — `GetEventsRequest` (required) -**Source:** [src/rpc/server.ts:932](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L932) +**Source:** [src/rpc/server.ts:1007](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1007) ### `server._getLatestLedger()` @@ -210,7 +211,7 @@ _getEvents(request: GetEventsRequest): Promise; _getLatestLedger(): Promise; ``` -**Source:** [src/rpc/server.ts:1003](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1003) +**Source:** [src/rpc/server.ts:1078](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1078) ### `server._getLedgerEntries(keys)` @@ -222,7 +223,7 @@ _getLedgerEntries(...keys: LedgerKey[]): Promise; - **`...keys`** — `LedgerKey[]` (required) -**Source:** [src/rpc/server.ts:697](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L697) +**Source:** [src/rpc/server.ts:772](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L772) ### `server._getLedgers(request)` @@ -234,7 +235,7 @@ _getLedgers(request: GetLedgersRequest): Promise; - **`request`** — `GetLedgersRequest` (required) -**Source:** [src/rpc/server.ts:1594](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1594) +**Source:** [src/rpc/server.ts:1669](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1669) ### `server._getTransaction(hash)` @@ -246,7 +247,7 @@ _getTransaction(hash: string): Promise; - **`hash`** — `string` (required) -**Source:** [src/rpc/server.ts:820](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L820) +**Source:** [src/rpc/server.ts:895](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L895) ### `server._getTransactions(request)` @@ -258,7 +259,7 @@ _getTransactions(request: GetTransactionsRequest): Promise { - `getLatestLedger docs` -**Source:** [src/rpc/server.ts:999](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L999) +**Source:** [src/rpc/server.ts:1074](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1074) ### `server.getLedgerEntries(keys)` @@ -836,7 +837,7 @@ server.getLedgerEntries([key]).then(response => { - - `getLedgerEntries docs` - RpcServer._getLedgerEntries -**Source:** [src/rpc/server.ts:693](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L693) +**Source:** [src/rpc/server.ts:768](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L768) ### `server.getLedgerEntry(key)` @@ -848,7 +849,7 @@ getLedgerEntry(key: LedgerKey): Promise; - **`key`** — `LedgerKey` (required) -**Source:** [src/rpc/server.ts:708](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L708) +**Source:** [src/rpc/server.ts:783](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L783) ### `server.getLedgers(request)` @@ -914,7 +915,7 @@ const nextPage = await server.getLedgers({ - `getLedgers docs` -**Source:** [src/rpc/server.ts:1578](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1578) +**Source:** [src/rpc/server.ts:1653](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1653) ### `server.getNetwork()` @@ -943,7 +944,7 @@ server.getNetwork().then((network) => { - `getNetwork docs` -**Source:** [src/rpc/server.ts:973](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L973) +**Source:** [src/rpc/server.ts:1048](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1048) ### `server.getSACBalance(address, sac, networkPassphrase)` @@ -1002,7 +1003,7 @@ console.log( - - getLedgerEntries - https://developers.stellar.org/docs/tokens/stellar-asset-contract -**Source:** [src/rpc/server.ts:1463](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1463) +**Source:** [src/rpc/server.ts:1538](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1538) ### `server.getTransaction(hash)` @@ -1040,7 +1041,7 @@ server.getTransaction(transactionHash).then((tx) => { - `getTransaction docs` -**Source:** [src/rpc/server.ts:793](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L793) +**Source:** [src/rpc/server.ts:868](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L868) ### `server.getTransactions(request)` @@ -1076,7 +1077,7 @@ server.getTransactions({ - https://developers.stellar.org/docs/data/rpc/api-reference/methods/getTransactions -**Source:** [src/rpc/server.ts:853](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L853) +**Source:** [src/rpc/server.ts:928](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L928) ### `server.getTrustline(account, asset)` @@ -1133,7 +1134,7 @@ the version info - https://developers.stellar.org/docs/data/rpc/api-reference/methods/getVersionInfo -**Source:** [src/rpc/server.ts:1413](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1413) +**Source:** [src/rpc/server.ts:1488](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1488) ### `server.pollTransaction(hash, opts)` @@ -1173,7 +1174,7 @@ const txStatus = await server.pollTransaction(h, { }); // this will take 5,050 seconds to complete ``` -**Source:** [src/rpc/server.ts:746](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L746) +**Source:** [src/rpc/server.ts:821](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L821) ### `server.prepareTransaction(tx)` @@ -1262,7 +1263,60 @@ server.sendTransaction(transaction).then(result => { - - module:rpc.assembleTransaction - `simulateTransaction docs` -**Source:** [src/rpc/server.ts:1169](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1169) +**Source:** [src/rpc/server.ts:1244](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1244) + +### `server.queryContract(contractId, method, args, networkPassphrase)` + +Performs a read-only call to a contract method and returns the decoded result. + +This is a convenience wrapper for one-line contract state queries: it builds +a `contract.Client` for the contract, simulates the method call, and +returns the spec-decoded return value — no manual transaction assembly, +signing, or submission required. + +Works for both Wasm contracts and built-in Stellar Asset Contracts (SACs): +the embedded SAC spec is used automatically for SACs (see +`contract.Client.from`). The query reuses this server's transport +(headers, interceptors, `allowHttp`). + +```ts +queryContract(contractId: string, method: string, args: Record = {}, networkPassphrase?: string): Promise; +``` + +**Parameters** + +- **`contractId`** — `string` (required) — the contract to query (`C...`) +- **`method`** — `string` (required) — the contract method to call +- **`args`** — `Record` (optional) (default: `{}`) — named arguments for the method, keyed by parameter name + (omit for methods that take no arguments) +- **`networkPassphrase`** — `string` (optional) — (optional) the network passphrase. If omitted, a + request about network information will be made (see `getNetwork`). + You can refer to `Networks` for a list of built-in passphrases, + e.g., `Networks.TESTNET`. + +**Returns** + +The method's decoded return value + +**Throws** + +- If the contract has no such method, or if the simulation fails. + +**Example** + +```ts +const decimals = await server.queryContract( + "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5", + "decimals", +); +const balance = await server.queryContract( + "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5", + "balance", + { id: "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ" }, +); +``` + +**Source:** [src/rpc/server.ts:697](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L697) ### `server.requestAirdrop(address, friendbotUrl)` @@ -1312,7 +1366,7 @@ server - - `Friendbot docs` - `Friendbot.Api.Response` -**Source:** [src/rpc/server.ts:1275](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1275) +**Source:** [src/rpc/server.ts:1350](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1350) ### `server.sendTransaction(transaction)` @@ -1373,7 +1427,7 @@ server.sendTransaction(transaction).then((result) => { - - `transaction docs` - `sendTransaction docs` -**Source:** [src/rpc/server.ts:1224](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1224) +**Source:** [src/rpc/server.ts:1299](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1299) ### `server.simulateTransaction(tx, addlResources, authMode)` @@ -1439,7 +1493,7 @@ server.simulateTransaction(transaction).then((sim) => { - module:rpc.Server#prepareTransaction - module:rpc.assembleTransaction -**Source:** [src/rpc/server.ts:1067](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1067) +**Source:** [src/rpc/server.ts:1142](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1142) ## rpc.assembleTransaction diff --git a/src/contract/client.ts b/src/contract/client.ts index ac1bbf81a..9650b1bca 100644 --- a/src/contract/client.ts +++ b/src/contract/client.ts @@ -195,10 +195,12 @@ export class Client { } const { rpcUrl, contractId, allowHttp, headers } = options; - const server = new Server(rpcUrl, { - allowHttp, - headers, - }); + const server = + options.server ?? + new Server(rpcUrl, { + allowHttp, + headers, + }); const instance = await server.getContractInstance(contractId); diff --git a/src/rpc/server.ts b/src/rpc/server.ts index 6bda01ef8..7b919cda2 100644 --- a/src/rpc/server.ts +++ b/src/rpc/server.ts @@ -656,6 +656,81 @@ export class RpcServer { return wasmBuffer; } + /** + * Performs a read-only call to a contract method and returns the decoded result. + * + * This is a convenience wrapper for one-line contract state queries: it builds + * a {@link contract.Client} for the contract, simulates the method call, and + * returns the spec-decoded return value — no manual transaction assembly, + * signing, or submission required. + * + * Works for both Wasm contracts and built-in Stellar Asset Contracts (SACs): + * the embedded SAC spec is used automatically for SACs (see + * {@link contract.Client.from}). The query reuses this server's transport + * (headers, interceptors, `allowHttp`). + * + * @typeParam T - the expected (decoded) return type of the method + * @param contractId - the contract to query (`C...`) + * @param method - the contract method to call + * @param args - named arguments for the method, keyed by parameter name + * (omit for methods that take no arguments) + * @param networkPassphrase - (optional) the network passphrase. If omitted, a + * request about network information will be made (see {@link getNetwork}). + * You can refer to {@link Networks} for a list of built-in passphrases, + * e.g., `Networks.TESTNET`. + * @returns The method's decoded return value + * @throws If the contract has no such method, or if the simulation fails. + * + * @example + * ```ts + * const decimals = await server.queryContract( + * "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5", + * "decimals", + * ); + * const balance = await server.queryContract( + * "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5", + * "balance", + * { id: "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ" }, + * ); + * ``` + */ + public async queryContract( + contractId: string, + method: string, + args: Record = {}, + networkPassphrase?: string, + ): Promise { + const passphrase = + networkPassphrase ?? (await this.getNetwork()).passphrase; + + // Dynamically import to avoid a static circular dependency: the contract + // module imports this rpc module. The import resolves at call time, by + // which point this module has finished loading. + const { Client } = await import("../contract/client.js"); + + // `Client.from` is SAC-aware: it uses the embedded SAC spec for built-in + // Stellar Asset Contracts and downloads Wasm otherwise. We pass + // `server: this` so the call reuses this server's transport. + const client = await Client.from({ + contractId, + rpcUrl: this.serverURL.toString(), + networkPassphrase: passphrase, + server: this, + }); + + // Methods are attached dynamically from the spec, so they aren't on the + // static `Client` type — hence the cast. + const invoke = (client as unknown as Record)[method]; + if (typeof invoke !== "function") { + throw new TypeError(`Contract ${contractId} has no method '${method}'`); + } + + // Awaiting builds + simulates the read-only call (`simulate` defaults to + // true); `.result` is the spec-decoded return value. + const assembled = await invoke(args); + return assembled.result as T; + } + /** * Reads the current value of arbitrary ledger entries directly. * diff --git a/test/unit/server/soroban/query_contract.test.ts b/test/unit/server/soroban/query_contract.test.ts new file mode 100644 index 000000000..b63362947 --- /dev/null +++ b/test/unit/server/soroban/query_contract.test.ts @@ -0,0 +1,92 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from "vitest"; +import * as StellarSdk from "../../../../src/index.js"; + +import { serverUrl } from "../../../constants"; + +const { Server } = StellarSdk.rpc; +const { Client } = StellarSdk.contract; + +describe("Server#queryContract", () => { + let server: any; + const contractId = "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5"; + const networkPassphrase = "Test SDF Network ; September 2015"; + + beforeEach(() => { + server = new Server(serverUrl); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("defaults the network passphrase to getNetwork() when not provided", async () => { + const getNetwork = vi + .spyOn(server, "getNetwork") + .mockResolvedValue({ passphrase: networkPassphrase } as any); + const from = vi.spyOn(Client, "from").mockResolvedValue({ + decimals: vi.fn().mockResolvedValue({ result: 7 }), + } as any); + + const result = await server.queryContract(contractId, "decimals"); + + expect(result).toBe(7); + expect(getNetwork).toHaveBeenCalledOnce(); + expect(from).toHaveBeenCalledWith( + expect.objectContaining({ + contractId, + rpcUrl: server.serverURL.toString(), + networkPassphrase, + server, + }), + ); + }); + + it("uses the provided networkPassphrase and skips getNetwork()", async () => { + const getNetwork = vi.spyOn(server, "getNetwork"); + const from = vi.spyOn(Client, "from").mockResolvedValue({ + decimals: vi.fn().mockResolvedValue({ result: 7 }), + } as any); + + await server.queryContract( + contractId, + "decimals", + {}, + "Custom Network ; 2024", + ); + + expect(getNetwork).not.toHaveBeenCalled(); + expect(from).toHaveBeenCalledWith( + expect.objectContaining({ networkPassphrase: "Custom Network ; 2024" }), + ); + }); + + it("forwards named args to the contract method and returns the decoded result", async () => { + const balance = vi.fn().mockResolvedValue({ result: 100n }); + vi.spyOn(server, "getNetwork").mockResolvedValue({ + passphrase: networkPassphrase, + } as any); + vi.spyOn(Client, "from").mockResolvedValue({ balance } as any); + + const args = { + id: "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + }; + const result = await server.queryContract(contractId, "balance", args); + + expect(balance).toHaveBeenCalledWith(args); + expect(result).toBe(100n); + }); + + it("throws a TypeError when the contract has no such method", async () => { + vi.spyOn(server, "getNetwork").mockResolvedValue({ + passphrase: networkPassphrase, + } as any); + vi.spyOn(Client, "from").mockResolvedValue({} as any); + + await expect(server.queryContract(contractId, "missing")).rejects.toThrow( + TypeError, + ); + await expect(server.queryContract(contractId, "missing")).rejects.toThrow( + /no method 'missing'/, + ); + }); +}); From c1e427d59739ed40e3550b68a063a2ed967db755 Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 30 Jun 2026 11:23:06 -0400 Subject: [PATCH 2/5] Sanitize method name --- docs/reference/network-rpc.md | 50 +++++++++---------- src/rpc/server.ts | 10 +++- .../server/soroban/query_contract.test.ts | 14 ++++++ 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/docs/reference/network-rpc.md b/docs/reference/network-rpc.md index ab4d1b786..f9f445e6f 100644 --- a/docs/reference/network-rpc.md +++ b/docs/reference/network-rpc.md @@ -203,7 +203,7 @@ _getEvents(request: GetEventsRequest): Promise; - **`request`** — `GetEventsRequest` (required) -**Source:** [src/rpc/server.ts:1007](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1007) +**Source:** [src/rpc/server.ts:1013](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1013) ### `server._getLatestLedger()` @@ -211,7 +211,7 @@ _getEvents(request: GetEventsRequest): Promise; _getLatestLedger(): Promise; ``` -**Source:** [src/rpc/server.ts:1078](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1078) +**Source:** [src/rpc/server.ts:1084](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1084) ### `server._getLedgerEntries(keys)` @@ -223,7 +223,7 @@ _getLedgerEntries(...keys: LedgerKey[]): Promise; - **`...keys`** — `LedgerKey[]` (required) -**Source:** [src/rpc/server.ts:772](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L772) +**Source:** [src/rpc/server.ts:778](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L778) ### `server._getLedgers(request)` @@ -235,7 +235,7 @@ _getLedgers(request: GetLedgersRequest): Promise; - **`request`** — `GetLedgersRequest` (required) -**Source:** [src/rpc/server.ts:1669](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1669) +**Source:** [src/rpc/server.ts:1675](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1675) ### `server._getTransaction(hash)` @@ -247,7 +247,7 @@ _getTransaction(hash: string): Promise; - **`hash`** — `string` (required) -**Source:** [src/rpc/server.ts:895](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L895) +**Source:** [src/rpc/server.ts:901](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L901) ### `server._getTransactions(request)` @@ -259,7 +259,7 @@ _getTransactions(request: GetTransactionsRequest): Promise { - `getLatestLedger docs` -**Source:** [src/rpc/server.ts:1074](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1074) +**Source:** [src/rpc/server.ts:1080](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1080) ### `server.getLedgerEntries(keys)` @@ -837,7 +837,7 @@ server.getLedgerEntries([key]).then(response => { - - `getLedgerEntries docs` - RpcServer._getLedgerEntries -**Source:** [src/rpc/server.ts:768](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L768) +**Source:** [src/rpc/server.ts:774](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L774) ### `server.getLedgerEntry(key)` @@ -849,7 +849,7 @@ getLedgerEntry(key: LedgerKey): Promise; - **`key`** — `LedgerKey` (required) -**Source:** [src/rpc/server.ts:783](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L783) +**Source:** [src/rpc/server.ts:789](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L789) ### `server.getLedgers(request)` @@ -915,7 +915,7 @@ const nextPage = await server.getLedgers({ - `getLedgers docs` -**Source:** [src/rpc/server.ts:1653](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1653) +**Source:** [src/rpc/server.ts:1659](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1659) ### `server.getNetwork()` @@ -944,7 +944,7 @@ server.getNetwork().then((network) => { - `getNetwork docs` -**Source:** [src/rpc/server.ts:1048](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1048) +**Source:** [src/rpc/server.ts:1054](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1054) ### `server.getSACBalance(address, sac, networkPassphrase)` @@ -1003,7 +1003,7 @@ console.log( - - getLedgerEntries - https://developers.stellar.org/docs/tokens/stellar-asset-contract -**Source:** [src/rpc/server.ts:1538](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1538) +**Source:** [src/rpc/server.ts:1544](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1544) ### `server.getTransaction(hash)` @@ -1041,7 +1041,7 @@ server.getTransaction(transactionHash).then((tx) => { - `getTransaction docs` -**Source:** [src/rpc/server.ts:868](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L868) +**Source:** [src/rpc/server.ts:874](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L874) ### `server.getTransactions(request)` @@ -1077,7 +1077,7 @@ server.getTransactions({ - https://developers.stellar.org/docs/data/rpc/api-reference/methods/getTransactions -**Source:** [src/rpc/server.ts:928](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L928) +**Source:** [src/rpc/server.ts:934](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L934) ### `server.getTrustline(account, asset)` @@ -1134,7 +1134,7 @@ the version info - https://developers.stellar.org/docs/data/rpc/api-reference/methods/getVersionInfo -**Source:** [src/rpc/server.ts:1488](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1488) +**Source:** [src/rpc/server.ts:1494](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1494) ### `server.pollTransaction(hash, opts)` @@ -1174,7 +1174,7 @@ const txStatus = await server.pollTransaction(h, { }); // this will take 5,050 seconds to complete ``` -**Source:** [src/rpc/server.ts:821](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L821) +**Source:** [src/rpc/server.ts:827](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L827) ### `server.prepareTransaction(tx)` @@ -1263,7 +1263,7 @@ server.sendTransaction(transaction).then(result => { - - module:rpc.assembleTransaction - `simulateTransaction docs` -**Source:** [src/rpc/server.ts:1244](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1244) +**Source:** [src/rpc/server.ts:1250](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1250) ### `server.queryContract(contractId, method, args, networkPassphrase)` @@ -1366,7 +1366,7 @@ server - - `Friendbot docs` - `Friendbot.Api.Response` -**Source:** [src/rpc/server.ts:1350](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1350) +**Source:** [src/rpc/server.ts:1356](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1356) ### `server.sendTransaction(transaction)` @@ -1427,7 +1427,7 @@ server.sendTransaction(transaction).then((result) => { - - `transaction docs` - `sendTransaction docs` -**Source:** [src/rpc/server.ts:1299](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1299) +**Source:** [src/rpc/server.ts:1305](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1305) ### `server.simulateTransaction(tx, addlResources, authMode)` @@ -1493,7 +1493,7 @@ server.simulateTransaction(transaction).then((sim) => { - module:rpc.Server#prepareTransaction - module:rpc.assembleTransaction -**Source:** [src/rpc/server.ts:1142](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1142) +**Source:** [src/rpc/server.ts:1148](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1148) ## rpc.assembleTransaction diff --git a/src/rpc/server.ts b/src/rpc/server.ts index 7b919cda2..50ea76acb 100644 --- a/src/rpc/server.ts +++ b/src/rpc/server.ts @@ -719,8 +719,14 @@ export class RpcServer { }); // Methods are attached dynamically from the spec, so they aren't on the - // static `Client` type — hence the cast. - const invoke = (client as unknown as Record)[method]; + // static `Client` type — hence the cast. The `Client` constructor attaches + // each method under a sanitized identifier (e.g. a contract method named + // `delete` becomes `delete_`), so resolve the key the same way before + // looking it up; the method still invokes under its real on-chain name. + const { sanitizeIdentifier } = await import("../bindings/utils.js"); + const invoke = (client as unknown as Record)[ + sanitizeIdentifier(method) + ]; if (typeof invoke !== "function") { throw new TypeError(`Contract ${contractId} has no method '${method}'`); } diff --git a/test/unit/server/soroban/query_contract.test.ts b/test/unit/server/soroban/query_contract.test.ts index b63362947..fdd6e0027 100644 --- a/test/unit/server/soroban/query_contract.test.ts +++ b/test/unit/server/soroban/query_contract.test.ts @@ -76,6 +76,20 @@ describe("Server#queryContract", () => { expect(result).toBe(100n); }); + it("resolves methods whose names are sanitized by Client (e.g. reserved words)", async () => { + // `Client` attaches a contract method named `delete` under `delete_`. + const deleteFn = vi.fn().mockResolvedValue({ result: true }); + vi.spyOn(server, "getNetwork").mockResolvedValue({ + passphrase: networkPassphrase, + } as any); + vi.spyOn(Client, "from").mockResolvedValue({ delete_: deleteFn } as any); + + const result = await server.queryContract(contractId, "delete"); + + expect(deleteFn).toHaveBeenCalledOnce(); + expect(result).toBe(true); + }); + it("throws a TypeError when the contract has no such method", async () => { vi.spyOn(server, "getNetwork").mockResolvedValue({ passphrase: networkPassphrase, From 500cff897fcdf9de6e5d53d3337e2666af3608c5 Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 30 Jun 2026 12:06:01 -0400 Subject: [PATCH 3/5] Check only contract methods before invoking --- docs/reference/network-rpc.md | 50 ++++++------ src/rpc/server.ts | 15 +++- .../server/soroban/query_contract.test.ts | 76 ++++++++++++++++--- 3 files changed, 103 insertions(+), 38 deletions(-) diff --git a/docs/reference/network-rpc.md b/docs/reference/network-rpc.md index f9f445e6f..fc9df204c 100644 --- a/docs/reference/network-rpc.md +++ b/docs/reference/network-rpc.md @@ -203,7 +203,7 @@ _getEvents(request: GetEventsRequest): Promise; - **`request`** — `GetEventsRequest` (required) -**Source:** [src/rpc/server.ts:1013](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1013) +**Source:** [src/rpc/server.ts:1022](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1022) ### `server._getLatestLedger()` @@ -211,7 +211,7 @@ _getEvents(request: GetEventsRequest): Promise; _getLatestLedger(): Promise; ``` -**Source:** [src/rpc/server.ts:1084](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1084) +**Source:** [src/rpc/server.ts:1093](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1093) ### `server._getLedgerEntries(keys)` @@ -223,7 +223,7 @@ _getLedgerEntries(...keys: LedgerKey[]): Promise; - **`...keys`** — `LedgerKey[]` (required) -**Source:** [src/rpc/server.ts:778](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L778) +**Source:** [src/rpc/server.ts:787](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L787) ### `server._getLedgers(request)` @@ -235,7 +235,7 @@ _getLedgers(request: GetLedgersRequest): Promise; - **`request`** — `GetLedgersRequest` (required) -**Source:** [src/rpc/server.ts:1675](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1675) +**Source:** [src/rpc/server.ts:1684](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1684) ### `server._getTransaction(hash)` @@ -247,7 +247,7 @@ _getTransaction(hash: string): Promise; - **`hash`** — `string` (required) -**Source:** [src/rpc/server.ts:901](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L901) +**Source:** [src/rpc/server.ts:910](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L910) ### `server._getTransactions(request)` @@ -259,7 +259,7 @@ _getTransactions(request: GetTransactionsRequest): Promise { - `getLatestLedger docs` -**Source:** [src/rpc/server.ts:1080](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1080) +**Source:** [src/rpc/server.ts:1089](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1089) ### `server.getLedgerEntries(keys)` @@ -837,7 +837,7 @@ server.getLedgerEntries([key]).then(response => { - - `getLedgerEntries docs` - RpcServer._getLedgerEntries -**Source:** [src/rpc/server.ts:774](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L774) +**Source:** [src/rpc/server.ts:783](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L783) ### `server.getLedgerEntry(key)` @@ -849,7 +849,7 @@ getLedgerEntry(key: LedgerKey): Promise; - **`key`** — `LedgerKey` (required) -**Source:** [src/rpc/server.ts:789](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L789) +**Source:** [src/rpc/server.ts:798](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L798) ### `server.getLedgers(request)` @@ -915,7 +915,7 @@ const nextPage = await server.getLedgers({ - `getLedgers docs` -**Source:** [src/rpc/server.ts:1659](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1659) +**Source:** [src/rpc/server.ts:1668](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1668) ### `server.getNetwork()` @@ -944,7 +944,7 @@ server.getNetwork().then((network) => { - `getNetwork docs` -**Source:** [src/rpc/server.ts:1054](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1054) +**Source:** [src/rpc/server.ts:1063](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1063) ### `server.getSACBalance(address, sac, networkPassphrase)` @@ -1003,7 +1003,7 @@ console.log( - - getLedgerEntries - https://developers.stellar.org/docs/tokens/stellar-asset-contract -**Source:** [src/rpc/server.ts:1544](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1544) +**Source:** [src/rpc/server.ts:1553](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1553) ### `server.getTransaction(hash)` @@ -1041,7 +1041,7 @@ server.getTransaction(transactionHash).then((tx) => { - `getTransaction docs` -**Source:** [src/rpc/server.ts:874](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L874) +**Source:** [src/rpc/server.ts:883](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L883) ### `server.getTransactions(request)` @@ -1077,7 +1077,7 @@ server.getTransactions({ - https://developers.stellar.org/docs/data/rpc/api-reference/methods/getTransactions -**Source:** [src/rpc/server.ts:934](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L934) +**Source:** [src/rpc/server.ts:943](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L943) ### `server.getTrustline(account, asset)` @@ -1134,7 +1134,7 @@ the version info - https://developers.stellar.org/docs/data/rpc/api-reference/methods/getVersionInfo -**Source:** [src/rpc/server.ts:1494](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1494) +**Source:** [src/rpc/server.ts:1503](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1503) ### `server.pollTransaction(hash, opts)` @@ -1174,7 +1174,7 @@ const txStatus = await server.pollTransaction(h, { }); // this will take 5,050 seconds to complete ``` -**Source:** [src/rpc/server.ts:827](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L827) +**Source:** [src/rpc/server.ts:836](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L836) ### `server.prepareTransaction(tx)` @@ -1263,7 +1263,7 @@ server.sendTransaction(transaction).then(result => { - - module:rpc.assembleTransaction - `simulateTransaction docs` -**Source:** [src/rpc/server.ts:1250](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1250) +**Source:** [src/rpc/server.ts:1259](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1259) ### `server.queryContract(contractId, method, args, networkPassphrase)` @@ -1366,7 +1366,7 @@ server - - `Friendbot docs` - `Friendbot.Api.Response` -**Source:** [src/rpc/server.ts:1356](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1356) +**Source:** [src/rpc/server.ts:1365](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1365) ### `server.sendTransaction(transaction)` @@ -1427,7 +1427,7 @@ server.sendTransaction(transaction).then((result) => { - - `transaction docs` - `sendTransaction docs` -**Source:** [src/rpc/server.ts:1305](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1305) +**Source:** [src/rpc/server.ts:1314](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1314) ### `server.simulateTransaction(tx, addlResources, authMode)` @@ -1493,7 +1493,7 @@ server.simulateTransaction(transaction).then((sim) => { - module:rpc.Server#prepareTransaction - module:rpc.assembleTransaction -**Source:** [src/rpc/server.ts:1148](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1148) +**Source:** [src/rpc/server.ts:1157](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1157) ## rpc.assembleTransaction diff --git a/src/rpc/server.ts b/src/rpc/server.ts index 50ea76acb..132d867b3 100644 --- a/src/rpc/server.ts +++ b/src/rpc/server.ts @@ -718,16 +718,25 @@ export class RpcServer { server: this, }); + // Validate against the contract spec (keyed by the real on-chain name) + // first, so a `method` that collides with a built-in `Client`/prototype + // member (e.g. `txFromJSON`, `toString`) is rejected rather than silently + // invoking the wrong function. + const isContractMethod = client.spec + .funcs() + .some((fn) => fn.name().toString() === method); + // Methods are attached dynamically from the spec, so they aren't on the // static `Client` type — hence the cast. The `Client` constructor attaches // each method under a sanitized identifier (e.g. a contract method named - // `delete` becomes `delete_`), so resolve the key the same way before - // looking it up; the method still invokes under its real on-chain name. + // `delete` becomes `delete_`), so resolve the key the same way; the method + // still invokes under its real on-chain name. const { sanitizeIdentifier } = await import("../bindings/utils.js"); const invoke = (client as unknown as Record)[ sanitizeIdentifier(method) ]; - if (typeof invoke !== "function") { + + if (!isContractMethod || typeof invoke !== "function") { throw new TypeError(`Contract ${contractId} has no method '${method}'`); } diff --git a/test/unit/server/soroban/query_contract.test.ts b/test/unit/server/soroban/query_contract.test.ts index fdd6e0027..ce4078935 100644 --- a/test/unit/server/soroban/query_contract.test.ts +++ b/test/unit/server/soroban/query_contract.test.ts @@ -6,6 +6,23 @@ import { serverUrl } from "../../../constants"; const { Server } = StellarSdk.rpc; const { Client } = StellarSdk.contract; +/** + * Build a fake `contract.Client`: `spec.funcs()` reports the declared on-chain + * method names, and `attached` holds the dynamically-attached method functions + * (keyed by their sanitized identifiers, exactly as the real Client does). + */ +function fakeClient(opts: { + specNames: string[]; + attached?: Record; +}) { + return { + spec: { + funcs: () => opts.specNames.map((name) => ({ name: () => name })), + }, + ...(opts.attached ?? {}), + }; +} + describe("Server#queryContract", () => { let server: any; const contractId = "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5"; @@ -23,9 +40,12 @@ describe("Server#queryContract", () => { const getNetwork = vi .spyOn(server, "getNetwork") .mockResolvedValue({ passphrase: networkPassphrase } as any); - const from = vi.spyOn(Client, "from").mockResolvedValue({ - decimals: vi.fn().mockResolvedValue({ result: 7 }), - } as any); + const from = vi.spyOn(Client, "from").mockResolvedValue( + fakeClient({ + specNames: ["decimals"], + attached: { decimals: vi.fn().mockResolvedValue({ result: 7 }) }, + }) as any, + ); const result = await server.queryContract(contractId, "decimals"); @@ -43,9 +63,12 @@ describe("Server#queryContract", () => { it("uses the provided networkPassphrase and skips getNetwork()", async () => { const getNetwork = vi.spyOn(server, "getNetwork"); - const from = vi.spyOn(Client, "from").mockResolvedValue({ - decimals: vi.fn().mockResolvedValue({ result: 7 }), - } as any); + const from = vi.spyOn(Client, "from").mockResolvedValue( + fakeClient({ + specNames: ["decimals"], + attached: { decimals: vi.fn().mockResolvedValue({ result: 7 }) }, + }) as any, + ); await server.queryContract( contractId, @@ -65,7 +88,9 @@ describe("Server#queryContract", () => { vi.spyOn(server, "getNetwork").mockResolvedValue({ passphrase: networkPassphrase, } as any); - vi.spyOn(Client, "from").mockResolvedValue({ balance } as any); + vi.spyOn(Client, "from").mockResolvedValue( + fakeClient({ specNames: ["balance"], attached: { balance } }) as any, + ); const args = { id: "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", @@ -77,12 +102,18 @@ describe("Server#queryContract", () => { }); it("resolves methods whose names are sanitized by Client (e.g. reserved words)", async () => { - // `Client` attaches a contract method named `delete` under `delete_`. + // A contract method named `delete` is declared as `delete` in the spec but + // attached on the Client under the sanitized key `delete_`. const deleteFn = vi.fn().mockResolvedValue({ result: true }); vi.spyOn(server, "getNetwork").mockResolvedValue({ passphrase: networkPassphrase, } as any); - vi.spyOn(Client, "from").mockResolvedValue({ delete_: deleteFn } as any); + vi.spyOn(Client, "from").mockResolvedValue( + fakeClient({ + specNames: ["delete"], + attached: { delete_: deleteFn }, + }) as any, + ); const result = await server.queryContract(contractId, "delete"); @@ -90,11 +121,36 @@ describe("Server#queryContract", () => { expect(result).toBe(true); }); + it("does not invoke built-in Client members that aren't contract methods", async () => { + // `txFromJSON` is a real Client method but not a contract method; passing it + // must throw, not call the built-in. + const txFromJSON = vi.fn(); + vi.spyOn(server, "getNetwork").mockResolvedValue({ + passphrase: networkPassphrase, + } as any); + vi.spyOn(Client, "from").mockResolvedValue( + fakeClient({ + specNames: ["balance"], + attached: { balance: vi.fn(), txFromJSON }, + }) as any, + ); + + await expect( + server.queryContract(contractId, "txFromJSON"), + ).rejects.toThrow(/no method 'txFromJSON'/); + expect(txFromJSON).not.toHaveBeenCalled(); + }); + it("throws a TypeError when the contract has no such method", async () => { vi.spyOn(server, "getNetwork").mockResolvedValue({ passphrase: networkPassphrase, } as any); - vi.spyOn(Client, "from").mockResolvedValue({} as any); + vi.spyOn(Client, "from").mockResolvedValue( + fakeClient({ + specNames: ["decimals"], + attached: { decimals: vi.fn().mockResolvedValue({ result: 7 }) }, + }) as any, + ); await expect(server.queryContract(contractId, "missing")).rejects.toThrow( TypeError, From fee15c0e715af03ca078bfa55492e505811065be Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 30 Jun 2026 14:42:42 -0400 Subject: [PATCH 4/5] e2e test happy path --- test/e2e/src/query-contract.test.ts | 97 +++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 test/e2e/src/query-contract.test.ts diff --git a/test/e2e/src/query-contract.test.ts b/test/e2e/src/query-contract.test.ts new file mode 100644 index 000000000..00f54df84 --- /dev/null +++ b/test/e2e/src/query-contract.test.ts @@ -0,0 +1,97 @@ +import { expect, describe, it, beforeAll } from "vitest"; +import { + Asset, + BASE_FEE, + Keypair, + Operation, + TransactionBuilder, + rpc, +} from "../../../lib/esm/index.js"; +import { + clientFor, + generateFundedKeypair, + networkPassphrase, + server, +} from "./util.js"; + +/** + * Deploy the Stellar Asset Contract (SAC) for the given asset and return its + * contract id. `createStellarAssetContract` is a Soroban operation, so it must + * be simulated and assembled before being sent. + */ +async function deploySac(asset: Asset, source: Keypair): Promise { + const account = await server.getAccount(source.publicKey()); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase, + }) + .setTimeout(30) + .addOperation(Operation.createStellarAssetContract({ asset })) + .build(); + + const sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) { + throw new Error(`SAC deploy simulation failed: ${sim.error}`); + } + const assembled = rpc.assembleTransaction(tx, sim).build(); + assembled.sign(source); + + const resp = await server.sendTransaction(assembled); + const result = await server.pollTransaction(resp.hash, { attempts: 15 }); + if (result.status !== rpc.Api.GetTransactionStatus.SUCCESS) { + throw new Error(`SAC deploy failed: ${result.status}`); + } + return asset.contractId(networkPassphrase); +} + +// Happy-path coverage for `rpc.Server.queryContract` against a real network, +// exercising both branches of the contract.Client.from SAC detection: a regular +// Wasm contract and a built-in Stellar Asset Contract. +describe("Server#queryContract", () => { + describe("Wasm contract", () => { + let contractId: string; + + beforeAll(async () => { + ({ contractId } = await clientFor("customTypes")); + }); + + it("reads a zero-arg method and decodes the result", async () => { + const count = await server.queryContract(contractId, "get_count"); + expect(count).toBe(0); + }); + + it("reads a method that takes arguments", async () => { + const result = await server.queryContract( + contractId, + "multi_args", + { a: 1, b: true }, + ); + expect(result).toBe(1); + }); + }); + + describe("Stellar Asset Contract (SAC)", () => { + let sacId: string; + let issuer: Keypair; + + beforeAll(async () => { + issuer = await generateFundedKeypair(); + const asset = new Asset("TST", issuer.publicKey()); + sacId = await deploySac(asset, issuer); + }); + + it("reads decimals from the embedded SAC spec", async () => { + expect(await server.queryContract(sacId, "decimals")).toBe(7); + }); + + it("reads symbol from the embedded SAC spec", async () => { + expect(await server.queryContract(sacId, "symbol")).toBe("TST"); + }); + + it("reads name from the embedded SAC spec", async () => { + expect(await server.queryContract(sacId, "name")).toBe( + `TST:${issuer.publicKey()}`, + ); + }); + }); +}); From d6945d0aeda4b5de7bc12d61c9090e7448d91212 Mon Sep 17 00:00:00 2001 From: Iveta Date: Thu, 2 Jul 2026 16:14:35 -0400 Subject: [PATCH 5/5] Make contract.Client factories generic for typed runtime clients (#1504) * Make contract.Client factories generic for typed runtime clients * Add `rpc.Server.getContractMethods` for contract method discovery (#1505) * Add rpc.Server.getContractMethods for contract method discovery * Document queryContract and getContractMethods in the invoke-a-contract guide * Type the contract.Client examples in the invoke and auth guides --- docs/guides/06-invoke-a-contract.md | 76 ++++++- docs/guides/07-contract-auth.md | 13 +- docs/reference/contracts-client.md | 52 ++++- docs/reference/network-rpc.md | 213 ++++++++++++++---- src/contract/client.ts | 61 ++++- src/rpc/api.ts | 23 ++ src/rpc/server.ts | 83 +++++++ test/e2e/src/query-contract.test.ts | 20 ++ .../soroban/get_contract_methods.test.ts | 174 ++++++++++++++ 9 files changed, 645 insertions(+), 70 deletions(-) create mode 100644 test/unit/server/soroban/get_contract_methods.test.ts diff --git a/docs/guides/06-invoke-a-contract.md b/docs/guides/06-invoke-a-contract.md index 1040fbe29..ea475ec3d 100644 --- a/docs/guides/06-invoke-a-contract.md +++ b/docs/guides/06-invoke-a-contract.md @@ -23,7 +23,8 @@ free and safe to repeat. [Deploy the Increment Contract](https://developers.stellar.org/docs/build/smart-contracts/getting-started/deploy-increment-contract) tutorial once (about 20 to 30 minutes), then paste the contract ID it prints into `contractId` below. You will not touch the CLI again in this guide. - Generating a typed client from a contract is covered later in the series. + This guide types the client with a small hand-written interface; generating one + from a contract's spec is covered later in the series. - The examples use testnet RPC at `https://soroban-testnet.stellar.org`. ## Connect and load the contract @@ -41,9 +42,18 @@ import { contract, Keypair, Networks } from "@stellar/stellar-sdk"; const rpcUrl = "https://soroban-testnet.stellar.org"; const networkPassphrase = Networks.TESTNET; +// Describe just the methods you call. `Client.from()` uses this to type the +// returned client, so the calls below are checked and autocompleted — no code +// generation needed. +interface IncrementContract { + increment: ( + options?: contract.MethodOptions, + ) => Promise>; +} + const { signTransaction } = contract.basicNodeSigner(keypair, networkPassphrase); -const client = await contract.Client.from({ +const client = await contract.Client.from({ contractId, rpcUrl, networkPassphrase, @@ -54,10 +64,52 @@ const client = await contract.Client.from({ Here `keypair` is your funded account from [Connect and Fund an Account](/guides/01-connect-and-fund/) and `contractId` is -your deployed contract's `C...` ID. Because the client is built from the live -contract at runtime, its methods are **not typed**: TypeScript does not know -`client.increment` exists, so calls below use `(client as any)`. A fully typed -client comes from generating bindings, covered later in the series. +your deployed contract's `C...` ID. The client is built from the live contract at +runtime, so TypeScript cannot infer its methods on its own. Passing an interface +to [`Client.from()`](/reference/contracts-client/#contractclient) types them: +`client.increment()` below is fully typed and autocompleted, with no code +generation. For a contract with many methods, generate that interface from its +spec (covered later in the series) rather than writing it by hand. + +## Query contract state + +Sometimes you only want to **inspect** a contract or **read** a value from it, not +change anything. For that, `rpc.Server` has two one-line shortcuts that build the +contract's interface for you — including the built-in spec for Stellar Asset +Contracts (SACs) — so they work from just a contract ID, with no client setup. + +[`getContractMethods`](/reference/network-rpc/#servergetcontractmethodscontractid-networkpassphrase) +lists a contract's callable methods and their signatures, which is handy when you +are inspecting a contract you did not write. +[`queryContract`](/reference/network-rpc/#serverquerycontractcontractid-method-args-networkpassphrase) +runs a **read-only** +call and returns the decoded result. It simulates the call the same way the preview +below does, so it needs no signing or fee, but it hands you the value directly. Here +both run against a token contract — discover its methods, then read one: + +```ts +import { rpc } from "@stellar/stellar-sdk"; + +const server = new rpc.Server(rpcUrl); + +// Discover what the contract exposes, from just its ID. +const methods = await server.getContractMethods(tokenId); +// [ +// { name: "decimals", inputs: [], outputs: ["U32"] }, +// { name: "balance", inputs: [{ name: "id", type: "Address" }], outputs: ["I128"] }, +// { name: "transfer", inputs: [...], outputs: [] }, +// ] + +// Read one of its read-only methods in a single line. +const decimals = await server.queryContract(tokenId, "decimals"); + +const balance = await server.queryContract(tokenId, "balance", { + id: "G...", // named arguments, keyed by the method's parameter names +}); +``` + +`queryContract` is for reads only. To **change** state you build a client and sign a +transaction, as shown next. ## Preview a call with simulation @@ -68,7 +120,7 @@ no signature. Read the predicted return value from [`tx.result`](/reference/contracts-client/#contractassembledtransaction): ```ts -const tx = await (client as any).increment(); +const tx = await client.increment(); tx.result; // the value the call would return; nothing has been sent ``` @@ -113,6 +165,12 @@ const rpcUrl = "https://soroban-testnet.stellar.org"; const networkPassphrase = Networks.TESTNET; const contractId = "C..."; // your deployed increment contract (see Prerequisites) +interface IncrementContract { + increment: ( + options?: contract.MethodOptions, + ) => Promise>; +} + async function main() { const server = new rpc.Server(rpcUrl); const keypair = Keypair.random(); @@ -125,7 +183,7 @@ async function main() { // Fund a throwaway account to invoke from (the RPC-side friendbot). await server.fundAddress(keypair.publicKey()); - const client = await contract.Client.from({ + const client = await contract.Client.from({ contractId, rpcUrl, networkPassphrase, @@ -134,7 +192,7 @@ async function main() { }); // Preview the call for free with simulation. - const tx = await (client as any).increment(); + const tx = await client.increment(); console.log("preview:", tx.result); // Sign and send to apply it on-chain. diff --git a/docs/guides/07-contract-auth.md b/docs/guides/07-contract-auth.md index f5ff9f153..b936f6d09 100644 --- a/docs/guides/07-contract-auth.md +++ b/docs/guides/07-contract-auth.md @@ -108,7 +108,7 @@ authorization entry that account must sign. Build the transaction as in need to sign: ```ts -const tx = await (client as any).increment({ user: signer.publicKey(), value: 1 }); +const tx = await client.increment({ user: signer.publicKey(), value: 1 }); tx.needsNonInvokerSigningBy(); // [signer.publicKey()] ``` @@ -218,6 +218,13 @@ const rpcUrl = "https://soroban-testnet.stellar.org"; const networkPassphrase = Networks.TESTNET; const contractId = "C..."; // your deployed Auth contract (see Prerequisites) +interface AuthContract { + increment: ( + args: { user: string; value: number }, + options?: contract.MethodOptions, + ) => Promise>; +} + async function main() { const server = new rpc.Server(rpcUrl); @@ -234,7 +241,7 @@ async function main() { source, networkPassphrase, ); - const client = await contract.Client.from({ + const client = await contract.Client.from({ contractId, rpcUrl, networkPassphrase, @@ -243,7 +250,7 @@ async function main() { }); // A call that requires `signer` (not the source) to authorize it. - const tx = await (client as any).increment({ + const tx = await client.increment({ user: signer.publicKey(), value: 1, }); diff --git a/docs/reference/contracts-client.md b/docs/reference/contracts-client.md index ce6bdb63d..f426dd99e 100644 --- a/docs/reference/contracts-client.md +++ b/docs/reference/contracts-client.md @@ -632,9 +632,9 @@ transaction. class Client { constructor(spec: Spec, options: ClientOptions); static deploy(args: Record | null, options: MethodOptions & Omit & { address?: string; format?: "base64" | "hex"; salt?: Uint8Array | Buffer; wasmHash: string | Buffer }): Promise>; - static from(options: ClientOptions): Promise; - static fromWasm(wasm: Buffer, options: ClientOptions): Promise; - static fromWasmHash(wasmHash: string | Buffer, options: ClientOptions, format: "base64" | "hex" = "hex"): Promise; + static from(options: ClientOptions): Promise; + static fromWasm(wasm: Buffer, options: ClientOptions): Promise; + static fromWasmHash(wasmHash: string | Buffer, options: ClientOptions, format: "base64" | "hex" = "hex"): Promise; readonly options: ClientOptions; readonly spec: Spec; txFromJSON(json: string): AssembledTransaction; @@ -679,7 +679,7 @@ SAC spec is used instead of downloading Wasm, since a SAC has no Wasm executable on-chain. ```ts -static from(options: ClientOptions): Promise; +static from(options: ClientOptions): Promise; ``` **Parameters** @@ -694,14 +694,24 @@ A Promise that resolves to a Client instance. - If the provided options object does not contain both rpcUrl and contractId. -**Source:** [src/contract/client.ts:192](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L192) +**Example** + +```ts +interface MyContract { + increment: (opts?: MethodOptions) => Promise>; +} +const client = await contract.Client.from(options); +const tx = await client.increment(); // typed +``` + +**Source:** [src/contract/client.ts:237](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L237) ### `Client.fromWasm(wasm, options)` Generates a Client instance from the provided ClientOptions and the contract's wasm binary. ```ts -static fromWasm(wasm: Buffer, options: ClientOptions): Promise; +static fromWasm(wasm: Buffer, options: ClientOptions): Promise; ``` **Parameters** @@ -717,7 +727,17 @@ A Promise that resolves to a Client instance. - If the contract spec cannot be obtained from the provided wasm binary. -**Source:** [src/contract/client.ts:176](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L176) +**Example** + +```ts +interface MyContract { + increment: (opts?: MethodOptions) => Promise>; +} +const client = await contract.Client.fromWasm(wasm, options); +const tx = await client.increment(); // typed +``` + +**Source:** [src/contract/client.ts:204](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L204) ### `Client.fromWasmHash(wasmHash, options, format)` @@ -725,7 +745,7 @@ Generates a Client instance from the provided ClientOptions and the contract's w The wasmHash can be provided in either hex or base64 format. ```ts -static fromWasmHash(wasmHash: string | Buffer, options: ClientOptions, format: "base64" | "hex" = "hex"): Promise; +static fromWasmHash(wasmHash: string | Buffer, options: ClientOptions, format: "base64" | "hex" = "hex"): Promise; ``` **Parameters** @@ -742,7 +762,17 @@ A Promise that resolves to a Client instance. - If the provided options object does not contain an rpcUrl. -**Source:** [src/contract/client.ts:148](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L148) +**Example** + +```ts +interface MyContract { + increment: (opts?: MethodOptions) => Promise>; +} +const client = await contract.Client.fromWasmHash(hash, options); +const tx = await client.increment(); // typed +``` + +**Source:** [src/contract/client.ts:162](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L162) ### `client.options` @@ -770,7 +800,7 @@ txFromJSON(json: string): AssembledTransaction; - **`json`** — `string` (required) -**Source:** [src/contract/client.ts:224](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L224) +**Source:** [src/contract/client.ts:269](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L269) ### `client.txFromXDR(xdrBase64)` @@ -782,7 +812,7 @@ txFromXDR(xdrBase64: string): AssembledTransaction; - **`xdrBase64`** — `string` (required) -**Source:** [src/contract/client.ts:237](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L237) +**Source:** [src/contract/client.ts:282](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L282) ## contract.DEFAULT_TIMEOUT diff --git a/docs/reference/network-rpc.md b/docs/reference/network-rpc.md index fc9df204c..7681c7f57 100644 --- a/docs/reference/network-rpc.md +++ b/docs/reference/network-rpc.md @@ -118,6 +118,7 @@ class Server { getClaimableBalance(id: string): Promise; getContractData(contract: string | Address | Contract, key: ScVal, durability: Durability = Durability.Persistent): Promise; getContractInstance(contractId: string): Promise; + getContractMethods(contractId: string, networkPassphrase?: string): Promise; getContractWasmByContractId(contractId: string): Promise>; getContractWasmByHash(wasmHash: string | Buffer, format: "base64" | "hex" | undefined = undefined): Promise>; getEvents(request: GetEventsRequest): Promise; @@ -159,7 +160,7 @@ constructor(serverURL: string, opts: Options = {}); - **`serverURL`** — `string` (required) - **`opts`** — `Options` (optional) (default: `{}`) -**Source:** [src/rpc/server.ts:170](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L170) +**Source:** [src/rpc/server.ts:182](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L182) ### `server.httpClient` @@ -183,7 +184,7 @@ server.httpClient.interceptors.request.use((config) => { }); ``` -**Source:** [src/rpc/server.ts:169](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L169) +**Source:** [src/rpc/server.ts:181](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L181) ### `server.serverURL` @@ -191,7 +192,7 @@ server.httpClient.interceptors.request.use((config) => { readonly serverURL: URL; ``` -**Source:** [src/rpc/server.ts:152](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L152) +**Source:** [src/rpc/server.ts:164](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L164) ### `server._getEvents(request)` @@ -203,7 +204,7 @@ _getEvents(request: GetEventsRequest): Promise; - **`request`** — `GetEventsRequest` (required) -**Source:** [src/rpc/server.ts:1022](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1022) +**Source:** [src/rpc/server.ts:1105](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1105) ### `server._getLatestLedger()` @@ -211,7 +212,7 @@ _getEvents(request: GetEventsRequest): Promise; _getLatestLedger(): Promise; ``` -**Source:** [src/rpc/server.ts:1093](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1093) +**Source:** [src/rpc/server.ts:1176](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1176) ### `server._getLedgerEntries(keys)` @@ -223,7 +224,7 @@ _getLedgerEntries(...keys: LedgerKey[]): Promise; - **`...keys`** — `LedgerKey[]` (required) -**Source:** [src/rpc/server.ts:787](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L787) +**Source:** [src/rpc/server.ts:870](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L870) ### `server._getLedgers(request)` @@ -235,7 +236,7 @@ _getLedgers(request: GetLedgersRequest): Promise; - **`request`** — `GetLedgersRequest` (required) -**Source:** [src/rpc/server.ts:1684](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1684) +**Source:** [src/rpc/server.ts:1767](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1767) ### `server._getTransaction(hash)` @@ -247,7 +248,7 @@ _getTransaction(hash: string): Promise; - **`hash`** — `string` (required) -**Source:** [src/rpc/server.ts:910](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L910) +**Source:** [src/rpc/server.ts:993](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L993) ### `server._getTransactions(request)` @@ -259,7 +260,7 @@ _getTransactions(request: GetTransactionsRequest): Promise { - `getLedgerEntries docs` -**Source:** [src/rpc/server.ts:203](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L203) +**Source:** [src/rpc/server.ts:215](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L215) ### `server.getAccountEntry(address)` @@ -405,7 +406,7 @@ server.getAccountEntry(accountId).then((account) => { - `getLedgerEntries docs` -**Source:** [src/rpc/server.ts:226](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L226) +**Source:** [src/rpc/server.ts:238](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L238) ### `server.getAssetBalance(address, asset, networkPassphrase)` @@ -451,7 +452,7 @@ const balance = await server.getAssetBalance("GD...", usdc); console.log(balance.balanceEntry?.amount); ``` -**Source:** [src/rpc/server.ts:376](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L376) +**Source:** [src/rpc/server.ts:388](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L388) ### `server.getClaimableBalance(id)` @@ -487,7 +488,7 @@ server.getClaimableBalance(id).then((entry) => { - `getLedgerEntries docs` -**Source:** [src/rpc/server.ts:308](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L308) +**Source:** [src/rpc/server.ts:320](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L320) ### `server.getContractData(contract, key, durability)` @@ -535,7 +536,7 @@ server.getContractData(contractId, key, Durability.Temporary).then(data => { - `getLedgerEntries docs` -**Source:** [src/rpc/server.ts:477](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L477) +**Source:** [src/rpc/server.ts:489](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L489) ### `server.getContractInstance(contractId)` @@ -569,7 +570,52 @@ const instance = await server.getContractInstance( console.log(instance.executable().switch().name); ``` -**Source:** [src/rpc/server.ts:546](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L546) +**Source:** [src/rpc/server.ts:558](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L558) + +### `server.getContractMethods(contractId, networkPassphrase)` + +Lists a contract's callable methods and their signatures. + +A discovery helper for tooling, dapps, and agents that need to inspect an +arbitrary contract without knowing its interface up front. It resolves the +contract's spec (embedded in the Wasm for regular contracts, or the +built-in spec for Stellar Asset Contracts — see `contract.Client.from`) +and reports each declared function's name, inputs, and outputs. No method +is invoked or simulated; this performs only the spec lookup. + +The complement to `queryContract`: list methods here, then call a +read-only one with `server.queryContract(contractId, method, args?)`. + +```ts +getContractMethods(contractId: string, networkPassphrase?: string): Promise; +``` + +**Parameters** + +- **`contractId`** — `string` (required) — the contract to inspect (`C...`) +- **`networkPassphrase`** — `string` (optional) — (optional) the network passphrase. If omitted, a + request about network information will be made (see `getNetwork`). + You can refer to `Networks` for a list of built-in passphrases, + e.g., `Networks.TESTNET`. + +**Returns** + +The contract's methods, in the order they appear in the spec + +**Example** + +```ts +const methods = await server.getContractMethods( + "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5", +); +// [ +// { name: "decimals", inputs: [], outputs: ["U32"] }, +// { name: "balance", inputs: [{ name: "id", type: "Address" }], outputs: ["I128"] }, +// { name: "transfer", inputs: [...], outputs: [] }, +// ] +``` + +**Source:** [src/rpc/server.ts:793](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L793) ### `server.getContractWasmByContractId(contractId)` @@ -612,7 +658,7 @@ server.getContractWasmByContractId(contractId).then(wasmBuffer => { }); ``` -**Source:** [src/rpc/server.ts:588](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L588) +**Source:** [src/rpc/server.ts:600](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L600) ### `server.getContractWasmByHash(wasmHash, format)` @@ -652,7 +698,7 @@ server.getContractWasmByHash(wasmHash).then(wasmBuffer => { }); ``` -**Source:** [src/rpc/server.ts:632](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L632) +**Source:** [src/rpc/server.ts:644](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L644) ### `server.getEvents(request)` @@ -710,7 +756,7 @@ server.getEvents({ - `getEvents docs` -**Source:** [src/rpc/server.ts:1016](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1016) +**Source:** [src/rpc/server.ts:1099](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1099) ### `server.getFeeStats()` @@ -729,7 +775,7 @@ the fee stats - https://developers.stellar.org/docs/data/rpc/api-reference/methods/getFeeStats -**Source:** [src/rpc/server.ts:1489](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1489) +**Source:** [src/rpc/server.ts:1572](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1572) ### `server.getHealth()` @@ -757,7 +803,7 @@ server.getHealth().then((health) => { - `getLedgerEntries docs` -**Source:** [src/rpc/server.ts:435](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L435) +**Source:** [src/rpc/server.ts:447](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L447) ### `server.getLatestLedger()` @@ -787,7 +833,7 @@ server.getLatestLedger().then((response) => { - `getLatestLedger docs` -**Source:** [src/rpc/server.ts:1089](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1089) +**Source:** [src/rpc/server.ts:1172](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1172) ### `server.getLedgerEntries(keys)` @@ -837,7 +883,7 @@ server.getLedgerEntries([key]).then(response => { - - `getLedgerEntries docs` - RpcServer._getLedgerEntries -**Source:** [src/rpc/server.ts:783](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L783) +**Source:** [src/rpc/server.ts:866](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L866) ### `server.getLedgerEntry(key)` @@ -849,7 +895,7 @@ getLedgerEntry(key: LedgerKey): Promise; - **`key`** — `LedgerKey` (required) -**Source:** [src/rpc/server.ts:798](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L798) +**Source:** [src/rpc/server.ts:881](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L881) ### `server.getLedgers(request)` @@ -915,7 +961,7 @@ const nextPage = await server.getLedgers({ - `getLedgers docs` -**Source:** [src/rpc/server.ts:1668](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1668) +**Source:** [src/rpc/server.ts:1751](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1751) ### `server.getNetwork()` @@ -944,7 +990,7 @@ server.getNetwork().then((network) => { - `getNetwork docs` -**Source:** [src/rpc/server.ts:1063](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1063) +**Source:** [src/rpc/server.ts:1146](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1146) ### `server.getSACBalance(address, sac, networkPassphrase)` @@ -1003,7 +1049,7 @@ console.log( - - getLedgerEntries - https://developers.stellar.org/docs/tokens/stellar-asset-contract -**Source:** [src/rpc/server.ts:1553](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1553) +**Source:** [src/rpc/server.ts:1636](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1636) ### `server.getTransaction(hash)` @@ -1041,7 +1087,7 @@ server.getTransaction(transactionHash).then((tx) => { - `getTransaction docs` -**Source:** [src/rpc/server.ts:883](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L883) +**Source:** [src/rpc/server.ts:966](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L966) ### `server.getTransactions(request)` @@ -1077,7 +1123,7 @@ server.getTransactions({ - https://developers.stellar.org/docs/data/rpc/api-reference/methods/getTransactions -**Source:** [src/rpc/server.ts:943](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L943) +**Source:** [src/rpc/server.ts:1026](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1026) ### `server.getTrustline(account, asset)` @@ -1116,7 +1162,7 @@ server.getTrustline(accountId, asset).then((entry) => { - `getLedgerEntries docs` -**Source:** [src/rpc/server.ts:265](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L265) +**Source:** [src/rpc/server.ts:277](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L277) ### `server.getVersionInfo()` @@ -1134,7 +1180,7 @@ the version info - https://developers.stellar.org/docs/data/rpc/api-reference/methods/getVersionInfo -**Source:** [src/rpc/server.ts:1503](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1503) +**Source:** [src/rpc/server.ts:1586](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1586) ### `server.pollTransaction(hash, opts)` @@ -1174,7 +1220,7 @@ const txStatus = await server.pollTransaction(h, { }); // this will take 5,050 seconds to complete ``` -**Source:** [src/rpc/server.ts:836](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L836) +**Source:** [src/rpc/server.ts:919](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L919) ### `server.prepareTransaction(tx)` @@ -1263,7 +1309,7 @@ server.sendTransaction(transaction).then(result => { - - module:rpc.assembleTransaction - `simulateTransaction docs` -**Source:** [src/rpc/server.ts:1259](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1259) +**Source:** [src/rpc/server.ts:1342](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1342) ### `server.queryContract(contractId, method, args, networkPassphrase)` @@ -1316,7 +1362,7 @@ const balance = await server.queryContract( ); ``` -**Source:** [src/rpc/server.ts:697](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L697) +**Source:** [src/rpc/server.ts:709](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L709) ### `server.requestAirdrop(address, friendbotUrl)` @@ -1366,7 +1412,7 @@ server - - `Friendbot docs` - `Friendbot.Api.Response` -**Source:** [src/rpc/server.ts:1365](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1365) +**Source:** [src/rpc/server.ts:1448](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1448) ### `server.sendTransaction(transaction)` @@ -1427,7 +1473,7 @@ server.sendTransaction(transaction).then((result) => { - - `transaction docs` - `sendTransaction docs` -**Source:** [src/rpc/server.ts:1314](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1314) +**Source:** [src/rpc/server.ts:1397](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1397) ### `server.simulateTransaction(tx, addlResources, authMode)` @@ -1493,7 +1539,7 @@ server.simulateTransaction(transaction).then((sim) => { - module:rpc.Server#prepareTransaction - module:rpc.assembleTransaction -**Source:** [src/rpc/server.ts:1157](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1157) +**Source:** [src/rpc/server.ts:1240](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/server.ts#L1240) ## rpc.assembleTransaction @@ -1699,6 +1745,95 @@ latestLedger: number; **Source:** [src/rpc/api.ts:415](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/api.ts#L415) +### rpc.Api.ContractMethod + +A callable method declared in a contract's spec, as returned by +`RpcServer.getContractMethods`. + +```ts +interface ContractMethod { + doc?: string; + inputs: ContractMethodInput[]; + name: string; + outputs: string[]; +} +``` + +**Source:** [src/rpc/api.ts:717](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/api.ts#L717) + +#### `contractMethod.doc` + +the method's spec doc string, when the contract declares one + +```ts +doc?: string; +``` + +**Source:** [src/rpc/api.ts:725](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/api.ts#L725) + +#### `contractMethod.inputs` + +the method's parameters, in declaration order + +```ts +inputs: ContractMethodInput[]; +``` + +**Source:** [src/rpc/api.ts:721](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/api.ts#L721) + +#### `contractMethod.name` + +the on-chain method name + +```ts +name: string; +``` + +**Source:** [src/rpc/api.ts:719](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/api.ts#L719) + +#### `contractMethod.outputs` + +human-readable return type name(s); empty when the method returns void + +```ts +outputs: string[]; +``` + +**Source:** [src/rpc/api.ts:723](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/api.ts#L723) + +### rpc.Api.ContractMethodInput + +A single input parameter of a `ContractMethod`. + +```ts +interface ContractMethodInput { + name: string; + type: string; +} +``` + +**Source:** [src/rpc/api.ts:706](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/api.ts#L706) + +#### `contractMethodInput.name` + +the declared parameter name + +```ts +name: string; +``` + +**Source:** [src/rpc/api.ts:708](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/api.ts#L708) + +#### `contractMethodInput.type` + +a human-readable type name, e.g. `U32`, `Address`, or a UDT's name + +```ts +type: string; +``` + +**Source:** [src/rpc/api.ts:710](https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/api.ts#L710) + ### rpc.Api.EventFilter ```ts diff --git a/src/contract/client.ts b/src/contract/client.ts index 9650b1bca..44aebb01e 100644 --- a/src/contract/client.ts +++ b/src/contract/client.ts @@ -139,17 +139,31 @@ export class Client { * Generates a Client instance from the provided ClientOptions and the contract's wasm hash. * The wasmHash can be provided in either hex or base64 format. * + * @typeParam T - An interface describing the contract's methods, used to type + * the returned client. Defaults to `unknown`, so calling without a type + * argument yields a plain `Client` (backward compatible). Provide it to get + * typed, autocompleted contract methods without code generation. + * * @param wasmHash - The hash of the contract's wasm binary, in either hex or base64 format. * @param options - The ClientOptions object containing the necessary configuration, including the rpcUrl. * @param format - (optional) The format of the provided wasmHash, either "hex" or "base64". Defaults to "hex". * @returns A Promise that resolves to a Client instance. * @throws If the provided options object does not contain an rpcUrl. + * + * @example + * ```ts + * interface MyContract { + * increment: (opts?: MethodOptions) => Promise>; + * } + * const client = await contract.Client.fromWasmHash(hash, options); + * const tx = await client.increment(); // typed + * ``` */ - static async fromWasmHash( + static async fromWasmHash( wasmHash: Buffer | string, options: ClientOptions, format: "hex" | "base64" = "hex", - ): Promise { + ): Promise { if (!options || !options.rpcUrl) { throw new TypeError("options must contain rpcUrl"); } @@ -162,20 +176,37 @@ export class Client { }); const wasm = await server.getContractWasmByHash(wasmHash, format); - return Client.fromWasm(wasm, options); + return Client.fromWasm(wasm, options); } /** * Generates a Client instance from the provided ClientOptions and the contract's wasm binary. * + * @typeParam T - An interface describing the contract's methods, used to type + * the returned client. Defaults to `unknown`, so calling without a type + * argument yields a plain `Client` (backward compatible). Provide it to get + * typed, autocompleted contract methods without code generation. + * * @param wasm - The contract's wasm binary as a Buffer. * @param options - The ClientOptions object containing the necessary configuration. * @returns A Promise that resolves to a Client instance. * @throws If the contract spec cannot be obtained from the provided wasm binary. + * + * @example + * ```ts + * interface MyContract { + * increment: (opts?: MethodOptions) => Promise>; + * } + * const client = await contract.Client.fromWasm(wasm, options); + * const tx = await client.increment(); // typed + * ``` */ - static async fromWasm(wasm: Buffer, options: ClientOptions): Promise { + static async fromWasm( + wasm: Buffer, + options: ClientOptions, + ): Promise { const spec = await Spec.fromWasm(wasm); - return new Client(spec, options); + return new Client(spec, options) as unknown as Client & T; } /** @@ -185,11 +216,25 @@ export class Client { * SAC spec is used instead of downloading Wasm, since a SAC has no Wasm * executable on-chain. * + * @typeParam T - An interface describing the contract's methods, used to type + * the returned client. Defaults to `unknown`, so calling without a type + * argument yields a plain `Client` (backward compatible). Provide it to get + * typed, autocompleted contract methods without code generation. + * * @param options - The ClientOptions object containing the necessary configuration, including the contractId and rpcUrl. * @returns A Promise that resolves to a Client instance. * @throws If the provided options object does not contain both rpcUrl and contractId. + * + * @example + * ```ts + * interface MyContract { + * increment: (opts?: MethodOptions) => Promise>; + * } + * const client = await contract.Client.from(options); + * const tx = await client.increment(); // typed + * ``` */ - static async from(options: ClientOptions): Promise { + static async from(options: ClientOptions): Promise { if (!options || !options.rpcUrl || !options.contractId) { throw new TypeError("options must contain rpcUrl and contractId"); } @@ -211,14 +256,14 @@ export class Client { // Lazily load the (large) embedded SAC spec so bundlers can code-split // it out of the common path; it's only needed for built-in SACs. const { SAC_SPEC } = await import("../bindings/sac-spec.js"); - return new Client(new Spec(SAC_SPEC), options); + return new Client(new Spec(SAC_SPEC), options) as unknown as Client & T; } const wasm = await server.getContractWasmByHash( instance.executable().wasmHash(), ); - return Client.fromWasm(wasm, options); + return Client.fromWasm(wasm, options); } txFromJSON = (json: string): AssembledTransaction => { diff --git a/src/rpc/api.ts b/src/rpc/api.ts index a19efa088..8a03764bf 100644 --- a/src/rpc/api.ts +++ b/src/rpc/api.ts @@ -701,4 +701,27 @@ export namespace Api { /** a base-64 encoded {@link xdr.LedgerCloseMeta} instance */ metadataXdr: string; } + + /** A single input parameter of a {@link ContractMethod}. */ + export interface ContractMethodInput { + /** the declared parameter name */ + name: string; + /** a human-readable type name, e.g. `U32`, `Address`, or a UDT's name */ + type: string; + } + + /** + * A callable method declared in a contract's spec, as returned by + * `RpcServer.getContractMethods`. + */ + export interface ContractMethod { + /** the on-chain method name */ + name: string; + /** the method's parameters, in declaration order */ + inputs: ContractMethodInput[]; + /** human-readable return type name(s); empty when the method returns void */ + outputs: string[]; + /** the method's spec doc string, when the contract declares one */ + doc?: string; + } } diff --git a/src/rpc/server.ts b/src/rpc/server.ts index 132d867b3..90b4b7c67 100644 --- a/src/rpc/server.ts +++ b/src/rpc/server.ts @@ -134,6 +134,18 @@ function findCreatedAccountSequenceInTransactionMeta( throw new Error("No account created in transaction"); } +/** + * Best-effort, human-readable name for a contract spec type, e.g. + * `scSpecTypeU32` becomes `U32` and `scSpecTypeAddress` becomes `Address`. + * User-defined types (structs/enums/unions) are shown by their declared name. + */ +function contractSpecTypeName(td: xdr.ScSpecTypeDef): string { + if (td.switch().value === xdr.ScSpecType.scSpecTypeUdt().value) { + return td.udt().name().toString(); + } + return td.switch().name.replace(/^scSpecType/, ""); +} + /** * Handles the network connection to a Soroban RPC instance, exposing an * interface for requests to that instance. @@ -746,6 +758,77 @@ export class RpcServer { return assembled.result as T; } + /** + * Lists a contract's callable methods and their signatures. + * + * A discovery helper for tooling, dapps, and agents that need to inspect an + * arbitrary contract without knowing its interface up front. It resolves the + * contract's spec (embedded in the Wasm for regular contracts, or the + * built-in spec for Stellar Asset Contracts — see {@link contract.Client.from}) + * and reports each declared function's name, inputs, and outputs. No method + * is invoked or simulated; this performs only the spec lookup. + * + * The complement to {@link queryContract}: list methods here, then call a + * read-only one with `server.queryContract(contractId, method, args?)`. + * + * @param contractId - the contract to inspect (`C...`) + * @param networkPassphrase - (optional) the network passphrase. If omitted, a + * request about network information will be made (see {@link getNetwork}). + * You can refer to {@link Networks} for a list of built-in passphrases, + * e.g., `Networks.TESTNET`. + * @returns The contract's methods, in the order they appear in the spec + * + * @example + * ```ts + * const methods = await server.getContractMethods( + * "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5", + * ); + * // [ + * // { name: "decimals", inputs: [], outputs: ["U32"] }, + * // { name: "balance", inputs: [{ name: "id", type: "Address" }], outputs: ["I128"] }, + * // { name: "transfer", inputs: [...], outputs: [] }, + * // ] + * ``` + */ + public async getContractMethods( + contractId: string, + networkPassphrase?: string, + ): Promise { + const passphrase = + networkPassphrase ?? (await this.getNetwork()).passphrase; + + // Dynamically import to avoid a static circular dependency: the contract + // module imports this rpc module. The import resolves at call time, by + // which point this module has finished loading. + const { Client } = await import("../contract/client.js"); + + // `Client.from` is SAC-aware: it uses the embedded SAC spec for built-in + // Stellar Asset Contracts and downloads Wasm otherwise. We pass + // `server: this` so the call reuses this server's transport. + const client = await Client.from({ + contractId, + rpcUrl: this.serverURL.toString(), + networkPassphrase: passphrase, + server: this, + }); + + return client.spec.funcs().map((fn) => { + const doc = fn.doc().toString(); + const method: Api.ContractMethod = { + name: fn.name().toString(), + inputs: fn.inputs().map((input) => ({ + name: input.name().toString(), + type: contractSpecTypeName(input.type()), + })), + outputs: fn.outputs().map(contractSpecTypeName), + }; + if (doc) { + method.doc = doc; + } + return method; + }); + } + /** * Reads the current value of arbitrary ledger entries directly. * diff --git a/test/e2e/src/query-contract.test.ts b/test/e2e/src/query-contract.test.ts index 00f54df84..15c41bad0 100644 --- a/test/e2e/src/query-contract.test.ts +++ b/test/e2e/src/query-contract.test.ts @@ -68,6 +68,18 @@ describe("Server#queryContract", () => { ); expect(result).toBe(1); }); + + it("lists the contract's methods and signatures", async () => { + const methods = await server.getContractMethods(contractId); + const byName = new Map(methods.map((m) => [m.name, m])); + + // The same methods exercised by queryContract above must show up. + expect(byName.get("get_count")).toMatchObject({ inputs: [] }); + expect(byName.get("multi_args")?.inputs.map((i) => i.name)).toEqual([ + "a", + "b", + ]); + }); }); describe("Stellar Asset Contract (SAC)", () => { @@ -93,5 +105,13 @@ describe("Server#queryContract", () => { `TST:${issuer.publicKey()}`, ); }); + + it("lists the built-in SAC methods from the embedded spec", async () => { + const names = (await server.getContractMethods(sacId)).map((m) => m.name); + // A representative subset of the well-known SAC interface. + expect(names).toEqual( + expect.arrayContaining(["decimals", "name", "balance", "transfer"]), + ); + }); }); }); diff --git a/test/unit/server/soroban/get_contract_methods.test.ts b/test/unit/server/soroban/get_contract_methods.test.ts new file mode 100644 index 000000000..66a2e458e --- /dev/null +++ b/test/unit/server/soroban/get_contract_methods.test.ts @@ -0,0 +1,174 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from "vitest"; +import * as StellarSdk from "../../../../src/index.js"; + +import { serverUrl } from "../../../constants"; + +const { Server } = StellarSdk.rpc; +const { Client } = StellarSdk.contract; +const { xdr } = StellarSdk; + +/** A `ScSpecTypeDef` for a user-defined type referenced by name. */ +function udt(name: string) { + return xdr.ScSpecTypeDef.scSpecTypeUdt(new xdr.ScSpecTypeUdt({ name })); +} + +/** A real `ScSpecFunctionV0`, so the type-name mapping is exercised for real. */ +function func(opts: { + doc?: string; + name: string; + inputs?: { name: string; type: xdr.ScSpecTypeDef }[]; + outputs?: xdr.ScSpecTypeDef[]; +}) { + return new xdr.ScSpecFunctionV0({ + doc: opts.doc ?? "", + name: opts.name, + inputs: (opts.inputs ?? []).map( + (i) => + new xdr.ScSpecFunctionInputV0({ + doc: "", + name: i.name, + type: i.type, + }), + ), + outputs: opts.outputs ?? [], + }); +} + +/** Build a fake `contract.Client` whose `spec.funcs()` returns the given funcs. */ +function fakeClient(funcs: xdr.ScSpecFunctionV0[]) { + return { spec: { funcs: () => funcs } }; +} + +describe("Server#getContractMethods", () => { + let server: any; + const contractId = "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5"; + const networkPassphrase = "Test SDF Network ; September 2015"; + + beforeEach(() => { + server = new Server(serverUrl); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("defaults the network passphrase to getNetwork() when not provided", async () => { + const getNetwork = vi + .spyOn(server, "getNetwork") + .mockResolvedValue({ passphrase: networkPassphrase } as any); + const from = vi + .spyOn(Client, "from") + .mockResolvedValue(fakeClient([func({ name: "decimals" })]) as any); + + await server.getContractMethods(contractId); + + expect(getNetwork).toHaveBeenCalledOnce(); + expect(from).toHaveBeenCalledWith( + expect.objectContaining({ + contractId, + rpcUrl: server.serverURL.toString(), + networkPassphrase, + server, + }), + ); + }); + + it("uses the provided networkPassphrase and skips getNetwork()", async () => { + const getNetwork = vi.spyOn(server, "getNetwork"); + const from = vi + .spyOn(Client, "from") + .mockResolvedValue(fakeClient([func({ name: "decimals" })]) as any); + + await server.getContractMethods(contractId, "Custom Network ; 2024"); + + expect(getNetwork).not.toHaveBeenCalled(); + expect(from).toHaveBeenCalledWith( + expect.objectContaining({ networkPassphrase: "Custom Network ; 2024" }), + ); + }); + + it("maps names, inputs, and outputs to readable type names", async () => { + vi.spyOn(server, "getNetwork").mockResolvedValue({ + passphrase: networkPassphrase, + } as any); + vi.spyOn(Client, "from").mockResolvedValue( + fakeClient([ + func({ + name: "decimals", + outputs: [xdr.ScSpecTypeDef.scSpecTypeU32()], + }), + func({ + name: "balance", + inputs: [{ name: "id", type: xdr.ScSpecTypeDef.scSpecTypeAddress() }], + outputs: [xdr.ScSpecTypeDef.scSpecTypeI128()], + }), + func({ + name: "transfer", + inputs: [ + { name: "from", type: xdr.ScSpecTypeDef.scSpecTypeAddress() }, + { name: "to", type: xdr.ScSpecTypeDef.scSpecTypeAddress() }, + { name: "amount", type: xdr.ScSpecTypeDef.scSpecTypeI128() }, + ], + }), + ]) as any, + ); + + const methods = await server.getContractMethods(contractId); + + expect(methods).toEqual([ + { name: "decimals", inputs: [], outputs: ["U32"] }, + { + name: "balance", + inputs: [{ name: "id", type: "Address" }], + outputs: ["I128"], + }, + { + name: "transfer", + inputs: [ + { name: "from", type: "Address" }, + { name: "to", type: "Address" }, + { name: "amount", type: "I128" }, + ], + outputs: [], + }, + ]); + }); + + it("resolves user-defined types by their declared name", async () => { + vi.spyOn(server, "getNetwork").mockResolvedValue({ + passphrase: networkPassphrase, + } as any); + vi.spyOn(Client, "from").mockResolvedValue( + fakeClient([ + func({ + name: "submit", + inputs: [{ name: "order", type: udt("Order") }], + outputs: [udt("Receipt")], + }), + ]) as any, + ); + + const [method] = await server.getContractMethods(contractId); + + expect(method.inputs).toEqual([{ name: "order", type: "Order" }]); + expect(method.outputs).toEqual(["Receipt"]); + }); + + it("includes doc only when the spec declares one", async () => { + vi.spyOn(server, "getNetwork").mockResolvedValue({ + passphrase: networkPassphrase, + } as any); + vi.spyOn(Client, "from").mockResolvedValue( + fakeClient([ + func({ name: "documented", doc: "Returns the token decimals." }), + func({ name: "undocumented" }), + ]) as any, + ); + + const [documented, undocumented] = + await server.getContractMethods(contractId); + + expect(documented.doc).toBe("Returns the token decimals."); + expect(undocumented).not.toHaveProperty("doc"); + }); +});