The Arkade Asset protocol is designed to operate in a hybrid environment, with assets moving seamlessly between off-chain Arkade transactions and on-chain Bitcoin transactions. This architecture imposes a critical requirement: a unified view of the asset state.
This ensures that an asset's history is unbroken and its ownership is unambiguous, regardless of how it is transferred. Arkade Asset V1 is a UTXO-native asset system for Bitcoin transactions inspired by Runes and Liquid Assets.
Arkade Assets are projected onto Bitcoin transactions by embedding a data packet, the Arkade Asset V1 packet.
Each Arkade Asset V1 packet, embedded in a Bitcoin output via OP_RETURN semantics, contains an ordered list of Asset Groups which define asset details along with indexes of transaction inputs and outputs that are carrying this asset and the amounts. The order is important for fresh asset mint operations.
Assets are identified by an Asset ID, which is always a pair: AssetId: (genesis_txid, group_index)
genesis_txid = the transaction where the asset was first mintedgroup_index = the index of the asset group inside that genesis transactionThere are two cases:
(this_txid, group_index), where this_txid is the current transaction hash. Since this is the genesis transaction for that asset, this_txid = genesis_txid.(genesis_txid, group_index)When a fresh asset is being created, its asset group may specify a control asset. A fresh asset may be issued while its control asset is also being freshly minted in the same transaction.
Control assets allow additional, future reissuance of a token, and are themselves assets. If an asset group increases supply (Σout > Σin), the corresponding control asset MUST appear in the same transaction. This requirement applies to both fresh issuance and reissuance.
If an asset did not specify a control asset at genesis, it cannot be reissued and its total supply is forever capped at the amount created in its genesis transaction.
Control Asset Rules:
No Self-Reference: An asset MUST NOT reference itself as its own control asset.
Single-Level Control: Only the direct control asset is required for reissuance. Control is NOT transitive - if Asset A is controlled by Asset B, and Asset B is controlled by Asset C, reissuing Asset A requires only Asset B (not C).
Supply Finalization: Burning the control asset (explicitly or by not including it in outputs) permanently locks the controlled asset's supply. This is intentional behavior for finalizing an asset's supply. Existing tokens continue to circulate normally.
Arkade Asset V1 supports projecting multiple assets onto a single UTXO, and BTC amounts are orthogonal and not included in asset accounting.
Asset amounts are atomic units, and supply management is managed through UTXO spending conditions.
Arkade Asset supports a flexible, onchain key-value model for metadata in the asset group. Well-known keys (e.g., name, ticker, decimals) can be defined in a separate standards document, but any key-value pair is valid.
Metadata is defined at genesis and is immutable—it cannot be changed after the asset is created. This design eliminates race conditions in the 2-step async execution model and ensures metadata can be verified without indexer state injection.
Genesis Metadata
When an asset is first created (i.e., the AssetId is omitted from the group), the optional Metadata map in the Group defines its permanent metadata. This is useful for defining core properties like names, images, or application-specific data.
Metadata Hashing (Taproot-aligned Merkle Tree)
The metadataHash is the Merkle root of the asset's metadata, computed at genesis. The tree construction is aligned with BIP-341 taptrees, enabling a single generalized OP_MERKLEPATHVERIFY opcode for merkle inclusion proofs.
Tagged Hash Primitive (BIP-341)
All Merkle tree hashes use the tagged hash construction from BIP-341:
tagged_hash(tag, msg) = SHA256(SHA256(tag) || SHA256(tag) || msg)
This provides domain separation — a leaf hash can never collide with a branch hash or with hashes from other protocols.
Leaf Construction
leaf[i] = tagged_hash("ArkadeAssetLeaf", leaf_version || varuint(len(key[i])) || key[i] || varuint(len(value[i])) || value[i])
"ArkadeAssetLeaf" tag separates Arkade metadata leaves from Taproot's "TapLeaf" and from branch hashesleaf_version (1 byte, currently 0x00) enables future metadata encoding formats without changing the tree structureBranch Construction
branch = tagged_hash("ArkadeAssetBranch", min(left, right) || max(left, right))
"ArkadeAssetBranch" provides domain separation from Taproot's "TapBranch". The generalized OP_MERKLEPATHVERIFY opcode accepts the branch tag as a stack parameter, supporting both tree typesOdd Leaf Handling: If a tree level has an odd number of nodes, the unpaired node is promoted to the next level without hashing.
Exactly one OP_RETURN output must contain the Arkade Asset protocol packet, prefixed with magic bytes. The packet itself is a top-level TLV (Type-Length-Value) stream, allowing multiple data types to coexist within a single transaction.
scriptPubKey = OP_RETURN <Magic_Bytes> <TLV_Stream>
0x41524b ("ARK")0x00-0x3F: Self-delimiting types. Type || Payload (no length field)0x40-0x7F: Variable-length spec types. Type || Length (varint) || Payload0x80-0xFF: Extensions. Type || Length (varint) || Payload (parsers can skip unknown)Multiple OP_RETURN Handling: If a transaction contains multiple OP_RETURN outputs with ARK magic bytes (0x41524b), or multiple Type 0x00 (Assets) records across TLV streams, only the first Type 0x00 record found by output index order is processed. Subsequent Asset records are ignored.
The Arkade Asset data is identified by Type = 0x00. As a self-delimiting type (range 0x00-0x3F), no length field is needed.
<Type: 0x00> <Asset_Payload>
Note (Implicit Burn Policy): If a transaction spends any UTXOs known to carry Arkade Asset balances but contains no OP_RETURN with an Arkade Asset packet (Type 0x00), those balances are considered irrecoverably burned. Indexers MUST remove such balances from their state.
Packet := {
GroupCount : varuint
Groups[GroupCount] : Group
}
Group := {
AssetId? : AssetId # absent => fresh asset (AssetId* = (this_txid, group_index))
ControlAsset? : AssetRef # Genesis only: Defines the control asset for reissuance.
Metadata? : map<string, string> # Genesis only: Immutable metadata set at asset creation.
InputCount : varuint
Inputs[InputCount] : AssetInput
OutputCount : varuint
Outputs[OutputCount] : AssetOutput
}
While the specification uses a logical TLV (Type-Length-Value) model, the canonical binary encoding employs specific optimizations for compactness.
Group Optional Fields: Presence Byte
Instead of using a type marker for each optional field within a Group, the implementation uses a single presence byte. This byte is a bitfield that precedes the group's data, where each bit signals the presence of an optional field:
bit 0 (0x01): AssetId is present.bit 1 (0x02): ControlAsset is present (genesis only).bit 2 (0x04): Metadata is present (genesis only).bits 3-7: Reserved for future protocol extensions. Parsers MUST ignore these bits if set.The fields, if present, follow in that fixed order. This is more compact than a full TLV scheme for a small, fixed set of optional fields.
Byte Order: Little-Endian
All multi-byte integer fields are encoded in little-endian, consistent with Bitcoin's serialization convention. This applies to:
gidx fields in AssetId and AssetRefvin (input index) in AssetInputvout (output index) in AssetOutputAmount Encoding: Varint
All amount fields (u64) use Bitcoin's CompactSize varint encoding:
0x00-0xFC: 1 byte (values 0-252)0xFD + u16 LE: 3 bytes (values 253-65535)0xFE + u32 LE: 5 bytes (values 65536-4294967295)0xFF + u64 LE: 9 bytes (values > 4294967295)This saves 7 bytes per NFT amount (amt=1) compared to fixed u64.
Variant Types: Type Markers
For data structures that represent one of several variants (a oneof structure), a type marker byte is used. This is consistent with the logical TLV model.
AssetRef: 0x01 for BY_ID, 0x02 for BY_GROUP.AssetInput: 0x01 for LOCAL, 0x02 for INTENT.Type marker values are interpreted in the context of the structure being parsed; identical numeric values in different structures do not conflict.
All multi-byte integer fields are little-endian encoded (matching Bitcoin's convention).
AssetId := { txid: bytes32, gidx: u16 LE } # genesis tx id + group index
AssetRef := oneof {
0x01 BY_ID { assetid: AssetId } # if existing asset
| 0x02 BY_GROUP { gidx: u16 LE } # if fresh asset (does not exist yet therefore no AssetId)
}
# BY_GROUP forward references are ALLOWED - gidx may reference a group that appears later in the packet.
AssetInput := oneof {
0x01 LOCAL { vin: u16 LE, amount: varint } # input from same transaction's prevouts
| 0x02 INTENT { txid: bytes32, vout: u16 LE, amount: varint } # output from intent transaction
}
AssetOutput := { vout: u16 LE, amount: varint } # output within same transaction
Note: The intent system enables users to signal participation in a batch for new VTXOs. Intents are Arkade-specific ownership proofs that signals vtxos (and their asset) for later claiming by a commitment transaction and its batches.
For implementers, here is the complete binary format:
# OP_RETURN Structure
OP_RETURN := "ARK" || AssetMarker || Packet
AssetMarker := 0x00 # Identifier for op_ret asset data
# Asset Packet
Packet := {
GroupCount: varint
Groups[GroupCount]: Group
}
Group := {
Presence: u8 # bits: 0x01=AssetId, 0x02=ControlAsset, 0x04=Metadata
AssetId?: AssetId # if presence & 0x01
ControlAsset?: AssetRef # if presence & 0x02 (genesis only)
Metadata?: Metadata # if presence & 0x04 (genesis only)
InputCount: varint
Inputs[InputCount]: AssetInput
OutputCount: varint
Outputs[OutputCount]: AssetOutput
}
AssetId := { txid: bytes32, gidx: u16 LE }
AssetRef := oneof {
0x01 BY_ID: AssetId
0x02 BY_GROUP: u16 LE # gidx reference within same packet
}
Metadata := {
Count: varint
Entries[Count]: { key_len: varint, key: bytes, value_len: varint, value: bytes }
}
AssetInput := oneof {
0x01 LOCAL: { vin: u16 LE, amount: varint }
0x02 INTENT: { txid: bytes32, vout: u16 LE, amount: varint }
}
AssetOutput := { vout: u16 LE, amount: varint }
Old Asset VTXO → [Intent TX] → [Commitment TX] → New Asset VTXOs
Intent Transaction:
Commitment Transaction:
Composability:
A single intent can mix collaborative exits and VTXOs. The BIP322-signed configuration message embedded in the intent specifies the type of each output:
Intent TX:
vout 0 → collaborative exit (on-chain)
vout 1 → new VTXO
vout 2 → new VTXO
Asset packet:
{ vout: 0, amount: 50 } # 50 tokens to on-chain
{ vout: 1, amount: 30 } # 30 tokens to VTXO
{ vout: 2, amount: 20 } # 20 tokens to VTXO
Intent Lifecycle
AssetID Validation: If AssetId is present, it must reference a valid genesis asset transaction and group index.
Metadata Validation: If Metadata is present, AssetId must be absent.
Control Asset Validation: The ControlAsset property must be present in the Genesis Transaction. One of two types is verified:
AssetId is present, it must reference an existing Asset Group ID.GroupIDX is present, len(AssetGroups) > GroupIDX, and Asset Group Index != GroupIDX.Zero Amount Validation: All asset amounts MUST be greater than zero. An input or output with amount = 0 is INVALID.
Input Amount Validation:
LOCAL Asset Input amounts MUST match the actual asset balances of referenced VTXOs.
INTENT Asset Input amounts MUST match the actual asset balances of referenced intents transaction output.
Output Index Validation: Asset Output indices MUST reference valid VTXOs. Out-of-bound indices render the transaction INVALID.
Cross Amount Validation: Total Output amount MUST be less than or equal to Total Input amount, unless Control Asset Is Provided
For detailed transaction examples, including diagrams, packet definitions, and code, please see examples.md.
Proving that an asset was genuinely issued by a specific entity (e.g., Tether issuing a stablecoin) can be accomplished by signing a message with the private key corresponding to a relevant UTXO. This is typically done using a standard like BIP322 (Signatures for P2TR). There are two primary methods:
1. Proof of Genesis (Historical Proof)
This method proves who the original creator of an asset was by linking them to the funding of the genesis transaction.
"We, Tether, certify that the Arkade Asset with genesis txid [genesis_txid] and group index [gidx] is the official USDT-Arkade."2. Proof of Control (Dynamic Proof)
This method proves who has administrative rights over an asset (e.g., the ability to reissue it). This is the most robust method for proving ongoing authenticity.
"As the current controller of USDT-Arkade, Tether authorizes this action at block height X."In summary, Proof of Genesis establishes historical origin, a one-time, static origin of an asset, Proof of Control provides an ongoing mechanism to demonstrate administrative authority - supporting actions such as reissuance or periodic attestations of authenticity - by linking the asset to a live, controlled UTXO on the Bitcoin blockchain.
To ensure data integrity and consistency with the underlying Bitcoin blockchain, the Arkade Asset (onchain) indexer is designed to handle blockchain reorganizations (reorgs) and transaction replacements (RBF).
The indexer's state (including all asset definitions, UTXO balances, and processed transactions) is not stored in a single monolithic file. Instead, it is versioned by block height. After processing all transactions in a block, the indexer saves a complete snapshot of the new state into a file named state_<height>.json.
blockHeight: -1.state_n.json and the internal blockHeight becomes n.state_(n).json, applies transactions from block n+1, and saves the result to state_(n+1).json.Transactions are applied on a per-block basis. To process block n, the indexer first loads the state from the previous block (state_(n-1).json) and applies all transactions from block n to a temporary, in-memory copy of the state. Only if all transactions in block n are valid under the Arkade Asset rules and applied successfully is the new state committed to disk as state_n.json. If any transaction fails, the indexer MUST NOT advance its state or write state_n.json (i.e., block n is not applied by the indexer).
If a blockchain reorganization occurs, the external process monitoring the blockchain must instruct the indexer to roll back its state. For example, if block 101 is orphaned and replaced by a new block 101', the process is as follows:
rollbackLastBlock() method is called. This deletes the state file for the most recent block (e.g., state_101.json).state_100.json), making it the current active state.applyBlock() method, which will create a new state_101.json.This mechanism ensures that the indexer's view of asset ownership remains synchronized with the canonical chain, providing a robust foundation for applications built on Arkade Assets.
The indexer implementation described here operates on confirmed blocks only. It does not watch the mempool for unconfirmed transactions. This design choice has several implications:
The intent system provides native support for Arkade's batch swap mechanism, enabling seamless asset continuity across VTXO transitions.
In Arkade, users periodically perform batch swaps to:
Without intents, assets in old VTXOs would be lost during batch swaps, requiring complex workarounds or operator liquidity fronting.
With intent transfers, the batch swap process becomes:
User Submits Intent:
Operator Builds Commitment Transaction:
graph LR
A[Old VTXO
• LOL: 100] --> B[Intent TX]
B --> C[INTENT Output
LOL: 100 locked]
D[Commitment TX] --> E[New VTXO
• LOL: 100]
C -.-> DUsers can exit assets to on-chain outputs by specifying collaborative exit in their intent's BIP322 configuration. The commitment transaction's asset packet aggregates all collaborative exit claims and places those assets at on-chain outputs via LOCAL outputs.
This mechanism ensures that Arkade Assets work seamlessly within Arkade's batch swap architecture while maintaining the protocol's trust-minimized properties.
Arkade uses special transaction types for operator security that are exempt from Arkade Asset validation. These transactions protect the operator's BTC liquidity and do not represent asset operations.
Checkpoint transactions are defense mechanisms that allow the Arkade operator to protect against griefing attacks on preconfirmed VTXOs.
Asset Validation Rules:
Forfeit transactions allow the operator to reclaim funds when detecting double-spend attempts on settled VTXOs.
Asset Validation Rules:
When the Arkade virtual mempool partially unrolls (broadcasts transactions to Bitcoin in sequence), asset ownership follows the tip of the virtual mempool, not intermediate states.
Example: Consider a virtual mempool chain A → B → C where an asset is transferred from A to C:
Virtual Mempool State (preconfirmed):
A (asset origin) → B → C (asset destination)
If the chain partially unrolls to B (transactions A and B are broadcast to Bitcoin, C remains preconfirmed):
After Partial Unroll to B:
A, B (broadcast to Bitcoin)
C (remains in virtual mempool, preconfirmed)
Asset Resolution:
C in the virtual mempoolC) as the current unspent asset locationA or BThese rules ensure that:
This document outlines the introspection opcodes available in Arkade Script for interacting with Arkade Assets, along with the high-level API structure and example contracts.
For base opcodes (transaction introspection, arithmetic, cryptographic, etc.), see Introspector.
These opcodes provide access to the Arkade Asset V1 packet embedded in the transaction.
All Asset IDs are represented as two stack items: (txid32, gidx_u16). txid32 is the transaction ID of the genesis transaction where the asset was minted, and gidx_u16 is the index of the asset group within that genesis transaction.
| Opcode | Stack Effect | Description |
|---|---|---|
OP_INSPECTNUMASSETGROUPS |
→ count_u16 |
Number of groups in the Arkade Asset packet |
OP_INSPECTASSETGROUPASSETID gidx_u16 |
→ txid32 gidx_u16 |
Resolved AssetId of group gidx_u16. Issuance group uses this_txid as its genesis transaction. |
OP_INSPECTASSETGROUPCTRL gidx_u16 |
→ -1 | txid32 gidx_u16 |
Control AssetId if present, else -1 |
OP_FINDASSETGROUPBYASSETID txid32 gidx_u16 |
→ -1 | gidx_u16 |
Find group index, or -1 if absent |
| Opcode | Stack Effect | Description |
|---|---|---|
OP_INSPECTASSETGROUPMETADATAHASH gidx_u16 |
→ hash32 |
Immutable metadata Merkle root (set at genesis) |
| Opcode | Stack Effect | Description |
|---|---|---|
OP_INSPECTASSETGROUPNUM gidx_u16 source_u8 |
→ count_u16 or input_count_u16 output_count_u16 |
Count of inputs/outputs. source: 0=inputs, 1=outputs, 2=both |
OP_INSPECTASSETGROUP gidx_u16 j_u32 source_u8 |
→ type_u8 data... amount_u64 |
j_u32-th input/output of group gidx_u16. source: 0=input, 1=output |
OP_INSPECTASSETGROUPSUM gidx_u16 source_u8 |
→ sum_u64 or input_sum_u64 output_sum_u64 |
Sum of amounts. source: 0=inputs, 1=outputs, 2=both |
OP_INSPECTASSETGROUP return values by type:
| Type | type_u8 |
Additional Data |
|---|---|---|
| LOCAL input | 0x01 |
input_index_u32 amount_u64 |
| INTENT input | 0x02 |
txid_32 (intent_txid) |
| LOCAL output | 0x01 |
output_index_u32 amount_u64 |
| Opcode | Stack Effect | Description |
|---|---|---|
OP_INSPECTOUTASSETCOUNT o_u32 |
→ count_u32 |
Number of asset entries assigned to output o_u32 |
OP_INSPECTOUTASSETAT o_u32 t_u32 |
→ txid32 gidx_u16 amount_u64 |
t_u32-th asset at output o_u32 |
OP_INSPECTOUTASSETLOOKUP o_u32 txid32 gidx_u16 |
→ amount_u64 | -1 |
Amount of asset (txid32, gidx_u16) at output o_u32, or -1 if not found |
| Opcode | Stack Effect | Description |
|---|---|---|
OP_INSPECTINASSETCOUNT i_u32 |
→ count_u32 |
Number of assets declared for input i_u32 |
OP_INSPECTINASSETAT i_u32 t_u32 |
→ txid32 gidx_u16 amount_u64 |
t_u32-th asset declared for input i_u32 |
OP_INSPECTINASSETLOOKUP i_u32 txid32 gidx_u16 |
→ amount_u64 | -1 |
Declared amount for asset (txid32, gidx_u16) at input i_u32, or -1 if not found |
The following API provides syntactic sugar for Arkade Script contracts. Each property/method is documented with its translation to underlying opcodes.
tx.assetGroups.length // → OP_INSPECTNUMASSETGROUPS
tx.assetGroups.find(assetId)
// → OP_FINDASSETGROUPBYASSETID assetId.txid assetId.gidx
// Returns: group index, or -1 if not found
tx.assetGroups[k].assetId // → OP_INSPECTASSETGROUPASSETID k
// Returns: { txid: bytes32, gidx: int }
tx.assetGroups[k].isFresh // → OP_INSPECTASSETGROUPASSETID k
// OP_DROP OP_TXID OP_EQUAL
// True if assetId.txid == this_txid (new asset)
tx.assetGroups[k].control // → OP_INSPECTASSETGROUPCTRL k
// Returns: AssetId (txid32, gidx_u16), or -1 if no control
// Metadata hash (immutable, set at genesis)
tx.assetGroups[k].metadataHash
// → OP_INSPECTASSETGROUPMETADATAHASH k
// Returns the immutable metadata Merkle root
// Counts
tx.assetGroups[k].numInputs
// → OP_INSPECTASSETGROUPNUM k 0
tx.assetGroups[k].numOutputs
// → OP_INSPECTASSETGROUPNUM k 1
// Sums
tx.assetGroups[k].sumInputs
// → OP_INSPECTASSETGROUPSUM k 0
tx.assetGroups[k].sumOutputs
// → OP_INSPECTASSETGROUPSUM k 1
// Computed: delta = sumOutputs - sumInputs
tx.assetGroups[k].delta // → OP_INSPECTASSETGROUPSUM k 2 OP_SUB64
// Positive = mint, Negative = burn, Zero = transfer
// Per-group inputs/outputs
tx.assetGroups[k].inputs[j]
// → OP_INSPECTASSETGROUP k j 0
// Returns: AssetInput object
tx.assetGroups[k].outputs[j]
// → OP_INSPECTASSETGROUP k j 1
// Returns: AssetOutput object
// AssetInput (from OP_INSPECTASSETGROUP k j 0)
tx.assetGroups[k].inputs[j].type // LOCAL (0x01) or INTENT (0x02)
tx.assetGroups[k].inputs[j].amount // Asset amount (u64)
// LOCAL input additional fields:
tx.assetGroups[k].inputs[j].inputIndex // Transaction input index (u32)
// INTENT input additional fields:
tx.assetGroups[k].inputs[j].txid // Intent transaction ID (bytes32)
tx.assetGroups[k].inputs[j].outputIndex // Output index in intent tx (u32)
// AssetOutput (from OP_INSPECTASSETGROUP k j 1)
tx.assetGroups[k].outputs[j].type // LOCAL (0x01) or INTENT (0x02)
tx.assetGroups[k].outputs[j].amount // Asset amount (u64)
// LOCAL output additional fields:
tx.assetGroups[k].outputs[j].outputIndex // Transaction output index (u32)
tx.assetGroups[k].outputs[j].scriptPubKey
// → OP_INSPECTASSETGROUP k j 1
// (extract output index)
// OP_INSPECTOUTPUTSCRIPTPUBKEY
// INTENT output additional fields:
tx.assetGroups[k].outputs[j].outputIndex // Output index in same tx (u32)
tx.inputs[i].assets.length
// → OP_INSPECTINASSETCOUNT i
tx.inputs[i].assets[t].assetId
tx.inputs[i].assets[t].amount
// → OP_INSPECTINASSETAT i t
tx.inputs[i].assets.lookup(assetId)
// → OP_INSPECTINASSETLOOKUP i assetId.txid assetId.gidx
// Returns: amount (> 0) or -1 if not found
tx.outputs[o].assets.length
// → OP_INSPECTOUTASSETCOUNT o
tx.outputs[o].assets[t].assetId
tx.outputs[o].assets[t].amount
// → OP_INSPECTOUTASSETAT o t
tx.outputs[o].assets.lookup(assetId)
// → OP_INSPECTOUTASSETLOOKUP o assetId.txid assetId.gidx
// Returns: amount (> 0) or -1 if not found
// Asset ID - identifies an asset by its genesis transaction and group index
struct AssetId {
txid: bytes32,
gidx: int
}
// Asset reference for control assets
struct AssetRef {
byId: bool, // true for BY_ID, false for BY_GROUP
assetId: AssetId, // Used when byId = true
groupIndex: int // Used when byId = false (references group in same tx)
}
// Input types
enum AssetInputType { LOCAL = 0x01, INTENT = 0x02 }
struct AssetInputLocal {
type: AssetInputType, // LOCAL
inputIndex: int, // Transaction input index
amount: bigint
}
struct AssetInputIntent {
type: AssetInputType, // INTENT
txid: bytes32, // Intent transaction ID
outputIndex: int, // Output index in intent tx
amount: bigint
}
// Output types
enum AssetOutputType { LOCAL = 0x01, INTENT = 0x02 }
struct AssetOutputLocal {
type: AssetOutputType, // LOCAL
outputIndex: int, // Transaction output index
amount: bigint
}
struct AssetOutputIntent {
type: AssetOutputType, // INTENT
outputIndex: int, // Output index in same tx (locked for claim)
amount: bigint
}
// Check if an asset is present in transaction
let groupIndex = tx.assetGroups.find(assetId);
require(groupIndex != null, "Asset not found");
// Check if asset is at a specific output
let amount = tx.outputs[o].assets.lookup(assetId);
require(amount > 0, "Asset not at output");
// Check if group creates a new asset (txid matches this transaction)
let group = tx.assetGroups[k];
require(group.isFresh, "Must be fresh issuance");
// Equivalent low-level check:
// OP_INSPECTASSETGROUPASSETID k → txid gidx
// OP_DROP OP_TXID OP_EQUAL → bool
let group = tx.assetGroups[k];
// Transfer (no supply change)
require(group.delta == 0, "Must be transfer");
// Mint (supply increase)
require(group.delta > 0, "Must be mint");
// Burn (supply decrease)
require(group.delta < 0, "Must be burn");
let group = tx.assetGroups.find(assetId);
require(group != null, "Asset not found");
require(group.control == expectedControlId, "Wrong control asset");
Note: Amount fields use varint encoding. Small values (0-252) use 1 byte, large values use more. The TypeScript codec handles this automatically.
This example demonstrates a fresh issuance of a new asset A, which is controlled by a pre-existing control asset C. The control asset C must be present in the same transaction for the issuance of A to be valid. The control asset must be present, but its input and output amounts do not need to match.
flowchart LR TX[(this_txid)] i0["input index 0
• C: 1"] --> TX TX --> o0["output index 0
• C: 1"] TX --> o1["output index 1
• A: 500"] TX --> o2["output index 2
• A: 500"]
Group[0] (Control Asset C):
AssetId: (txidC, gidxC) (points to an existing asset)Inputs: (i:0, amt:1)Outputs: (o:0, amt:1)Group[1] (New Asset A):
AssetId: Omitted (fresh issuance, new ID is (this_txid, 1))ControlAsset: BY_ID { assetid: {txidC, gidxC} } (points to asset C)Outputs: (o:1, amt:500), (o:2, amt:500)This is how you would construct the transaction packet using the arkade-assets-codec library.
import { Packet } from './arkade-assets-codec';
// Example A: fresh issuance with a pre-existing control asset.
const controlTxidHex = '11'.repeat(32);
const controlGidx = 0;
const payload: Packet = {
groups: [
// Group[0] Control: A pre-existing control asset, spent and re-created.
{
assetId: { txidHex: controlTxidHex, gidx: controlGidx },
inputs: [{ type: 'LOCAL', i: 0, amt: 1n }],
outputs: [{ type: 'LOCAL', o: 0, amt: 1n }]
},
// Group[1] Token: A fresh issuance, controlled by group 0.
// AssetId is omitted, which indicates this is a genesis (fresh asset).
{
controlAsset: { gidx: 0 }, // References Group[0]
metadata: { name: 'Token A' }, // Immutable metadata set at genesis
inputs: [],
outputs: [
{ type: 'LOCAL', o: 1, amt: 500n },
{ type: 'LOCAL', o: 2, amt: 500n }
]
},
]
};
// This payload would then be encoded and put into an OP_RETURN.
This example shows a standard transfer of a single asset (LOL) from multiple inputs to multiple outputs. The key requirement for a valid transfer is that the total amount of the asset in the inputs equals the total amount in the outputs (i.e., Σinputs = Σoutputs).
flowchart LR TX[(TX)] i0["input index 0
• LOL: 100"] --> TX i1["input index 1
• LOL: 40"] --> TX TX --> o0["output index 0
• LOL: 70"] TX --> o1["output index 1
• LOL: 70"]
AssetId: (txidL, gidxL)Inputs: (i:0, amt:100), (i:1, amt:40)Outputs: (o:0, amt:70), (o:1, amt:70)import { Packet } from './arkade-assets-codec';
const lolAssetId = { txidHex: '70'.repeat(32), gidx: 0 };
const payload: Packet = {
groups: [
{
assetId: lolAssetId,
inputs: [
{ type: 'LOCAL', i: 0, amt: 100n },
{ type: 'LOCAL', i: 1, amt: 40n },
],
outputs: [
{ type: 'LOCAL', o: 0, amt: 70n },
{ type: 'LOCAL', o: 1, amt: 70n },
],
},
]
};
This example demonstrates how to burn assets. A burn occurs when the sum of an asset's inputs is greater than the sum of its outputs (Σinputs > Σoutputs). In this case, two inputs containing the XYZ asset are spent, but no outputs are created for that asset group, resulting in the total amount being burned.
flowchart LR TX[(TX)] i0["input index 0
• XYZ: 30"] --> TX i1["input index 1
• XYZ: 10"] --> TX
AssetId: (txidX, gidxX)Inputs: (i:0, amt:30), (i:1, amt:10)Outputs: []import { Packet } from './arkade-assets-codec';
const xyzAssetId = { txidHex: '88'.repeat(32), gidx: 0 }; // Placeholder
const payload: Packet = {
groups: [
{
assetId: xyzAssetId,
inputs: [
{ type: 'LOCAL', i: 0, amt: 30n },
{ type: 'LOCAL', i: 1, amt: 10n },
],
outputs: [], // No outputs for this group, so all inputs are burned
},
]
};
This example shows how to reissue more units of an existing asset (A). Reissuance is a transaction where the output amount of an asset is greater than its input amount (Σoutputs > Σinputs). This is only allowed if the asset was created with a control asset, and that control asset (C) is present in the reissuance transaction.
flowchart LR TX[(TX)] i0["input index 0
• C: 1"] --> TX i1["input index 1
• A: 200"] --> TX TX --> o0["output index 0
• C: 1"] TX --> o1["output index 1
• A: 230"]
Group[0] (Control Asset C):
AssetId: (txidC, gidxC)Inputs: (i:0, amt:1)Outputs: (o:0, amt:1)Group[1] (Reissued Asset A):
AssetId: (txidA, gidxA)Inputs: (i:1, amt:200)Outputs: (o:1, amt:230)C is present in Group[0].import { Packet } from './arkade-assets-codec';
const controlAssetId = { txidHex: 'cc'.repeat(32), gidx: 0 };
const reissuedAssetId = { txidHex: 'aa'.repeat(32), gidx: 1 };
const payload: Packet = {
groups: [
{
assetId: controlAssetId,
inputs: [{ type: 'LOCAL', i: 0, amt: 1n }],
outputs: [{ type: 'LOCAL', o: 0, amt: 1n }],
},
{
assetId: reissuedAssetId,
inputs: [{ type: 'LOCAL', i: 1, amt: 200n }],
outputs: [{ type: 'LOCAL', o: 1, amt: 230n }],
},
]
};
An input UTXO is not limited to holding only one type of asset. This example demonstrates a transaction where a single input (input index 0) contains quantities of two different assets, X and Y. Both asset groups reference the same input index to spend their respective amounts.
flowchart LR TX[(TX)] i0["input index 0
• X: 10
• Y: 50"] --> TX TX --> o0["output index 0
• X: 10"] TX --> o1["output index 1
• Y: 50"]
Group[0] (Asset X):
AssetId: (txidX, gidxX)Inputs: (i:0, amt:10)Outputs: (o:0, amt:10)Group[1] (Asset Y):
AssetId: (txidY, gidxY)Inputs: (i:0, amt:50)Outputs: (o:1, amt:50)import { Packet } from './arkade-assets-codec';
const assetX = { txidHex: '55'.repeat(32), gidx: 0 };
const assetY = { txidHex: '66'.repeat(32), gidx: 1 };
const payload: Packet = {
groups: [
{
assetId: assetX,
inputs: [{ type: 'LOCAL', i: 0, amt: 10n }],
outputs: [{ type: 'LOCAL', o: 0, amt: 10n }],
},
{
assetId: assetY,
inputs: [{ type: 'LOCAL', i: 0, amt: 50n }],
outputs: [{ type: 'LOCAL', o: 1, amt: 50n }],
},
]
};
A single transaction can contain operations for multiple, independent assets. This example shows two separate asset transfers (P and Q) happening within the same transaction. Each asset has its own group in the packet.
flowchart LR
TX[(TX)]
subgraph Asset P
i0["input 0
• P: 10"] --> TX
TX --> o0["output 0
• P: 10"]
end
subgraph Asset Q
i1["input 1
• Q: 50"] --> TX
TX --> o1["output 1
• Q: 50"]
endGroup[0] (Asset P):
AssetId: (txidP, gidxP)Inputs: (i:0, amt:10)Outputs: (o:0, amt:10)Group[1] (Asset Q):
AssetId: (txidQ, gidxQ)Inputs: (i:1, amt:50)Outputs: (o:1, amt:50)import { Packet } from './arkade-assets-codec';
const assetP = { txidHex: 'ab'.repeat(32), gidx: 0 };
const assetQ = { txidHex: 'cd'.repeat(32), gidx: 0 };
const payload: Packet = {
groups: [
{
assetId: assetP,
inputs: [{ type: 'LOCAL', i: 0, amt: 10n }],
outputs: [{ type: 'LOCAL', o: 0, amt: 10n }],
},
{
assetId: assetQ,
inputs: [{ type: 'LOCAL', i: 1, amt: 50n }],
outputs: [{ type: 'LOCAL', o: 1, amt: 50n }],
},
]
};
The intent system allows assets to be moved across Arkade batches. It's a two-stage process: lock and claim.
INTENT outputs, signaling participation in a batch swap.INTENT inputs and places them at new VTXOs via LOCAL outputs.Intent Transaction
flowchart LR IntentTX[(Intent TX)] i0["LOCAL input
• T: 100
from old VTXO"] --> IntentTX IntentTX --> o_intent["INTENT output
• T: 100
• o: 0 (locked)"]
Commitment Transaction
flowchart LR CommitTX[(Commitment TX)] i_intent["INTENT input
• T: 100
• txid: intent_txid
• o: 0"] --> CommitTX CommitTX --> o0["LOCAL output
• T: 100
• o: 0 (new VTXO)"]
Intent Packet
AssetId: (txidT, gidxT)Inputs: (type:LOCAL, i:0, amt:100)Outputs: (type:INTENT, o:0, amt:100)Commitment Packet
AssetId: (txidT, gidxT)Inputs: (type:INTENT, txid:intent_txid, o:0, amt:100)Outputs: (type:LOCAL, o:0, amt:100)import { Packet } from './arkade-assets-codec';
const assetId = { txidHex: 'dd'.repeat(32), gidx: 0 };
const intentTxid = Buffer.alloc(32); // Will be the hash of the intent transaction
// Intent Transaction Payload (user submits to join batch)
const intentPayload: Packet = {
groups: [
{
assetId: assetId,
inputs: [{ type: 'LOCAL', i: 0, amt: 100n }],
outputs: [{ type: 'INTENT', o: 0, amt: 100n }],
},
]
};
// Commitment Transaction Payload (operator builds batch)
const commitmentPayload: Packet = {
groups: [
{
assetId: assetId,
inputs: [{
type: 'INTENT',
txid: intentTxid, // Hash of the intent transaction
o: 0, // Output index in intent tx
amt: 100n
}],
outputs: [{ type: 'LOCAL', o: 0, amt: 100n }],
},
]
};
A single intent can mix VTXOs and collaborative exits:
// Intent with mixed destinations (specified via BIP322 config message)
const mixedIntentPayload: Packet = {
groups: [
{
assetId: assetId,
inputs: [{ type: 'LOCAL', i: 0, amt: 100n }],
outputs: [
{ type: 'INTENT', o: 0, amt: 30n }, // → new VTXO
{ type: 'INTENT', o: 1, amt: 70n }, // → collaborative exit (on-chain)
],
},
]
};
This example demonstrates an Arkade Script contract that facilitates a trustless 1-for-1 swap of Asset A for Asset B. The contract is placed on the output holding Asset A. To spend this output, the transaction must also provide 1 unit of Asset B and send it to the contract's address, ensuring a fair exchange.
The script performs the following checks:
B with an amount of 1.A is being spent.B is being sent to the same address that held Asset A.// Define Asset IDs
OP_PUSHBYTES_32 <asset_B_txid>
OP_PUSHBYTES_1 <asset_B_gidx>
OP_ASSETID
// Check that 1 unit of Asset B is an input
OP_PUSHINT_1
OP_GETASSET_IN
OP_EQUAL
OP_VERIFY
// Check that Asset B is sent to the current contract's output script
OP_PUSHINT_1
OP_GETASSET_OUT
OP_EQUAL
OP_VERIFY
flowchart LR
TX[(Swap TX)]
subgraph Inputs
direction LR
i0["input 0
• A: 1
(Gated by Contract)"]
i1["input 1
• B: 1"]
end
subgraph Outputs
direction LR
o0["output 0
• A: 1
(To Taker)"]
o1["output 1
• B: 1
(To Original Owner)"]
end
i0 --> TX
i1 --> TX
TX --> o0
TX --> o1
This contract demonstrates a 2-of-2 multi-signature vault for an asset. To spend the asset held by this contract, two valid signatures must be provided corresponding to the two public keys defined in the script.
OP_CHECKSIG twice to validate the provided signatures against the public keys. OP_SWAP is used to reorder the stack for the second signature check.// PubKey1 Sig1
OP_PUSHBYTES_33 <pubkey1>
OP_CHECKSIG
OP_VERIFY
// PubKey2 Sig2
OP_PUSHBYTES_33 <pubkey2>
OP_CHECKSIG
OP_VERIFY
flowchart LR
TX[(Withdraw TX)]
subgraph Inputs
i0["input 0
• Vaulted Asset: 100
(2-of-2 Multi-sig)"]
end
subgraph Signatures
sig1["Signature from Key 1"]
sig2["Signature from Key 2"]
end
subgraph Outputs
o0["output 0
• Vaulted Asset: 100
(To new destination)"]
end
i0 --> TX
sig1 --> TX
sig2 --> TX
TX --> o0
This advanced contract creates a synthetic asset (SynthUSD) that is pegged to another asset (BaseAsset). The contract ensures that new SynthUSD can only be issued if a corresponding amount of BaseAsset is locked in the same transaction. Conversely, SynthUSD can be burned to unlock the BaseAsset.
The script uses introspection opcodes to check the asset balances for both the synthetic and base assets across inputs and outputs.
SynthUSD and BaseAsset.(Σout_Synth - Σin_Synth) + (Σout_Base - Σin_Base) == 0. This means that for every unit of SynthUSD created, one unit of BaseAsset must be deposited, and for every unit of SynthUSD burned, one unit of BaseAsset is returned.// Assume synth_gidx and base_gidx are on the stack
// Calculate delta for SynthUSD: (sumOutputs - sumInputs)
<synth_gidx> <1> OP_INSPECTASSETGROUPSUM
<synth_gidx> <0> OP_INSPECTASSETGROUPSUM
OP_SUB
// Calculate delta for BaseAsset: (sumOutputs - sumInputs)
<base_gidx> <1> OP_INSPECTASSETGROUPSUM
<base_gidx> <0> OP_INSPECTASSETGROUPSUM
OP_SUB
// Verify peg: delta(SynthUSD) + delta(BaseAsset) == 0
OP_ADD
OP_0
OP_EQUALVERIFY
flowchart LR
TX[(Issuance TX)]
subgraph Inputs
i0["input 0
• BaseAsset: 100"]
end
subgraph Outputs
o0["output 0
• BaseAsset: 100
(Locked in contract)"]
o1["output 1
• SynthUSD: 100
(Newly issued)"]
end
i0 --> TX
TX --> o0
TX --> o1
This document outlines the design for ArkadeKitties, a decentralized game for collecting and breeding unique digital cats, built entirely on the Ark protocol using Arkade Assets and Arkade Script.
ArkadeKitties are unique, collectible digital assets. Each Kitty is a non-fungible Arkade Asset with an amount of 1 and has a distinct set of traits determined by its genetic code (genome), which is stored immutably on-chain as asset metadata. Players can buy, sell, and breed their Kitties to create new, rare offspring.
The entire system is trustless. Ownership is enforced by the Ark protocol, and all game logic, including breeding, is executed by on-chain Arkade Script contracts, eliminating the need for a central server.
Each ArkadeKitty is a unique Arkade Asset with an amount of 1. The asset is non-fungible and can be owned and transferred like any other asset on the network.
Species Control via Delta Enforcement: All Kitties share the same control asset (the "Species Control" asset). The Species Control group must be present in any breeding transaction with delta == 0 (no minting or burning of the control asset itself). This ensures the control asset is retained and can authorize future breeding operations.
Species Control Asset: A single control asset defines the species. Every Kitty's group MUST set control to this exact assetId. Transactions that mint or reissue Kitties MUST include the Species Control group with delta == 0. Minting the control and the controlled asset in the same transaction is allowed by spec and supported by the tools.
Genesis Asset (optional lore): A special "Genesis Kitty" can still exist as the first Kitty minted under the Species Control. Its assetId may be referenced off-chain for lore/UX, but authorization is strictly enforced by the Species Control.
Provenance Verification: To prevent counterfeit assets, the BreedKitties contract enforces that any parent Kitty (and any child) sets its control reference to the Species Control assetId. Any asset with a different or missing control reference cannot be used for breeding and cannot be minted by the contract.
Naming and API conventions:
- This document uses the example sugar API:
tx.assetGroups.find(...),group.metadataHash,group.numInputs,group.delta.- Type names are kept consistent in the contract:
assetId,pubkey,Sig.- A group's
assetIdidentifies the group; the lineage pointer is a separate control asset reference, accessed via minimal opcode helpers.
The appearance and traits of each Kitty are determined by its metadata, which contains its genetic code. This metadata is structured as a key-value map and is committed to the chain via a Merkle root in the metadataHash field.
Example Metadata (on-chain committed keys only):
{
"generation": "0",
"genome": "733833e4519f1811c5f81b12ab391cb3"
}
Note: Visual traits like color, pattern, and cooldown are deterministically derived from the genome (see "Example Genome Breakdown") and are not committed as separate metadata keys on-chain.
Breeding is the core game mechanic. A player can select two Kitties they own (the "Sire" and "Dame") and combine them in a transaction that calls the breeding contract. The contract validates the parents and creates a new Kitty with a mixed genome.
The BreedKitties contract is the heart of the game. It ensures that new Kitties are only created from valid parents and that their genomes are mixed deterministically. The user provides the parents' genome and generation; the contract recomputes the two-leaf Merkle root and verifies it matches the on-chain metadataHash. Crucially, the contract spends and retains the Species Control asset in every successful breeding transaction, so mints are only possible through the contract.
pragma arkade ^1.0.0;
// Merkle verification helper for 2-leaf Kitty metadata (generation, genome)
// Uses tagged hashes for leaves (ArkadeAssetLeaf) and branches (ArkadeAssetBranch).
function verifyKittyMetadata(genLeaf: bytes32, genomeLeaf: bytes32, root: bytes32) internal returns (bool) {
// Branch uses ArkadeAssetBranch tag with lexicographic sorting
// Since children are sorted, order is determined by comparing the two hashes.
let first = min(genLeaf, genomeLeaf);
let second = max(genLeaf, genomeLeaf);
return tagged_hash("ArkadeAssetBranch", first + second) == root;
}
// Canonical metadata Merkle root for ArkadeKitties (two entries: generation, genome)
// Encoding follows arkade-assets.md Taproot-aligned leaves:
// leaf = tagged_hash("ArkadeAssetLeaf", leaf_version || varuint(len(key)) || key || varuint(len(value)) || value)
// leaf_version = 0x00. Leaf order follows serialization order: generation, genome.
// We encode generation as 8-byte big-endian (BE).
function computeKittyMetadataRoot(genome: bytes32, generationBE8: bytes8) internal returns (bytes32) {
// Precomputed key+length prefixes (with leaf_version 0x00 prepended):
// generation leaf prefix: 0x00 || 0x0a || "generation" || 0x08
const GEN_LEAF_PREFIX: bytes = 0x000a67656e65726174696f6e08;
// genome leaf prefix: 0x00 || 0x06 || "genome" || 0x20
const GENOME_LEAF_PREFIX: bytes = 0x000667656e6f6d6520;
let genLeaf = tagged_hash("ArkadeAssetLeaf", GEN_LEAF_PREFIX + generationBE8);
let genomeLeaf = tagged_hash("ArkadeAssetLeaf", GENOME_LEAF_PREFIX + genome);
// 2-leaf Merkle root using ArkadeAssetBranch with lexicographic sorting
let first = min(genLeaf, genomeLeaf);
let second = max(genLeaf, genomeLeaf);
return tagged_hash("ArkadeAssetBranch", first + second);
}
// A simple, deterministic function to mix two genomes (opcode-friendly)
// Use hashing instead of bytewise XOR to avoid byte arithmetic on-chain.
function mixGenomes(genomeA: bytes32, genomeB: bytes32, entropy: bytes32) internal returns (bytes32) {
// There is a small chance of a "mutation" (1 in 256).
// This is triggered by the last byte of entropy being zero.
if (entropy[31] == 0) {
// On mutation, the new genome is a pseudorandom hash of all inputs.
return sha256(genomeA + genomeB + entropy);
}
// Perform a trait-by-trait crossover. Each multi-byte trait is inherited as a single unit.
// The 32-byte genome is structured as follows:
// - Bytes 0-2: Body Color (24-bit RGB)
// - Bytes 3-5: Pattern Color (24-bit RGB)
// - Bytes 6-8: Eye Color (24-bit RGB)
// - Bytes 9-10: Body Pattern
// - Bytes 11-12: Eye Shape
// - Bytes 13-14: Mouth & Nose Shape
// - Byte 15: Cooldown Index
// - Bytes 16-18: Reserved Trait 1 (e.g., for animations)
// - Bytes 19-21: Reserved Trait 2 (e.g., for voice)
// - Bytes 22-23: Reserved Trait 3
// - Bytes 24-25: Reserved Trait 4
// - Bytes 26-27: Reserved Trait 5
// - Bytes 28-29: Reserved Trait 6
// - Bytes 30-31: Reserved Trait 7
// To implement this without loops, we build a 32-byte mask by unrolling the logic for each trait.
// The decision for each trait block is based on the first byte of entropy for that block.
bytes32 mask = 0x;
mask += (entropy[0] < 128) ? 0xFFFFFF : 0x000000; // Body Color
mask += (entropy[3] < 128) ? 0xFFFFFF : 0x000000; // Pattern Color
mask += (entropy[6] < 128) ? 0xFFFFFF : 0x000000; // Eye Color
mask += (entropy[9] < 128) ? 0xFFFF : 0x0000; // Body Pattern
mask += (entropy[11] < 128) ? 0xFFFF : 0x0000; // Eye Shape
mask += (entropy[13] < 128) ? 0xFFFF : 0x0000; // Mouth & Nose
mask += (entropy[15] < 128) ? 0xFF : 0x00; // Cooldown
mask += (entropy[16] < 128) ? 0xFFFFFF : 0x000000; // Reserved 1
mask += (entropy[19] < 128) ? 0xFFFFFF : 0x000000; // Reserved 2
mask += (entropy[22] < 128) ? 0xFFFF : 0x0000; // Reserved 3
mask += (entropy[24] < 128) ? 0xFFFF : 0x0000; // Reserved 4
mask += (entropy[26] < 128) ? 0xFFFF : 0x0000; // Reserved 5
mask += (entropy[28] < 128) ? 0xFFFF : 0x0000; // Reserved 6
mask += (entropy[30] < 128) ? 0xFFFF : 0x0000; // Reserved 7
// The final genome is composed in a single bitwise operation.
return (genomeA & mask) | (genomeB & ~mask);
}
function computeChildGeneration(sireGenerationBE8: bytes8, dameGenerationBE8: bytes8) internal returns (bytes8) {
let sireGen = sireGenerationBE8.toInt64();
let dameGen = dameGenerationBE8.toInt64();
let parentMaxGen = (sireGen >= dameGen ? sireGen : dameGen);
let childGen = parentMaxGen + 1;
return childGen.toBytesBE(8);
}
// --- ENTROPY-AWARE BREEDING CONTRACTS (COMMIT-REVEAL) ---
// Contract 1: Commits to a breeding pair and a secret salt.
// This creates a temporary UTXO locked with the BreedRevealContract script.
contract BreedCommit(
assetId speciesControlId,
script feeScript, // A generic script for the fee output
int fee, // The required fee to prevent spam
pubkey oracle // The public key of the oracle to be used for the reveal
int timeout // The timeout for the reveal to occur
) {
function commit(
// Sire & Dame details
sireId: assetId, sireGenome: bytes32, sireGenerationBE8: bytes8, script sireOwner,
dameId: assetId, dameGenome: bytes32, dameGenerationBE8: bytes8, script dameOwner,
// A secret salt from the user, hashed
saltHash: bytes32,
// The output index for the reveal UTXO
revealOutputIndex: int,
// The output index for the fee UTXO
feeOutputIndex: int,
// the script for the new Kitty owner
newKittyOwner: script,
) {
// 1. Verify a fee is paid to the designated fee script
require(tx.outputs[feeOutputIndex].scriptPubKey == feeScript, "Fee output script mismatch");
require(tx.outputs[feeOutputIndex].value >= fee, "Fee not paid");
require(tx.outputs[revealOutputIndex].assets.lookup(speciesControlId) == 1, "Species Control not locked in reveal output");
require(tx.outputs[revealOutputIndex].assets.lookup(sireId) == 1, "Sire not locked in reveal output");
require(tx.outputs[revealOutputIndex].assets.lookup(dameId) == 1, "Dame not locked in reveal output");
// 2. Verify parent assets are present and valid
let sireGroup = tx.assetGroups.find(sireId);
let dameGroup = tx.assetGroups.find(dameId);
require(sireGroup != null && dameGroup != null, "Sire and Dame assets must be spent");
require(sireGroup.control == speciesControlId, "Sire not Species-Controlled");
require(dameGroup.control == speciesControlId, "Dame not Species-Controlled");
require(sireGroup.metadataHash == computeKittyMetadataRoot(sireGenome, sireGenerationBE8), "Sire metadata hash mismatch");
require(dameGroup.metadataHash == computeKittyMetadataRoot(dameGenome, dameGenerationBE8), "Dame metadata hash mismatch");
// 2. Verify Species Control asset is present and retained
let speciesGroup = tx.assetGroups.find(speciesControlId);
require(speciesGroup != null && speciesGroup.delta == 0, "Species Control must be present and retained");
// 3. Construct the reveal script and enforce its creation
// The off-chain client is responsible for constructing the exact reveal script by
// parameterizing the BreedReveal contract template with the details of this commit.
// The commit contract then verifies that the output at the specified index is locked
// with this exact script, which it reconstructs here for verification.
Script revealScript = new BreedReveal(
speciesControlId,
oracle,
sireId, dameId,
sireGenome, sireGenerationBE8,
dameGenome, dameGenerationBE8,
saltHash,
sireOwner, dameOwner,
newKittyOwner,
tx.time + timeout,
);
require(tx.outputs[revealOutputIndex].scriptPubKey == revealScript, "Reveal output script mismatch");
}
}
// Contract 2: Spends the commit UTXO, verifies oracle randomness, and creates the new Kitty.
contract BreedReveal(
// Note: All parameters are now baked into the contract's script at creation time.
assetId speciesControlId,
pubkey oracle,
assetId sireId, assetId dameId,
bytes32 sireGenome, bytes8 sireGenerationBE8,
bytes32 dameGenome, bytes8 dameGenerationBE8,
bytes32 saltHash,
script sireOwner, script dameOwner,
script newKittyOwner,
int expirationTime,
) {
function reveal(
// User reveals their secret salt
salt: bytes32,
// Oracle provides randomness and a signature
oracleRand: bytes32,
oracleSig: signature,
// The assetId of the new Kitty being created
newKittyId: assetId,
kittyOutputIndex: int,
sireOutputIndex: int,
dameOutputIndex: int,
speciesControlOutputIndex: int,
) {
// 1. Verify the user's salt
require(sha256(salt) == saltHash, "Invalid salt");
// 2. Verify the oracle's signature over the randomness, bound to this specific commit
// The message includes the outpoint of the commit UTXO to prevent signature replay.
let commitOutpoint = tx.input.current.outpoint;
require(checkDataSig(oracleSig, sha256(commitOutpoint + oracleRand), oracle), "Invalid oracle signature");
// 3. Verify Species Control is present and retained (delta == 0)
let speciesGroup = tx.assetGroups.find(speciesControlId);
require(speciesGroup != null && speciesGroup.delta == 0, "Species Control must be present and retained");
require(tx.outputs[speciesControlOutputIndex].assets.lookup(speciesControlId) == 1, "Species Control not in output");
// 4. Find the new Kitty's asset group
let newKittyGroup = tx.assetGroups.find(newKittyId);
require(newKittyGroup != null, "New Kitty asset group not found");
require(newKittyGroup.isFresh && newKittyGroup.delta == 1, "Child must be a fresh NFT");
require(newKittyGroup.control == speciesControlId, "Child not Species-Controlled");
let newKittyOutput = tx.outputs[kittyOutputIndex];
require(newKittyOutput.assets.lookup(newKittyId) == 1, "New Kitty not locked in output");
require(newKittyOutput.scriptPubKey == newKittyOwner, "New Kitty must be sent to a P2PKH address");
// 5. Generate the unpredictable genome and expected metadata hash
let entropy = sha256(salt + oracleRand);
let newGenome = mixGenomes(sireGenome, dameGenome, entropy);
let expectedMetadataHash = computeKittyMetadataRoot(newGenome, computeChildGeneration(sireGenerationBE8, dameGenerationBE8));
// 6. Enforce all Kitty creation rules (verify genesis metadata hash)
require(newKittyGroup.metadataHash == expectedMetadataHash, "Child metadata hash mismatch");
}
// If the reveal doesn't happen, allow parents to be reclaimed.
function refund(dameOutputIndex: int, sireOutputIndex: int, speciesControlOutputIndex: int) {
// 1. Check that the timeout has passed
require(tx.locktime >= expirationTime, "Timeout not yet reached");
// 2. Verify parents are returned to their owners
require(tx.outputs[sireOutputIndex].assets.lookup(sireId) == 1, "Sire not refunded");
require(tx.outputs[sireOutputIndex].scriptPubKey == sireOwner, "Sire not refunded to owner");
require(tx.outputs[dameOutputIndex].assets.lookup(dameId) == 1, "Dame not refunded");
require(tx.outputs[dameOutputIndex].scriptPubKey == dameOwner, "Dame not refunded to owner");
// 3. Verify Species Control is retained (delta == 0)
let speciesGroup = tx.assetGroups.find(speciesControlId);
require(speciesGroup != null && speciesGroup.delta == 0, "Species Control must be retained");
require(tx.outputs[speciesControlOutputIndex].assets.lookup(speciesControlId) == 1, "Species Control not in output");
}
}
A key design principle in Arkade Script is the separation of concerns between on-chain contracts and off-chain clients (e.g., a user's wallet or a web interface).
On-Chain (The Contract): The BreedCommit and BreedReveal contracts act as a trustless arbiter. Their only job is to enforce the rules of the game. They verify parent Kitties, check oracle signatures, and validate the properties of the new child Kitty.
Off-Chain (The Client): The user's client is responsible for transaction construction. This now happens in two stages:
commit. It provides parent details and a saltHash, and creates an output locked with the BreedReveal script.reveal, and includes the new child Kitty output with the correct (and now known) metadata.If the client constructs a transaction that violates the on-chain rules (e.g., calculates the wrong genome), the contract will reject it, and the transaction will fail.
The visual appearance of a Kitty is derived directly from its genome. The 32-byte genome is treated as a series of gene segments, where each segment maps to a specific trait. This mapping is deterministic and public, allowing any client to render a Kitty just by reading its on-chain genome.
Example Genome Breakdown:
The 32-byte genome is a blueprint for a Kitty's appearance and attributes. Below is the definitive mapping from genome bytes to traits.
| Byte(s) | Trait | Interpretation |
|---|---|---|
0-2 |
Body Color | A 24-bit RGB value for the main fur. |
3-5 |
Pattern Color | A 24-bit RGB value for spots, stripes, etc. |
6-8 |
Eye Color | A 24-bit RGB value for the iris. |
9-10 |
Body Pattern | A 16-bit value mapping to a pattern style and variations. |
11-12 |
Eye Shape | A 16-bit value mapping to an eye shape. |
13-14 |
Mouth & Nose Shape | A 16-bit value mapping to a mouth and nose style. |
15 |
Cooldown Index | An 8-bit value mapping to breeding speed. |
16-18 |
Reserved Trait 1 | Reserved for future use (e.g., animations). |
19-21 |
Reserved Trait 2 | Reserved for future use (e.g., voice). |
22-23 |
Reserved Trait 3 | Reserved for future use. |
24-25 |
Reserved Trait 4 | Reserved for future use. |
26-27 |
Reserved Trait 5 | Reserved for future use. |
28-29 |
Reserved Trait 6 | Reserved for future use. |
30-31 |
Reserved Trait 7 | Reserved for future use. |
The deterministic nature of the initial mixGenomes function means that a breeder could predict the outcome of a breeding event before initiating it. This allows for "grinding"—running simulations off-chain to find favorable outcomes and only committing those transactions.
To ensure fair and unpredictable breeding, we introduce entropy using a commit-reveal scheme combined with an external oracle.
saltHash). This locks in their choice.oracleRand) and signs it, binding it to the user's specific commit transaction. To prevent oracle bias (where the oracle could try many random values and pick a favorable one), the oracle must operate as a Verifiable Random Function (VRF). A VRF ensures that for a given input (the commit transaction ID), there is only one possible valid random output, removing the oracle's ability to influence the outcome.salt and combines it with the oracleRand. This combined, unpredictable value is used as entropy to generate the new Kitty's genome.This two-step process ensures that neither the user nor the oracle can unilaterally control the outcome, making the breeding process genuinely random.