Skip to content

Commit 6a7898e

Browse files
authored
Handle built-in Stellar Asset Contracts (SACs) in contract.Client.from (#1501)
* Handle SAC in contract.Client.from * Docs updated * Fix tests * Optimize * Fix and update docs * Update error response * Rollup: enable inlineDynamicImports * Update error message + outdated comment * Add rpc.Server.queryContract for one-line read-only contract calls (#1502) * Add rpc.Server.queryContract for one-line read-only contract calls * Sanitize method name * Check only contract methods before invoking * e2e test happy path * 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 * Updated changelog * Return { result, isReadCall } from queryContract (#1511)
1 parent baa80c4 commit 6a7898e

15 files changed

Lines changed: 1504 additions & 87 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ config/tsconfig.tmp.json
4242
# scratch: per-guide tests, never shipped
4343
test-guides/
4444
.vuln-hunt/
45+
scratch/

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ A breaking change will get clearly marked in this log.
66

77
## Unreleased
88

9+
### Added
10+
- `rpc.Server.queryContract<T>(contractId, method, args?, networkPassphrase?)`: a one-line read-only contract call that builds a client, simulates the method, and returns `{ result, isReadCall }` — the spec-decoded return value plus whether this specific call is a signature-free read that wrote no state (per-call, reflecting the given `args`). No manual transaction assembly, signing, or submission. Works for both Wasm contracts and built-in Stellar Asset Contracts (SACs) ([#1502](https://github.com/stellar/js-stellar-sdk/pull/1502)).
11+
- `rpc.Server.getContractMethods(contractId, networkPassphrase?)`: lists a contract's callable methods and their signatures (name, inputs, outputs, and doc string) for discovery and tooling, without invoking or simulating anything. Adds the `Api.ContractMethod` and `Api.ContractMethodInput` types ([#1502](https://github.com/stellar/js-stellar-sdk/pull/1502)).
12+
- `rpc.Server.getContractInstance(contractId)`: returns a contract's `xdr.ScContractInstance` (its executable and instance storage) ([#1501](https://github.com/stellar/js-stellar-sdk/pull/1501)).
13+
- `contract.Client.from`, `fromWasm`, and `fromWasmHash` are now generic (`<T>`) and return `Client & T`, giving typed, autocompleted contract methods without code generation. The type parameter defaults to `unknown`, so existing untyped calls are unchanged ([#1502](https://github.com/stellar/js-stellar-sdk/pull/1502)).
14+
- `ClientOptions.server`: pass an existing `rpc.Server` to `contract.Client.from` to reuse its transport (headers, interceptors, `allowHttp`) instead of constructing a new one ([#1502](https://github.com/stellar/js-stellar-sdk/pull/1502)).
15+
16+
### Changed
17+
- `contract.Client.from` now supports built-in Stellar Asset Contracts (SACs): when the contract's executable is a SAC, the client is built from the embedded SAC spec (lazily imported so bundlers can code-split it out of the common path) instead of downloading Wasm, which a SAC has none of on-chain ([#1501](https://github.com/stellar/js-stellar-sdk/pull/1501)).
18+
- `rpc.Server.getContractWasmByContractId` now rejects a SAC with a structured `{ code: 400 }` error pointing to `contract.Client.from`, instead of failing while decoding a nonexistent Wasm hash; the not-found rejection is now `{ code: 404, message: "Could not obtain contract instance from server" }` ([#1501](https://github.com/stellar/js-stellar-sdk/pull/1501)).
19+
- The UMD (`dist/`) build now sets `inlineDynamicImports` so the single-file bundle stays whole despite the SAC spec's lazy `import()` ([#1501](https://github.com/stellar/js-stellar-sdk/pull/1501)).
20+
921
## [v16.0.1](https://github.com/stellar/js-stellar-sdk/compare/v16.0.0...v16.0.1)
1022

1123
### Fixed

docs/guides/06-invoke-a-contract.md

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ free and safe to repeat.
2323
[Deploy the Increment Contract](https://developers.stellar.org/docs/build/smart-contracts/getting-started/deploy-increment-contract)
2424
tutorial once (about 20 to 30 minutes), then paste the contract ID it prints
2525
into `contractId` below. You will not touch the CLI again in this guide.
26-
Generating a typed client from a contract is covered later in the series.
26+
This guide types the client with a small hand-written interface; generating one
27+
from a contract's spec is covered later in the series.
2728
- The examples use testnet RPC at `https://soroban-testnet.stellar.org`.
2829

2930
## Connect and load the contract
@@ -41,9 +42,18 @@ import { contract, Keypair, Networks } from "@stellar/stellar-sdk";
4142
const rpcUrl = "https://soroban-testnet.stellar.org";
4243
const networkPassphrase = Networks.TESTNET;
4344

45+
// Describe just the methods you call. `Client.from<T>()` uses this to type the
46+
// returned client, so the calls below are checked and autocompleted — no code
47+
// generation needed.
48+
interface IncrementContract {
49+
increment: (
50+
options?: contract.MethodOptions,
51+
) => Promise<contract.AssembledTransaction<number>>;
52+
}
53+
4454
const { signTransaction } = contract.basicNodeSigner(keypair, networkPassphrase);
4555

46-
const client = await contract.Client.from({
56+
const client = await contract.Client.from<IncrementContract>({
4757
contractId,
4858
rpcUrl,
4959
networkPassphrase,
@@ -54,10 +64,65 @@ const client = await contract.Client.from({
5464

5565
Here `keypair` is your funded account from
5666
[Connect and Fund an Account](/guides/01-connect-and-fund/) and `contractId` is
57-
your deployed contract's `C...` ID. Because the client is built from the live
58-
contract at runtime, its methods are **not typed**: TypeScript does not know
59-
`client.increment` exists, so calls below use `(client as any)`. A fully typed
60-
client comes from generating bindings, covered later in the series.
67+
your deployed contract's `C...` ID. The client is built from the live contract at
68+
runtime, so TypeScript cannot infer its methods on its own. Passing an interface
69+
to [`Client.from<T>()`](/reference/contracts-client/#contractclient) types them:
70+
`client.increment()` below is fully typed and autocompleted, with no code
71+
generation. For a contract with many methods, generate that interface from its
72+
spec (covered later in the series) rather than writing it by hand.
73+
74+
## Query contract state
75+
76+
Sometimes you only want to **inspect** a contract or **read** a value from it, not
77+
change anything. For that, `rpc.Server` has two one-line shortcuts that build the
78+
contract's interface for you — including the built-in spec for Stellar Asset
79+
Contracts (SACs) — so they work from just a contract ID, with no client setup.
80+
81+
[`getContractMethods`](/reference/network-rpc/#servergetcontractmethodscontractid-networkpassphrase)
82+
lists a contract's callable methods and their signatures, which is handy when you
83+
are inspecting a contract you did not write. The spec it reports carries no
84+
read/write flag, so to learn whether a *specific* call would change state, invoke
85+
it with `queryContract` and read its `isReadCall` (see below).
86+
[`queryContract`](/reference/network-rpc/#serverquerycontractcontractid-method-args-networkpassphrase)
87+
runs a **read-only**
88+
call and returns the decoded result. It simulates the call the same way the preview
89+
below does, so it needs no signing or fee, but it hands you the value directly. Here
90+
both run against a token contract — discover its methods, then read one:
91+
92+
```ts
93+
import { rpc } from "@stellar/stellar-sdk";
94+
95+
const server = new rpc.Server(rpcUrl);
96+
97+
// Discover what the contract exposes, from just its ID.
98+
const methods = await server.getContractMethods(tokenId);
99+
// [
100+
// { name: "decimals", inputs: [], outputs: ["U32"] },
101+
// { name: "balance", inputs: [{ name: "id", type: "Address" }], outputs: ["I128"] },
102+
// { name: "transfer", inputs: [...], outputs: [] },
103+
// ]
104+
105+
// Read one of its read-only methods in a single line.
106+
const { result: decimals, isReadCall } = await server.queryContract<number>(
107+
tokenId,
108+
"decimals",
109+
);
110+
111+
const { result: balance } = await server.queryContract<bigint>(
112+
tokenId,
113+
"balance",
114+
{
115+
id: "G...", // named arguments, keyed by the method's parameter names
116+
},
117+
);
118+
```
119+
120+
Alongside the decoded `result`, `queryContract` returns `isReadCall`: whether
121+
*this* call — for the exact arguments given — wrote no state and needed no
122+
signature. It is per-call, not a fixed property of the method. Since
123+
`queryContract` never signs or sends, `isReadCall: false` means the `result` is
124+
only a simulation preview of a call that would change state; to apply such a
125+
change you build a client and sign a transaction, as shown next.
61126

62127
## Preview a call with simulation
63128

@@ -68,7 +133,7 @@ no signature. Read the predicted return value from
68133
[`tx.result`](/reference/contracts-client/#contractassembledtransaction):
69134

70135
```ts
71-
const tx = await (client as any).increment();
136+
const tx = await client.increment();
72137

73138
tx.result; // the value the call would return; nothing has been sent
74139
```
@@ -113,6 +178,12 @@ const rpcUrl = "https://soroban-testnet.stellar.org";
113178
const networkPassphrase = Networks.TESTNET;
114179
const contractId = "C..."; // your deployed increment contract (see Prerequisites)
115180

181+
interface IncrementContract {
182+
increment: (
183+
options?: contract.MethodOptions,
184+
) => Promise<contract.AssembledTransaction<number>>;
185+
}
186+
116187
async function main() {
117188
const server = new rpc.Server(rpcUrl);
118189
const keypair = Keypair.random();
@@ -125,7 +196,7 @@ async function main() {
125196
// Fund a throwaway account to invoke from (the RPC-side friendbot).
126197
await server.fundAddress(keypair.publicKey());
127198

128-
const client = await contract.Client.from({
199+
const client = await contract.Client.from<IncrementContract>({
129200
contractId,
130201
rpcUrl,
131202
networkPassphrase,
@@ -134,7 +205,7 @@ async function main() {
134205
});
135206

136207
// Preview the call for free with simulation.
137-
const tx = await (client as any).increment();
208+
const tx = await client.increment();
138209
console.log("preview:", tx.result);
139210

140211
// Sign and send to apply it on-chain.

docs/guides/07-contract-auth.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ authorization entry that account must sign. Build the transaction as in
108108
need to sign:
109109

110110
```ts
111-
const tx = await (client as any).increment({ user: signer.publicKey(), value: 1 });
111+
const tx = await client.increment({ user: signer.publicKey(), value: 1 });
112112

113113
tx.needsNonInvokerSigningBy(); // [signer.publicKey()]
114114
```
@@ -218,6 +218,13 @@ const rpcUrl = "https://soroban-testnet.stellar.org";
218218
const networkPassphrase = Networks.TESTNET;
219219
const contractId = "C..."; // your deployed Auth contract (see Prerequisites)
220220

221+
interface AuthContract {
222+
increment: (
223+
args: { user: string; value: number },
224+
options?: contract.MethodOptions,
225+
) => Promise<contract.AssembledTransaction<number>>;
226+
}
227+
221228
async function main() {
222229
const server = new rpc.Server(rpcUrl);
223230

@@ -234,7 +241,7 @@ async function main() {
234241
source,
235242
networkPassphrase,
236243
);
237-
const client = await contract.Client.from({
244+
const client = await contract.Client.from<AuthContract>({
238245
contractId,
239246
rpcUrl,
240247
networkPassphrase,
@@ -243,7 +250,7 @@ async function main() {
243250
});
244251

245252
// A call that requires `signer` (not the source) to authorize it.
246-
const tx = await (client as any).increment({
253+
const tx = await client.increment({
247254
user: signer.publicKey(),
248255
value: 1,
249256
});

docs/reference/contracts-client.md

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -632,9 +632,9 @@ transaction.
632632
class Client {
633633
constructor(spec: Spec, options: ClientOptions);
634634
static deploy<T = Client>(args: Record<string, any> | null, options: MethodOptions & Omit<ClientOptions, "contractId"> & { address?: string; format?: "base64" | "hex"; salt?: Uint8Array<ArrayBufferLike> | Buffer<ArrayBufferLike>; wasmHash: string | Buffer<ArrayBufferLike> }): Promise<AssembledTransaction<T>>;
635-
static from(options: ClientOptions): Promise<Client>;
636-
static fromWasm(wasm: Buffer, options: ClientOptions): Promise<Client>;
637-
static fromWasmHash(wasmHash: string | Buffer<ArrayBufferLike>, options: ClientOptions, format: "base64" | "hex" = "hex"): Promise<Client>;
635+
static from<T = unknown>(options: ClientOptions): Promise<Client & T>;
636+
static fromWasm<T = unknown>(wasm: Buffer, options: ClientOptions): Promise<Client & T>;
637+
static fromWasmHash<T = unknown>(wasmHash: string | Buffer<ArrayBufferLike>, options: ClientOptions, format: "base64" | "hex" = "hex"): Promise<Client & T>;
638638
readonly options: ClientOptions;
639639
readonly spec: Spec;
640640
txFromJSON<T>(json: string): AssembledTransaction<T>;
@@ -674,8 +674,12 @@ static deploy<T = Client>(args: Record<string, any> | null, options: MethodOptio
674674
675675
Generates a Client instance from the provided ClientOptions, which must include the contractId and rpcUrl.
676676
677+
If the contract is a built-in Stellar Asset Contract (SAC), the embedded
678+
SAC spec is used instead of downloading Wasm, since a SAC has no Wasm
679+
executable on-chain.
680+
677681
```ts
678-
static from(options: ClientOptions): Promise<Client>;
682+
static from<T = unknown>(options: ClientOptions): Promise<Client & T>;
679683
```
680684
681685
**Parameters**
@@ -690,14 +694,24 @@ A Promise that resolves to a Client instance.
690694
691695
- If the provided options object does not contain both rpcUrl and contractId.
692696
693-
**Source:** [src/contract/client.ts:188](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L188)
697+
**Example**
698+
699+
```ts
700+
interface MyContract {
701+
increment: (opts?: MethodOptions) => Promise<AssembledTransaction<number>>;
702+
}
703+
const client = await contract.Client.from<MyContract>(options);
704+
const tx = await client.increment(); // typed
705+
```
706+
707+
**Source:** [src/contract/client.ts:237](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L237)
694708

695709
### `Client.fromWasm(wasm, options)`
696710

697711
Generates a Client instance from the provided ClientOptions and the contract's wasm binary.
698712

699713
```ts
700-
static fromWasm(wasm: Buffer, options: ClientOptions): Promise<Client>;
714+
static fromWasm<T = unknown>(wasm: Buffer, options: ClientOptions): Promise<Client & T>;
701715
```
702716
703717
**Parameters**
@@ -713,15 +727,25 @@ A Promise that resolves to a Client instance.
713727
714728
- If the contract spec cannot be obtained from the provided wasm binary.
715729
716-
**Source:** [src/contract/client.ts:176](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L176)
730+
**Example**
731+
732+
```ts
733+
interface MyContract {
734+
increment: (opts?: MethodOptions) => Promise<AssembledTransaction<number>>;
735+
}
736+
const client = await contract.Client.fromWasm<MyContract>(wasm, options);
737+
const tx = await client.increment(); // typed
738+
```
739+
740+
**Source:** [src/contract/client.ts:204](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L204)
717741

718742
### `Client.fromWasmHash(wasmHash, options, format)`
719743

720744
Generates a Client instance from the provided ClientOptions and the contract's wasm hash.
721745
The wasmHash can be provided in either hex or base64 format.
722746

723747
```ts
724-
static fromWasmHash(wasmHash: string | Buffer<ArrayBufferLike>, options: ClientOptions, format: "base64" | "hex" = "hex"): Promise<Client>;
748+
static fromWasmHash<T = unknown>(wasmHash: string | Buffer<ArrayBufferLike>, options: ClientOptions, format: "base64" | "hex" = "hex"): Promise<Client & T>;
725749
```
726750
727751
**Parameters**
@@ -738,7 +762,17 @@ A Promise that resolves to a Client instance.
738762
739763
- If the provided options object does not contain an rpcUrl.
740764
741-
**Source:** [src/contract/client.ts:148](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L148)
765+
**Example**
766+
767+
```ts
768+
interface MyContract {
769+
increment: (opts?: MethodOptions) => Promise<AssembledTransaction<number>>;
770+
}
771+
const client = await contract.Client.fromWasmHash<MyContract>(hash, options);
772+
const tx = await client.increment(); // typed
773+
```
774+
775+
**Source:** [src/contract/client.ts:162](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L162)
742776

743777
### `client.options`
744778

@@ -766,7 +800,7 @@ txFromJSON<T>(json: string): AssembledTransaction<T>;
766800

767801
- **`json`**`string` (required)
768802

769-
**Source:** [src/contract/client.ts:201](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L201)
803+
**Source:** [src/contract/client.ts:269](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L269)
770804

771805
### `client.txFromXDR(xdrBase64)`
772806

@@ -778,7 +812,7 @@ txFromXDR<T>(xdrBase64: string): AssembledTransaction<T>;
778812

779813
- **`xdrBase64`**`string` (required)
780814

781-
**Source:** [src/contract/client.ts:214](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L214)
815+
**Source:** [src/contract/client.ts:282](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/client.ts#L282)
782816

783817
## contract.DEFAULT_TIMEOUT
784818

0 commit comments

Comments
 (0)