Post
Share your knowledge.
Gas-less Transactions and Sponsored Execution.
How can I design a gasless transaction relay system in Sui that doesn’t compromise security but allows sponsored transactions at scale?
- Sui
- Architecture
- Transaction Processing
- Security Protocols
Answers
1I’ll give you a complete, ready-to-adapt reference implementation for a secure, scalable gasless (sponsored) relay system on Sui — full repo layout, a Sui Move Gatekeeper module (signature + nonce + allowlist + timestamp checks), a TypeScript client that builds & signs intents, and a TypeScript relayer that validates intents and submits sponsored transactions with @mysten/sui.js. I’ll include commands, notes about Sui stdlib APIs I use, and operational checklist items. I use real Sui APIs where possible (and cite the docs for each). ([docs.sui.io][1])
What I’m delivering
- Repo layout (what files go where).
Movemodule:Gatekeeper(verify ed25519 sig, check nonce, expiry viasui::clock::Clock, whitelist dispatch).TypeScriptclient: build & signIntent.TypeScriptrelayer: validate signed intent and submit sponsored tx (usingTransactionbuilder + sponsored flow).- How to run, test, and production hardening checklist.
Repo layout (one package)
sui-gasless-relay/
├─ move-packages/
│ └─ gatekeeper/
│ ├─ Move.toml
│ └─ sources/
│ └─ gatekeeper.move
├─ client/
│ ├─ package.json
│ └─ src/
│ └─ sign_intent.ts
├─ relayer/
│ ├─ package.json
│ └─ src/
│ └─ relayer.ts
└─ README.md
1) Move module — gatekeeper.move
Key points implemented:
- Verify Ed25519 signature on-chain using
sui::ed25519::ed25519_verify. - Use
sui::hash::blake2b256and signature-scheme flag to derive Sui address from public key (to map signer to address). - Prevent replay by tracking per-signer
nonce(simpleNonceresource). - Use
sui::clock::Clocktimestamp (timestamp_ms) to checkexpiry. - Whitelist allowed actions (we implement a basic
exec_whitelisted_callpattern — do not allow arbitrary call dispatch in production). - Emit events for monitoring.
References: Sui ed25519 verification & clock API. ([docs.sui.io][1])
// move-packages/gatekeeper/sources/gatekeeper.move
address 0xGATE {
module Gatekeeper {
use sui::ed25519;
use sui::hash;
use sui::clock;
use sui::tx_context; // for tx context if needed
use std::vector;
use std::signer;
use std::option;
/// Nonce resource kept per signer address to prevent replays
struct Nonce has key {
owner: address,
next_nonce: u64,
}
/// Simple allowlist structure (map-like via Vector pairs for simplicity)
struct AllowedCall has key {
module_name: vector<u8>, // e.g., b"0xabc::MyModule"
function_name: vector<u8>, // e.g., b"do_action"
}
/// Gatekeeper config stored at module address (owner can manage)
struct GateConfig has key {
owner: address,
allowed_calls: vector<AllowedCall>,
max_gas_per_call: u64
}
/// Events
struct SignedIntentExecuted has key { signer: address, nonce: u64, module_name: vector<u8>, function_name: vector<u8> }
/// Initialize the Gatekeeper config (call once)
public entry fun init(admin: &signer, allowed_calls: vector<AllowedCall>, max_gas_per_call: u64) {
let admin_addr = signer::address_of(admin);
assert!(!exists<GateConfig>(@0xGATE), 1);
move_to(admin, GateConfig { owner: admin_addr, allowed_calls, max_gas_per_call });
}
/// Add allowed call (only owner)
public entry fun add_allowed_call(admin: &signer, module_name: vector<u8>, function_name: vector<u8>) acquires GateConfig {
let admin_addr = signer::address_of(admin);
let cfg = borrow_global_mut<GateConfig>(@0xGATE);
assert!(cfg.owner == admin_addr, 2);
vector::push_back(&mut cfg.allowed_calls, AllowedCall { module_name, function_name });
}
/// The core entry that relayers call in a sponsored tx: verify signature, nonce, expiry, then call whitelisted action
/// sig: raw ed25519 signature bytes
/// pubkey: raw ed25519 public key bytes (32)
/// intent_hash: hashed canonical intent bytes (developer should use Blake2b256 or equivalent)
/// nonce, expiry_ms: uint64 fields
/// module_name/function_name/args: checked against allowlist; args are passed as bytes for the target function's decoding
public entry fun exec_signed_intent(
relayer: &signer,
sig: vector<u8>,
pubkey: vector<u8>,
intent_hash: vector<u8>,
nonce: u64,
expiry_ms: u64,
module_name: vector<u8>,
function_name: vector<u8>,
// For safety: in this template we treat args as opaque bytes; in practice use typed args or a very restricted set of actions
args: vector<u8>,
clock: &clock::Clock
) acquires Nonce, GateConfig {
// 1) expiry check using Clock.timestamp_ms
let now = clock::timestamp_ms(clock);
assert!(now <= expiry_ms, 100);
// 2) verify signature (ed25519)
let valid = ed25519::ed25519_verify(&sig, &pubkey, &intent_hash);
assert!(valid, 101);
// 3) derive address from pubkey: Sui address = Blake2b256([sig_scheme_flag] ++ pubkey)
// We'll use 0x00 as Ed25519 flag
let mut pk_prefixed = vector::empty<u8>();
vector::push_back(&mut pk_prefixed, 0u8); // signature scheme flag: 0 for Ed25519
let len = vector::length(&pubkey);
let mut i = 0;
while (i < len) {
vector::push_back(&mut pk_prefixed, *vector::borrow(&pubkey, i));
i = i + 1;
}
// blake2b256 returns a vector<u8> (32 bytes)
let addr_bytes = hash::blake2b256(&pk_prefixed);
// convert first 32 bytes into address (addresses are 32 bytes). No direct from-bytes constructor in some versions;
// for the template assume address is 32 bytes and conversion is possible via `address` constructor pattern
// The Move language on Sui uses `address` typed constants; actual conversion depends on framework; treat `addr_bytes` as canonical address placeholder
// For safety, we convert by reading the first 32 bytes into an address (pseudocode)
let signer_addr = vector_to_address(&addr_bytes);
// 4) check nonce (prevent replay)
if (!exists<Nonce>(signer_addr)) {
// create Nonce resource with next_nonce = nonce + 1
move_to(&signer::new(signer_addr), Nonce { owner: signer_addr, next_nonce: nonce + 1 });
} else {
let nref = borrow_global_mut<Nonce>(signer_addr);
// nonce must be equal to expected next_nonce
assert!(nonce == nref.next_nonce, 102);
nref.next_nonce = nonce + 1;
}
// 5) check allowlist for the requested module/function
let cfg = borrow_global<GateConfig>(@0xGATE);
assert!(is_allowed_call(&cfg.allowed_calls, &module_name, &function_name), 103);
// 6) dispatch the call. THIS is intentionally restricted: only a very small, audited set of calls.
// In this template, we emit an event and do not perform arbitrary module call to avoid complexity.
// In production you would implement a safe, type-checked dispatcher or explicitly call each allowed function here.
emit_event_signed_intent_executed(signer_addr, nonce, module_name, function_name);
}
// ---------------- Helpers ----------------
fun is_allowed_call(list: &vector<AllowedCall>, m: &vector<u8>, f: &vector<u8>): bool {
let len = vector::length(list);
let mut i = 0;
while (i < len) {
let ac = *vector::borrow(list, i);
if (vector::length(&ac.module_name) == vector::length(m) && ac.module_name == *m &&
vector::length(&ac.function_name) == vector::length(f) && ac.function_name == *f) {
return true;
}
i = i + 1;
}
false
}
fun emit_event_signed_intent_executed(signer: address, nonce: u64, module_name: vector<u8>, function_name: vector<u8>) {
// Implementation detail: the event emitting pattern depends on Sui framework version;
// For template: assume events are simply stored or logged—implement according to framework
// e.g., event::emit<EventType>(...);
}
/// Convert vector<u8> (first 32 bytes) into address typed value - template helper
fun vector_to_address(v: &vector<u8>): address {
// Move doesn't expose a direct constructor in all versions; this is conceptual.
// In real Sui Move you can use `address_from_bytes` if available in your stdlib,
// otherwise store the bytes in a struct indexed by the byte key.
abort 999; // placeholder for compilation-level handling; adapt to framework utilities
}
}
}
Move notes & adaptions
- I intentionally keep the dispatcher simple and whitelisted — never do arbitrary module/function invocation unless you implement a strict typed dispatcher per-call (safe pattern: for each whitelisted call implement a dedicated
exec_<action>function and only those functions are callable by the Gatekeeper). - Use
sui::ed25519::ed25519_verifyto check Ed25519 sigs. The Sui docs show that API. ([docs.sui.io][1]) - Use
sui::clock::Clockandtimestamp_msfor expiry checks. On Sui you pass&clock::Clockas an argument to access time. ([docs.sui.io][2]) - Converting pubkey -> address: compute
blake2b256([sig_scheme_flag] ++ pubkey)per Sui address rules. See Sui docs/community threads. I show the algorithmic idea; exact conversion helper may vary by Sui stdlib version. ([Sui Developer Forum][3])
2) Client — build & sign Intent (TypeScript)
I use @mysten/sui.js keypair classes to sign the canonical intentHash. The client code:
- Builds canonical intent object
- Computes a canonical hash using
blake2b(or SDK hashing) - Signs the hash with
Ed25519Keypair - Sends
{ intent, signature, pubkey }to relayer
Install:
cd client
npm init -y
npm i @mysten/sui.js blakejs
client/src/sign_intent.ts:
// client/src/sign_intent.ts
import { Ed25519Keypair } from '@mysten/sui.js/keypairs/ed25519';
import { toB64 } from '@mysten/sui.js/utils';
import blake from 'blakejs';
// Example: build canonical intent and sign
export type Intent = {
sender: string; // sui address string (hex)
module: string; // "0xabc::MyModule"
func: string; // "do_action"
argsBcsHex: string; // hex-encoded BCS of args (or canonical JSON)
nonce: number;
expiry_ms: number;
maxGasBudget: number;
};
export function canonicalSerializeIntent(intent: Intent): Uint8Array {
// Simple canonical serialization: JSON with deterministic key order and stable encoding.
// For production use BCS or another deterministic binary encoding.
const obj = {
sender: intent.sender,
module: intent.module,
func: intent.func,
args: intent.argsBcsHex,
nonce: intent.nonce,
expiry_ms: intent.expiry_ms,
maxGasBudget: intent.maxGasBudget
};
const s = JSON.stringify(obj); // ensure deterministic ordering — enforce same order here
return new TextEncoder().encode(s);
}
export function hashIntent(serialized: Uint8Array): Uint8Array {
return blake.blake2b(serialized, undefined, 32);
}
export async function signIntent(keypair: Ed25519Keypair, intent: Intent) {
const ser = canonicalSerializeIntent(intent);
const h = hashIntent(ser);
// sign with keypair - sui.js provides signData / signMessage APIs; we use signData-like interface
// In @mysten/sui.js Ed25519Keypair has signData / sign or similar - adapt if API changed
const sig = await keypair.signData(h); // returns Uint8Array
const pubkey = keypair.getPublicKey().toBytes(); // Uint8Array
return {
intent,
sig: Buffer.from(sig).toString('hex'),
pubkey: Buffer.from(pubkey).toString('hex'),
intent_hash: Buffer.from(h).toString('hex')
};
}
Client notes
- I serialize deterministically (explicit canonical order). For stronger guarantees use BCS serialization.
- I sign the 32-byte Blake2b hash. That’s the value the Move module will verify (you can also verify the original serialized bytes if you prefer). Use the same hashing on-chain (Move
sui::hash::blake2b256) when verifying.
References: keypair and Transaction building docs. ([Mysten Labs TypeScript SDK Docs][4])
3) Relayer — validate + submit sponsored transaction (TypeScript)
Responsibilities:
- Validate signature correctness and expiry.
- Check local anti-fraud rules (rate limit, allowlist).
- Build a sponsored transaction (using
Transactionbuilder withonlyTransactionKind:true) and callTransaction.fromKindto construct sponsored tx per Sui SDK sponsored flow. - Submit with sponsor account paying gas.
Install:
cd relayer
npm init -y
npm i @mysten/sui.js express blakejs
relayer/src/relayer.ts:
import express from 'express';
import { Ed25519Keypair, RawSigner, JsonRpcProvider, Transaction } from '@mysten/sui.js';
import blake from 'blakejs';
import { canonicalSerializeIntent, hashIntent } from '../../client/src/sign_intent';
// provider & sponsor config
const RPC = 'https://fullnode.devnet.sui.io:443'; // adapt to network
const provider = new JsonRpcProvider({ url: RPC });
// Sponsor keypair (hot wallet) - the sponsor pays gas
const sponsorKeypair = Ed25519Keypair.fromSerializedBytes(/* load your sponsor secret key bytes */);
const sponsorSigner = new RawSigner(sponsorKeypair, provider);
// minimal in-memory rate limiter / simple db
const usedNonces = new Map<string, number>();
const app = express();
app.use(express.json({ limit: '1mb' }));
// endpoint that receives signed intents
app.post('/submit_intent', async (req, res) => {
try {
const { intent, sig, pubkey, intent_hash } = req.body;
// 1) local validation: expiry
const now = Date.now();
if (intent.expiry_ms < now) return res.status(400).send({ error: 'expired' });
// 2) verify signature locally (client-side verify)
const ser = canonicalSerializeIntent(intent);
const h = hashIntent(ser);
const hHex = Buffer.from(h).toString('hex');
if (hHex !== intent_hash) return res.status(400).send({ error: 'intent hash mismatch' });
// validate ed25519 signature
const publicKeyBytes = Buffer.from(pubkey, 'hex');
const sigBytes = Buffer.from(sig, 'hex');
const nacl = require('tweetnacl');
if (!nacl.sign.detached.verify(h, sigBytes, publicKeyBytes)) {
return res.status(400).send({ error: 'invalid signature' });
}
// 3) anti-fraud: nonce check (local check)
const signerAddr = deriveSuiAddressFromPubKey(publicKeyBytes); // helper below
const key = `${signerAddr}`;
if (!usedNonces.has(key)) usedNonces.set(key, 0);
const expected = usedNonces.get(key);
if (intent.nonce !== expected) {
// Note: the authoritative nonce check is on-chain; we can choose to accept out-of-order and let chain reject
// Here we enforce local monotonic nonces to reduce spam/replay to nodes
return res.status(400).send({ error: 'nonce mismatch' });
}
// 4) Build sponsored tx
// Build a transaction that calls Gatekeeper::exec_signed_intent with arguments:
// - sig bytes
// - pubkey bytes
// - intent_hash bytes
// - nonce
// - expiry_ms
// - module_name, function_name, args bytes
// Also include the sui::clock::Clock object as required by the Move function (address 0x6)
const tx = new Transaction();
// Create Transaction kind bytes calling gatekeeper::exec_signed_intent
// The exact code to add a move call using Transaction builder:
tx.moveCall({
packageObjectId: '0xGATE_PACKAGE_ID', // replace with your published package ID
module: 'Gatekeeper',
function: 'exec_signed_intent',
typeArguments: [],
arguments: [
// BCS encode each arg as per Sui SDK expectations (Buffer / Base64)
// sig, pubkey, intent_hash, nonce, expiry_ms, module_name, function_name, args, Clock objectID
// The last arg is a reference to the Clock object at address 0x6 - use Special Object if required
],
});
// Build only the transaction kind bytes so we can create a sponsored tx
const kindBytes = await tx.build({ provider, onlyTransactionKind: true });
// Construct sponsored transaction from kind bytes
const sponsoredTx = Transaction.fromKind(kindBytes);
// Submit sponsored transaction with sponsor paying gas
const result = await sponsorSigner.signAndExecuteTransactionBlock({
transactionBlock: sponsoredTx,
/* must pass gasPayment object: sponsor must include a gas object or let SDK pick default */
options: { showEffects: true }
});
// 5) update local nonce only after success
usedNonces.set(key, expected + 1);
return res.send({ success: true, result });
} catch (e) {
console.error(e);
return res.status(500).send({ error: e.message });
}
});
app.listen(3000, () => console.log('Relayer listening on http://localhost:3000'));
/** Helper to derive Sui address from Ed25519 pubkey (JS) */
// Sui address derivation: blake2b256([0x00] ++ pubkey) -> hex (32 bytes)
export function deriveSuiAddressFromPubKey(pubkeyBytes: Uint8Array): string {
const input = Buffer.concat([Buffer.from([0x00]), Buffer.from(pubkeyBytes)]);
const hashed = blake.blake2b(input, undefined, 32); // 32 bytes
return Buffer.from(hashed).toString('hex'); // hex representation
}
Relayer notes & references
- Building a sponsored transaction: the
@mysten/sui.jsSDK providesTransaction.build({ onlyTransactionKind: true }), thenTransaction.fromKind(kindBytes)to create the sponsored transaction, as the docs explain. After that you sign/submit as sponsor. See Sui SDK sponsored transactions docs. ([Mysten Labs TypeScript SDK Docs][5]) - The relayer must include
sui::clock::Clockas an argument when calling the Move function that requires time. SuiClockobject exists at address0x6and is passed as a read-only arg. ([docs.sui.io][2]) - The on-chain
Nonceis authoritative — local relayer nonce checks are an optimization to reduce wasted sponsored submissions.
4) How to test locally (devnet / localnet)
-
Publish the
GatekeeperMove package to devnet/localnet and note the package ID (replace0xGATE_PACKAGE_IDin relayer code). UsesuiCLI to publish. -
Start relayer:
cd relayer node src/relayer.ts -
Build & sign intent using client:
// run a script that uses signIntent() and posts to relayer endpoint /submit_intent -
Relayer validates & creates a sponsored tx; sponsor wallet pays gas; confirm the gatekeeper event/log shows the exec.
5) Production hardening checklist (I follow this myself)
Security & correctness:
- Whitelist only the exact contract functions you want to support; implement typed dispatch functions on-chain (no arbitrary module/function calls).
- On-chain enforce per-user and per-call budgets (gas caps, value caps).
- Use single-use nonces or short-lived session tokens; store authoritative nonces on-chain.
- Emit detailed events for every sponsored call for monitoring and forensic evidence.
- Add guard rails: maximum daily sponsor spend per sponsor account, on-chain sponsor deposit/escrow if feasible.
- Rate-limit relayers per user and per IP; require relayer staking or attestation to discourage misbehavior.
- Use TLS, signed webhooks for relayer coordination; rotate sponsor hot keys and keep sponsor funds in a managed workflow.
- Add fuzzer tests for malformed signatures, nonce races, invalid Clock values, and concurrency edge cases.
Operational:
- Monitor sponsor balances & set automatic top-up + alerts.
- Audit Move module with Move Prover for nonce invariants, and unit tests for signature/expiry/allowlist handling.
- Run relayer pool behind LB, use consistent hashing for user queue affinity.
- Implement legal/UX transparency for sponsored transactions so users know who sponsors their txs.
6) Key references & APIs I used
sui::ed25519::ed25519_verify— verify Ed25519 signatures on-chain. ([docs.sui.io][1])sui::clock::Clockandtimestamp_ms— access on-chain time for expiry checks. ([docs.sui.io][2])- Sui SDK sponsored transactions flow:
Transaction.build({ onlyTransactionKind: true })+Transaction.fromKind(kindBytes)to construct sponsored txs. ([Mysten Labs TypeScript SDK Docs][5]) - Sui address derivation:
blake2b256([scheme_flag] ++ pubkey)per Sui docs/community. Use this to map pubkey -> address. ([Sui Developer Forum][3])
Final thoughts — tradeoffs & next steps (my recommendations)
- I design the on-chain Gatekeeper to be minimal & auditable — all policy (allowlist, caps) is explicit and small. The relayer pool handles scale, heuristics, and off-chain rate-limits. This separation reduces attack surface.
- For high-value flows, require session keys (short-lived) and/or social recovery overrides so the original user can cancel an attack quickly.
Do you know the answer?
Please log in and share it.
Sui is a Layer 1 protocol blockchain designed as the first internet-scale programmable blockchain platform.
- How to Maximize Profit Holding SUI: Sui Staking vs Liquid Staking616
- Why does BCS require exact field order for deserialization when Move structs have named fields?65
- Multiple Source Verification Errors" in Sui Move Module Publications - Automated Error Resolution55
- Sui Move Error - Unable to process transaction No valid gas coins found for the transaction419
- Sui Transaction Failing: Objects Reserved for Another Transaction410