thruput-sdk 0.58.0

Thruput smart-contract SDK for Rust (rv32 guest runtime and native test harness)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
This SDK facilitates writing Thruput contracts in Rust.

A contract is an ordinary Rust crate. Its persistent state lives in one or
more structs deriving [`Storage`](derive@crate::Storage); its behavior lives
in one or more impl or trait blocks marked
[`#[contract]`](macro@crate::contract), which generate the guest entrypoints
and a typed client that other contracts and tests call through.

```rust,ignore
use thruput_sdk::storage::Value;
use thruput_sdk::*;

#[derive(Storage)]
pub struct MyContract {
    contract_owner: Value<ContractAddress>,
}

#[contract]
impl MyContract {
    pub fn initialize(&mut self, contract_owner: &ContractAddress) {
        self.contract_owner.set(contract_owner);
    }

    pub fn get_owner(&self) -> ContractAddress {
        self.contract_owner.get()
    }
}
```

`#[derive(Storage)]` can also be spelled
[`#[storage]`](macro@crate::storage).

That expands to a `MyContractClient` for callers, guest entrypoints for the
deployed artifact, and a contract spec describing the interface. Declaring
exceptions is a separate step, covered under
[calling and errors](#calling-and-errors).

Sections: [Cargo features](#cargo-features), [storage](#storage),
[calling and errors](#calling-and-errors), [events](#events),
[authorization](#authorization), [arithmetic](#arithmetic),
[system functions](#system-functions),
[interfaces and specs](#interfaces-and-specs), and [testing](#testing).

# Cargo features

The features are layered: each implies the one above it. A contract
targeting `riscv32im-unknown-none-elf` normally enables none of them; the
host-side test build enables `dev`.

- **(none)** — the default, and what a deployed contract is built with:
  `no_std`, no allocator, no host dependencies. [`mod@storage`], events,
  exceptions, authorization and the syscall wrappers all work here. Only the
  allocating and host-side APIs need more.
- **`alloc`** — links the [`alloc`] crate, making `Vec`, `String`, and other
  allocating types usable in contract code and in storage. See
  [Allocation]#allocation for what backs it on a guest.
- **`std`** — implies `alloc`. Adds the interfaces that need `std`, owned
  (allocating) contract-spec models, and detailed XDR decode errors. Host-only:
  enabling `std` for a `riscv32` build is a hard compile error, as is omitting
  it for a native build.
- **`dev`** — implies `std`. The native mock executor and everything that
  hangs off it: [`TestEnvironment`], [`client_test`], [`test_rv32()`],
  [`declare_mock_contract!`], and the storage-cost helpers. A contract crate
  forwards this from its own `dev` feature. It is the only feature that pulls
  `thruput-native-exec`, and through it the hogd FFI library, into the
  dependency graph. That is why it is a feature: crates that only read
  contract specs build `thruput-sdk` without it and never fetch that library.
- **`logging`** — trace contract calls during native tests. Not implied by
  `dev`; enable it when you want the trace.
- **`source-build`** — compile the cmake source-build path into
  `thruput-sys`. It does not select that path: `HOGD_FFI_BUILD=1` does, and
  it needs this feature to have something to select. Set both to build the
  hogd FFI library from source rather than download a prebuilt one, which is
  also the only way to build on a platform with no prebuilt binary. Only
  meaningful alongside `dev`.

With `alloc`, owned `String` values can also be contract parameters and return
values, including inside `Option`. Borrowed `&str` inputs and
`heapless::String<N>` are alternatives when a bounded representation is useful.

A contract crate wires them up like this:

```toml
[features]
# Yours, not the SDK's. `#[contract]` gates guest entrypoint codegen on it,
# so a dependency cannot inject entrypoints into your binary.
entrypoints = []
dev = ["thruput-sdk/dev"]

[lib]
crate-type = ["rlib"]

# The rv32 artifact. `thruput build -p my-contract` derives
# `--bin my-contract` from the package name, so this name must match it.
# src/main.rs is a `#![no_std] #![no_main]` wrapper whose `extern crate
# my_contract;` force-links the rlib so the entrypoint records reach the
# binary.
[[bin]]
name = "my-contract"
path = "src/main.rs"
required-features = ["entrypoints"]

[dependencies]
thruput-sdk = { workspace = true }

[dev-dependencies]
my-contract = { path = ".", features = ["dev"] }
```

Two things that look like features are keyed on the target instead:

- **Diagnostics.** The `Display` impls on exception types and the formatting
  they need are host-only. Off a Cargo feature, no dependency can turn them
  on for a deployed contract, so they cannot change what a contract computes.
- **The guest runtime.** `#[global_allocator]`, `#[panic_handler]`, `_start`
  and entrypoint dispatch come from `thruput-rv32-runtime`, force-linked on
  `riscv32` and absent from host builds.

# Allocation

### Stack and heap budgets

Each RV32 invocation shares a fixed memory budget between stack, static data
and heap. Increasing the stack reserve reduces heap capacity; enabling `alloc`
does not increase memory. See the runtime
[stack and memory configuration](https://github.com/thruputchain/thruput-sdk-rs/blob/main/crates/rv32-runtime/src/lib.rs)
for defaults and overrides.

Without `alloc`, SDK call scratch and entrypoint calldata use fixed buffers
of about 4 KiB. An owned [`exn::RawException`] also holds an inline buffer of
about 4 KiB. Result temporaries and nested calls can keep several buffers on
the stack, even when the exception payload itself is small. Inlining can keep
those slots in a caller's frame after their values are no longer live, so
source scopes alone do not establish peak stack use.

| Need | Choice |
| --- | --- |
| Bounded, short-lived values and scratch | Stack storage is appropriate; budget the whole call chain. |
| Classify a caught exception immediately | [`MapExceptions`] returns a compact value without retaining its wire buffer. |
| Keep the full exception for later | Ordinary `try_*` retains an owned error; account for its storage. |
| Dynamic collections or larger working buffers | Enable `alloc` and bound cumulative allocation per invocation. |

The guest allocator does not reclaim dropped values during an invocation.
SDK call scratch is pooled under `alloc`, but other allocations, including
owned exceptions and collection growth, can accumulate. Construct large
buffers directly in heap storage: `Box::new(large_value)` can still create a
large stack temporary.

Native tests enable `dev`, which enables `alloc`, and do not establish guest
stack headroom. Test release RV32 artifacts with maximum inputs, nested calls
and error paths.

### Static

Static allocations are facilitated in contracts by [`ContractCell`] and
[`contract_local!`].

### Heap

With feature `alloc`:

- The [`alloc`] crate is linked in, giving access to allocating datatypes like
  `String` and `Vec`.
- A simple bump allocator is installed by the guest runtime crate
  (`thruput-rv32-runtime`). Allocations are not freed until the current
  contract invocation terminates.

Datatypes defined by third-party libraries or by the user may be used in memory
and in persistent storage, noting that:

- Heap allocation requires feature `alloc`.
- Persistent storage fields must use SDK storage types or nested structs derived
  with [`Storage`]derive@crate::Storage.

# Storage

The [`mod@storage`] module provides a high-level layer over persistent state,
including collection types like [`Map`](storage::Map),
[`Vector`](storage::Vector) and [`Array`](storage::Array). It is available in every feature configuration.

Important storage-model rules:

- Plain data in persistent state must be wrapped in [`Value<T>`]storage::Value.
- [`Map<K, V>`]storage::Map entries are individually addressed and may be
  absent.
- [`Vector<S>`]storage::Vector is sparse-capable.
  [`Vector::len()`]storage::Vector::len reports the in-bounds extent;
  individual slots may still be empty if their storage has been deleted.
- [`Array<S, N>`]storage::Array is a fixed-length `Map<u32, S>`: indices
  `0..N` are always in bounds, and individual elements may be unset.
- Use [`Vector::push_with()`]storage::Vector::push_with for the common
  append-and-initialize flow, and
  [`Vector::push_empty_slot()`]storage::Vector::push_empty_slot only when you
  intentionally want to expose the new slot before initializing it.
- [`Value::load_mut()`]storage::Value::load_mut returns an auto-flushing edit
  guard: the updated value is written back when the guard is dropped.
- Nested storage structs inside collections must be wrapped in
  [`Node<T>`]storage::Node, for example `Vector<Node<MyFields>>`.

Two types enforce their invariant in the storage layer instead of in contract
code: [`NNInt`](storage::NNInt) for counters that must not go negative, and
[`Balance`](storage::Balance), whose `increase`/`decrease` fail on overflow
and overdraft in every build profile (see [Arithmetic](#arithmetic)). This
matters most under concurrency: when two transactions in the same block both
move a balance, the check has to hold against the committed value, not the
one the contract read.
[`Deletable`](derive@crate::Deletable) derives recursive deletion for a
storage struct.

Each contract's state occupies its own storage domain. `#[contract]` generates
a per-crate test asserting the crate's own roots do not collide; a binary that
links several contract crates should also invoke
[`storage_domain_uniqueness_test!`] so the aggregate is checked.

The low-level syscall wrappers ([`bytestring_set`] and friends) will corrupt
this module's bookkeeping if pointed at the same keys. Use them, with
[`static_key!`](macro@crate::static_key), only for state that
[`mod@storage`] does not own.

# Contract macros

The [`storage`](macro@crate::storage) macro is shorthand for
`#[derive(Storage)]`.

The [`contract`](macro@crate::contract) macro exports contract methods for
cross-contract calls and also generates client types for calling those methods.
It applies to a trait, a trait impl, an inherent impl, or a free function —
see its own page for the interface-versus-implementation split, `throws`, and
entrypoint-identifier customization.

Client naming defaults:

- `#[contract] trait Foo` generates `FooClient`
- `#[contract] impl Foo { ... }` generates `FooClient`
- `client = CustomClient` is an override, not a requirement

# Calling And Errors

[`ContractClient`] is the low-level call surface. Most code goes through a
generated client, which exposes these calling styles:

- `client.method(...)`: success-oriented path. Typed exceptions are re-thrown to
  the caller, and faults stay faults.
- `client.try_method(...)`: inspection path. Returns
  `Result<ReturnType, CallError>`. A caught exception stays undecoded: match
  it with [`match_exn!`], or decode it as a named type with `try_as` /
  `try_decode`.
- `client.map_exceptions(classify).try_method(...)`: returns
  `Result<ReturnType, RawCallError<X>>`, where the mapper produces `X`
  from a borrowed [`exn::RawExceptionRef`]. See [`MapExceptions`] for the
  callback contract.

```rust,ignore
match token.try_transfer(&to, amount) {
    Ok(()) => { /* ... */ }
    Err(CallError::Fault(f)) => { /* ... */ }
    Err(e) => match_exn!(e, {
        ERC20Error::TooLarge               => { /* ... */ }
        LibraryExn::NonNegativeIntOverflow => { /* ... */ }
        _ => e.re_throw(),
    }),
}
```

When only a classification is needed, avoid retaining the full exception:

```rust,no_run
# #![allow(unexpected_cfgs)]
# use thruput_sdk::*;
# #[contract_exception]
# pub enum ERC20Error { TooLarge = 1 }
# #[contract]
# pub trait Token { fn transfer(to: &ContractAddress, amount: u64); }
# fn example(token: TokenClient, to: ContractAddress, amount: u64) {
let outcome = token.map_exceptions(|exn| {
    match_exn!(exn, {
        ERC20Error::TooLarge => true,
        _ => exn.re_throw(),
    })
}).try_transfer(&to, amount);

match outcome {
    Ok(()) => { /* ... */ }
    Err(RawCallError::Exception(true)) => { /* handle TooLarge */ }
    Err(other) => { /* handle call faults and other failures */ }
}
# }
```

Everything structural — `Ok`, `Fault`, `Invalid` — stays an ordinary Rust
`match` with real exhaustiveness checking; the macro's only job is typed
downcasting. See [`match_exn!`] for the matching rules.

Use [`InvokeConfig`] (or the generated `with_gas_limit` constructor) to bound
the gas a subcall may spend, and [`deploy_contract`] to create new contracts.
[`get_deploy_address`] computes the resulting address ahead of time.

At the frame level, contract execution ends in one of three states:

- Success: return data is produced and the frame commits.
- Exception: the frame reverts with a structured payload ([`throw`], library
  exception, or forwarded exception).
- Fault: the frame reverts without a structured exception payload
  (for example `panic!`).

Only success commits the current invocation frame's storage changes.

### Exceptions

A throwable type is one annotated with
[`#[contract_exception]`](macro@crate::contract_exception) (or
[`#[exn]`](macro@crate::exn) if you write your own derives), which implements
[`Exn`] and gives the type a selector derived from its canonical signature.
Clients dispatch on that selector, so a caller can tell your exceptions apart
from an unrelated contract's without agreeing on discriminant numbering.
Declare which ones a contract may throw with `throws = ...` on `#[contract]`.

The SDK's own failures arrive as [`LibraryExn`]. Absence (a missing key, an
empty queue) is an expected outcome, and comes back the same way; identify it
with [`is_storage_missing`](LibraryExn::is_storage_missing), or use
[`none_if_missing`](NoneIfMissing::none_if_missing) to narrow just that case
to `None` while re-throwing everything else.

A `try_*` function never aborts the VM on its own account: its syscalls,
buffer fetches and decoding all report failure through the return type, so the
only way one aborts is a closure you handed it. [`mod@exn`] states this in
full.

# Events

Emit with [`mod@event`]; derive the type with
[`#[derive(Event)]`](derive@crate::Event).

Declare emitted types with `#[contract(emits(MyEvent))]` so their definitions
are retained in the generated contract spec. Emitting an event at runtime does
not by itself retain its spec; see [`#[contract]`](macro@crate::contract).

Derived events reserve topic 0 for the event signature. That signature is based
on the package name, package version, and event type name, so changing any of
those changes the emitted signature topic.

Thruput currently supports up to three total topics per event. With
`#[derive(Event)]`, that means the signature plus up to two additional
`#[topic]` fields.

Topic fields use their [`IntoTopic`] representation. Supported scalar types
encode directly; strings and byte slices are hashed and cannot be recovered
from a topic. Non-topic fields form the decoded payload.
[`TypedEvent`] holds the raw topics and that payload. The harness queries
[`assert_event`](TransactResult::assert_event) and
[`filter_events`](TransactResult::filter_events) provide this typed view.

# Authorization

Authorization travels with the call. A [`Signed<T>`] parameter is a handle to
a host-held auth token: it encodes to zero bytes, and the handle rides
alongside the XDR arguments. A method signature therefore states in its types
which arguments must have been authorized.

[`Signed::consume`] hands back the payload and the address that minted the
token. Any contract can mint a token whose payload decodes as `T`, so
consuming proves only that some contract minted it and that this contract is
its intended consumer. **Check the returned minter against an address you
trust before acting on the payload.** Skipping that check is an authorization
bypass.

- [`#[contract_type]`]macro@crate::contract_type makes a struct or enum
  usable as an argument, return value, or auth-token payload. It generates
  the auth-handle walkers that let a `Signed<T>` nest inside it (an enum
  variant included), and the `Mintable` preimage mirror used to mint
  tokens.
- [`mod@signer`] holds the token vocabulary: `Signed<Opaque>` for a token whose
  payload type is not yet known, `Signed::assume` to reinterpret one, and
  `AssumeTokens` to destructure a `Vec<Signed<Opaque>>` into a typed tuple.
- [`get_auth_claim`] reads the current transaction's `AuthClaim`; for a
  contract acting as an account, [`set_address_required_weight`],
  [`add_address_public_key`], and [`remove_address_public_key`] declare the
  public keys and weights its address requires.

`thruput-signed-op` is the wallet-facing shape of this — Thruput's
ERC-1271 equivalent, with the message typed and domain-separated in the
spirit of EIP-712 and replay-protected by the chain's uniquifier model.
`execute_signed_op` returns the one auth token a wallet mints — the root of
whatever it authorizes, with tokens for downstream contracts as child nodes.
The caller names its payload type with `Signed::assume`, then consumes it
and checks its minter against the wallet address it trusts.

### Minting limits

A token node carries at most **4** direct children
(`thruput_xdr::auth_token::kAuthTokenMaxChildNodes`), and [`mint_auth_token`]
**panics** past that rather than reporting it. The count is per node and per
level of the preimage — containers do not open a new level, so five elements
in a `Vec<Signed<u64>>` field are five children of one node. A nested
`Signed<T>` does open a level: its own payload gets its own budget of four.

This matters more on chain than in a test: a panicking contract faults with
no message, code or location ([#358]), so a preimage that outgrew the cap is
indistinguishable from any other trap. Keep preimage shapes within the cap by
construction.

The encoded payload is separately capped at 256 bytes
(`kAuthTokenMaxLen`), which *is* reported — as
`AuthTokenError::MintEncodeFailure`.

[#358]: https://github.com/thruputchain/thruput-sdk-rs/issues/358

# Arithmetic

[`U256`] and its signed counterpart [`I256`] behave like `u128` and `i128`,
including on overflow: the operators wrap when `overflow-checks` is off — as
in every contract profile — and panic when it is on. **The native test harness
builds with overflow checks on**, so an overflow that fails a test wraps
silently on-chain. A passing test proves nothing about overflow behavior in
deployment.

Signed division is the one exception, and it follows the primitives: `I256::MIN
/ -1` and `I256::MIN % -1` panic in *every* profile, because that is what
`i128` does — the hardware alternative is a trap, so Rust does not gate it on
`overflow-checks`. Use `wrapping_div` / `wrapping_rem` if you need the wrap.

Both types store eight 32-bit limbs, which is what the rv32 guest multiplies
natively, and [`I256`] is the same bits read signed rather than a second
implementation. Prefer them over a general-purpose big-integer crate: those
typically use 64-bit limbs and multiply through `u128`, which the guest has to
synthesize, so they cost far more code and gas for the same arithmetic.

For money math, be explicit rather than relying on operators:

- `checked_add` / `checked_sub` / `checked_mul` on the primitives and on
  [`U256`], with a typed throw on `None`;
- [`mul_div_u128`] / [`checked_mul_div_u128`] for `a * b / d` with a 256-bit
  intermediate (the panics of [`mul_div_u128`] are unconditional, not
  `overflow-checks`-gated);
- [`U256::mul_div`] when the operands are themselves 256-bit — full
  precision, on an exact 512-bit intermediate;
- the storage-level guards [`NNInt`]storage::NNInt and
  [`Balance`]storage::Balance, which check in every profile.

Beyond that, [`U256`] and [`I256`] carry OpenZeppelin's `Math.sol` and
`SignedMath.sol` surfaces — `mul_shr`, `mul_mod`, `mod_exp`, `inv_mod`,
`average`, `ceil_div`, and `integer_sqrt`/`log2`/`log10`/`log256` with a
[`Rounding`] direction — so a Solidity port has the same vocabulary. Every
fallible one ships as a `checked_` form beside the panicking one.

# System functions

The SDK provides convenient wrappers around Thruput syscalls:

- [`deploy_contract`]: deploy other contracts
- [`ContractClient`]: call other contracts (typically via clients generated by
  [`contract`]macro@crate::contract)
- [`get_caller`], [`get_current_address`], [`contract_abort`],
  [`contract_revert`]: frame context and exit routines
- [`crypto`]: hash and signature operations
- [`Hasher`]: incremental hashing
- Key-value storage primitives, re-exported at the crate root:
  - [`delete`], [`delete_exclusive`]: remove entries
  - [`bytestring_set`], [`bytestring_get_sized`]: byte blobs
  - [`non_negative_int_set`], [`non_negative_int_inc`]: non-negative integers
  - [`retrocausal_queue_insert`], [`retrocausal_queue_consume_sized`]:
    retrocausal queues
- Low-level syscall bindings:
  - [`mod@sys`]
- Emitting events:
  - [`mod@event`]

Context about the running transaction:

- [`mod@block`]: the height of the block being executed in
- [`mod@stats`]: transaction statistics, including
  [`gas_remaining()`]stats::gas_remaining
- [`log_str`], [`fn@log`]: contract logging

Other useful modules and types:

- [`mod@types`]
  - [`ContractAddress`]ContractAddress
  - [`StorageKey`]StorageKey
  - [`PublicKey`]PublicKey

# Interfaces And Specs

`#[contract]` on a *trait* defines a reusable interface; `#[contract]` on an
impl of that trait exports an implementation. Callers depend on the interface
crate and get the same generated client either way. That is how the standard
interfaces in `standard-interfaces/` (the `thruput-*-if` crates:
`thruput-erc20-if`, `thruput-erc20-metadata-if`, `thruput-erc20-pausable-if`,
`thruput-erc20-permit-if`, `thruput-erc721-if`, `thruput-erc165-if`,
`thruput-access-control-if`, `thruput-signed-op-if`) are consumed.

Every `#[contract]` trait also carries an [`InterfaceId`] as
`Trait::INTERFACE_ID`, derived from its name and major version, and every
`#[contract]` impl block leaves a record of its interface's id in the
contract's [interface table](mod@interface_table). `thruput-erc165` is the
query surface: `impl_erc165!()` adds a `supports_interface` entrypoint
answered by `implements!(id)` from the impls that actually linked, and a
`supports_interfaces` entrypoint that answers a bounded list of ids in one
call. A contract without it carries no table in its deployed image.

Alongside the entrypoints, the macros emit a machine-readable **contract
spec**: the interface's methods, their argument and return types, and the
exceptions it declares. Types reach the spec through
[`#[contract_type]`](macro@crate::contract_type) or
[`ContractTypeSpec`](derive@crate::ContractTypeSpec).

# Testing

Three testing lanes, all behind feature `dev`:

- [`client_test`]: simplest path for exercising a generated client against a
  mock registration
- [`TestEnvironment`]crate::TestEnvironment: direct control over environment,
  blocks, deployment, and multi-contract scenarios
- [`test_rv32()`]: run a built rv32 contract artifact in the test harness

When a test needs one transaction to see another's committed changes, call
[`TestEnvironment::advance_block`] between them. Transactions in the same block
read the same committed snapshot; see [`TestEnvironment::transact`].

Typical native client test, with the contract crate calling
`thruput_sdk::declare_mock_contract!();` in its `lib.rs` to generate
`register_mock_contract`:

```rust,ignore
use my_contract::MyContractClient;

thruput_sdk::client_test(
    |env| my_contract::register_mock_contract(env),
    |client: &MyContractClient| {
        // Call methods on `client`
    },
);
```

Direct environment control:

```rust,ignore
use thruput_sdk::{ContractAddress, Named, StorageKey, TestEnvironment};

let env = TestEnvironment::no_cost();
let addr = ContractAddress::named("my_test");

env.transact(|tx| {
    tx.call(&addr).run(|| {
        let key = StorageKey::default();
        thruput_sdk::bytestring_set(&key, b"hello");
    })
})
.assert_success();
```

Use the full `env.transact` form when a test needs multiple transactions,
asserts on aborts or faults, or decodes typed results.

The chain settings an environment runs under -- the storage cost model, the
network hash, the genesis root, and the network config parameters -- come
from [`TestEnvironment::builder()`](crate::TestEnvironment::builder) when a
test wants something other than the defaults. It is entirely optional; the
constructors above are unaffected by it.

```rust,ignore
// A contract's behavior at the chain's live-hasher cap, without needing a
// chain configured that way. Every unset parameter keeps its stock value.
let env = TestEnvironment::builder()
    .no_cost()
    .with_max_sha256_buffers(2)
    .build();
```

Advanced helpers are available when you need lower-level control:

- [`contract_test!`]
- [`into_root_call_result()`]
- [`load_rv32_contract()`]
- [`test_rv32()`]