Gasless Voucher Server
About
The gasless voucher server is the off-chain service that makes voucher-based UX practical. It holds the issuer account, submits voucher extrinsics, exposes a REST API for frontend clients, and manages the lifecycle of vouchers tied to a specific target program.
Without this backend layer, a frontend can know that gasless UX is desirable, but it has no trusted place to keep the issuer key or coordinate voucher issuance safely.
Why the pattern matters
Gasless UX is not a single call. A real application needs to manage the full voucher lifecycle:
- issue a voucher for a user,
- avoid issuing duplicates unnecessarily,
- inspect whether a voucher is active,
- prolong it when more balance or time is needed,
- revoke it when the session or entitlement should end.
That is why the example is a server pattern rather than a one-off helper.
High-level flow
There are two layers in the implementation:
- the HTTP server in
src/index.ts, - the chain-facing
GaslessServiceinsrc/lib.ts.
The flow for voucher issuance looks like this:
- frontend calls
POST /gasless/voucher/request, - Express validates the request body,
- the route calls
gaslessService.issueIfNeeded(...), GaslessServicechecks whether an active voucher already exists,- if none exists, it builds
api.voucher.issue(...), - the backend signer submits the extrinsic,
- the service waits for
VoucherIssued, - the route returns
{ voucherId }to the client.
The HTTP route in src/index.ts stays intentionally small:
app.post("/gasless/voucher/request", async (req, res) => {
const { account, amount = 20_000_000_000_000, durationInSec = 3_600 } = req.body;
if (!account) {
return res.status(400).json({ error: "account is required" });
}
const voucherId: HexString = await gaslessService.issueIfNeeded(
account,
programId,
amount,
Number(durationInSec)
);
return res.status(200).json({ voucherId });
});That is a good backend sign: the route owns input validation and HTTP response shape, while the service owns every chain-specific concern.
Why issueIfNeeded(...) matters
The route uses an idempotent entrypoint:
const voucherId: HexString = await gaslessService.issueIfNeeded(
account,
programId,
amount,
Number(durationInSec)
);This is more than a convenience. Frontends often request gasless support on mount, reconnect, or page refresh. Without an idempotent server-side path, those UX patterns could burn issuer funds by creating duplicate vouchers.
The implementation protects against duplication in two different ways:
inFlightVoucherIssuesprevents the same(account, programId)request from being started twice concurrently,api.voucher.getAllForAccount(account)checks whether an acceptable voucher already exists on-chain.
That is exactly the kind of backend logic that is easy to miss if the article only describes the endpoint shape.
Service-level architecture
GaslessService owns:
- the Gear API connection,
- the voucher issuer account,
- an in-memory submission queue,
- in-flight deduplication for voucher creation,
- helper methods for issue, status, prolong, revoke, and lookup.
That separation keeps HTTP concerns out of the chain-integration layer.
The service constructor makes that boundary explicit:
constructor() {
this.api = new GearApi({ providerAddress: process.env.NODE_URL });
this.voucherAccount = this.getVoucherAccount();
}The route layer never deals with API construction or signer loading directly.
Deduplication and queueing
Two details in GaslessService are especially important for a production-oriented design.
In-flight deduplication
The service stores in-flight voucher requests in:
private readonly inFlightVoucherIssues = new Map<VoucherIssueKey, Promise<HexString>>();This prevents duplicate concurrent issuance attempts for the same (account, programId) pair.
Submission queue
The service also serializes issuer operations through:
private submissionQueue: Promise<void> = Promise.resolve();That matters because nonce handling on a single signer account becomes fragile under concurrency.
These two fields are some of the most important backend-specific code in the example. Without them, the service would appear correct in light testing and then become unstable under concurrent usage.
Core issuance flow
The chain-side issuance happens through issueInternal(...):
- wait for API and crypto readiness,
- convert duration from seconds to blocks,
- normalize the spender account,
- build
api.voucher.issue(...), - fetch the next nonce,
- sign and send with the issuer account,
- wait for
VoucherIssued, - cache voucher metadata for later reads.
The core fragment is:
const { extrinsic } = await this.api.voucher.issue(
accountId,
amount,
durationInBlocks,
[programId],
false
);Then the service resolves only when the block contains VoucherIssued.
The event-driven promise is the most important code block in the file:
const voucherId = await new Promise<HexString>((resolve, reject) => {
extrinsic.signAndSend(
this.voucherAccount,
{ nonce },
({ events, status }) => {
if (!status.isInBlock) return;
const viEvent = events.find(
({ event }) => event.method === "VoucherIssued"
);
if (viEvent) {
const data = viEvent.event.data as any;
const id = data.voucherId.toHex() as HexString;
voucherInfoStorage[id] = { durationInSec, amount };
resolve(id);
return;
}
const efEvent = events.find(
({ event }) => event.method === "ExtrinsicFailed"
);
reject(
efEvent
? this.api.getExtrinsicFailedError(efEvent.event)
: new Error("VoucherIssued event not found in block")
);
}
);
});That is what lets the backend return a concrete voucherId only after it has real evidence of success.
Why event-based resolution is used
The service does not assume that submitting the extrinsic is enough. It waits for chain events so that it can:
- extract the actual
voucherId, - distinguish success from
ExtrinsicFailed, - return a concrete result to the frontend.
That mirrors the same philosophy as the stronger contract/frontend patterns: real success should be tied to the relevant event boundary.
Status and normalization flow
The status route contains one more small but useful backend detail:
const normalizedId = voucherId.startsWith("0x")
? (voucherId as `0x${string}`)
: (`0x${voucherId}` as `0x${string}`);This makes the HTTP interface slightly more forgiving while keeping the service layer strict about identifier format.
Status, prolong, and revoke flows
The server also exposes:
GET /gasless/voucher/:voucherId/statusPOST /prolongPOST /revoke
These routes are important because gasless UX is rarely just “issue once and forget.” Long-lived sessions and repeated interactions often require voucher inspection or renewal.
Even though those routes are short, they expose non-trivial service behavior:
- sequenced signer operations,
- voucher lifecycle mutation,
- error mapping from chain failures into HTTP responses,
- one place for nonce-sensitive issuer activity.
Production guidance
- Make issuance idempotent from the server side, not only from the frontend side.
- Serialize submissions per issuer account or use a dedicated nonce manager.
- Scope vouchers tightly to the intended program.
- Keep issuer keys only on the backend.
- Treat event-based confirmation as the real success boundary for issuance.
- Keep route handlers tiny and push event handling, nonce sensitivity, and voucher lifecycle rules into the service layer.