Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 27: Off-Chain Signature × On-Chain Verification

Learning Objective: Deeply understand the Ed25519 signature verification mechanism in the world::sig_verify module, 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 InputPurposeReading Focus
Message bytesRaw encoding of off-chain factsCheck if off-chain signature and on-chain verification use exactly the same byte sequence
Signature blobflag + raw_sig + public_keyCheck length, slice order, and signature algorithm identifier
AdminACL / authorized addressBusiness-allowed server identityCheck that “signature correct” and “signer authorized” are two layers of validation

Key Entry Functions

EntryPurposeWhat to Confirm
sig_verify related validation entryVerify signature binding to messageWhether intent prefix is correctly added, whether bytes are strictly compared
Business contract validation wrapper functionConnect signature verification to business flowWhether nonce, expiration time, object binding are validated together
sponsor / server whitelist entryRestrict acceptable server identitiesWhether 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
  • AdminACL solves “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”:

  1. Byte layer: Are the message_bytes seen off-chain and on-chain exactly identical?
  2. Cryptographic layer: Was the signature truly generated by that private key?
  3. Identity layer: Does the address corresponding to this private key belong to an allowed server?
  4. 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.

InformationSourceContract Can Read Directly?
Player ship position coordinatesGame server real-time calculation
Whether a player is near a buildingGame physics engine
Today’s PvP kill resultsGame combat server
On-chain object stateSui 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 SignPersonalMessage approach 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

RiskProtection Mechanism
Forged signatureEd25519 cryptographic guarantee
Replay attack (same proof submitted repeatedly)deadline_ms expiration time + one-time verification mark
Wrong server signaturederive_address_from_public_key verifies address match
Unregistered serverServerAddressRegistry whitelist filtering

9. Practice Exercises

  1. Signature Verification Tool: Implement a “signature generator” in TypeScript that generates pass permit signatures for players using test keys
  2. Single-Use Credential: Design a contract that receives server-issued “single-use items,” marks them as “used” on-chain after verification to prevent replay
  3. Multi-Server Support: Read the design of ServerAddressRegistry and think about how to support multiple game server nodes signing the same credential

Chapter Summary

ConceptKey Points
Ed25519 signature formatflag(1) + sig(64) + pubkey(32) = 97 bytes
PersonalMessage intent0x030000 prefix + message, Blake2b256 digest
Address verification`Blake2b256(0x00
Match syntaxMove 2024 new feature, replaces if/else branches
tabulate! macroConcise 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.