Chapter 02 · Foundations
buildGenericRequestFromDetails and signRequest do all the work that is not tab-specific. Everything else in the app is validation and flag-setting.
FIGURE 2.1 · The six steps, with the shared ones highlighted.
src/routes/utils.ts is 251 lines and contains the entire shared protocol. If you read
only two functions in this repo, read these.
const req = new primitives.GenericRequest({
details, // ordinal-wrapped request objects
createdAt: new BN((Date.now() / 1000).toFixed(0)), // seconds, not ms
responseURIs, // built from the redirects field
flags: isTestnet ? FLAG_IS_TESTNET : BASE_FLAGS,
requestID
});
if (signed) {
req.signature = new VerifiableSignatureData({ systemID, identityID });
req.setSigned();
}
Three details carry weight. createdAt must be seconds — the library serialises it as a
uint32 and a millisecond value silently truncates. FLAG_IS_TESTNET is passed as the
initial flags value, not OR'd in later; setIsTestnet() does the OR for you.
And the signature object is created here but empty — the actual bytes are filled in by
signRequest.
| Form value | ResponseURI type | Wallet behaviour |
|---|---|---|
"1" | TYPE_REDIRECT | Redirect the user back to the URI (browser/deeplink) |
"2" | TYPE_POST | POST the signed response to the URI |
| anything else | — | Silently dropped; no URI is added |
The silent drop is worth knowing: a typo in the redirect type produces a request with no callback and no error, and the UI will happily show a QR that cannot answer.
const rawHash = request.getRawDataSha256(false); // no signature field
const sigRes = await verusId.interface.signData({
address: signingId,
datahash: rawHash.toString("hex")
});
request.signature.signatureAsVch = Buffer.from(sigRes.result.signature, "base64");
The daemon signs the hash, not the bytes — the app never sees a private key for this path. The
false argument to getRawDataSha256 is the "exclude signature" switch; passing
true here produces a signature over a message nobody receives. If signing fails, the error
message is unusually explicit about the likely cause (the signing identity is not in the wallet), and
that message is worth preserving rather than replacing with a generic 500.
| Helper | Rule worth remembering |
|---|---|
parseAddress | A string ending in @ becomes TYPE_FQN; anything else must pass fromBase58Check and becomes TYPE_I_ADDRESS |
requireString | Trimmed, non-empty, or ValidationError |
parseNumber / parseOptionalPositiveNumber | Positive and finite; the optional form floors to an integer (used for heights and expiry) |
parseJsonField | Accepts either a JSON string or an object; parse errors surface as validation errors |
ValidationError maps to HTTP 400; anything else is a 500 and gets logged. That split is the
reason the UI can show useful messages without leaking stack traces.
RPC_PORT === 18843 → VRSCTEST system id iJhCezBExJHvtyH3fGhNnt2NhU4Ztkf2yq otherwise → VRSC system id i5w5MuNik5NtLcYmNzcvaoixooEebB6MGV
getRpcConfig() derives isTestnet from the port, and the signing path repeats
that comparison rather than passing a flag. It is consistent, but it means a malformed
RPC_PORT silently lands you on mainnet semantics — check the port first when debugging.
src/routes/utils.ts ├── parseAddress / requireString / parseNumber / parseJsonField ├── parseRedirectsField / buildResponseUris ├── buildGenericRequestFromDetails ├── signRequest └── getRpcConfig / getServiceSignerConfig