Core Protocol
Solana made execution fast. The edges around execution (offline authorization, signing isolation, and receiver privacy) remain exposed. Sirius Labs secures them with three composable layers, each closing a gap the previous one leaves open.
MORA
Pay on Solana with zero internet at transaction time.
Lock funds once into an escrow. Sign compact offline vouchers (84-byte message + 64-byte signature). Settle on-chain when reconnected.
AirSign
The signing key never touches a networked device.
MORA vouchers are signed on a permanently air-gapped device. The signature travels back via fountain-coded QR codes. No USB, no Bluetooth, no attack surface.
GrayBox
Accept payments without revealing wallet identity.
Each payment is routed to a one-time x25519 ECDH stealth address derived per transfer. The recipient's identity stays off-chain entirely.
Each layer is independently deployable. Use MORA alone, add AirSign for signing isolation, add GrayBox for receiver privacy, or run the full stack.
Architecture
The three layers are independently deployable and each closes a distinct attack surface. Today they run separately on devnet. The full-stack flow below is the target design: GrayBox first (recipient setup), then AirSign (offline signing), then MORA (on-chain settlement).
Layer Stack
| Order | Layer | Closes | Output |
|---|---|---|---|
| 1 | GrayBox | Deanonymization: recipient identity never appears on-chain | One-time x25519 ECDH stealth address |
| 2 | AirSign | Key exposure: signing key never touches a networked device | Ed25519 signature via fountain QR |
| 3 | MORA | Connectivity: payment authorized without live RPC | 84-byte signed voucher, Receipt PDA |
Composition Flow (target design)
A full-stack payment is designed to follow this path. Steps 1 to 5 work today; step 6 is not wired yet.
- Bob publishes a GrayBox Meta-Address (spendPub + viewPub).
- Alice derives a one-time stealth address from Bob's Meta-Address.
- Alice builds an 84-byte MORA message that names the stealth address as payee. Her phone shows it as fountain-coded QR to her air-gapped AirSign signer, which signs it and returns the 64-byte signature the same way. No USB, no Bluetooth.
- Alice's phone shows the signed voucher to Bob as a QR code. Neither side needs a connection. Bob's app checks it offline: signature, escrow ownership, amount within the escrow, and his offline limit.
- When Bob is back online, he (or any relayer) submits Ed25519 verify + settle. A Receipt PDA is created for the nonce, so the same voucher can never settle twice. The SOL lands at the stealth address.
- Not wired yet: routing that payment through the GrayBox quarantine vault. MORA's
settlepays the payee address directly; the vault only holds funds that arrive through its owndepositinstruction. Connecting the two is on the roadmap.
On-chain Programs
| Program | Network | Address |
|---|---|---|
| MORA escrow | Devnet | 9fcXHD3pHDKLX79JuVgCEKQiqYkvVqFtpoAEVjBq4aJ8 |
| GrayBox quarantine vault | Devnet | 75HuPfb2n7SD7KtcQnVpCW5SVN3RP9gZ9vTXP4D4ha6C |
Wallet Connection Setup
All on-chain interactions require a connected Solana wallet. Use the official @solana/wallet-adapter packages to support Phantom, Backpack, and other wallets.
Installation
npm install @solana/wallet-adapter-react \
@solana/wallet-adapter-react-ui \
@solana/wallet-adapter-wallets \
@solana/wallet-adapter-base \
@solana/web3.jsProvider Setup (Next.js App Router)
// components/WalletProvider.tsx
"use client";
import { FC, ReactNode, useMemo } from "react";
import {
ConnectionProvider,
WalletProvider,
} from "@solana/wallet-adapter-react";
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
import { PhantomWalletAdapter } from "@solana/wallet-adapter-wallets";
import "@solana/wallet-adapter-react-ui/styles.css";
const DEVNET_RPC = "https://api.devnet.solana.com";
export const AppWalletProvider: FC<{ children: ReactNode }> = ({ children }) => {
const wallets = useMemo(() => [new PhantomWalletAdapter()], []);
return (
<ConnectionProvider endpoint={DEVNET_RPC}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>{children}</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
};Building an AnchorProvider
"use client"; // this hook can only be used in client components
import { useMemo } from "react";
import { AnchorProvider, Idl, Program } from "@coral-xyz/anchor";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import idl from "./mora.json"; // IDL from the MORA repo (see Installation)
function useMoraProgram() {
const { connection } = useConnection();
const wallet = useWallet();
return useMemo(() => {
const provider = new AnchorProvider(
connection,
wallet as any, // wallet-adapter satisfies AnchorWallet interface
{ commitment: "confirmed" }
);
return new Program(idl as Idl, provider); // program ID comes from the IDL
}, [connection, wallet]);
}MORA: Offline Payment Escrow
MORA lets users pay on Solana with zero internet at transaction time. Alice locks funds once into an escrow PDA. This is the only moment internet is required. From that point, she signs MORA vouchers (84-byte message, 64-byte detached signature) completely offline. Bob settles whenever he reconnects.
On-chain Program
Program ID (devnet): 9fcXHD3pHDKLX79JuVgCEKQiqYkvVqFtpoAEVjBq4aJ8
Instructions
| Instruction | Description |
|---|---|
| create_escrow(seed, amount, expires_at) | Lock SOL into a PDA derived from ["escrow", authority, seed]. Internet required only once. |
| settle(nonce, amount) | Submit 2-instruction tx: Ed25519 native verify, then settle. Transfers lamports to the payee and creates the Receipt PDA ["receipt", escrow, nonce]. |
| close_escrow | After expires_at (set by the payer at creation), returns the unspent remainder and rent to the authority. |
Voucher Format
The signed message is 84 bytes: "MORA" (4) | escrow (32) | nonce_u64_le (8) | payee (32) | amount_u64_le (8)
The complete voucher transmitted to Bob is 148 bytes: 84-byte message + 64-byte detached Ed25519 signature.
Both fit comfortably in a single QR code (QR v6 supports up to 224 bytes of binary data).
Security Guarantees
MORA enforces these guarantees on-chain. None of them requires the payer to be online at payment time.
| Attack | What an attacker tries | How MORA stops it |
|---|---|---|
| Double-spend | Spend more than the escrow holds, by signing more vouchers than it can cover | The escrow tracks spent. settle only succeeds if amount <= amount - spent, otherwise it fails with InsufficientFunds. The escrow can never pay out more than was locked. |
| Double-settlement | Submit the same voucher on-chain twice (e.g. from two devices) | Each nonce derives a unique Receipt PDA, created with an init constraint. A second settle with the same nonce fails with already in use before any lamports move. |
| Signature forgery | Forge or tamper with a voucher without the payer's private key | Each settle call is a 2-instruction atomic tx: Solana's native Ed25519 program verifies the signature first, and settle checks that the signer is the escrow owner and the bytes match (SignerMismatch, MsgMismatch). If anything fails, the entire tx is rejected. |
| Late settlement | Settle after the payer has reclaimed the escrow | settle fails with Expired after expires_at; only then can the payer call close_escrow. |
Offline double-spend: how MORA bounds it
On-chain, an escrow can never pay out more than it holds. Offline, a payer could still sign vouchers that add up to more than the escrow, and then the first receiver to settle is paid. No offline system can rule this out without a connection, so MORA makes the exposure small and known in advance. A receiver accepts a voucher offline only if all of these hold:
accept offline ⇔ Ed25519 signature valid
∧ escrow = PDA("escrow", authority, seed)
∧ amount ≤ escrow amount at creation
∧ amount ≤ receiver's offline limit
∧ now < expires_at
accept online ⇔ the same checks against the live escrow
∧ amount ≤ escrow.amount − escrow.spent
∧ no Receipt PDA for this nonce yet
on-chain ⇒ Σ settled ≤ escrow.amount (always)
receiver's worst case ≤ offline limit × vouchers accepted before reconnectingThe offline limit works like a card terminal's floor limit and is set by the receiver; it starts at zero, so offline acceptance is off until the receiver chooses a limit. Larger payments wait for a connection, where the voucher is checked against the live escrow before it is accepted. Short escrows and settling as soon as a connection returns keep the window small.
What a receiver can know offline
| When | What is known |
|---|---|
| Checked offline | The escrow belongs to the signer (its address derives from the owner's key and seed). The owner signed exactly these 84 bytes, so payee, amount and nonce cannot be altered. The amount fits inside the escrow the voucher draws from. |
| Only known at settle | Whether the funds are still there: a payer can promise the same escrow twice, and whoever settles first is paid. Whether settle lands before expires_at, after which the payer can reclaim the rest. |
| How the risk is bounded | The receiver's offline limit, an on-chain check whenever the receiver is online, short escrows, and settling as soon as a connection returns. |
Status and limits
Proof on devnet
From the end-to-end test (2026-10-09) and the first payment from a real phone (2026-10-10). Every signature opens on Solana Explorer.
| Step | What happened | Transaction |
|---|---|---|
| create_escrow | Payer locks SOL into the escrow PDA. | 3DvMBfYp… → |
| Ed25519 verify + settle | Receiver submits the offline voucher. | 5nFdUsUy… → |
| Replay the same voucher | Rejected on-chain: the receipt PDA already exists (expected failure). | 3zkojVJZ… → |
| close_escrow | After expiry, the remainder and rent return to the payer. | 5gMFyNgU… → |
| Phone: offline balance | Android app, approved in Solflare, locks 0.1 SOL into an escrow. | 4cWReqMt… → |
| Phone: offline payment collected | Signed in airplane mode; the receiver settled it later (0.01 SOL). | 2xKXYK9W… → |
Installation
npm install @coral-xyz/anchor @solana/web3.js tweetnacl # Download the IDL and save it as mora.json: # https://github.com/sirius-labs-dev/mora/blob/main/landing/public/demo/mora.json
TypeScript SDK (@mora/sdk)
The same protocol code the Mora Android app runs: build, sign, encode and verify vouchers offline, and build the three escrow instructions. It works with the deployed program as is. The package is not on npm yet; wallet and POS teams can apply for SDK access. The examples on this page use only public packages and the IDL.
| Who | When | Call |
|---|---|---|
| Payer | online, once | createEscrowIx |
| Payer | offline | buildVoucherMessage → sign → encodeVoucherQr |
| Receiver | offline | decodeVoucherQr → verifyVoucherOffline |
| Receiver | back online | settleIxs → send |
| Payer | after expiry | closeEscrowIx |
import {
buildVoucherMessage, encodeVoucherQr, escrowPda,
decodeVoucherQr, verifyVoucherOffline, settleIxs,
} from "@mora/sdk";
// Payer, offline: an Ed25519 signature over 84 bytes, no RPC
const escrow = escrowPda(payer.publicKey, seed);
const message = buildVoucherMessage({ escrow, nonce, payee, amount });
const signature = nacl.sign.detached(message, payer.secretKey);
const qr = encodeVoucherQr({ message, signature },
{ authority: payer.publicKey, seed, expiresAt, escrowAmount });
// Receiver: check offline, settle once back online
const voucher = decodeVoucherQr(qr);
const check = verifyVoucherOffline(voucher);
if (!check.ok) throw new Error(check.reason);
const tx = new Transaction().add(
...settleIxs(receiver.publicKey, voucher.meta!.authority, voucher));TypeScript Types
import { PublicKey } from "@solana/web3.js";
import { BN } from "@coral-xyz/anchor";
interface MoraVoucher {
escrow: PublicKey;
nonce: bigint; // u64, unique per payment
payee: PublicKey;
amount: bigint; // lamports (u64)
message: Uint8Array; // the 84 signed bytes
signature: Uint8Array; // 64-byte Ed25519 sig
}
// On-chain Escrow account, as returned by program.account.escrow.fetch()
interface EscrowAccount {
authority: PublicKey; // the payer, who signs vouchers
seed: BN;
amount: BN; // lamports locked
spent: BN; // lamports already settled
expiresAt: BN; // unix seconds
bump: number;
}Escrow PDA Derivation
import { PublicKey } from "@solana/web3.js";
import { Buffer } from "buffer";
const MORA_PROGRAM_ID = new PublicKey(
"9fcXHD3pHDKLX79JuVgCEKQiqYkvVqFtpoAEVjBq4aJ8"
);
const u64le = (n: bigint) => {
const b = Buffer.alloc(8);
b.writeBigUInt64LE(n);
return b;
};
// Seeds: "escrow" | authority | seed (u64 LE)
function deriveEscrowPda(authority: PublicKey, seed: bigint): PublicKey {
const [escrowPda] = PublicKey.findProgramAddressSync(
[Buffer.from("escrow"), authority.toBuffer(), u64le(seed)],
MORA_PROGRAM_ID
);
return escrowPda;
}
// Seeds: "receipt" | escrow | nonce (u64 LE). One per settled voucher.
function deriveReceiptPda(escrow: PublicKey, nonce: bigint): PublicKey {
const [receiptPda] = PublicKey.findProgramAddressSync(
[Buffer.from("receipt"), escrow.toBuffer(), u64le(nonce)],
MORA_PROGRAM_ID
);
return receiptPda;
}Building nonceBuffer and amountBuffer
Buffer is not available in the browser by default. In client components, add import { Buffer } from "buffer"; and install with npm install buffer.import { Buffer } from "buffer"; // npm install buffer (browser polyfill)
// Both nonce and amount are 8-byte little-endian unsigned 64-bit integers
// nonce — increment per voucher to prevent replay
const nonce = BigInt(1); // start at 1, increment each payment
const nonceBuffer = Buffer.alloc(8);
nonceBuffer.writeBigUInt64LE(nonce);
// amount — payment in lamports (1 SOL = 1_000_000_000 lamports)
const lamports = BigInt(500_000_000); // 0.5 SOL
const amountBuffer = Buffer.alloc(8);
amountBuffer.writeBigUInt64LE(lamports);TypeScript Integration
import { AnchorProvider, Program, BN } from "@coral-xyz/anchor";
import { Ed25519Program, Keypair, PublicKey, Transaction } from "@solana/web3.js";
import { Buffer } from "buffer"; // npm install buffer
import nacl from "tweetnacl";
// MoraVoucher: see TypeScript Types. u64le, deriveEscrowPda, deriveReceiptPda:
// see Escrow PDA Derivation. program: see useMoraProgram (Wallet Setup).
async function createEscrow(
program: Program,
authority: PublicKey,
lamports: bigint,
expiresInSeconds: number
): Promise<{ escrow: PublicKey; seed: bigint }> {
const seed = BigInt(Date.now()); // any unique u64 per escrow
const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
await program.methods
.createEscrow(new BN(seed.toString()), new BN(lamports.toString()), new BN(expiresAt))
.accounts({ authority })
.rpc();
return { escrow: deriveEscrowPda(authority, seed), seed };
}
function signVoucher(
escrow: PublicKey,
nonce: bigint,
payee: PublicKey,
amount: bigint,
keypair: Keypair
): MoraVoucher {
const message = Buffer.concat([
Buffer.from("MORA"),
escrow.toBuffer(),
u64le(nonce),
payee.toBuffer(),
u64le(amount),
]); // 84 bytes
const signature = nacl.sign.detached(message, keypair.secretKey);
return { escrow, nonce, payee, amount, message, signature };
}
async function settleVoucher(
program: Program,
voucher: MoraVoucher,
payer: PublicKey // escrow authority, the voucher signer
): Promise<string> {
// The Ed25519 verify instruction must come right before settle.
const verifyIx = Ed25519Program.createInstructionWithPublicKey({
publicKey: payer.toBytes(),
message: voucher.message,
signature: voucher.signature,
});
const settleIx = await program.methods
.settle(new BN(voucher.nonce.toString()), new BN(voucher.amount.toString()))
.accountsPartial({
submitter: program.provider.publicKey!, // anyone can submit
escrow: voucher.escrow,
payee: voucher.payee,
receipt: deriveReceiptPda(voucher.escrow, voucher.nonce),
})
.instruction();
const provider = program.provider as AnchorProvider;
try {
return await provider.sendAndConfirm(new Transaction().add(verifyIx, settleIx));
} catch (err: unknown) {
if (err instanceof Error && err.message.includes("already in use")) {
throw new Error(`Nonce ${voucher.nonce} already settled. Use a fresh nonce.`);
}
throw err;
}
}Read the balance and close the escrow
// What is still payable from an escrow, and until when
async function escrowBalance(program: Program, escrow: PublicKey) {
const info = await program.provider.connection.getAccountInfo(escrow);
if (!info) throw new Error("Escrow not found: it may already be closed");
const e = program.coder.accounts.decode("escrow", info.data);
const remaining = BigInt(e.amount.toString()) - BigInt(e.spent.toString());
const expiresAt = Number(e.expiresAt.toString());
return { remaining, expiresAt, expired: Date.now() / 1000 >= expiresAt };
}
// The payer reclaims the unspent remainder and rent
async function closeEscrow(program: Program, authority: PublicKey, escrow: PublicKey) {
// Only after expires_at; otherwise the program returns NotExpired.
return program.methods
.closeEscrow()
.accountsPartial({ authority, escrow })
.rpc();
}Error Handling
try {
await settleVoucher(program, voucher, payer);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("already in use")) {
console.error("Voucher already redeemed. Nonce:", voucher.nonce);
} else if (msg.includes("SignerMismatch") || msg.includes("MsgMismatch")) {
console.error("Voucher not signed by the escrow owner, or bytes altered");
} else if (msg.includes("Expired")) {
console.error("Escrow expired. Call close_escrow to recover funds.");
} else if (msg.includes("InsufficientFunds")) {
console.error("Escrow underfunded for this voucher amount");
} else {
throw err;
}
}Error Codes
| Code | Name | When |
|---|---|---|
| 6000 | ZeroAmount | create_escrow or settle with amount 0 |
| 6001 | InvalidExpiry | create_escrow with expires_at in the past |
| 6002 | Expired | settle after expires_at |
| 6003 | NotExpired | close_escrow before expires_at |
| 6004 | InsufficientFunds | settle amount is more than amount − spent |
| 6005 | SelfPayment | payee is the escrow owner |
| 6006 | Ed25519IxMissing | no Ed25519 verify instruction right before settle |
| 6007 | WrongVerifyProgram | the preceding instruction is not the Ed25519 program |
| 6008 | MalformedSig | Ed25519 instruction data is malformed |
| 6009 | SignerMismatch | the voucher was not signed by the escrow owner |
| 6010 | MsgMismatch | the signed bytes do not match this escrow, nonce, payee and amount |
| 6011 | Underflow | arithmetic underflow (should not happen) |
| 6012 | Overflow | arithmetic overflow (should not happen) |
A replayed voucher fails earlier, in the System Program, with already in use: its Receipt PDA already exists.
Tests
# Localnet solana-test-validator --reset --quiet & anchor test --skip-local-validator # Devnet smoke anchor test --skip-local-validator --skip-deploy --provider.cluster devnet
Mobile App (Android)
The Mora Android app runs the same protocol code on a phone. It is a preview build on devnet.
| Screen | What it does |
|---|---|
| Offline balance | Load an offline balance once: the app locks SOL into an escrow, approved in your wallet (tested with Solflare). When the connection drops, the app says so and keeps working. |
| Pay offline | Scan the receiver's payment request, confirm, and show the signed code. No RPC call. |
| Receive and collect | The receiver checks the code offline against their offline limit, then the app settles it on Solana as soon as there is a connection. |
AirSign: Air-Gapped Signing
MORA already removes internet from the payment moment, but the signing key still lives on a device that touches networks. AirSign closes that gap: the signing key never touches a networked device. MORA vouchers are signed on a permanently air-gapped device; the signature travels back via fountain-coded QR codes with no USB, no Bluetooth, no attack surface.
How It Works
Online device Offline device
────────── ──────────────
TX → QR frames → Scan + reconstruct
↓
Scan signature ← Sign → QR frames
↓
Submit to SolanaKey Derivation
Keys are derived using Argon2id KDF from a passphrase. No seed phrase export, no USB, no Bluetooth. The QR channel uses fountain codes that tolerate significant frame loss, approximately 30% at practical QR frame counts (RaptorQ / RFC 6330: ~1-2% encoding overhead, allowing recovery from proportionally high erasure rates).
Setup
| Device | Role | URL |
|---|---|---|
| Offline (signer) | Scans TX QR, signs, returns signature QR | air-sign-signer-web.vercel.app/?role=signer |
| Online (coordinator) | Displays TX as QR, scans signature, submits | air-sign-signer-web.vercel.app/?role=coordinator |
AirSign vs Hardware Wallet
| Capability | Hardware Wallet | AirSign |
|---|---|---|
| No USB / Bluetooth attack surface | ✗ | ✓ |
| M-of-N multisig | vendor-dependent | ✓ |
| Open, auditable stack | partial | ✓ |
| Fountain code frame loss tolerance | n/a | ✓ |
| Native browser WASM | adapter only | ✓ |
GrayBox: Receiver Privacy
MORA hides when you pay. AirSign hides how you sign. GrayBox hides who receives. x25519 ECDH stealth address derivation (using Ed25519 keys via Curve25519) ensures the recipient's real wallet never appears on-chain. Every payment lands at a unique one-time address with no traceable link back to the recipient's identity. Privacy is achieved without any zero-knowledge dependency.
min_attestations of its oracle set (M-of-N) before the deposit's release_authority can call release to a treasury address. A dirty verdict or an expired deposit goes back to the depositor through refund. In the current devnet implementation, Sirius Labs operates the oracle; the design supports third-party AML providers. Experimental and unaudited.On-chain Program
Program ID (devnet): 75HuPfb2n7SD7KtcQnVpCW5SVN3RP9gZ9vTXP4D4ha6C
How It Works
The Indexer is a GrayBox server-side component that continuously polls Solana program accounts using Bob's view key. When it detects a matching deposit, it notifies the gateway which triggers the AML attestation flow.
| Step | Actor | Action |
|---|---|---|
| 1 | Bob | Generates Meta-Address (spendPub + viewPub) |
| 2 | Alice | Derives one-time stealth address from Meta-Address (x25519 ECDH) |
| 3 | Alice | Deposits funds into the quarantine vault, recorded against the stealth address |
| 4 | Indexer | Scans chain with Bob's view key, detects deposit, notifies gateway |
| 5 | AML Oracles | Attest the funds (M-of-N). Clean: the quarantine gate opens. Dirty: refund. |
| 6 | Release authority | Releases funds to a fresh treasury address, or refunds the depositor |
Steps 3, 5, and 6 are on-chain Anchor program calls (deposit, attest, release / refund). Step 4 (Indexer) is an off-chain service. See the quarantine-vault program on GitHub for the IDL and instruction accounts.
TypeScript Types
interface GrayBoxMetaAddress {
spendPub: string; // base58 Ed25519 public key
viewPub: string; // base58 Ed25519 public key
}Live on mainnet: GrayBox Agent Checkout for PayBox
GrayBox Agent Checkout is our plugin for the PayBox (MoonPay) marketplace. An AI agent buys from a merchant in USDC; every payment is AML-screened before the merchant sees it. Clean payments are released to the merchant's one-time address, so the merchant's treasury never shows up next to the buyer. Anything else is refunded.
| Tool | What it does |
|---|---|
| list_merchants | The businesses that accept agent payments, their items, prices and token. |
| screen_address | Screen any Solana wallet against the sources below and return the evidence. |
| checkout | Build an unsigned USDC payment for an item; PayBox signs it in the agent's wallet. |
| checkout_status | Screen the payer, then release the payment to the merchant or refund it, once. |
Screening uses public sources: the OFAC SDN list's Solana addresses, Circle (USDC) and Tether (USDT) freeze state on mainnet, and the wallets that funded the payer. If a source cannot be read, the verdict is incomplete and the money stays put; it is never released. Each settlement writes the verdict and an evidence hash to an on-chain memo.
Proof on mainnet
Real purchases made by an AI agent through PayBox on 2026-10-17, 0.01 USDC each. The flagged payments come from a demo wallet placed on the operator list, because nobody can hold a sanctioned wallet's key; screening a real OFAC-listed address with screen_address flags it on the OFAC list and on its frozen USDC and USDT accounts.
| Step | What happened | Transaction |
|---|---|---|
| Clean payment | Agent pays 0.01 USDC from a clean PayBox wallet (EkQnUwWv…). | h2nwWvaY… → |
| Released | Verdict clean: released to the merchant's one-time address. | 4rLFDU32… → |
| Flagged payment | 0.01 USDC from the demo flagged wallet (X3ASdmoy…, a test entry in the operator list). | 4EXDe8Bz… → |
| Refunded | Verdict dirty: refunded to the payer; the merchant never receives it. | 4znWFsXD… → |
| Second clean payment | Another 0.01 USDC from the clean wallet. | 51yWF92p… → |
| Released | Released once. | 5AQv7xwi… → |
| Duplicate release | A concurrent second release of the same payment fails: its settlement marker account is already in use (expected failure). | Rn2xoygv… → |
Quick Start: Full Stack Integration
End-to-end flow: MORA escrow (online, once) → GrayBox stealth address → AirSign air-gapped signing → MORA settle. The SOL lands at the stealth address; routing it through the GrayBox quarantine vault is not wired yet.
1. Fund escrow: MORA (online, once)
// components/SetupEscrow.tsx
"use client";
import { useWallet, useConnection } from "@solana/wallet-adapter-react";
import { AnchorProvider, BN, Idl, Program } from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
import { Buffer } from "buffer"; // npm install buffer
import idl from "./mora.json";
export function SetupEscrow() {
const { connection } = useConnection();
const wallet = useWallet();
async function handleSetup() {
if (!wallet.publicKey) return;
const provider = new AnchorProvider(connection, wallet as any, {
commitment: "confirmed",
});
const program = new Program(idl as Idl, provider);
const seed = new BN(Date.now());
const expiresAt = new BN(Math.floor(Date.now() / 1000) + 7 * 24 * 3600); // 7 days
await program.methods
.createEscrow(seed, new BN("2000000000"), expiresAt) // 2 SOL budget
.accounts({ authority: wallet.publicKey })
.rpc({ commitment: "confirmed" });
const [escrowPda] = PublicKey.findProgramAddressSync(
[Buffer.from("escrow"), wallet.publicKey.toBuffer(), seed.toArrayLike(Buffer, "le", 8)],
program.programId
);
console.log("Escrow funded:", escrowPda.toBase58());
// Save escrowPda and seed: every voucher names the escrow
}
return (
<button
onClick={handleSetup}
style={{ padding: "8px 20px", borderRadius: "8px", background: "#1d4ed8", color: "#fff", fontWeight: 600, cursor: "pointer" }}
>
Fund Escrow
</button>
);
}2. Get stealth address: GrayBox
Bob shares his Meta-Address (spendPub + viewPub). Alice derives a one-time stealth address from it with x25519 ECDH (see GrayBox: How It Works). The result, stealthPubkey, is the payee in the voucher below.
3. Sign voucher offline: MORA + AirSign
import nacl from "tweetnacl"; // npm install tweetnacl
import { PublicKey, Keypair } from "@solana/web3.js";
import { Buffer } from "buffer"; // npm install buffer (browser polyfill)
// On air-gapped device — AirSign handles QR transport of keypair and message
// escrowPda: PublicKey from Step 1 | stealthPubkey: PublicKey from Step 2
// keypair: Keypair of the payer (never leaves the offline device)
const nonce = BigInt(1); // increment per payment
const lamports = BigInt(500_000_000); // 0.5 SOL
const nonceBuffer = Buffer.alloc(8);
nonceBuffer.writeBigUInt64LE(nonce);
const amountBuffer = Buffer.alloc(8);
amountBuffer.writeBigUInt64LE(lamports);
// 84-byte message: "MORA"(4) | escrow(32) | nonce(8) | payee(32) | amount(8)
const msg = Buffer.concat([
Buffer.from("MORA"),
escrowPda.toBuffer(),
nonceBuffer,
stealthPubkey.toBuffer(),
amountBuffer,
]);
const signature = nacl.sign.detached(msg, keypair.secretKey);
// AirSign carries msg + signature over fountain QR → online device scans4. Settle MORA voucher
Bob's online device scans the AirSign QR, recovers the signature, and settles the voucher.
import { Ed25519Program, PublicKey, Transaction } from "@solana/web3.js";
import { BN } from "@coral-xyz/anchor";
import { Buffer } from "buffer";
// Bob's online device received msg + signature from the AirSign QR scan.
// alicePubkey: escrow authority | nonce, lamports: from Step 3
const [receiptPda] = PublicKey.findProgramAddressSync(
[Buffer.from("receipt"), escrowPda.toBuffer(), nonceBuffer],
program.programId
);
// Ed25519 native verify + settle are atomic in one tx.
// Anyone can submit: here Bob pays the fee.
const settleIx = await program.methods
.settle(new BN(nonce.toString()), new BN(lamports.toString()))
.accountsPartial({
submitter: wallet.publicKey,
escrow: escrowPda,
payee: stealthPubkey,
receipt: receiptPda,
})
.instruction();
try {
const settleTx = await provider.sendAndConfirm(new Transaction().add(
Ed25519Program.createInstructionWithPublicKey({
publicKey: alicePubkey.toBytes(),
message: msg,
signature,
}),
settleIx
));
console.log("MORA settled:", settleTx);
} catch (err: unknown) {
const m = err instanceof Error ? err.message : String(err);
if (m.includes("already in use")) {
throw new Error("Voucher already redeemed. Use a fresh nonce.");
}
throw err;
}deposit instruction, and connecting the two is on the roadmap. See GrayBox: How It Works for the vault flow on its own.Contact & Support
For integration support, partnerships, or enterprise inquiries: hello@siriuslabs.tech