QR-initiated MiniApps
This technical guide provides a generic implementation pattern for QR-initiated Circles MiniApps. It focuses on the core technical building blocks required to make these flows reliable and production-safe.
If you are not a developer, you can skip the full technical guide and use this Agents.md file to get started instead.
AGENTS.md
QR-Initiated Circles MiniApps
This document defines how an agentic or application layer should implement QR-initiated Circles MiniApps safely and deterministically.
It is intended for developers building flows where a user scans a QR code, completes a CRC transfer in Gnosis App, and the application reacts only after the payment has been correctly identified, validated, and finalized.
⸻
Purpose
A QR-initiated Circles MiniApp is a flow where: 1. Your app creates a short-lived payment intent. 2. That intent is encoded into Circles transfer data. 3. A Gnosis App deep link is generated and rendered as a QR code. 4. The user scans the QR code and completes the transfer. 5. Your backend watches Circles transfer-data events. 6. The app decodes the event payload and matches it to the expected intent. 7. The app verifies the actual transferred amount from the transaction receipt. 8. The app runs business logic exactly once.
This pattern is useful for: • kiosk or booth payments • store checkout flows • game payouts or claims • event ticketing or access control • merchant refunds • lightweight payment-triggered app actions
The critical goal is to make payment detection deterministic, safe, and idempotent.
⸻
Core Principles
Any implementation based on this flow should follow these rules:
2.1 Never rely on amount alone
Amounts are not unique identifiers. Multiple users may send the same amount, and the same user may repeat a payment. Always match on transfer data that contains a unique reference tied to your intent.
2.2 Treat transfer data as the source of intent identity
The encoded transfer data should contain a unique application-level reference that your system generated before rendering the QR code.
2.3 Verify the real transfer from the receipt
Event matching identifies a likely candidate transaction. Final confirmation must verify the amount and success using the transaction receipt and relevant transfer logs.
2.4 Make every business action idempotent
A confirmed payment may be seen more than once due to retries, polling overlap, restarts, or chain indexing behavior. Business logic must only execute once per intent.
2.5 Keep intents short-lived
QR codes should not remain valid forever. Every intent should expire and be rejected if a matching payment arrives too late.
⸻
High-Level Flow
Step 1: Create a short-lived intent
Before generating the QR code, create an intent object containing at least: • intentId • reference • amount • recipient • expiresAt
Suggested shape:
export type PaymentIntent = { intentId: string; reference: string; amount: string; recipient: 0x${string}; expiresAt: string; status: "pending" | "confirmed" | "expired" | "failed"; actionStatus: "pending" | "done"; txHash?: 0x${string}; createdAt: string; };
Step 2: Encode the reference into transfer data
Use the Circles SDK utility encoder rather than implementing custom byte serialization.
Step 3: Build the Gnosis App deep link
Construct the transfer URL using the recipient, amount, and encoded data.
Step 4: Render the QR code
Display the deep link as a QR code in your kiosk, web page, shared device flow, or point-of-sale screen.
Step 5: Query transfer-data events
Your backend should poll or watch circles_events for CrcV2_TransferData events scoped to the recipient address.
Step 6: Decode and match event data
Decode each event payload and extract the reference from the transfer data.
Step 7: Verify the amount from receipt logs
Once a reference match is found, load the transaction receipt and verify the actual transferred amount.
Step 8: Run business action exactly once
Use an idempotent lock so the final action cannot be executed multiple times.
⸻
Intent Creation
The intent is the backbone of the flow.
A strong intent should: • be created server-side • have a globally unique intentId • have a unique reference • have a clear recipient address • have the exact expected CRC amount • have a short expiration time • track both payment confirmation and business-action execution state
Example intent creation
import { randomUUID } from "crypto";
export function createIntent(input: { amount: string; recipient: 0x${string}; ttlMs?: number; }) { const intentId = randomUUID(); const reference = CRC-${intentId.slice(0, 8).toUpperCase()}; const expiresAt = new Date(Date.now() + (input.ttlMs ?? 5 * 60 * 1000)).toISOString();
return { intentId, reference, amount: input.amount, recipient: input.recipient, expiresAt, status: "pending" as const, actionStatus: "pending" as const, createdAt: new Date().toISOString(), }; }
Recommendations • Keep TTL short, typically 2 to 10 minutes. • Use a dedicated reference rather than exposing raw database IDs. • Do not reuse references. • Store a canonical lowercase recipient address for comparison.
⸻
Gnosis App Deep Link Format
For CRC transfer flows, the URL format is:
https://app.gnosis.io/transfer/{recipientAddress}/crc?amount={amountCrc}&data={urlEncodedTransferData}
Parameters • recipientAddress: the expected receiving address • amount: human-readable CRC amount string • data: URL-encoded transfer-data payload used for deterministic matching
Example
https://app.gnosis.io/transfer/0xabc...123/crc?amount=12.5&data=0x010001000b4352432d414243313233
This deep link opens Gnosis App with a prefilled CRC transfer that includes your application-defined transfer data.
⸻
Use the Circles SDK Transfer Data Encoder
Use the SDK utilities instead of manually encoding bytes.
import { encodeCrcV2TransferData, decodeCrcV2TransferData, } from "@aboutcircles/sdk-utils";
Encoded structure
The transfer-data format contains: • version (1 byte) • type (2 bytes) • length (2 bytes) • payload (N bytes)
This matters because the SDK gives you: • consistent encoding • consistent decoding • validation of malformed payloads • safer parsing for version and length mismatches
Do not reinvent this format unless you fully control both sides and accept decoding risk.
⸻
Choosing the Right Transfer Data Type
Two useful types are:
0x0001 — UTF-8 text payload
Use this when you only need a simple reference string.
Best for: • one-off checkout references • claim IDs • deterministic intent references • the simplest possible matching logic
Example:
const encoded = encodeCrcV2TransferData(["CRC-7F3A912B"], 0x0001);
0x1001 — UTF-8 message + metadata payload
Use this when you want a user-readable message and structured metadata.
Best for: • richer checkout descriptions • merchant display messages • combining human-readable context with machine-readable reference metadata
Example:
const encoded = encodeCrcV2TransferData( ["Payment for order", "ref=CRC-7F3A912B"], 0x1001, );
Recommendation • Use 0x0001 if all you need is a single unique reference. • Use 0x1001 if you want message + metadata while still preserving deterministic matching.
For most agent flows, 0x0001 is the safest default because the extraction logic is simpler.
⸻
Building the QR URL
Minimal builder
import { encodeCrcV2TransferData } from "@aboutcircles/sdk-utils";
export function buildQrUrl(input: { recipientAddress: string; amountCrc: string; reference: string; }) { const encodedData = encodeCrcV2TransferData([input.reference], 0x0001); const dataParam = encodeURIComponent(encodedData);
return https://app.gnosis.io/transfer/${input.recipientAddress}/crc?amount=${input.amountCrc}&data=${dataParam}; }
Richer context builder
import { encodeCrcV2TransferData } from "@aboutcircles/sdk-utils";
export function buildRichQrUrl(input: { recipientAddress: string; amountCrc: string; intentId: string; reference: string; }) { const encodedData = encodeCrcV2TransferData( ["Booth payment", ref=${input.reference};intent=${input.intentId}], 0x1001, );
return https://app.gnosis.io/transfer/${input.recipientAddress}/crc?amount=${input.amountCrc}&data=${encodeURIComponent(encodedData)}; }
Implementation notes • Always URL-encode the data field. • Keep the amount canonical and display-safe. • Do not embed sensitive server-side secrets in transfer data. • Prefer compact metadata because users may encounter the data inside wallets or logs.
⸻
Rendering the QR Code
QR rendering is straightforward, but a few UX details matter.
Recommended UX
Display: • the QR code • recipient label or merchant name • amount to pay • expiration timer • fallback deep link button for mobile users • a status state such as Waiting for payment...
Recommended states • pending: waiting for payment • detected: reference match found, verifying receipt • confirmed: payment confirmed • expired: intent expired • failed: payment invalid or verification failed
Operational advice • Regenerate the QR when the intent expires. • Keep the intent identifier server-side; only expose what is needed. • Consider rotating intents automatically in kiosk environments.
⸻
Detecting Transactions with circles_events
Your backend should query transfer-data events scoped to the expected recipient.
RPC request
{ "jsonrpc": "2.0", "id": 1, "method": "circles_events", "params": [ "", null, null, ["CrcV2_TransferData"] ] }
Detection strategy 1. Query circles_events. 2. Only consider events that belong to the expected recipient. 3. Decode each event’s transfer data. 4. Extract the embedded reference. 5. Compare it to the expected reference for the intent. 6. If the reference matches, fetch the transaction receipt. 7. Verify transferred amount and success. 8. Mark the intent as confirmed.
Important rule
Do not finalize on event detection alone.
The event tells you that matching transfer data likely exists. Your application still needs to verify the actual transfer amount and the successful transaction outcome from the receipt.
⸻
Decoding and Matching Transfer Data
Use the SDK decoder for all parsing.
Reference extraction helpers
import { decodeCrcV2TransferData } from "@aboutcircles/sdk-utils";
type MatchResult = { matched: boolean; reason?: string };
function extractReferenceFromDecoded(payload: unknown, type: number): string | null { if (type === 0x0001 && typeof payload === "string") { return payload.trim(); }
if ( type === 0x1001 && payload && typeof payload === "object" && "metadata" in payload && typeof (payload as { metadata: string }).metadata === "string" ) { const metadata = (payload as { metadata: string }).metadata; const m = metadata.match(/(?:^|[;\s])ref=([^;\s]+)/i); return m?.[1] ?? null; }
return null; }
export function dataMatchesReference(eventData: string, expectedReference: string): MatchResult { try { const decoded = decodeCrcV2TransferData(eventData); const found = extractReferenceFromDecoded(decoded.payload, decoded.type);
if (!found) {
return { matched: false, reason: "reference not found in decoded payload" };
}
return {
matched: found.toLowerCase() === expectedReference.trim().toLowerCase(),
reason: found,
};} catch { return { matched: false, reason: "decode failed" }; } }
Why this matters
This gives you a deterministic match based on your app-generated reference rather than on heuristic matching such as amount-only or sender-only logic.
Matching rules
When evaluating a candidate event: • recipient must match the intent recipient • intent must not be expired • decoded payload must contain a reference • extracted reference must equal the expected reference • receipt must confirm success • receipt logs must confirm the correct transferred amount
⸻
Amount Verification from Receipt Logs
A matching reference is necessary but not sufficient.
After a reference match, load the transaction receipt and compute the actual transferred amount.
Why receipt verification is required
You need to confirm: • the transaction succeeded • the intended transfer actually happened • the amount is correct • the funds moved from the relevant sender path to the expected recipient
Verification approach
For Circles flows: • fetch the transaction receipt by transactionHash • parse the relevant transfer logs • sum all relevant transfer amounts for matching from -> to paths that correspond to your expected recipient • compare the total to the amount expected by the intent
Important rules • Never trust UI inputs alone. • Never trust a matched reference without checking the receipt. • If your flow allows path-based or multi-hop mechanics, compute the actual delivered amount to the final recipient rather than assuming a single direct transfer event is enough.
Suggested verification result
type ReceiptVerificationResult = { ok: boolean; actualAmount?: string; reason?: string; };
Failure examples
Reject confirmation if: • receipt status is failed • no relevant transfer logs exist • recipient does not match • actual amount is lower than expected • transaction was found after intent expiry and your business rules disallow late settlement
⸻
Idempotent Post-Confirmation Actions
After payment is confirmed, your application may need to perform a business action such as: • issue a refund • release digital goods • mark an order as paid • unlock a game reward • issue a ticket or access credential • trigger downstream fulfillment
This action must be idempotent.
Example
await withIntentLock(intentId, async () => { const current = await loadIntent(intentId); if (current.actionStatus === "done") return;
await performBusinessAction(current); await markActionDone(intentId); });
Why this is mandatory
Without idempotency, the same payment could trigger duplicate side effects because of: • polling overlap • webhook retries • server restarts • duplicate queue jobs • eventual consistency in your own persistence layer
Minimum requirement
Your application should have: • an intent-level lock • a persistent actionStatus • atomic state transitions where possible
⸻
Minimal End-to-End Example
import { encodeCrcV2TransferData, decodeCrcV2TransferData, } from "@aboutcircles/sdk-utils";
// 1) Build QR URL const transferData = encodeCrcV2TransferData( [CRC-${intentId.slice(0, 8).toUpperCase()}], 0x0001, );
const url = https://app.gnosis.io/transfer/${recipient}/crc?amount=${amount}&data=${encodeURIComponent(transferData)};
// 2) Query transfer-data events const rpcBody = { jsonrpc: "2.0", id: 1, method: "circles_events", params: [recipient, null, null, ["CrcV2_TransferData"]], };
// 3) Decode + match const decoded = decodeCrcV2TransferData(event.data); if (decoded.type !== 0x0001) return; if (decoded.payload !== expectedReference) return;
// 4) Verify receipt amounts, then finalize await finalizeConfirmedIntent(intentId, event.transactionHash);
This is the core flow in its simplest form.
In production, you should add: • expiration checks • lowercase address normalization • retry policy • idempotency guards • structured logging • receipt verification logic • monitoring and alerting
⸻
Recommended Agent Responsibilities
If you are implementing this as a multi-step agent or service, split responsibilities clearly.
15.1 Intent Service
Responsible for: • creating intents • generating references • storing expiration and status • preventing duplicate active intents when needed
15.2 QR Builder
Responsible for: • encoding transfer data • constructing the Gnosis App deep link • returning the QR payload to the frontend
15.3 Event Watcher
Responsible for: • polling circles_events • filtering candidate events • decoding transfer data • matching events to pending intents
15.4 Receipt Verifier
Responsible for: • loading transaction receipts • computing actual transferred amount • verifying success and recipient correctness
15.5 Finalization Worker
Responsible for: • acquiring intent lock • running business logic once • updating persistent state • handling retries safely
Keeping these concerns separate makes failures easier to reason about and prevents business logic from leaking into low-level detection code.
⸻
Suggested Persistence Model
A simple database table or collection might include:
type IntentRecord = { intentId: string; reference: string; recipient: string; amount: string; expiresAt: string; status: "pending" | "confirmed" | "expired" | "failed"; actionStatus: "pending" | "done"; txHash: string | null; matchedAt: string | null; confirmedAt: string | null; createdAt: string; updatedAt: string; };
Optional indexes: • unique index on reference • index on status • index on recipient • index on expiresAt
This helps polling workers find all active intents efficiently.
⸻
Failure Modes and How to Handle Them
Decode failure
Cause: • malformed or unexpected transfer data
Action: • ignore candidate event • log decode error for observability
Reference missing
Cause: • payload type not supported • metadata malformed
Action: • ignore event • log reason
Amount mismatch
Cause: • partial payment • wrong amount entered by user • transfer path produced unexpected result
Action: • do not finalize • mark intent for review or keep pending according to business rules
Expired intent
Cause: • user paid too late
Action: • reject or route to manual handling based on product policy
Duplicate processing attempt
Cause: • multiple workers or retries
Action: • lock by intentId • check actionStatus before executing business logic
Receipt unavailable temporarily
Cause: • indexing lag or RPC delay
Action: • retry with bounded backoff • do not mark confirmed until receipt verification succeeds
⸻
Security and Operational Guidance
Do • generate intents server-side • use unique references • keep intents short-lived • verify from receipt logs before finalizing • ensure business actions are idempotent • normalize addresses before comparing • use structured logs with intent IDs and tx hashes • monitor failed matches and decode errors
Do not • match on amount alone • trust the presence of transfer data without receipt validation • expose secrets in transfer metadata • allow unlimited lifetime QR intents • run irreversible business actions before verification completes
⸻
Example Logging Fields
Structured logs help a lot when debugging production payment flows.
Suggested fields: • intentId • reference • recipient • expectedAmount • txHash • eventType • decodedType • matchedReference • actualAmount • status • reason
Example:
logger.info({ intentId, reference, txHash, matchedReference, expectedAmount, actualAmount, status: "confirmed", }, "qr payment confirmed");
⸻
Recommended Production Sequence
A production-safe sequence looks like this: 1. Create intent in backend. 2. Persist pending intent. 3. Encode reference using encodeCrcV2TransferData. 4. Build Gnosis App transfer URL. 5. Render QR code. 6. Poll circles_events for the recipient. 7. Decode CrcV2_TransferData payloads. 8. Match extracted reference to pending intent. 9. Fetch transaction receipt. 10. Verify receipt success and transferred amount. 11. Mark intent as confirmed with txHash. 12. Acquire idempotent lock. 13. Run business action. 14. Mark actionStatus = done. 15. Return success to UI or downstream system.
⸻
When to Use 0x0001 vs 0x1001
Use 0x0001 when: • you want the simplest possible detection flow • you only need one reference string • machine matching matters more than user-facing description
Use 0x1001 when: • you want a user-readable message in the transfer data • you want metadata such as ref=...;intent=... • you need richer context for downstream parsing
In general: • start with 0x0001 • move to 0x1001 only when richer metadata adds clear product value
⸻
What Happens After Confirmation
Once the payment has been successfully decoded and verified, your application can continue with product-specific logic.
Examples: • pay out winnings in a game • process a refund in a store flow • mark a merch order as paid • unlock a claim flow • issue event access • trigger inventory release or fulfillment
The Circles payment flow should be treated as the trusted confirmation layer, and your business logic should sit cleanly behind that boundary.
⸻
Reference Implementations
You can use these repositories as practical starting points for your own app architecture: • aboutcircles/circles-gnosisApp-starter-kit • aboutcircles/merch-shop-miniapp
These are useful for understanding how to structure: • Gnosis App integration • miniapp flow design • transfer handling • real product experiences on top of Circles
⸻
Final Guidance
Using encodeCrcV2TransferData and decodeCrcV2TransferData together with circles_events and CrcV2_TransferData gives you a robust foundation for QR-initiated Circles MiniApps.
The reason this approach works well is that it separates the payment flow into clear layers: • intent creation • deterministic transfer annotation • recipient-scoped event discovery • payload decoding and reference matching • receipt-based amount verification • idempotent business-action execution
That separation makes the system easier to reason about, easier to debug, and much safer to run in real payment-triggered applications.
If you are building any QR-based Circles experience, this should be your baseline architecture.
QR initiated Circles MiniApps generally have the following flow:
Create a short-lived intent (
intentId,reference,amount,recipient,expiresAt).Encode intent reference into transfer data.
Build a Gnosis App deep link URL.
Render QR from that URL.
Query transfer-data events for the recipient.
Decode and match event
dataagainst your intent.Verify transfer amount from receipt logs.
Run your business action.
Gnosis App Deep Link Format
For CRC transfer flows:
https://app.gnosis.io/transfer/{recipientAddress}/crc?amount={amountCrc}&data={urlEncodedTransferData}recipientAddress: expected receiving addressamount: human-readable CRC amount stringdata: URL-encoded transfer-data payload used for deterministic matching
Example:
Use the Circles SDK Transfer Data Encoder
Use SDK utils instead of custom encoding:
Encoded structure:
version(1 byte)type(2 bytes)length(2 bytes)payload(N bytes)
This gives consistent decoding and catches malformed data (version/length mismatch).
Which Data Type to Use
0x0001: UTF-8 text payload0x1001: UTF-8 message + metadata payload
Recommended:
Use
0x0001when you only need a reference string.Use
0x1001when you want message + structured metadata.
0x0001 example:
0x1001 example:
Build QR URL with Encoded Data
Richer context example:
Detect Transactions with Transfer Data RPC Query
Use circles_events scoped to the expected recipient and filtered to transfer-data events.
Request:
Detection:
Fetch events via
circles_events.Filter
to == recipientAddress.Decode each event
data.Match decoded reference to expected intent reference.
Verify amount and tx success via receipt logs.
Do not match on amount alone.
Decode and Extract Reference Reliably
Verify Amount from Receipt Logs
After reference match, load tx receipt and compute actual transferred amount.
For Circles flows, sum all relevant transfer amounts for matching from -> to across receipt logs, then compare:
Idempotent Post-Confirmation Actions
Minimal End-to-End Example
Using encodeCrcV2TransferData / decodeCrcV2TransferData plus circles_events (CrcV2_TransferData) makes QR detection deterministic and keeps transactions properly annotated in Gnosis App for better UX.
Now that the payment/user action is successfully decoded, you can proceed with your app's own business logic, be it a payout for winning a game, processing refund for a store, etc.
Here are some examples you can use as a base of your own app:
Last updated
Was this helpful?