Covenant Source Code

Three covenants. One atomic game. Zero servers.

Complete source code for the Player, Shop, and Opponent covenants running on Kaspa Testnet-12. Every line is production code from The DAG Gate.

Architecture Overview

                        Browser (static HTML + WASM)
                                  |
                          wRPC WebSocket
                                  |
                        TN12 Kaspa Node
                                  |
                    +---------+---+---+---------+
                    |         |       |         |
              [Player UTXO] [Shop UTXO] [Opponent UTXO]
               160 bytes     56 bytes    80 bytes
                    |         |       |         |
                    |    ICC: atomic tx    |    |
                    +----[2-in, 2-out]----+    |
                    |                          |
                    +----[2-in, 2-out]---------+
                         ICC: PvP combat

Character creation deploys all three covenants + funds the wallet in one transaction (5 outputs) directly from on-node mining rewards. Every game action spends and recreates the relevant covenant UTXOs via validateOutputState. Shop purchases and PvP combat use Inter-Covenant Communication (ICC) — two covenants updated atomically in a single tx. Combat is deterministic — seeded from the BlockDAG's tip hash via xorshift64 PRNG, replayable and verifiable by anyone.

CovenantSizeState FieldsAuthValidates
Player160 bytesowner, hp, gold, levelSchnorr sigoutput 0
Shop56 bytesgold_collectedNone (public)output 1
Opponent80 byteshp, goldNone (public)output 1

The Compiled Scripts

These hex strings are the compiled SilverScript output. Each is a complete Kaspa script that enforces state transitions via validateOutputState. The placeholder bytes (e.g. aa...aa for the owner pubkey, 14000000... for HP) are patched at runtime with the actual state values.

PLAYER — 160 bytes
20aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
08140000000000000008000000000000000008010000000000000057795479876958
795879ac69567900a269557900a269547978a269537901207c7e577958cd587c7e
577958cd587c7e577958cd587c7e7e7e7eb976c97602a00094013c937cbc7eaa02
000001aa7e01207e7c7e01877e00c3876975757575757575757551
Player script layout:
Bytes 0–33: owner x-only pubkey (32 bytes + push opcode)
Bytes 34–42: HP (int64 little-endian)
Bytes 43–51: Gold (int64 little-endian)
Bytes 52–60: Level (int64 little-endian)
Bytes 61–160: checkSig + validateOutputState logic
SHOP — 56 bytes
0800000000000000007800a0697652799358cd587c7eb976c9
7601389459937cbc7eaa02000001aa7e01207e7c7e01877e51c38769757551
Shop script layout:
Bytes 0–9: gold_collected (int64 little-endian)
Bytes 10–56: validates output 1, no signature required
Anyone can call sell(payment) — the covenant only checks that new_gold_collected == old_gold_collected + payment.
OPPONENT — 80 bytes
083200000000000000086400000000000000537900a269527900a269
537958cd587c7e537958cd587c7e7eb976c9760150940112937cbc7eaa
02000001aa7e01207e7c7e01877e51c387697575757551
Opponent script layout:
Bytes 0–9: HP (int64 LE, default 50)
Bytes 10–18: Gold (int64 LE, default 100)
Bytes 19–80: validates output 1, no signature
Public NPC — anyone can call fight(newHp, newGold).

Script Building — Runtime State Patching

The compiled scripts are templates. At runtime, we patch in the actual state values using simple hex string replacement. No recompilation, no AST manipulation — just splice the int64LE bytes at known offsets.

PLAYER
buildPlayerScript(pubkeyHex, hp, gold, level) {
  let s = PLAYER_SCRIPT_HEX.replaceAll('aa'.repeat(32), pubkeyHex);
  s = s.substring(0, 68) + int64LE(hp)    + s.substring(84);
  s = s.substring(0, 86) + int64LE(gold)  + s.substring(102);
  s = s.substring(0, 104) + int64LE(level) + s.substring(120);
  return s;
}
SHOP
buildShopScript(goldCollected) {
  let s = SHOP_SCRIPT_HEX;
  s = s.substring(0, 2) + int64LE(goldCollected) + s.substring(18);
  return s;
}
OPPONENT
buildOpponentScript(hp, gold) {
  let s = OPPONENT_SCRIPT_HEX;
  s = s.substring(0, 2) + int64LE(hp)   + s.substring(18);
  s = s.substring(0, 20) + int64LE(gold) + s.substring(36);
  return s;
}

The int64LE helper converts a number to 8-byte little-endian hex:

function int64LE(n) {
  const buf = new ArrayBuffer(8);
  new DataView(buf).setBigInt64(0, BigInt(n), true);
  return Array.from(new Uint8Array(buf))
    .map(b => b.toString(16).padStart(2, '0')).join('');
}

Deployment — Three Covenants, One Transaction

Character creation deploys all three covenants + funds the player wallet in a single tx directly from on-node mining rewards. Each covenant holds 0.2 KAS to stay under the block storage mass limit (small UTXOs incur high storage mass penalties).

OutputCovenantValue
0Player20,000,000 sompi (0.2 KAS)
1Shop20,000,000 sompi (0.2 KAS)
2Opponent20,000,000 sompi (0.2 KAS)
3Wallet20,000,000 sompi (0.2 KAS)
4Changeremainder to faucet
async createPlayerAndShop(kaspa, privateKey, pubkeyHex, hp, gold, level, fundingUtxos) {
  const playerScript = this.buildPlayerScript(pubkeyHex, hp, gold, level);
  const playerSpk = kaspa.ScriptBuilder.fromScript(playerScript)
    .createPayToScriptHashScript();
  const shopScript = this.buildShopScript(0);
  const shopSpk = kaspa.ScriptBuilder.fromScript(shopScript)
    .createPayToScriptHashScript();
  const oppScript = this.buildOpponentScript(50, 100);
  const oppSpk = kaspa.ScriptBuilder.fromScript(oppScript)
    .createPayToScriptHashScript();
  const changeSpk = kaspa.payToAddressScript(privateKey.toAddress('testnet-12'));

  // ... gather inputs, calculate totals ...

  const tx = new kaspa.Transaction({
    version: 0,
    inputs,
    outputs: [
      { value: 10000000n, scriptPublicKey: playerSpk },   // Player
      { value: 5000000n,  scriptPublicKey: shopSpk },     // Shop
      { value: 5000000n,  scriptPublicKey: oppSpk },      // Opponent
      { value: change,    scriptPublicKey: changeSpk },   // Change
    ],
    lockTime: 0n,
    subnetworkId: '0000000000000000000000000000000000000000',
    gas: 0n, payload: '',
  });

  const signedTx = kaspa.signTransaction(tx, [privateKey], false);
  const rpc = await this.ensureRpc(kaspa);
  return rpc.submitTransaction({ transaction: signedTx, allowOrphan: false });
}

State Updates — Spend and Recreate

Every game action (combat, level-up, inn rest) calls updatePlayerUtxo. This spends the current covenant UTXO and creates a new one with updated state. The on-chain script enforces that the output script matches the expected new state via validateOutputState.

PLAYER::UPDATE
async updatePlayerUtxo(kaspa, privateKey, pubkeyHex,
  curHp, curGold, curLevel,
  newHp, newGold, newLevel,
  covenantUtxo
) {
  const currentScript = this.buildPlayerScript(pubkeyHex, curHp, curGold, curLevel);
  const newScript     = this.buildPlayerScript(pubkeyHex, newHp, newGold, newLevel);
  const newSpk     = kaspa.ScriptBuilder.fromScript(newScript).createPayToScriptHashScript();
  const currentSpk = kaspa.ScriptBuilder.fromScript(currentScript).createPayToScriptHashScript();

  const covenantValue = BigInt(covenantUtxo.utxoEntry.amount);
  const fee = 10000n + BigInt(Math.floor(Math.random() * 1000));

  // Step 1: Build unsigned tx for signing
  const unsignedTx = new kaspa.Transaction({
    version: 0,
    inputs: [{
      previousOutpoint: covenantUtxo.outpoint,
      signatureScript: '', sequence: 0n, sigOpCount: 1,
      utxo: { amount: covenantValue, scriptPublicKey: currentSpk, ... },
    }],
    outputs: [{ value: covenantValue - fee, scriptPublicKey: newSpk }],
    ...
  });

  // Step 2: Create Schnorr signature over the tx
  const sigHex = kaspa.createInputSignature(unsignedTx, 0, privateKey);

  // Step 3: Build sig_script: <sig> <pubkey> <newHp> <newGold> <newLevel> <redeem>
  const argSb = new kaspa.ScriptBuilder();
  argSb.addData(pubBytes);
  argSb.addI64(BigInt(newHp));
  argSb.addI64(BigInt(newGold));
  argSb.addI64(BigInt(newLevel));

  const redeemSb = new kaspa.ScriptBuilder();
  redeemSb.addData(currentScriptBytes);
  const sigScript = sigHex + argSb.toString() + redeemSb.toString();

  // Step 4: Submit with sig_script
  const signedTx = new kaspa.Transaction({
    inputs: [{ previousOutpoint, signatureScript: sigScript, ... }],
    outputs: [{ value: covenantValue - fee, scriptPublicKey: newSpk }],
    ...
  });

  return rpc.submitTransaction({ transaction: signedTx, allowOrphan: false });
}
The sig_script anatomy for P2SH covenant spending:

<schnorr_sig> <pubkey> <arg1> <arg2> ... <redeem_script>

The redeem script is the current compiled covenant (with current state). The node hashes it, confirms it matches the P2SH address being spent, then executes the script. The script uses validateOutputState to verify that output 0 contains the new state built from the provided arguments.

ICC: Shop Purchase (Player + Shop Atomic Tx)

When a player buys an item, both the Player and Shop covenants must update atomically. The Player's gold decreases; the Shop's gold_collected increases by the payment amount. One transaction, two P2SH inputs, two P2SH outputs.

  Input 0: Player covenant (validates output 0)
    sig_script: <sig> <pubkey> <hp> <newGold> <level> <player_redeem>

  Input 1: Shop covenant (validates output 1)
    sig_script: <payment> <shop_redeem>

  Output 0: New Player UTXO (gold decreased)
  Output 1: New Shop UTXO (gold_collected increased)
ICC: PURCHASE
async purchaseFromShop(kaspa, privateKey, pubkeyHex,
  curHp, curGold, curLevel, newGold, payment,
  playerUtxo, shopUtxo, shopGoldCollected
) {
  // Build current and new scripts for both covenants
  const playerScript    = this.buildPlayerScript(pubkeyHex, curHp, curGold, curLevel);
  const newPlayerScript = this.buildPlayerScript(pubkeyHex, curHp, newGold, curLevel);
  const shopScript      = this.buildShopScript(shopGoldCollected);
  const newShopScript   = this.buildShopScript(shopGoldCollected + payment);

  // Two inputs, two outputs
  const unsignedTx = new kaspa.Transaction({
    inputs: [
      { previousOutpoint: playerUtxo.outpoint, sigOpCount: 1, ... },  // Player
      { previousOutpoint: shopUtxo.outpoint,   sigOpCount: 0, ... },  // Shop (no sig)
    ],
    outputs: [
      { value: playerAmt - fee, scriptPublicKey: newPlayerSpk },  // output 0
      { value: shopAmt,         scriptPublicKey: newShopSpk },    // output 1
    ],
  });

  // Player sig_script (input 0): signed by owner
  const sigHex = kaspa.createInputSignature(unsignedTx, 0, privateKey);
  const playerSigScript = sigHex
    + serialize(pubkey, curHp, newGold, curLevel)
    + serialize(playerRedeemScript);

  // Shop sig_script (input 1): no signature, just args
  const shopSigScript = serialize(payment) + serialize(shopRedeemScript);

  // Submit atomic tx
  return rpc.submitTransaction({ transaction: signedTx, allowOrphan: false });
}
Why this is trustless: The Player covenant at input 0 validates that output 0 contains the correct new player state. The Shop covenant at input 1 validates that output 1 contains the correct new shop state. Neither covenant trusts the other — each independently verifies its own output. The atomicity of the transaction guarantees both succeed or both fail.

ICC: PvP Combat (Player + Opponent Atomic Tx)

PvP combat follows the same ICC pattern. The Player covenant validates output 0 (new player HP/gold after combat). The Opponent covenant validates output 1 (new opponent HP/gold). The outcome of the fight determines the new state values.

  Input 0: Player covenant (validates output 0)
    sig_script: <sig> <pubkey> <newHp> <newGold> <level> <player_redeem>

  Input 1: Opponent covenant (validates output 1)
    sig_script: <newOppHp> <newOppGold> <opponent_redeem>

  Output 0: New Player UTXO (HP/gold changed by combat)
  Output 1: New Opponent UTXO (HP/gold changed by combat)
ICC: PVP COMBAT
async pvpFight(kaspa, privateKey, pubkeyHex,
  curHp, curGold, curLevel,
  newPlayerHp, newPlayerGold,
  oppHp, oppGold, newOppHp, newOppGold,
  playerUtxo, oppUtxo
) {
  // Player sig_script: signed, validates output 0
  const sigHex = kaspa.createInputSignature(unsignedTx, 0, privateKey);
  const playerSigScript = sigHex
    + serialize(pubkey, newPlayerHp, newPlayerGold, curLevel)
    + serialize(playerRedeemScript);

  // Opponent sig_script: unsigned (public NPC), validates output 1
  const oppSigScript = serialize(newOppHp, newOppGold)
    + serialize(oppRedeemScript);

  const signedTx = new kaspa.Transaction({
    inputs: [
      { previousOutpoint: playerUtxo.outpoint, signatureScript: playerSigScript },
      { previousOutpoint: oppUtxo.outpoint,    signatureScript: oppSigScript },
    ],
    outputs: [
      { value: playerAmt - fee, scriptPublicKey: newPlayerSpk },
      { value: oppAmt,          scriptPublicKey: newOppSpk },
    ],
  });

  return rpc.submitTransaction({ transaction: signedTx, allowOrphan: false });
}

Game Integration — How Actions Trigger Chain Updates

The game runs in the browser. Every meaningful action calls one of three functions:

Game ActionChain FunctionType
Forest combat win/losesyncToChain()Player update
Inn rest (heal)syncToChain()Player update
Level upsyncToChain()Player update
Death / respawnsyncToChain()Player update
Buy weapon/armorshopPurchase()ICC: Player+Shop
PvP arena fightpvpOnChain()ICC: Player+Opponent
PvP forfeitsyncToChain()Player update
SYNC TO CHAIN
async function syncToChain(s, action) {
  if (!Wallet._kaspa || !Wallet._privateKeyHex || !Wallet.funded) return;
  const ocHp = s._onChainHp;
  const ocGold = s._onChainGold;
  const ocLevel = s._onChainLevel;
  if (ocHp === undefined) return;

  // Skip if state hasn't changed
  if (s.hp === ocHp && s.gold === ocGold && s.level === ocLevel) return;

  // Prevent concurrent syncs
  if (syncToChain._busy) return;
  syncToChain._busy = true;

  try {
    const kaspa = Wallet._kaspa;
    const pk = new kaspa.PrivateKey(Wallet._privateKeyHex);
    const pub = pk.toPublicKey().toXOnlyPublicKey().toString();

    // Find the current covenant UTXO
    const covAddr = Covenant.getCovenantAddress(kaspa, pub, ocHp, ocGold, ocLevel);
    let covUtxo = covAddr ? await Covenant.findCovenantUtxo(covAddr) : null;

    // Fallback: use cached outpoint if UTXO not indexed yet
    if (!covUtxo && s._lastPlayerTxId) {
      covUtxo = buildCachedUtxo(s, kaspa, pub, ocHp, ocGold, ocLevel);
    }
    if (!covUtxo) return;

    // Spend current UTXO, create new one with updated state
    const result = await Covenant.updatePlayerUtxo(
      kaspa, pk, pub,
      ocHp, ocGold, ocLevel,    // current on-chain state
      s.hp, s.gold, s.level,    // new state from game
      covUtxo
    );

    // Track the new on-chain state
    s._onChainHp = s.hp;
    s._onChainGold = s.gold;
    s._onChainLevel = s.level;
    s._lastPlayerTxId = result.transactionId;
    s._lastPlayerAmount = result.playerOutputAmount;
    GameState.save(s);

    chainEmit('Player::update', action, result.transactionId);
  } catch (err) {
    console.log('Chain sync skipped:', err.message);
  } finally {
    syncToChain._busy = false;
  }
}

Wallet — Browser-Native Key Management

No extensions. No MetaMask. The browser generates a random private key, derives a TN12 address, and stores it in localStorage. An on-node faucet (funded by our CPU miner's block rewards) sends 1 KAS to deploy 3 covenants + fund the wallet — all in one transaction.

// Generate keypair
const bytes = crypto.getRandomValues(new Uint8Array(32));
const privateKeyHex = Array.from(bytes)
  .map(b => b.toString(16).padStart(2, '0')).join('');

// Derive address using @kasdk/web WASM
const pk = new kaspa.PrivateKey(privateKeyHex);
const address = pk.toAddress('testnet-12').toString();

// Fund + deploy covenants in one tx from on-node mining rewards
const result = await Covenant.createFromFaucet(kaspa, pk, pubkeyHex, hp, gold, level);
// Returns: { transactionId: "..." }
// One tx: coinbase UTXO → 3 covenants (0.2 KAS each) + wallet (0.2 KAS) + change

Network Layer — Direct wRPC to TN12 Node

The game connects directly to a Kaspa TN12 node via WebSocket RPC (wRPC). No REST API, no middleware, no proxy. The same node that validates blocks serves the game's UTXO queries and transaction submissions.

const COVENANT_NODE_WS = 'wss://tn12.dagknight.xyz';

async ensureRpc(kaspa) {
  if (this._rpc) return this._rpc;
  const rpc = new kaspa.RpcClient({ url: COVENANT_NODE_WS });
  await rpc.connect();
  this._rpc = rpc;
  return rpc;
}

// UTXO lookup
async findCovenantUtxo(address) {
  const resp = await rpc.getUtxosByAddresses({ addresses: [address] });
  const entries = resp.entries || resp || [];
  return entries.length > 0 ? entries[0] : null;
}

// Transaction submission
await rpc.submitTransaction({ transaction: signedTx, allowOrphan: false });

The Full Stack

Frontend:     Static HTML/CSS/JS on GitHub Pages
WASM SDK:     @kasdk/web (tx building, signing, address derivation)
Transport:    WebSocket (wRPC) to TN12 node
Node:         rusty-kaspa (covpp-reset2 branch) on DigitalOcean
Covenants:    SilverScript → compiled bytecode → P2SH UTXOs
State:        UTXO chain — each spend creates next state
Auth:         Schnorr signatures (player actions only)
ICC:          Multi-input P2SH txs (shop, PvP)
Faucet:       On-node CPU miner block rewards
Miner:        Custom tn12-miner (same rusty-kaspa codebase)
Registry:     Node.js indexer on same droplet (player list API)
Combat:       Deterministic — seeded from BlockDAG tip hash (xorshift64)
Backend:      None (registry is optional, game works without it).
Play The DAG Gate The Covenant Forge Integration Plan