Actions and signing

Purpose: How the reference client turns a click into a broadcast transaction — wallet, protobuf registry, the dual-lane queue, and charge gating.

Everything that changes game state goes through here. It is also where the two failure modes that plague new clients live: account sequence mismatch, and acting before you have charge.


Wallet

CosmJS, bech32 prefix structs, 12-word mnemonic from 16 bytes of entropy:

// src/js/managers/WalletManager.js
createMnemonic() {
  const getNewRandom = Random.getBytes(16);
  return Bip39.encode(getNewRandom).toString();
}

async createWallet(mnemonic) {
  return await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { prefix: "structs" });
}

No hdPaths is passed, so derivation falls back to the CosmJS default. If you need the exact path, read it off your installed version rather than assuming — this codebase does not state it.

The mnemonic is stored unencrypted in localStorage. Do not copy that. See SAFETY.md.

Off-chain proof signatures

Guild signup, login and address registration authenticate with a signature over a plain concatenated string — not an ADR-036 SignDoc. SHA-256 the UTF-8 bytes, sign the digest with secp256k1, return lowercase hex of the 64-byte fixed-length signature:

async createSignatureForProxyMessage(message, privateKey) {
  const encodedMessage = this.textEncoder.encode(message);
  const digest = sha256(encodedMessage);
  const rawSignature = await Secp256k1.createSignature(digest, privateKey);
  return this.bytesToHex(rawSignature.toFixedLength());
}

The message formats are exact — get a character wrong and the server rejects it:

Use Format
Guild signup GUILD{guildId}ADDRESS{address}NONCE{nonce}
Login LOGIN_GUILD{guildId}ADDRESS{address}DATETIME{unixTimestamp}
Address register PLAYER{playerId}ADDRESS{address}

Signing client and registry

// src/js/managers/SigningClientManager.js
this.registry = new Registry([...defaultRegistryTypes, ...msgTypes]);

this.wsUrl = this.publicEndpoint
    ? `wss://public.testnet.structs.network:26657`
    : `ws://${window.location.hostname}:26657`;

this.gameState.signingClient = await SigningStargateClient.connectWithSigner(
  this.wsUrl, wallet, { registry: this.registry },
);

msgTypes comes from src/js/ts/structs.structs/registry.ts — roughly 330 [typeUrl, GeneratedType] pairs covering messages, queries, events and domain objects. Type URLs are all of the form /structs.structs.Msg…:

["/structs.structs.MsgStructBuildInitiate", MsgStructBuildInitiate],

Fees are a single flat constant. No simulation, no gas price, no adjustment:

// src/js/constants/Fee.js
export const FEE = {
  amount: [{ denom: "ualpha", amount: "0" }],
  gas: "500000",
};

This differs from the CLI guidance in AGENTS.md, which uses --gas auto --gas-adjustment 1.5. Both work; the webapp just picks a limit generous enough for every message it sends and stops thinking about it.

Desktop adds a native virtual-player signer

The webapp queue above is still the primary-player action path, but current Structs Desktop has two additional virtual-player modes:

At sign-in, native_signer_import can transfer the mnemonic from the webview once. Rust normalizes it, verifies that m/44'/118'/0'/0/0 derives the signed-in device address, stores it in the OS keychain, and retains a zeroized BIP39 seed in memory. A child key at m/44'/118'/0'/0/N is derived per sign and dropped before the first await. Until import succeeds, native modes fall back to the webview bridge.

Native signing tracks account number/sequence per address, advances on mempool acceptance, and resets from the chain’s expected sequence on disagreement. A per-account mutex is shared with the JS bridge so modes cannot race one account; tx_gate_cap bounds cross-account concurrency. native_async returns after CheckTx acceptance and later emits tx_settled, while native waits for inclusion.

This is a custody change, not an MCP key-export feature: tool callers still request signatures and never receive the mnemonic, but the Rust process and OS keychain now belong inside the signing trust boundary. See agent-security.md.

creator is never stored in a queued payload — it is injected from the signing account at broadcast time, so a persisted queue cannot be replayed against the wrong account:

// src/js/models/SigningTransaction.js
toCosmosMsg(registry) {
  const type = registry.lookupType(this.message.typeUrl);
  if (!type) throw new Error(`Unknown typeUrl in registry: ${this.message.typeUrl}`);
  return {
    typeUrl: this.message.typeUrl,
    value: type.fromPartial({ ...this.message.payload, creator: this.accountAddress }),
  };
}

The dual-lane queue

SigningQueueManager is the most transferable idea in the client. Two lanes and one in-flight slot:

/** @type {SigningTransaction[]} Unordered lane; fired ASAP. */
this.immediateQueue = [];
/** @type {SigningTransaction[]} Ordered action lane; head gated lazily on charge. */
this.actionQueue = [];
/** @type {SigningTransaction|null} Tx currently being broadcast. */
this.inFlight = null;
Lane Enqueue For
Immediate enqueueImmediate(typeUrl, payload) Anything not charge-costed — notably proof-of-work completions
Action enqueueAction(typeUrl, payload, chargeCost) Charged game actions, strictly FIFO

On every block, transactOnBlock picks at most one transaction:

  1. Return immediately if something is in flight, the signing client is missing, or a transaction already went out at this height.
  2. If the action head exists, the confirmed last-action height is loaded, and projected charge covers its cost → broadcast the action head.
  3. Otherwise, if the immediate queue is non-empty → broadcast its head.

A blocked action head does not block the immediate lane. That is the whole point of the split: your mine completions keep flowing while a charged action waits for the bar to refill.

Sequence numbers: don’t track them, just serialise

The client never touches sequence numbers. It avoids account sequence mismatch with three mechanical guarantees:

CosmJS fetches and increments the sequence internally. Serialising submissions is enough. This is the same reasoning behind the CLI rule in AGENTS.md — one transaction at a time per account, roughly six seconds apart.

Recovering a silently dead signing socket

A WebSocket can remain OPEN after a suspended tab while no longer carrying traffic. SigningClientManager.resumeCheck() probes with getHeight() (not the memoised getChainId()), gives it five seconds, and rebuilds the client only when recent GRASS blocks prove the wider network is alive. Concurrent checks collapse onto one promise; unforced checks are throttled to five minutes. Resume triggers (visibilitychange, BFCache pageshow, online, and focus) force the probe, while a periodic watchdog uses the throttle.

Broadcast itself has a separate hard deadline. This prevents a dead transport from leaving inFlight set forever and wedging both lanes; the ordinary timeout-with-tx-hash path still polls for inclusion before declaring failure.

Charge gating

Charge is elapsed blocks since your last charged action, with a +1 offset:

// src/js/util/ChargeCalculator.js
calcCharge(currentBlockHeight, lastActionBlockHeight) {
  return currentBlockHeight - (lastActionBlockHeight + 1);
}

The queue projects the same value against a walking base:

projectedChargeAt(blockHeight) {
  return blockHeight - (this.getWalkingBase() + 1);
}

Three distinct heights are in play, and conflating them is a real bug:

Value Source Used for
confirmedLastActionBlockHeight GRASS lastAction, or REST at login The scheduler. The only trustworthy base
scheduleAnchorHeight Inclusion height of the last charged tx this client sent Advancing the base between confirmations
lastActionBlockHeight (optimistic) Set locally the moment you enqueue Display only — the charge bar

The walking base is max(confirmed, anchor). The optimistic value drains the bar in the UI immediately so the interface feels responsive, and deliberately never reaches the scheduler — otherwise a dropped transaction would leave the client believing it had spent charge it still has.

Hold the action lane until the confirmed height has loaded. The base defaults to 0, which projects as effectively infinite charge, and the client would empty its entire queue into one block. The reference client checks isConfirmedActionLoaded() before releasing anything.

When charge is short, the head simply stays put and is re-evaluated next block. Nothing is dropped and nothing is retried.

Persistence

The queue survives a reload. Key is signingQueue:{wsUrl}:{address}, so switching account or network cannot cross-contaminate. Snapshots older than 30 minutes, or corrupt JSON, are moved aside to {key}.corrupt rather than being parsed. An in-flight transaction found at startup has its attempt count incremented and is re-queued or dropped, because the client cannot know whether it landed.

Payloads are validated as JSON-safe at enqueue (assertSerializable), which is why BigInt throws — stringify before enqueueing.

Retries

// src/js/constants/SigningQueueConstants.js
DEFAULT_RETRY_LIMIT: 0,           // 0 retries => 1 attempt (no retries for now)
MAX_QUEUE_AGE_MS: 30 * 60 * 1000,
TIMEOUT_POLL_BLOCKS: 5,

One attempt by default, with no backoff — a retry, if enabled, goes to the tail of its lane and waits for the next eligible block. The one special case is a CosmJS TimeoutError carrying a txId, where the client polls getTx for five blocks before concluding failure, since a timeout is not the same as a rejection.


End to end: building a struct

DeployOffcanvas                 user releases on a deployable struct
  → SigningClientManager.queueMsgStructBuildInitiate(playerId, typeId, ambit, slot, cost)
      maps the ambit name to its enum index
      → SigningQueueManager.enqueueAction('/structs.structs.MsgStructBuildInitiate', …)
          → SigningTransaction.create → append to action lane → persist
  ← UI updates optimistically right away (offcanvas closes, pending build appears)

BlockListener (GRASS)           every block
  → gameState.setCurrentBlockHeight → BLOCK_HEIGHT_CHANGED
      → SigningQueueManager.transactOnBlock
          charge gate passes → toCosmosMsg → signAndBroadcast(address, [msg], FEE)
          on code 0 → advance scheduleAnchorHeight → settle → persist

StructListener (GRASS)          chain emits struct_block_build_start
  → refreshStruct, and spawn the BUILD proof-of-work task
StructListener                  chain emits struct_status = BUILT
  → clear the pending build, render the struct

Note where the UI updates: immediately on enqueue, not on confirmation. That is a deliberate responsiveness choice, and it is only safe because GRASS will correct the display when the real event arrives.


Error handling, and its gap

whenSettled(id) resolves — it never rejects — with the transaction object. Callers must check status themselves.

Failure Handling Reaches the user?
Unknown type URL failure handler no, console.warn
signAndBroadcast throws failure handler (timeout polled first) no
Broadcast code !== 0 treated as failure no
Signer address mismatch held, restored to lane head no, console.warn
Retries exhausted status DROPPED, settled no
#handleBroadcastFailure(tx, err) {
  console.warn('Sign and Broadcast Error:', err);
  tx.attempts++;
  tx.error = String(err);
  if (this.canRetry(tx)) {
    this.appendToLane(tx);
  } else {
    tx.status = TX_STATUS.DROPPED;
    this.settle(tx);
    this.persist();
  }
}

A SIGNING_TRANSACTION_SETTLED event is dispatched on every terminal state — and nothing in the codebase listens to it. Most call sites are fire-and-forget: the deploy flow calls .then() with no handler and never inspects the status. So a dropped build transaction leaves an optimistic pending-build in the UI with no error shown.

The auth flow is the one place it is done properly:

if (registerTx.status === TX_STATUS.SUCCEEDED) { … }

Do it the auth flow’s way. The queue already gives you the event and the status; the only missing piece is a listener that turns a DROPPED transaction into something the operator can see. That is a handful of lines, and it is the difference between an agent that knows its action failed and one that reports success it never achieved — see AGENTS.md rule 3, verify after acting.


Verified against structs-webapp 08e13e2 (2026-09-30): src/js/managers/WalletManager.js, SigningClientManager.js, SigningQueueManager.js, src/js/models/SigningTransaction.js.

Desktop integration verified against structs-desktop 3f04437d5606d6736df41301bb980893129a7912: mcp/native_signer.rs, vplayer_bridge.rs, tx_gate.rs, tx_retry.rs, and tools/system.rs.