Architecture Overview

The Mythical SDK uses a provider pattern for secure communication between your game and the Mythical Beings embedded wallet.

Three Packages
  • @mythicalb/ardor-provider - Used by your game (dApp)
  • @mythicalb/ardor-host - Used by the wallet host
  • @mythicalb/ardor-core - Shared utilities and types

Communication Flow

┌─────────────────┐     postMessage      ┌─────────────────┐
│                 │ ──────────────────►  │                 │
│   Your Game     │                      │  Wallet Host    │
│   (iframe)      │  ◄──────────────────  │  (parent)       │
│                 │     postMessage      │                 │
└─────────────────┘                      └─────────────────┘
        │                                        │
        │  MythicalProvider                      │  MythicalHost
        │  - connect()                           │  - onRequest()
        │  - sendIgnis()                         │  - wallet signing
        │  - etc.                                │  - tx broadcast
        └────────────────────────────────────────┘

Security Model

  • Origin validation - Communication only with trusted origins
  • Permission-based access - Actions require explicit user approval
  • User confirmation - Transactions require user confirmation in the wallet UI
  • No private key exposure - Keys never leave the wallet

Decimals & Precision

Understanding decimal handling is critical for accurate transactions on Ardor.

⚠️ Important

IGNIS uses 8 decimal places. Always use the conversion functions to avoid precision errors.

NQT (Nano Quantity)

All amounts on Ardor are stored as integers in the smallest unit (NQT):

  • 1 IGNIS = 100,000,000 NQT
  • 0.001 IGNIS = 100,000 NQT

Conversion Functions

import { 
  parseIgnisToNQT, 
  formatNQTToIgnis 
} from '@mythicalb/ardor-core';

// Human-readable → NQT (for sending)
const nqt = parseIgnisToNQT('1.5');      // "150000000"
const nqt2 = parseIgnisToNQT('0.001');   // "100000"

// NQT → Human-readable (for display)
const ignis = formatNQTToIgnis('150000000');  // "1.5"
const ignis2 = formatNQTToIgnis('100000');    // "0.001"

// Trailing zeros are trimmed
formatNQTToIgnis('100000000');  // "1" (not "1.00000000")

Asset Decimals

Different assets can have different decimal precisions (0-8):

import { 
  parseAssetQuantity, 
  formatAssetQuantity 
} from '@mythicalb/ardor-core';

// For an asset with 2 decimals:
parseAssetQuantity('1.5', 2);    // "150"
formatAssetQuantity('150', 2);  // "1.5"

// For an asset with 0 decimals (whole units only):
parseAssetQuantity('5', 0);     // "5"
formatAssetQuantity('5', 0);    // "5"

Best Practices

  • Always use string amounts to avoid JavaScript number precision issues
  • Convert to NQT before sending transactions
  • Convert from NQT when displaying to users
  • Store amounts as NQT strings in your database

Permissions System

The SDK uses a permission system to control what actions your game can perform.

Available Permissions

Permission Description Risk Level
READ_ACCOUNT Read account address and public key 🟢 Low
READ_BALANCES Read IGNIS and asset balances 🟢 Low
TX_SEND_IGNIS Send IGNIS transactions 🟡 Medium
TX_TRANSFER_ASSET Transfer assets/NFTs 🟡 Medium
TX_SEND_MESSAGE Send on-chain messages 🟡 Medium
TX_PLACE_ORDER Place marketplace orders 🟡 Medium
TX_CANCEL_ORDER Cancel marketplace orders 🟢 Low
SIGN_TOKEN Sign authentication tokens 🟢 Low

Requesting Permissions

// Request only what you need
const session = await provider.connect({
  appName: 'My Game',
  permissions: [
    'READ_ACCOUNT',
    'READ_BALANCES',
    'TX_SEND_IGNIS'
  ]
});

// Check granted permissions
if (session.permissions.includes('TX_SEND_IGNIS')) {
  // Can send IGNIS
}

Permission Errors

try {
  await provider.placeAskOrder({ ... });
} catch (error) {
  if (error.code === 'PERMISSION_DENIED') {
    // Request additional permissions
    await provider.connect({
      appName: 'My Game',
      permissions: [...currentPermissions, 'TX_PLACE_ORDER']
    });
  }
}

Messaging System

Send on-chain messages to other Ardor accounts, optionally encrypted.

Plain Messages

// Public message (visible to everyone)
await provider.sendMessage({
  recipientRS: 'ARDOR-XXXX-XXXX-XXXX-XXXXX',
  message: 'Hello from the game!'
});

// Message with fee bumping for priority
await provider.sendMessage({
  recipientRS: 'ARDOR-XXXX-XXXX-XXXX-XXXXX',
  message: 'Urgent message',
  feeNQT: '200000000' // 2 IGNIS fee
});

Encrypted Messages

// Private message (only sender and recipient can read)
await provider.sendEncryptedMessage({
  recipientRS: 'ARDOR-XXXX-XXXX-XXXX-XXXXX',
  message: 'This is a secret message'
});

// Encrypted with custom expiration
await provider.sendEncryptedMessage({
  recipientRS: 'ARDOR-XXXX-XXXX-XXXX-XXXXX',
  message: 'Secret trade offer',
  deadline: 60 // Expires in 60 minutes if not confirmed
});

Use Cases

  • Trade negotiations - Private messages between players
  • Game events - Broadcast announcements
  • NFT metadata - Attach data to transfers
  • Proof of action - Immutable game records

Marketplace Trading

The Ardor Asset Exchange allows trading of game assets with no centralized order book.

Order Book Concepts

  • Ask Order - Sell order: "I'm selling X assets at Y price"
  • Bid Order - Buy order: "I'm buying X assets at Y price"
  • Orders are matched automatically when prices overlap
  • Partial fills are supported

Price Calculation

// Price is in NQT per QNT (smallest unit)
// For an asset with 2 decimals:
// - 1 unit = 100 QNT
// - Price of 1 IGNIS per unit = 1,000,000 NQT per QNT

const assetDecimals = 2;
const pricePerUnit = '1'; // 1 IGNIS per unit

// Convert to NQT per QNT
const nqtPerUnit = parseIgnisToNQT(pricePerUnit);  // "100000000"
const qntPerUnit = Math.pow(10, assetDecimals);    // 100
const priceNQTPerQNT = BigInt(nqtPerUnit) / BigInt(qntPerUnit); // 1000000

Place Sell Order

await provider.placeAskOrder({
  assetId: '123456789',
  quantityQNT: '100',           // Sell 1 unit (2 decimals)
  priceNQTPerShare: '1000000'   // At 1 IGNIS per unit
});

Place Buy Order

await provider.placeBidOrder({
  assetId: '123456789',
  quantityQNT: '100',           // Buy 1 unit
  priceNQTPerShare: '1000000'   // At 1 IGNIS per unit
});

Cancel Orders

// Cancel a sell order
await provider.cancelAskOrder({ orderId: '987654321' });

// Cancel a buy order
await provider.cancelBidOrder({ orderId: '123456789' });

Token Authentication

Use signed tokens for secure backend authentication without exposing private keys.

How It Works

  1. Your game requests a signed token from the wallet
  2. User confirms in wallet UI
  3. Wallet signs a random token with private key
  4. Your game sends token + signature to your backend
  5. Backend verifies signature proves account ownership

Client-Side

async function login() {
  // 1. Get signed token from wallet
  const { token, signature, accountRS, timestamp } = 
    await provider.signToken();
  
  // 2. Send to backend for verification
  const response = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token, signature, accountRS, timestamp })
  });
  
  // 3. Store session token
  const { sessionToken } = await response.json();
  localStorage.setItem('session', sessionToken);
}

Server-Side Verification

// Using Ardor API to verify
async function verifySignature(token, signature, accountRS, timestamp) {
  // 1. Check token isn't expired (e.g., 5 min max)
  if (Date.now() - timestamp > 5 * 60 * 1000) {
    throw new Error('Token expired');
  }
  
  // 2. Verify signature using Ardor API
  const response = await fetch('https://ardor.jelurida.com/nxt', {
    method: 'POST',
    body: new URLSearchParams({
      requestType: 'verifyTokenSignature',
      signature,
      data: token,
      account: accountRS
    })
  });
  
  const result = await response.json();
  return result.verify === true;
}

Error Handling

Proper error handling ensures a good user experience.

Error Categories

try {
  await provider.sendIgnis({ ... });
} catch (error) {
  switch (error.code) {
    // User actions
    case 4001:
      console.log('User rejected the request');
      break;
    
    // Permission issues
    case 'PERMISSION_DENIED':
      console.log('Permission not granted');
      break;
    
    // Connection issues
    case 'NOT_CONNECTED':
      console.log('Wallet not connected');
      await provider.connect({ ... });
      break;
    
    // Insufficient funds
    case 'INSUFFICIENT_BALANCE':
      console.log('Not enough IGNIS');
      break;
    
    // Network issues
    case 'NETWORK_ERROR':
      console.log('Network problem, retry later');
      break;
    
    default:
      console.error('Unknown error:', error);
  }
}

User-Friendly Messages

const errorMessages = {
  4001: 'Transaction cancelled',
  'NOT_CONNECTED': 'Please connect your wallet first',
  'PERMISSION_DENIED': 'Additional permissions required',
  'INSUFFICIENT_BALANCE': 'Not enough IGNIS in your wallet',
  'INVALID_RECIPIENT': 'Invalid recipient address',
  'TIMEOUT': 'Request timed out, please try again'
};

function showError(error) {
  const message = errorMessages[error.code] || 'An error occurred';
  showNotification(message, 'error');
}

Fee Management

Understanding and managing transaction fees on Ardor.

Fee Structure

  • Minimum fee: 0.1 IGNIS (most transactions)
  • Fees are paid in IGNIS (child chain currency)
  • Higher fees = faster confirmation
  • Fees are burned (deflationary)

Default Fees

// Most transactions use minimum fee automatically
await provider.sendIgnis({
  recipientRS: '...',
  amount: { ignis: '1' }
}); // Uses ~0.1 IGNIS fee

Custom Fees

// Bump fee for priority
await provider.sendIgnis({
  recipientRS: '...',
  amount: { ignis: '1' },
  feeNQT: '50000000' // 0.5 IGNIS fee
});

// Large transactions may need higher fees
await provider.transferAsset({
  recipientRS: '...',
  assetId: '...',
  quantityQNT: '1000000',
  feeNQT: '100000000' // 1 IGNIS fee
});

Fee Estimation

// For complex operations, estimate first
const estimatedFee = await estimateTransactionFee({
  type: 'sendIgnis',
  amount: '100',
  hasMessage: true,
  messageLength: 500
});

console.log(`Estimated fee: ${formatNQTToIgnis(estimatedFee)} IGNIS`);

Security Guidelines

Best practices for secure SDK integration.

Origin Validation

// Always specify the exact host origin
const provider = new MythicalProvider('https://store.mythicalbeings.io');

// Never use wildcards or accept any origin

Input Validation

// Validate addresses before use
function isValidArdorAddress(address) {
  return /^ARDOR-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{5}$/.test(address);
}

// Validate amounts
function isValidAmount(amount) {
  const num = parseFloat(amount);
  return !isNaN(num) && num > 0 && num <= 1000000000;
}

// Use in transactions
async function sendPayment(recipient, amount) {
  if (!isValidArdorAddress(recipient)) {
    throw new Error('Invalid recipient address');
  }
  if (!isValidAmount(amount)) {
    throw new Error('Invalid amount');
  }
  
  await provider.sendIgnis({
    recipientRS: recipient,
    amount: { ignis: amount }
  });
}

Rate Limiting

// Prevent spam/abuse
const rateLimiter = {
  lastRequest: 0,
  minInterval: 1000, // 1 second between requests
  
  async throttle(fn) {
    const now = Date.now();
    const wait = this.minInterval - (now - this.lastRequest);
    
    if (wait > 0) {
      await new Promise(r => setTimeout(r, wait));
    }
    
    this.lastRequest = Date.now();
    return fn();
  }
};

// Use with SDK calls
await rateLimiter.throttle(() => provider.getBalances());

Session Management

// Check session validity regularly
setInterval(async () => {
  const session = await provider.getSession();
  if (!session) {
    // Session expired, re-authenticate
    handleSessionExpired();
  }
}, 60000); // Check every minute

// Handle wallet lock/unlock
provider.on('locked', () => {
  showLockScreen();
});

provider.on('unlocked', async () => {
  hideLockScreen();
  await refreshBalances();
});

Never Trust Client Data

// BAD: Trusting client-reported balances
async function purchaseItem(item) {
  const balances = await provider.getBalances();
  if (balances.ignisNQT >= item.priceNQT) {
    // ❌ Don't trust this! User could manipulate
    grantItemToUser(item);
  }
}

// GOOD: Verify on backend after transaction
async function purchaseItem(item) {
  const result = await provider.sendIgnis({
    recipientRS: SHOP_WALLET,
    amount: { ignis: item.price }
  });
  
  // ✅ Backend verifies transaction on blockchain
  await fetch('/api/verify-purchase', {
    method: 'POST',
    body: JSON.stringify({ 
      txHash: result.fullHash,
      itemId: item.id 
    })
  });
}

Play Hub Troubleshooting

Common issues when integrating with Play Hub sessions, wallets, and Supabase RPCs.

Game mode is not available.

The most common error for new game integrations. The RPC checks that both the game and mode exist and are enabled.

-- Check in Supabase SQL editor
select id, name, is_enabled from public.games where id = 'your_game_id';
select game_id, id, min_players, max_players, is_enabled
from public.game_modes where game_id = 'your_game_id';

If either row is missing, run your migration. If is_enabled is false, update it.

Session stuck in waiting

Test sessions left open during development block matchmaking logic. Clean them up:

select id, session_code, game_id, mode_id, status, created_at
from public.game_sessions
where status = 'waiting'
order by created_at desc;

Call mythical.sessions.leave(sessionId) in your app, or delete the row in dev.

FunctionsHttpError on wallet link/unlink

The deployed edge function is outdated. Redeploy the affected function:

supabase functions deploy playhub-link-wallet --project-ref zbmkhvpokopvhnochcjr
supabase functions deploy playhub-unlink-wallet --project-ref zbmkhvpokopvhnochcjr
supabase functions deploy playhub-wallet-challenge --project-ref zbmkhvpokopvhnochcjr

Guest score not appearing in leaderboard

This is by design. Guest accounts are intentionally blocked from writing permanent results. Show the player a prompt:

const user = await mythical.auth.getUser()

if (user.isGuest) {
  showPrompt('Sign in with email to save your score and appear in the leaderboard.')
  return
}

await mythical.sessions.finish(sessionId, { results })

Wallet linking fails with unique constraint error

The address is already linked to another profile. This is enforced by unique(chain, address) on profile_wallets. Show a clear message — do not silently fail.

try {
  await mythical.wallets.connect('polygon')
} catch (err) {
  if (err.message?.includes('profile_wallets_chain_address_key')) {
    alert('This wallet is already linked to another Play Hub account.')
  }
}

Realtime not firing after session changes

  • Check that Realtime replication is enabled for the table in the Supabase dashboard (Database → Replication).
  • Verify the filter string uses the correct column name and operator: id=eq.{sessionId}.
  • Make sure .subscribe() is called and the channel status is SUBSCRIBED.
const channel = supabase
  .channel(`session:${sessionId}`)
  .on('postgres_changes', {
    event: '*',
    schema: 'public',
    table: 'game_sessions',
    filter: `id=eq.${sessionId}`
  }, handler)
  .subscribe((status) => {
    console.log('Realtime status:', status) // should be SUBSCRIBED
  })

gen_random_bytes not found

Some Play Hub functions use search_path = public. In Supabase, pgcrypto lives in the extensions schema. The project ships a wrapper at public.gen_random_bytes(integer). If you deploy to a new environment and session codes fail to generate, verify the wrapper exists.

select routine_name from information_schema.routines
where routine_schema = 'public' and routine_name = 'gen_random_bytes';