|
| 1 | +--- |
| 2 | +title: Gasless Voucher Server |
| 3 | +--- |
| 4 | + |
| 5 | +## About |
| 6 | + |
| 7 | +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. |
| 8 | + |
| 9 | +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. |
| 10 | + |
| 11 | +## Why the pattern matters |
| 12 | + |
| 13 | +Gasless UX is not a single call. A real application needs to manage the full voucher lifecycle: |
| 14 | + |
| 15 | +- issue a voucher for a user, |
| 16 | +- avoid issuing duplicates unnecessarily, |
| 17 | +- inspect whether a voucher is active, |
| 18 | +- prolong it when more balance or time is needed, |
| 19 | +- revoke it when the session or entitlement should end. |
| 20 | + |
| 21 | +That is why the example is a server pattern rather than a one-off helper. |
| 22 | + |
| 23 | +## High-level flow |
| 24 | + |
| 25 | +There are two layers in the implementation: |
| 26 | + |
| 27 | +1. the HTTP server in `src/index.ts`, |
| 28 | +2. the chain-facing `GaslessService` in `src/lib.ts`. |
| 29 | + |
| 30 | +The flow for voucher issuance looks like this: |
| 31 | + |
| 32 | +1. frontend calls `POST /gasless/voucher/request`, |
| 33 | +2. Express validates the request body, |
| 34 | +3. the route calls `gaslessService.issueIfNeeded(...)`, |
| 35 | +4. `GaslessService` checks whether an active voucher already exists, |
| 36 | +5. if none exists, it builds `api.voucher.issue(...)`, |
| 37 | +6. the backend signer submits the extrinsic, |
| 38 | +7. the service waits for `VoucherIssued`, |
| 39 | +8. the route returns `{ voucherId }` to the client. |
| 40 | + |
| 41 | +The HTTP route in `src/index.ts` stays intentionally small: |
| 42 | + |
| 43 | +```ts |
| 44 | +app.post("/gasless/voucher/request", async (req, res) => { |
| 45 | + const { account, amount = 20_000_000_000_000, durationInSec = 3_600 } = req.body; |
| 46 | + |
| 47 | + if (!account) { |
| 48 | + return res.status(400).json({ error: "account is required" }); |
| 49 | + } |
| 50 | + |
| 51 | + const voucherId: HexString = await gaslessService.issueIfNeeded( |
| 52 | + account, |
| 53 | + programId, |
| 54 | + amount, |
| 55 | + Number(durationInSec) |
| 56 | + ); |
| 57 | + |
| 58 | + return res.status(200).json({ voucherId }); |
| 59 | +}); |
| 60 | +``` |
| 61 | + |
| 62 | +That is a good backend sign: the route owns input validation and HTTP response shape, while the service owns every chain-specific concern. |
| 63 | + |
| 64 | +## Why `issueIfNeeded(...)` matters |
| 65 | + |
| 66 | +The route uses an idempotent entrypoint: |
| 67 | + |
| 68 | +```ts |
| 69 | +const voucherId: HexString = await gaslessService.issueIfNeeded( |
| 70 | + account, |
| 71 | + programId, |
| 72 | + amount, |
| 73 | + Number(durationInSec) |
| 74 | +); |
| 75 | +``` |
| 76 | + |
| 77 | +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. |
| 78 | + |
| 79 | +The implementation protects against duplication in two different ways: |
| 80 | + |
| 81 | +1. `inFlightVoucherIssues` prevents the same `(account, programId)` request from being started twice concurrently, |
| 82 | +2. `api.voucher.getAllForAccount(account)` checks whether an acceptable voucher already exists on-chain. |
| 83 | + |
| 84 | +That is exactly the kind of backend logic that is easy to miss if the article only describes the endpoint shape. |
| 85 | + |
| 86 | +## Service-level architecture |
| 87 | + |
| 88 | +`GaslessService` owns: |
| 89 | + |
| 90 | +- the Gear API connection, |
| 91 | +- the voucher issuer account, |
| 92 | +- an in-memory submission queue, |
| 93 | +- in-flight deduplication for voucher creation, |
| 94 | +- helper methods for issue, status, prolong, revoke, and lookup. |
| 95 | + |
| 96 | +That separation keeps HTTP concerns out of the chain-integration layer. |
| 97 | + |
| 98 | +The service constructor makes that boundary explicit: |
| 99 | + |
| 100 | +```ts |
| 101 | +constructor() { |
| 102 | + this.api = new GearApi({ providerAddress: process.env.NODE_URL }); |
| 103 | + this.voucherAccount = this.getVoucherAccount(); |
| 104 | +} |
| 105 | +``` |
| 106 | + |
| 107 | +The route layer never deals with API construction or signer loading directly. |
| 108 | + |
| 109 | +## Deduplication and queueing |
| 110 | + |
| 111 | +Two details in `GaslessService` are especially important for a production-oriented design. |
| 112 | + |
| 113 | +### In-flight deduplication |
| 114 | + |
| 115 | +The service stores in-flight voucher requests in: |
| 116 | + |
| 117 | +```ts |
| 118 | +private readonly inFlightVoucherIssues = new Map<VoucherIssueKey, Promise<HexString>>(); |
| 119 | +``` |
| 120 | + |
| 121 | +This prevents duplicate concurrent issuance attempts for the same `(account, programId)` pair. |
| 122 | + |
| 123 | +### Submission queue |
| 124 | + |
| 125 | +The service also serializes issuer operations through: |
| 126 | + |
| 127 | +```ts |
| 128 | +private submissionQueue: Promise<void> = Promise.resolve(); |
| 129 | +``` |
| 130 | + |
| 131 | +That matters because nonce handling on a single signer account becomes fragile under concurrency. |
| 132 | + |
| 133 | +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. |
| 134 | + |
| 135 | +## Core issuance flow |
| 136 | + |
| 137 | +The chain-side issuance happens through `issueInternal(...)`: |
| 138 | + |
| 139 | +1. wait for API and crypto readiness, |
| 140 | +2. convert duration from seconds to blocks, |
| 141 | +3. normalize the spender account, |
| 142 | +4. build `api.voucher.issue(...)`, |
| 143 | +5. fetch the next nonce, |
| 144 | +6. sign and send with the issuer account, |
| 145 | +7. wait for `VoucherIssued`, |
| 146 | +8. cache voucher metadata for later reads. |
| 147 | + |
| 148 | +The core fragment is: |
| 149 | + |
| 150 | +```ts |
| 151 | +const { extrinsic } = await this.api.voucher.issue( |
| 152 | + accountId, |
| 153 | + amount, |
| 154 | + durationInBlocks, |
| 155 | + [programId], |
| 156 | + false |
| 157 | +); |
| 158 | +``` |
| 159 | + |
| 160 | +Then the service resolves only when the block contains `VoucherIssued`. |
| 161 | + |
| 162 | +The event-driven promise is the most important code block in the file: |
| 163 | + |
| 164 | +```ts |
| 165 | +const voucherId = await new Promise<HexString>((resolve, reject) => { |
| 166 | + extrinsic.signAndSend( |
| 167 | + this.voucherAccount, |
| 168 | + { nonce }, |
| 169 | + ({ events, status }) => { |
| 170 | + if (!status.isInBlock) return; |
| 171 | + |
| 172 | + const viEvent = events.find( |
| 173 | + ({ event }) => event.method === "VoucherIssued" |
| 174 | + ); |
| 175 | + |
| 176 | + if (viEvent) { |
| 177 | + const data = viEvent.event.data as any; |
| 178 | + const id = data.voucherId.toHex() as HexString; |
| 179 | + voucherInfoStorage[id] = { durationInSec, amount }; |
| 180 | + resolve(id); |
| 181 | + return; |
| 182 | + } |
| 183 | + |
| 184 | + const efEvent = events.find( |
| 185 | + ({ event }) => event.method === "ExtrinsicFailed" |
| 186 | + ); |
| 187 | + reject( |
| 188 | + efEvent |
| 189 | + ? this.api.getExtrinsicFailedError(efEvent.event) |
| 190 | + : new Error("VoucherIssued event not found in block") |
| 191 | + ); |
| 192 | + } |
| 193 | + ); |
| 194 | +}); |
| 195 | +``` |
| 196 | + |
| 197 | +That is what lets the backend return a concrete `voucherId` only after it has real evidence of success. |
| 198 | + |
| 199 | +## Why event-based resolution is used |
| 200 | + |
| 201 | +The service does not assume that submitting the extrinsic is enough. It waits for chain events so that it can: |
| 202 | + |
| 203 | +- extract the actual `voucherId`, |
| 204 | +- distinguish success from `ExtrinsicFailed`, |
| 205 | +- return a concrete result to the frontend. |
| 206 | + |
| 207 | +That mirrors the same philosophy as the stronger contract/frontend patterns: real success should be tied to the relevant event boundary. |
| 208 | + |
| 209 | +## Status and normalization flow |
| 210 | + |
| 211 | +The status route contains one more small but useful backend detail: |
| 212 | + |
| 213 | +```ts |
| 214 | +const normalizedId = voucherId.startsWith("0x") |
| 215 | + ? (voucherId as `0x${string}`) |
| 216 | + : (`0x${voucherId}` as `0x${string}`); |
| 217 | +``` |
| 218 | + |
| 219 | +This makes the HTTP interface slightly more forgiving while keeping the service layer strict about identifier format. |
| 220 | + |
| 221 | +## Status, prolong, and revoke flows |
| 222 | + |
| 223 | +The server also exposes: |
| 224 | + |
| 225 | +- `GET /gasless/voucher/:voucherId/status` |
| 226 | +- `POST /prolong` |
| 227 | +- `POST /revoke` |
| 228 | + |
| 229 | +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. |
| 230 | + |
| 231 | +Even though those routes are short, they expose non-trivial service behavior: |
| 232 | + |
| 233 | +- sequenced signer operations, |
| 234 | +- voucher lifecycle mutation, |
| 235 | +- error mapping from chain failures into HTTP responses, |
| 236 | +- one place for nonce-sensitive issuer activity. |
| 237 | + |
| 238 | +## Production guidance |
| 239 | + |
| 240 | +- Make issuance idempotent from the server side, not only from the frontend side. |
| 241 | +- Serialize submissions per issuer account or use a dedicated nonce manager. |
| 242 | +- Scope vouchers tightly to the intended program. |
| 243 | +- Keep issuer keys only on the backend. |
| 244 | +- Treat event-based confirmation as the real success boundary for issuance. |
| 245 | +- Keep route handlers tiny and push event handling, nonce sensitivity, and voucher lifecycle rules into the service layer. |
| 246 | + |
| 247 | +## Source code |
| 248 | + |
| 249 | +- [`infrastructure/gasless-server`](https://github.com/gear-foundation/vara-dapp-patterns/tree/master/infrastructure/gasless-server) |
0 commit comments