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.
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.
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 6eec7f7 (2026-07-21): src/js/managers/WalletManager.js,
SigningClientManager.js, SigningQueueManager.js, src/js/models/SigningTransaction.js.