Chapter 27: Off-Chain Signature × On-Chain Verification
Learning Objective: Deeply understand the Ed25519 signature verification mechanism in the
world::sig_verifymodule, and master the core security pattern of EVE Frontier: “game server signature → Move contract verification.”
Status: Teaching example. The verification flow in the text is a breakdown of the official implementation. When implementing, prioritize comparing with actual source code and tests.
Minimal Call Chain
Game server constructs message -> Ed25519 signature -> Player submits bytes/signature -> sig_verify module validates -> Contract continues execution
Corresponding Code Directory
Key Structs / Inputs
| Type or Input | Purpose | Reading Focus |
|---|---|---|
| Message bytes | Raw encoding of off-chain facts | Check if off-chain signature and on-chain verification use exactly the same byte sequence |
| Signature blob | flag + raw_sig + public_key | Check length, slice order, and signature algorithm identifier |
AdminACL / authorized address | Business-allowed server identity | Check that “signature correct” and “signer authorized” are two layers of validation |
Key Entry Functions
| Entry | Purpose | What to Confirm |
|---|---|---|
sig_verify related validation entry | Verify signature binding to message | Whether intent prefix is correctly added, whether bytes are strictly compared |
| Business contract validation wrapper function | Connect signature verification to business flow | Whether nonce, expiration time, object binding are validated together |
| sponsor / server whitelist entry | Restrict acceptable server identities | Whether it’s handled in layers separate from signature validation |
Most Easily Misunderstood Points
- Signature passing doesn’t equal business passing; business fields still need separate validation
- If even one byte differs in off-chain signature encoding, on-chain verification will inevitably fail
AdminACLsolves “who can submit/sponsor,” not “message content is definitely correct”
When reading the signature system, it’s recommended to break verification into 4 layers, not mix into one “verification passed means safe”:
- Byte layer: Are the
message_bytesseen off-chain and on-chain exactly identical? - Cryptographic layer: Was the signature truly generated by that private key?
- Identity layer: Does the address corresponding to this private key belong to an allowed server?
- Business layer: Are the message fields like player, object, deadline, nonce, quantity truly matching this call?
sig_verify only handles the first two layers and part of the third. What truly determines business security is how strict your outer wrapper function is.
1. Why Off-Chain Signatures?
A fundamental challenge in EVE Frontier: on-chain contracts cannot access real-time game world state.
| Information | Source | Contract Can Read Directly? |
|---|---|---|
| Player ship position coordinates | Game server real-time calculation | ❌ |
| Whether a player is near a building | Game physics engine | ❌ |
| Today’s PvP kill results | Game combat server | ❌ |
| On-chain object state | Sui state tree | ✅ |
Solution: The game server signs these “facts” into a message off-chain, the player submits this signature to the contract, and the contract verifies the signature’s authenticity.
2. Ed25519 Signature Format
Sui uses standard Ed25519 + personal message signature format.
Signature Composition
signature (97 bytes total):
┌─────────┬───────────────────┬──────────────────┐
│ flag │ raw_sig │ public_key │
│ 1 byte │ 64 bytes │ 32 bytes │
│ (0x00) │ (Ed25519 sig) │ (Ed25519 PK) │
└─────────┴───────────────────┴──────────────────┘
Constant Definitions (from source code)
const ED25519_FLAG: u8 = 0x00; // Ed25519 scheme identifier
const ED25519_SIG_LEN: u64 = 64; // Signature length
const ED25519_PK_LEN: u64 = 32; // Public key length
3. Source Code Deep Dive: sig_verify.move
3.1 Deriving Sui Address from Public Key
pub fun derive_address_from_public_key(public_key: vector<u8>): address {
assert!(public_key.length() == ED25519_PK_LEN, EInvalidPublicKeyLen);
// Sui address = Blake2b256(flag_byte || public_key)
let mut concatenated: vector<u8> = vector::singleton(ED25519_FLAG);
concatenated.append(public_key);
sui::address::from_bytes(hash::blake2b256(&concatenated))
}
Formula: sui_address = Blake2b256(0x00 || ed25519_public_key)
This means if you know the game server’s Ed25519 public key, you can predict its Sui address.
3.2 PersonalMessage Intent Prefix
// x"030000" is three bytes:
// 0x03 = IntentScope::PersonalMessage
// 0x00 = IntentVersion::V0
// 0x00 = AppId::Sui
let mut message_with_intent = x"030000";
message_with_intent.append(message);
let digest = hash::blake2b256(&message_with_intent);
⚠️ Important Detail: The message is directly appended (not BCS serialized), which differs from Sui wallet’s default signing behavior. The reason is that the game server’s Go/TypeScript side uses the
SignPersonalMessageapproach to directly operate on bytes.
3.3 Complete Verification Flow
pub fun verify_signature(
message: vector<u8>,
signature: vector<u8>,
expected_address: address,
): bool {
let len = signature.length();
assert!(len >= 1, EInvalidLen);
// 1. Extract scheme flag from first byte
let flag = signature[0];
// 2. Move 2024 match syntax (similar to Rust)
let (sig_len, pk_len) = match (flag) {
ED25519_FLAG => (ED25519_SIG_LEN, ED25519_PK_LEN),
_ => abort EUnsupportedScheme,
};
assert!(len == 1 + sig_len + pk_len, EInvalidLen);
// 3. Split signature bytes
let raw_sig = extract_bytes(&signature, 1, 1 + sig_len);
let raw_public_key = extract_bytes(&signature, 1 + sig_len, len);
// 4. Construct message digest with intent prefix
let mut message_with_intent = x"030000";
message_with_intent.append(message);
let digest = hash::blake2b256(&message_with_intent);
// 5. Verify public key corresponds to Sui address
let sig_address = derive_address_from_public_key(raw_public_key);
if (sig_address != expected_address) {
return false
};
// 6. Verify Ed25519 signature
match (flag) {
ED25519_FLAG => {
ed25519::ed25519_verify(&raw_sig, &raw_public_key, &digest)
},
_ => abort EUnsupportedScheme,
}
}
3.4 Byte Extraction Helper Function
// Move 2024's vector::tabulate! macro: concisely create slices
fun extract_bytes(source: &vector<u8>, start: u64, end: u64): vector<u8> {
vector::tabulate!(end - start, |i| source[start + i])
}
4. End-to-End Flow
Game Server (Go/Node.js)
│
├─ Construct message: message = bcs_encode(LocationProofMessage)
├─ Add intent prefix: msg_with_intent = 0x030000 + message
├─ Calculate digest: digest = blake2b256(msg_with_intent)
└─ Sign: signature = ed25519_sign(server_private_key, digest)
↓
Player calls contract (Sui PTB)
│
└─ verify_signature(message, flag+sig+pk, server_address)
↓
Move Contract
├─ Rebuild digest (same algorithm)
├─ Extract public_key from signature
├─ Verify address(public_key) == server_address (anti-forgery)
└─ ed25519_verify(sig, pk, digest) → true/false
The most easily overlooked aspect in this end-to-end flow is “what exactly is the signature binding to.” If the server signs something like “Player A can claim reward today” — a broad semantic — rather than “Player A can execute action=2 once for item_id=123 before deadline,” then while verification is correct, the permission boundary is still too wide. Many replay vulnerabilities and misuse vulnerabilities aren’t in the cryptographic algorithm but in the message semantics being too loose.
5. How to Use in Builder Contracts?
5.1 Basic Usage: Verifying Server-Issued Permits
module my_extension::server_permit;
use world::sig_verify;
use world::access::ServerAddressRegistry;
use std::bcs;
public struct PermitMessage has copy, drop {
player: address,
action_type: u8, // 1=pass, 2=item reward
item_id: u64,
deadline_ms: u64,
}
public fun redeem_server_permit(
server_registry: &ServerAddressRegistry,
message_bytes: vector<u8>,
signature: vector<u8>,
ctx: &mut TxContext,
) {
// 1. Deserialize message (assuming server used BCS serialization)
let msg = bcs::from_bytes<PermitMessage>(message_bytes);
// 2. Verify deadline
// (Actual implementation needs Clock, simplified here)
// 3. Verify signature from authorized server
// Get server address from registry
let server_addr = get_server_address(server_registry);
assert!(
sig_verify::verify_signature(message_bytes, signature, server_addr),
EInvalidSignature,
);
// 4. Execute business logic
assert!(msg.player == ctx.sender(), EPlayerMismatch);
// ...grant items, points, etc.
}
When actually writing Builder contracts, you should at minimum include 5 binding items: player, action_type, target object id, deadline, nonce/request_id. Missing any one could result in “signature itself is fine, but was used to do something not originally intended.” A simple principle: any field you don’t want users to replace, reuse, or delay execution should be included in the signed bytes. A well-designed permission system binds player_address, target_structure_id, target_location_hash, deadline_ms, and even business identifiers in data into an inseparable statement.
5.2 In Practice: Location Proof Verification (Preview of Ch.28 content)
verify_proximity in location.move is a typical application of sig_verify:
// world/sources/primitives/location.move
pub fun verify_proximity(
location: &Location,
proof: LocationProof,
server_registry: &ServerAddressRegistry,
clock: &Clock,
ctx: &mut TxContext,
) {
let LocationProof { message, signature } = proof;
// Step 1: Verify message fields (location hash, sender address, etc.)
validate_proof_message(&message, location, server_registry, ctx.sender());
// Step 2: BCS encode message
let message_bytes = bcs::to_bytes(&message);
// Step 3: Verify deadline not expired
assert!(is_deadline_valid(message.deadline_ms, clock), EDeadlineExpired);
// Step 4: Call sig_verify to verify signature!
assert!(
sig_verify::verify_signature(
message_bytes,
signature,
message.server_address,
),
ESignatureVerificationFailed,
)
}
6. From TypeScript to On-Chain: Complete Example
Server-Side Signing (TypeScript/Node.js)
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
import { blake2b } from '@noble/hashes/blake2b';
const serverKeypair = Ed25519Keypair.fromSecretKey(SERVER_PRIVATE_KEY);
// Construct message (consistent with BCS format in Move)
const message = {
server_address: serverKeypair.getPublicKey().toSuiAddress(),
player_address: playerAddress,
// ...other fields
};
// Serialize (BCS)
const messageBytes = bcs.serialize(PermitMessage, message);
// Add PersonalMessage intent prefix
const intentPrefix = new Uint8Array([0x03, 0x00, 0x00]);
const msgWithIntent = new Uint8Array([...intentPrefix, ...messageBytes]);
// Calculate Blake2b-256 digest
const digest = blake2b(msgWithIntent, { dkLen: 32 });
// Sign with server private key
const rawSig = serverKeypair.signData(digest); // 64 bytes
// Build complete signature: flag (1) + sig (64) + pubkey (32) = 97 bytes
const pubKey = serverKeypair.getPublicKey().toRawBytes(); // 32 bytes
const fullSignature = new Uint8Array([0x00, ...rawSig, ...pubKey]);
Player Submits to On-Chain (TypeScript/PTB)
const tx = new Transaction();
tx.moveCall({
target: `${PACKAGE_ID}::my_extension::redeem_server_permit`,
arguments: [
tx.object(SERVER_REGISTRY_ID),
tx.pure(bcs.vector(bcs.u8()).serialize(Array.from(messageBytes))),
tx.pure(bcs.vector(bcs.u8()).serialize(Array.from(fullSignature))),
],
});
await client.signAndExecuteTransaction({ signer: playerKeypair, transaction: tx });
7. Match Syntax: Move 2024 New Feature
sig_verify.move extensively uses Move 2024’s match expression:
// Move 2024 match (similar to Rust)
let (sig_len, pk_len) = match (flag) {
ED25519_FLAG => (ED25519_SIG_LEN, ED25519_PK_LEN),
_ => abort EUnsupportedScheme,
};
Compared to old syntax:
// Move old syntax
let sig_len: u64;
let pk_len: u64;
if (flag == ED25519_FLAG) {
sig_len = ED25519_SIG_LEN;
pk_len = ED25519_PK_LEN;
} else {
abort EUnsupportedScheme
};
8. Security Considerations
| Risk | Protection Mechanism |
|---|---|
| Forged signature | Ed25519 cryptographic guarantee |
| Replay attack (same proof submitted repeatedly) | deadline_ms expiration time + one-time verification mark |
| Wrong server signature | derive_address_from_public_key verifies address match |
| Unregistered server | ServerAddressRegistry whitelist filtering |
9. Practice Exercises
- Signature Verification Tool: Implement a “signature generator” in TypeScript that generates pass permit signatures for players using test keys
- Single-Use Credential: Design a contract that receives server-issued “single-use items,” marks them as “used” on-chain after verification to prevent replay
- Multi-Server Support: Read the design of
ServerAddressRegistryand think about how to support multiple game server nodes signing the same credential
Chapter Summary
| Concept | Key Points |
|---|---|
| Ed25519 signature format | flag(1) + sig(64) + pubkey(32) = 97 bytes |
| PersonalMessage intent | 0x030000 prefix + message, Blake2b256 digest |
| Address verification | `Blake2b256(0x00 |
| Match syntax | Move 2024 new feature, replaces if/else branches |
tabulate! macro | Concise byte slice operations |
Next Chapter: Location Proof Protocol — BCS serialization of LocationProof, proximity verification, and how to require players to “be present” in building contracts.