Architecture Overview
The Mythical SDK uses a provider pattern for secure communication between your game and the Mythical Beings embedded wallet.
- @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.
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 NQT0.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
- Your game requests a signed token from the wallet
- User confirms in wallet UI
- Wallet signs a random token with private key
- Your game sends token + signature to your backend
- 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 isSUBSCRIBED.
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';