The state-transition-sdk is a framework for implementing off-chain token transactions with on-chain proofs to prevent double-spending. This approach allows for scalable token management where the tokens themselves are self-contained entities that carry their complete history, ownership information, and cryptographic proofs.
Unlike traditional blockchain tokens that exist as entries in a shared ledger, these tokens are complete packages containing:
- Unique token ID and class ID
- Token value (with 18 decimal precision like Ethereum)
- Complete transaction history
- Cryptographic proofs of ownership
- State transition records
The system works as a state transition abstract machine where:
- Tokens exist in specific states (owned by someone)
- Transitions move tokens from one state to another
- Each transition is cryptographically verified and proven unique
- A token's history is a chain of these transitions
The protocol ensures privacy through several mechanisms:
- One-time use public keys (derived from secret + nonce)
- Pointer addressing (hiding ownership from observers)
- Salt-based obfuscation of transaction details
- Minimal on-chain footprint (only proofs, not token data)
This is the primary engine of the system, handling:
- Token creation (minting)
- Transaction generation
- Transaction import/export
- Verification of token status
- Collection and management of tokens
Key functions:
mint(): Creates a new token with proper proofscreateTx(): Generates a transaction for sending tokensimportFlow(): Imports tokens and transactionsexportFlow(): Exports tokens and transactionsgetTokenStatus(): Checks if a token is spendable and ownedcollectTokens(): Gathers all spendable tokens for a user
This is a CLI tool built on top of the state machine that provides user-friendly commands:
mint: Create new tokenspointer: Generate recipient pointers for receiving tokenssend: Create transactions to send tokensreceive: Import and resolve transactionssummary: List all owned tokens
Defines the Token class that represents the token entity with methods to:
- Validate the genesis (initial creation)
- Apply transactions to change state
- Update state based on transitions
- Get token statistics
The SDK includes a browser-based UI (docs/index.html) that provides:
- Token minting interface
- Transaction creation
- Recipient pointer generation
- Token importing
- Status checking
The transaction flow follows these steps:
-
Minting (Creation):
- Generate token ID, class, value
- Create initial state with owner's pubkey
- Get unicity proof from Unicity Aggregator
- Construct token with genesis state
-
Sending:
- Recipient creates pointer (hash of pubkey + nonce)
- Sender creates transaction with pointer as destination
- Sender obtains unicity proof to prevent double-spending
- Token + transaction exported as "transaction flow"
-
Receiving:
- Recipient imports transaction flow
- Resolves destination pointer using their secret + nonce
- Creates full destination state
- Converts transaction to transition in token history
- Token now owned by recipient
Based on the analysis of the state-transition-SDK and the reference implementation in UniRoad, we have developed a focused integration plan specifically for the UniQuake demo. This plan addresses two primary goals:
- Play-to-earn Strategy: Implementing a token-based entry fee and winner reward system
- Game State Integrity: Generating Unicity proofs for game state to ensure consistent gameplay across all clients
Unlike the UniRoad implementation that uses Y.js for synchronization, our demo will leverage the existing P2P networking infrastructure in UniQuake for token transmission, without requiring Y.js.
┌─────────────────────────┐ ┌──────────────────────────┐
│ Game Client │ │ Game Server │
│ │ │ │
│ ┌─────────────────┐ │ │ ┌─────────────────┐ │
│ │ Token Pool │ │ │ │ Token Pool │ │
│ └────────┬────────┘ │ │ └────────┬────────┘ │
│ │ │ │ │ │
│ ┌────────┴────────┐ │ │ ┌────────┴────────┐ │
│ │ TX Flow Engine │ │ │ │ TX Flow Engine │ │
│ └────────┬────────┘ │ │ └────────┬────────┘ │
│ │ │ │ │ │
│ │ │ │ │ │
└───────────┼─────────────┘ └───────────┼──────────────┘
│ │
│ │
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ P2P Transport Layer │
│ (existing UniQuake infrastructure) │
└──────────────────────────────────────────────────────────┘
The play-to-earn feature will follow this flow:
-
Client Session Entry:
- Client generates and mints tokens with a predefined value (
quake_test_coinclass) - When joining a game server, client sends an entry token to the server
- Server validates the token and allows access if valid
- Client generates and mints tokens with a predefined value (
-
Server Token Collection:
- Server collects and stores tokens from all participating clients
- Tokens are held in server's in-memory token pool during the game session
- Server keeps track of which player contributed which tokens
-
Winner Reward Distribution:
- At the end of the game session, the server identifies the winner
- Server transfers all collected tokens to the winner's address
- Winner receives and validates the transferred tokens
To ensure game state integrity:
-
Periodic State Snapshotting:
- Every 10 seconds, the server captures the complete game state
- This state includes all player positions, scores, and other relevant data
- The state is serialized into a deterministic format
-
State Token Generation:
- Server creates a unique token representing the game state
- The token includes a hash of the game state in its immutable data
- Server mints this token using the state-transition-SDK
-
Unicity Proof Generation:
- Server obtains a Unicity proof for the state token
- This proof verifies the uniqueness and integrity of the game state
- Proof is published to clients as verification of fair play
-
Client Verification:
- Clients receive the state token with Unicity proof
- Clients can verify the proof to ensure game state integrity
- Any mismatch indicates possible manipulation or inconsistency
Create lib/token-service.js that provides a wrapper around the tx-flow-engine:
const TXF = require('@unicitylabs/tx-flow-engine');
class TokenService {
constructor(secret, username) {
this.TXF = TXF;
this.secret = secret;
this.username = username;
this.pubkey = TXF.generateRecipientPubkeyAddr(secret);
this.transport = TXF.getHTTPTransport(TXF.defaultGateway());
// Token class identifiers
this.tokenTypes = {
COIN: TXF.validateOrConvert('token_class', 'quake_test_coin'),
STATE: TXF.validateOrConvert('token_class', 'quake_game_state')
};
// In-memory token storage
this.tokenPool = {
owned: new Map(), // Tokens owned by this user
pending: new Map(), // Tokens waiting to be processed
gameState: new Map() // Game state tokens for verification
};
}
// Generate and mint a new coin token
async mintCoin(value = '10') {
const tokenId = this.TXF.generateRandom256BitHex();
const nonce = this.TXF.generateRandom256BitHex();
const salt = this.TXF.generateRandom256BitHex();
const token = await this.TXF.mint({
token_id: tokenId,
token_class_id: this.tokenTypes.COIN,
token_value: value,
sign_alg: 'secp256k1',
hash_alg: 'sha256',
secret: this.secret,
nonce,
mint_salt: salt,
transport: this.transport
});
// Store in token pool
this.tokenPool.owned.set(tokenId, token);
return token;
}
// Generate a game state token
async createGameStateToken(gameState) {
const tokenId = this.TXF.generateRandom256BitHex();
const nonce = this.TXF.generateRandom256BitHex();
const salt = this.TXF.generateRandom256BitHex();
// Serialize and hash the game state
const stateHash = this.TXF.hashObject(gameState);
// Create immutable data with game state hash and timestamp
const immutableData = JSON.stringify({
state_hash: stateHash,
timestamp: Date.now(),
game_id: gameState.gameId,
frame: gameState.frame
});
const token = await this.TXF.mint({
token_id: tokenId,
token_class_id: this.tokenTypes.STATE,
token_value: '1', // Nominal value for state tokens
immutable_data: immutableData,
sign_alg: 'secp256k1',
hash_alg: 'sha256',
secret: this.secret,
nonce,
mint_salt: salt,
transport: this.transport
});
// Store in game state pool
this.tokenPool.gameState.set(tokenId, {
token,
originalState: gameState
});
return token;
}
// Send a token to a recipient
async sendToken(token, recipientPubkey) {
// Create a direct reference to recipient
const salt = this.TXF.generateRandom256BitHex();
const tx = await this.TXF.createTx(token, recipientPubkey, salt, this.secret, this.transport);
// Export the token flow for transmission
const tokenFlow = JSON.parse(this.TXF.exportFlow(token, tx));
// Remove from owned pool as it's being sent
this.tokenPool.owned.delete(token.tokenId);
return tokenFlow;
}
// Process a received token
async receiveToken(tokenFlowStr) {
const tokenFlow = typeof tokenFlowStr === 'string' ? JSON.parse(tokenFlowStr) : tokenFlowStr;
// Import the token flow
const token = this.TXF.importFlow(JSON.stringify(tokenFlow), this.secret);
// Check token status
const status = await this.TXF.getTokenStatus(token, this.secret, this.transport);
if (status.owned && status.unspent) {
// Add to owned pool
this.tokenPool.owned.set(token.tokenId, token);
return { success: true, token };
} else {
return {
success: false,
error: 'Token is not valid, not owned, or already spent'
};
}
}
// Collect spendable tokens for a given token class
async getSpendableTokens(tokenClassId) {
const result = [];
for (const token of this.tokenPool.owned.values()) {
if (token.tokenClass === tokenClassId) {
const status = await this.TXF.getTokenStatus(token, this.secret, this.transport);
if (status.owned && status.unspent) {
result.push(token);
}
}
}
return result;
}
// Verify a game state token
async verifyGameStateToken(stateToken) {
// Import the token
const token = typeof stateToken === 'string'
? this.TXF.importFlow(stateToken)
: stateToken;
// Verify token status
const status = await this.TXF.getTokenStatus(token, null, this.transport);
if (!status.valid) {
return { verified: false, error: 'Invalid token' };
}
// Get state data from token
try {
const stateData = JSON.parse(token.tokenData);
return {
verified: true,
stateHash: stateData.state_hash,
timestamp: stateData.timestamp,
gameId: stateData.game_id,
frame: stateData.frame
};
} catch (error) {
return { verified: false, error: 'Invalid state data in token' };
}
}
}
module.exports = { TokenService };Extend the game server to handle tokens for play-to-earn:
const { TokenService } = require('../lib/token-service');
class QuakeGameServer {
constructor(serverConfig) {
// Existing server initialization
// Initialize token service
this.tokenService = new TokenService(
serverConfig.secret,
serverConfig.serverName
);
// Track session tokens
this.sessionTokens = new Map(); // Map player ID to their entry token
// Set up message handlers for token operations
this.setupTokenHandlers();
}
setupTokenHandlers() {
// Handle entry token from client
this.on('token:entry', async (clientId, message) => {
try {
const { tokenFlow } = message;
// Process and validate the token
const result = await this.tokenService.receiveToken(tokenFlow);
if (result.success) {
// Store the token associated with this client
this.sessionTokens.set(clientId, result.token);
// Send acknowledgment to client
this.send(clientId, 'token:entry:ack', {
success: true,
message: 'Entry token accepted'
});
console.log(`Client ${clientId} joined with valid entry token`);
} else {
// Reject invalid token
this.send(clientId, 'token:entry:ack', {
success: false,
message: 'Invalid entry token'
});
// Optionally disconnect the client
this.disconnectClient(clientId, 'Invalid entry token');
}
} catch (error) {
console.error(`Error processing entry token from ${clientId}:`, error);
this.send(clientId, 'token:entry:ack', {
success: false,
message: `Error: ${error.message}`
});
}
});
}
// Handle game end and distribute rewards
async endGameSession(gameResult) {
try {
const { winnerId } = gameResult;
// Skip reward if no winner or no tokens collected
if (!winnerId || this.sessionTokens.size === 0) {
console.log('No winner or no tokens to distribute');
return;
}
console.log(`Game ended. Winner: ${winnerId}. Distributing ${this.sessionTokens.size} tokens`);
// Get winner's public key from connection data
const winnerPubkey = this.getClientPubkey(winnerId);
if (!winnerPubkey) {
console.error('Cannot find winner pubkey');
return;
}
// Send all collected tokens to the winner
const sentTokenFlows = [];
for (const [clientId, token] of this.sessionTokens.entries()) {
// Skip the winner's own token
if (clientId === winnerId) continue;
try {
// Send token to winner
const tokenFlow = await this.tokenService.sendToken(token, winnerPubkey);
sentTokenFlows.push(tokenFlow);
} catch (error) {
console.error(`Error sending token from ${clientId} to winner:`, error);
}
}
// Notify winner about rewards
this.send(winnerId, 'token:reward', {
tokens: sentTokenFlows,
message: `Congratulations! You've won ${sentTokenFlows.length} tokens.`
});
// Clear session tokens
this.sessionTokens.clear();
} catch (error) {
console.error('Error distributing rewards:', error);
}
}
// Generate game state token for integrity verification
async generateGameStateToken() {
try {
// Get current game state
const gameState = this.getCurrentGameState();
// Create a token with this state
const stateToken = await this.tokenService.createGameStateToken(gameState);
// Broadcast state token to all clients for verification
this.broadcast('game:state:token', {
tokenFlow: JSON.parse(this.tokenService.TXF.exportFlow(stateToken)),
timestamp: Date.now(),
frame: gameState.frame
});
return stateToken;
} catch (error) {
console.error('Error generating game state token:', error);
return null;
}
}
// Start periodic game state verification
startStateVerification(intervalMs = 10000) {
// Clear any existing interval
if (this.stateVerificationInterval) {
clearInterval(this.stateVerificationInterval);
}
// Set up new interval
this.stateVerificationInterval = setInterval(async () => {
if (this.isGameActive) {
await this.generateGameStateToken();
}
}, intervalMs);
}
// Stop state verification
stopStateVerification() {
if (this.stateVerificationInterval) {
clearInterval(this.stateVerificationInterval);
this.stateVerificationInterval = null;
}
}
}Extend the game client to work with the play-to-earn system:
const { TokenService } = require('../lib/token-service');
class QuakeGameClient {
constructor(clientConfig) {
// Existing client initialization
// Initialize token service
this.tokenService = new TokenService(
clientConfig.secret,
clientConfig.username
);
// Set up token message handlers
this.setupTokenHandlers();
}
setupTokenHandlers() {
// Handle token rewards from server
this.on('token:reward', async (message) => {
console.log('Received token rewards!', message.message);
// Process each received token
const receivedTokens = [];
for (const tokenFlow of message.tokens) {
try {
const result = await this.tokenService.receiveToken(tokenFlow);
if (result.success) {
receivedTokens.push(result.token);
}
} catch (error) {
console.error('Error processing reward token:', error);
}
}
// Update UI with new tokens
this.updateTokenUI();
console.log(`Successfully received ${receivedTokens.length} reward tokens`);
});
// Handle game state verification tokens
this.on('game:state:token', async (message) => {
try {
const { tokenFlow, timestamp, frame } = message;
// Verify the state token
const verification = await this.tokenService.verifyGameStateToken(tokenFlow);
if (verification.verified) {
// Check if client's game state matches the verified state
const localState = this.getCurrentGameState();
const localStateHash = this.tokenService.TXF.hashObject(localState);
if (verification.stateHash !== localStateHash) {
console.error('Game state mismatch detected!');
// Handle desynchronization - could request a state sync
this.requestStateSync();
} else {
console.log(`Game state verified at frame ${frame}`);
}
} else {
console.error('Invalid game state token received:', verification.error);
}
} catch (error) {
console.error('Error processing game state token:', error);
}
});
}
// Join a game by sending an entry token
async joinGameWithToken(serverInfo) {
try {
// Get or mint a coin token for entry
let entryTokens = await this.tokenService.getSpendableTokens(
this.tokenService.tokenTypes.COIN
);
// If no tokens available, mint a new one
if (entryTokens.length === 0) {
console.log('No entry tokens available, minting a new one...');
const newToken = await this.tokenService.mintCoin('10');
entryTokens = [newToken];
}
// Use the first available token
const entryToken = entryTokens[0];
// Connect to server
await this.connect(serverInfo);
// Send entry token to server
const tokenFlow = JSON.parse(this.tokenService.TXF.exportFlow(entryToken));
this.send('token:entry', { tokenFlow });
console.log('Sent entry token to server, waiting for acknowledgment...');
// Server will respond with token:entry:ack
} catch (error) {
console.error('Error joining game with token:', error);
throw error;
}
}
// Update token UI to show current token inventory
updateTokenUI() {
// Implementation depends on the UI framework
if (this.onTokenUpdate) {
// Get token counts
const coinTokens = Array.from(this.tokenService.tokenPool.owned.values())
.filter(token => token.tokenClass === this.tokenService.tokenTypes.COIN);
this.onTokenUpdate({
coinCount: coinTokens.length,
totalValue: coinTokens.reduce((sum, token) => sum + BigInt(token.tokenValue), BigInt(0)).toString()
});
}
}
}Create a module to handle consistent game state hashing and verification:
// lib/game-state-verifier.js
class GameStateVerifier {
constructor(txf) {
this.TXF = txf;
}
// Create a deterministic serialization of game state
serializeGameState(gameState) {
// Extract essential state data
const essentialState = {
gameId: gameState.gameId,
frame: gameState.frame,
timestamp: gameState.timestamp,
players: Object.fromEntries(
Object.entries(gameState.players).map(([id, player]) => [
id,
{
position: player.position,
health: player.health,
score: player.score,
// Only include necessary fields for state verification
}
])
),
// Include other essential state components
// but exclude visual or non-deterministic elements
};
// Sort object keys for deterministic serialization
return JSON.stringify(essentialState, (key, value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return Object.keys(value).sort().reduce((obj, key) => {
obj[key] = value[key];
return obj;
}, {});
}
return value;
});
}
// Hash a game state for compact representation
hashGameState(gameState) {
const serialized = this.serializeGameState(gameState);
return this.TXF.sha256(serialized);
}
// Verify if a local state matches a remote state hash
verifyStateHash(localState, remoteStateHash) {
const localHash = this.hashGameState(localState);
return localHash === remoteStateHash;
}
// Create a signed proof of the game state
async createStateProof(gameState, secret) {
const stateHash = this.hashGameState(gameState);
// Create a signature of the state hash using the secret
const signature = this.TXF.sign(stateHash, secret);
return {
stateHash,
signature,
timestamp: Date.now(),
frame: gameState.frame,
gameId: gameState.gameId
};
}
// Verify a state proof against a public key
verifyStateProof(proof, pubkey) {
return this.TXF.verify(proof.stateHash, proof.signature, pubkey);
}
}
module.exports = { GameStateVerifier };Client 1 Game Server Client 2
| | |
| | |
|-- Mint entry token | |
| | |
| | |-- Mint entry token
| | |
|-- Connect + Send token -->| |
| |-- Validate token |
|<-- Accept connection -----| |
| | |
| |<-- Connect + Send token --|
| |-- Validate token |
| |-- Accept connection ------>|
| | |
| | |
|<--------- Game session with both clients ------------>|
| | |
| |-- Determine winner (1) |
| | |
|<-- Send collected tokens --| |
| | |
|-- Process received tokens | |
| | |
Client 1 Game Server Client 2 Unicity Service
| | | |
| | | |
| |-- Capture game state | |
| |-- Hash game state | |
| | | |
| |-- Request proof --------|------------------------>|
| |<-- Return proof --------|-------------------------|
| | | |
| |-- Create state token | |
| | | |
|<-- Send state token ---| | |
| |-- Send state token ---->| |
| | | |
|-- Verify token | |-- Verify token |
|-- Compare with local | |-- Compare with local |
| game state | | game state |
| | | |
-
Token Management:
- Keep token operations simple and focused on the demo goals
- Use a fixed value for entry tokens (e.g., '10')
- Store tokens in memory for demo simplicity
- Use descriptive token IDs for easier debugging
-
Error Handling:
- Implement robust error handling for token operations
- Provide clear error messages in the UI/console
- Create fallback mechanisms for failed token operations
- Log all token-related events for troubleshooting
-
Performance Considerations:
- Keep game state tokens small by only including essential data
- Generate state tokens at reasonable intervals (10s is good for demo)
- Don't verify state during high-activity gameplay moments
- Consider throttling token operations during intense gameplay
-
Testing the Demo:
- Test with multiple clients to verify correct token collection
- Verify that all collected tokens are properly sent to the winner
- Test state verification by deliberately modifying a client's state
- Verify that the system correctly identifies and addresses state mismatches
This focused demo integration plan provides a clear path to implementing play-to-earn mechanics and game state verification in UniQuake using the state-transition-SDK. By leveraging the existing P2P networking infrastructure and implementing in-memory token management, the demo can showcase the power of blockchain-like token functionality without the complexity of distributed storage or external dependencies beyond the Unicity service itself.
The implementation demonstrates how games can incorporate verifiable digital assets and ensure gameplay integrity through cryptographic proofs, while maintaining the performance and responsiveness required for an action game like UniQuake.