Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.xandrlabs.ai/llms.txt

Use this file to discover all available pages before exploring further.

ALX Protocol uses EIP-712 typed data signing for protocol requests. EIP-712 produces human-readable, structured signatures that bind a request to a specific chain, contract, and agent address. This prevents replay attacks across chains and contracts, and allows any party to recover the signer address and verify authenticity without a centralized authority.

The SignedProtocolRequest shape

A signed request carries the query payload together with authentication metadata:
FieldTypeDescription
kbIdbytes32The KB identity hash being queried (0x-prefixed 32-byte hex)
querystringThe query string
agentaddressThe Ethereum address of the signing agent
nonceuint256A per-agent unique value to prevent replay
expiryuint64Unix timestamp in seconds after which the request is invalid
chainIduint256Chain ID the request is valid for (use 8453 for Base mainnet)
expiry is a Unix timestamp in seconds, not milliseconds. Use Math.floor(Date.now() / 1000) + ttlSeconds to compute it.

Domain configuration

The EIP-712 domain binds signatures to a specific deployment. For the ALX Protocol reference deployment on Base mainnet:
import {
  buildSignedProtocolRequestDomain,
} from "@alx/protocol";

const domain = buildSignedProtocolRequestDomain({
  chainId: 8453,
  verifyingContract: "0xD1F216E872a9ed4b90E364825869c2F377155B29",
});
// Returns:
// {
//   name: "AlexandrianProtocol",
//   version: "1",
//   chainId: "8453",
//   verifyingContract: "0xD1F2...B29"
// }
Always pass verifyingContract explicitly. The zero-address default (0x000...000) is safe only for off-chain verification. Using it on-chain allows signatures from one context to be replayed against any other contract that also accepts the zero-address domain.

Steps

1

Build the domain and request

Construct the EIP-712 domain and the request object you want to sign. Choose a nonce that has not been used before for this agent, and set an appropriate expiry window.
import {
  buildSignedProtocolRequestDomain,
  SIGNED_PROTOCOL_REQUEST_TYPES,
  type SignedProtocolRequest,
} from "@alx/protocol";

const domain = buildSignedProtocolRequestDomain({
  chainId: 8453,
  verifyingContract: "0xD1F216E872a9ed4b90E364825869c2F377155B29",
});

const request: SignedProtocolRequest = {
  kbId: "0xc3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2",
  query: "What is the JWT rotation policy?",
  agent: wallet.address,
  nonce: Date.now(),                                // unique per agent
  expiry: Math.floor(Date.now() / 1000) + 300,     // valid for 5 minutes
  chainId: 8453,
};
2

Sign with wallet.signTypedData

Pass the domain, types, and request to wallet.signTypedData. The SIGNED_PROTOCOL_REQUEST_TYPES constant from @alx/protocol provides the correct EIP-712 type definition.
import { ethers } from "ethers";
import { SIGNED_PROTOCOL_REQUEST_TYPES } from "@alx/protocol";

const provider = new ethers.JsonRpcProvider("https://mainnet.base.org");
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);

const signature = await wallet.signTypedData(
  domain,
  SIGNED_PROTOCOL_REQUEST_TYPES,
  request,
);
// "0x..." — 65-byte ECDSA signature
3

Transmit the signed request

Send domain, request, and signature to the receiving service. The receiver uses these three values to verify the request.
const payload = { domain, request, signature };
// Transmit to your service endpoint
4

Verify on the receiving side

Call verifySignedProtocolRequest with the domain, request, signature, current time, expected chain ID, and a NonceTracker instance. The function throws a SignedRequestValidationError on any failure and returns { ok: true, signer, request } on success.
import {
  verifySignedProtocolRequest,
  NonceTracker,
  SignedRequestValidationError,
} from "@alx/protocol";

// Create one NonceTracker per service instance; keep it in memory (or back it
// with a persistent store) for the lifetime of nonce retention.
const nonceTracker = new NonceTracker();

try {
  const { signer, request: normalizedRequest } = verifySignedProtocolRequest({
    domain,
    request,
    signature,
    now: Math.floor(Date.now() / 1000),  // current Unix timestamp in seconds
    expectedChainId: 8453,
    nonceTracker,
  });

  console.log("Verified signer:", signer);
  // signer === normalizedRequest.agent (checksummed address)
} catch (err) {
  if (err instanceof SignedRequestValidationError) {
    console.error("Validation failed:", err.code, err.message);
  }
  throw err;
}

Validation error codes

verifySignedProtocolRequest throws SignedRequestValidationError with one of the following code values when verification fails:
CodeCauseResolution
MALFORMED_REQUESTA required field is missing, has the wrong type, or kbId is not a valid 32-byte hex stringInspect the request object for type errors
INVALID_SIGNATUREThe signature cannot be decoded or does not produce a valid addressCheck that signTypedData used the same domain and types
SIGNER_MISMATCHThe address recovered from the signature does not match request.agentEnsure the signing wallet matches the agent field
EXPIRED_REQUESTnow >= request.expiryUse a longer expiry window or resend the request
CHAIN_MISMATCHrequest.chainId does not match expectedChainIdConfirm the request was built for the correct chain
NONCE_REUSEDThe (agent, nonce) pair has already been consumed by the NonceTrackerGenerate a new nonce for each request

NonceTracker and replay protection

NonceTracker tracks consumed (agent, nonce) pairs in memory. Call nonceTracker.consume(agent, nonce) during verification — it throws NONCE_REUSED if the pair has already been seen, and records it otherwise.
import { NonceTracker } from "@alx/protocol";

const nonceTracker = new NonceTracker();

// Check without consuming (e.g. for pre-validation)
const alreadyUsed = nonceTracker.has(agentAddress, nonce);

// Consume during verification (verifySignedProtocolRequest does this automatically
// when you pass nonceTracker)
nonceTracker.consume(agentAddress, nonce);
The nonce retention window should cover the maximum accepted request lifetime plus expected network delay. If you accept requests with a 5-minute expiry, keep nonces for at least 5–10 minutes. For a persistent deployment, back the tracker with Redis or a database so nonces survive process restarts.

Complete example

import { ethers } from "ethers";
import {
  buildSignedProtocolRequestDomain,
  SIGNED_PROTOCOL_REQUEST_TYPES,
  type SignedProtocolRequest,
} from "@alx/protocol";

const CONTRACT_ADDRESS = "0xD1F216E872a9ed4b90E364825869c2F377155B29";

async function buildSignedRequest(
  wallet: ethers.Wallet,
  kbId: string,
  query: string,
) {
  const domain = buildSignedProtocolRequestDomain({
    chainId: 8453,
    verifyingContract: CONTRACT_ADDRESS,
  });

  const request: SignedProtocolRequest = {
    kbId,
    query,
    agent: wallet.address,
    nonce: Date.now(),
    expiry: Math.floor(Date.now() / 1000) + 300, // 5-minute window
    chainId: 8453,
  };

  const signature = await wallet.signTypedData(
    domain,
    SIGNED_PROTOCOL_REQUEST_TYPES,
    request,
  );

  return { domain, request, signature };
}