You have a live simple-token contract from the previous chapter. Now you’ll
call it — both read-only (view) and state-changing (the build-and-sign
pattern from Transfer between accounts). Two modalities, full coverage.
Prerequisites · Deploy from ABI done. $CONTRACT, $ROOT_COMPONENT_ID,
and $HOLDER_COMPONENT_ID are set, plus the ABIs at
$ARTIFACTS/simple_token_*.abi.json. Alice’s holder was initialized with a
wallet balance of 12 in the Adding Functionality chapter.
Running against local or DevNet
Pick the tab for the environment you’re targeting and paste once. The
commands further down the lesson use $SERVER_URL and (on DevNet) the
persisted auth header, so they don’t need per-environment edits.
Make sure a local validator is running (gen service validator local --embedded-config in another terminal), then:export SERVER_URL="http://127.0.0.1:30001"
DevNet access. Ask your Gen Labs contact for the RPC URL and a bearer
token; substitute them for <DEVNET_RPC_URL> and <your-jwt> below.
Persisting the header with gen config set means the rest of the lesson
runs without --header on each command.
export SERVER_URL="<DEVNET_RPC_URL>"
"$GEN" config set header authorization "Bearer <your-jwt>"
Reading state with view
Reads on the Grid are free and don’t touch the activation pipeline. They go
to any RPC node, return immediately, and have no on-chain footprint.
simple-token’s holder exposes a get_balance() view method (added in
Adding Functionality):
"$GEN" client --rpc-url "$SERVER_URL" --json view \
--component-id "$HOLDER_COMPONENT_ID" \
--method get_balance
Should return 12. You can list all of a component’s view methods
straight from its ABI:jq -r '.methods[] | select(.access_modifier == "view") | .name' \
"$ARTIFACTS/simple_token_1_holder.abi.json"
use client_sdk::encode_args_from_object;
use client_sdk::payload::ViewPayload;
use dynamic_codecs::decode_result;
use vm_primitives::gvm::{GvmAccount, GvmComponentId};
use std::str::FromStr;
let holder_id = GvmComponentId::from_str(&std::env::var("HOLDER_COMPONENT_ID")?)?;
let alice = GvmAccount::from_str(&std::env::var("ALICE")?)?;
// Fetch the ABI once — reuse for repeated reads/writes against the
// same component.
let abi = client.get_abi_by_contract_code_id(
client.get_component(holder_id.clone()).await?
.header().component_code_id().contract_code_id().clone()
).await?;
let view = ViewPayload::builder()
.component_id(holder_id.clone())
.function("get_balance".to_string())
.args_bytes(encode_args_from_object(
&abi, "get_balance", &Default::default(),
)?)
.sender_account(alice)
.build();
let bytes = client.view(&view).await?;
let decoded = decode_result(&abi, "get_balance", &bytes)?;
println!("balance: {}", decoded.to_json());
Receipt · 0 activations · 0.000¢ USDG · ~5 ms · Reads don’t cost
anything. Build them into your hot path freely.
Writing state with build-activation + sign-and-submit
Writes use the same exact pattern as the Transfer between accounts
lesson. That is the point — once you’ve internalized one write flow,
you’ve internalized all of them.
For a write we need a recipient. Install a second holder on bob’s account
(repeat the install step from the previous chapter with --account "$BOB"),
then call transfer() to move 3 from alice’s holder to bob’s:
BOB_HOLDER_JSON=$("$GEN" client --rpc-url "$SERVER_URL" --json install \
--wallet alice \
--contract "$CONTRACT" \
--account "$BOB" \
--component-type-index 1)
BOB_HOLDER_COMPONENT_ID=$(jq -r '.result.component_id' <<<"$BOB_HOLDER_JSON")
Build the write request
TRANSFER_REQUEST=$(jq -nc \
--arg component_id "$HOLDER_COMPONENT_ID" \
--arg recipient "$BOB_HOLDER_COMPONENT_ID" \
--argjson amount 3 \
'{
component_id: $component_id,
function: "transfer",
params: { amount: $amount, recipient: $recipient }
}')
Same JSON shape as the standalone transfer. Only function and
params change between “transfer USDG”, “transfer simple-token”, or
any other write you’d build.Build the unsigned activation
BUILD_JSON=$("$GEN" client --rpc-url "$SERVER_URL" --json build-activation \
--wallet alice \
--request "$TRANSFER_REQUEST")
UNSIGNED=$(jq -r '.result.unsigned_activation_hex' <<<"$BUILD_JSON")
Sign and submit
"$GEN" wallet --rpc-url "$SERVER_URL" --json sign-and-submit-activation alice \
--unsigned-activation "$UNSIGNED" \
--no-wait
Verify with view
"$GEN" client --rpc-url "$SERVER_URL" --json view \
--component-id "$HOLDER_COMPONENT_ID" \
--method get_balance
# alice's balance → 9
"$GEN" client --rpc-url "$SERVER_URL" --json view \
--component-id "$BOB_HOLDER_COMPONENT_ID" \
--method get_balance
# bob's balance → 15 (he started at 12 too, since install() initializes wallet_balance: 12)
Uses the abi and holder_id from the view step above.Build the unsigned activation
use client_sdk::ActivationOptions;
use client_sdk::payload::ActivationPayload;
use serde_json::json;
let bob_holder = GvmComponentId::from_str(&std::env::var("BOB_HOLDER_COMPONENT_ID")?)?;
let params = json!({
"amount": 3,
"recipient": bob_holder.to_string(),
}).as_object().unwrap().clone();
let payload = ActivationPayload::builder()
.component_id(holder_id.clone())
.function("transfer".to_string())
.args_bytes(encode_args_from_object(&abi, "transfer", ¶ms)?)
.build();
Same ActivationPayload pattern as the standalone transfer. Only
function and params change.Sign and submit
use client_sdk::GenSigner;
use gen_rpc_wire::ActivationTag;
let signer: GenSigner = load_signer("alice")?;
let opts = ActivationOptions::builder()
.tag(ActivationTag::new(rand::random::<u32>() & 0x7FFF_FFFF))
.build();
let outcome = client
.sign_and_submit_and_wait_activation_with_options(&signer, &payload, &opts)
.await?;
Verify with another view
let verify = ViewPayload::builder()
.component_id(holder_id)
.function("get_balance".to_string())
.args_bytes(encode_args_from_object(
&abi, "get_balance", &Default::default(),
)?)
.sender_account(alice)
.build();
let bytes = client.view(&verify).await?;
println!("alice's balance: {}", decode_result(&abi, "get_balance", &bytes)?.to_json());
Receipt · 1 activation · 0.001¢ USDG · ~10 ms · A write is a write.
The contract function doesn’t change the price.
The read and write share the same ABI fetch and arg encoding — only the
payload type (ViewPayload vs ActivationPayload) and whether you sign
differ. That is the entire interface, in either environment.
What just happened
You confirmed the entire Grid call surface from a developer’s perspective is
exactly two things:
| What you want to do | How you do it | Cost |
|---|
| Read state | client view --component-id ... --method ... | free |
| Change state | build-activation → sign-and-submit-activation | 1 activation |
That’s the whole API. Every contract you’ll ever write or call on the Grid lives
inside that table.
The throughput claim, plainly
The validator pipeline has been benchmarked past 1,000,000 activations
per second on a 10-node cluster. The implication for you, the application
developer, is simple: the chain is not your bottleneck.
If your design requires:
- Every HTTP request to mint an on-chain receipt → fine.
- Every agent reasoning step to record a verifiable trace → fine.
- Every user keystroke in a multiplayer experience to be on-chain → unusual, but fine.
These would be absurd elsewhere. They are routine here. Build accordingly.
The performance figures above are design targets the validator architecture
is engineered to hit. They are not guarantees about any specific deployment —
production envelope depends on cluster sizing.