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.
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.
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} |
// 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.
The webapp queue above is still the primary-player action path, but current Structs Desktop has two additional virtual-player modes:
sync / async use the Rust ↔ webview bridge and CosmJS.native / native_async sign in Rust and broadcast through LCD REST.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 }),
};
}
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:
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.
The client never touches sequence numbers. It avoids account sequence mismatch with
three mechanical guarantees:
inFlight mutex, so at most one broadcast is outstanding;lastBroadcastHeight governor, so at most one transaction per block per address;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.
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 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 checksisConfirmedActionLoaded()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.
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.
// 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.
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.
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.