From 51426fd67b3ceee6b8fec599948bd3d6365cb0be Mon Sep 17 00:00:00 2001 From: msinkec Date: Mon, 15 Jun 2026 17:36:04 +0200 Subject: [PATCH 1/9] rebase asset introspection to current vm semantics --- docs/ArkadeKitties.md | 56 ++-- docs/arkade-primitives-spec.md | 84 ++--- docs/arkade-script-with-assets.md | 76 +++-- examples/arkade_kitties.ark | 44 +-- examples/arkade_kitties.hack | 95 +++--- examples/arkade_kitties.json | 200 ++++++++---- examples/bonds/bond_mint.ark | 24 +- examples/bonds/repayment_pool.ark | 171 +++++----- examples/controlled_mint.ark | 18 +- examples/fee_adapter.ark | 7 +- examples/nft_mint.ark | 47 ++- examples/nft_mint.json | 134 +++++--- examples/non_interactive_swap.ark | 10 +- examples/non_interactive_swap.hack | 20 +- examples/non_interactive_swap.json | 31 +- examples/options/cash_secured_put.ark | 13 +- examples/options/cash_secured_put.json | 36 +-- examples/options/covered_call.ark | 9 +- examples/options/covered_call.json | 20 +- examples/threshold_oracle.ark | 12 +- examples/token_vault.ark | 16 +- src/compiler/mod.rs | 431 ++++++++++++++++--------- src/models/mod.rs | 39 ++- src/opcodes/mod.rs | 2 + src/parser/grammar.pest | 50 ++- src/parser/mod.rs | 254 +++++++++++++-- src/typechecker/mod.rs | 19 +- src/validator/mod.rs | 202 +++++++++++- tests/asset_id_explicit_test.rs | 306 ++++++++++++++++++ tests/beacon_test.rs | 30 +- tests/bond_mint_test.rs | 7 +- tests/cash_secured_put_test.rs | 37 ++- tests/controlled_mint_test.rs | 20 +- tests/covered_call_test.rs | 33 +- tests/epoch_limiter_test.rs | 6 +- tests/fee_adapter_test.rs | 27 +- tests/group_properties_test.rs | 42 +-- tests/no_shadowing_test.rs | 23 +- tests/repayment_pool_test.rs | 10 +- tests/threshold_oracle_test.rs | 8 +- tests/token_vault_test.rs | 43 +-- 41 files changed, 1887 insertions(+), 825 deletions(-) create mode 100644 tests/asset_id_explicit_test.rs diff --git a/docs/ArkadeKitties.md b/docs/ArkadeKitties.md index 05562cc..753858a 100644 --- a/docs/ArkadeKitties.md +++ b/docs/ArkadeKitties.md @@ -138,7 +138,8 @@ function computeChildGeneration(sireGenerationBE8: bytes8, dameGenerationBE8: by // 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, + bytes32 speciesControlIdTxid, + int speciesControlIdGidx, 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 @@ -147,8 +148,8 @@ contract BreedCommit( ) { function commit( // Sire & Dame details - sireId: assetId, sireGenome: bytes32, sireGenerationBE8: bytes8, script sireOwner, - dameId: assetId, dameGenome: bytes32, dameGenerationBE8: bytes8, script dameOwner, + sireIdTxid: bytes32, sireIdGidx: int, sireGenome: bytes32, sireGenerationBE8: bytes8, script sireOwner, + dameIdTxid: bytes32, dameIdGidx: int, dameGenome: bytes32, dameGenerationBE8: bytes8, script dameOwner, // A secret salt from the user, hashed saltHash: bytes32, // The output index for the reveal UTXO @@ -163,20 +164,20 @@ contract BreedCommit( // 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"); + require(tx.outputs[revealOutputIndex].assets.lookup(speciesControlIdTxid, speciesControlIdGidx) == 1, "Species Control not locked in reveal output"); + require(tx.outputs[revealOutputIndex].assets.lookup(sireIdTxid, sireIdGidx) == 1, "Sire not locked in reveal output"); + require(tx.outputs[revealOutputIndex].assets.lookup(dameIdTxid, dameIdGidx) == 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); + let sireGroup = tx.assetGroups.find(sireIdTxid, sireIdGidx); + let dameGroup = tx.assetGroups.find(dameIdTxid, dameIdGidx); 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.controlIs(speciesControlIdTxid, speciesControlIdGidx), "Sire not Species-Controlled"); + require(dameGroup.controlIs(speciesControlIdTxid, speciesControlIdGidx), "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); + let speciesGroup = tx.assetGroups.find(speciesControlIdTxid, speciesControlIdGidx); require(speciesGroup != null && speciesGroup.delta == 0, "Species Control must be present and retained"); // 3. Construct the reveal script and enforce its creation @@ -186,9 +187,10 @@ contract BreedCommit( // with this exact script, which it reconstructs here for verification. Script revealScript = new BreedReveal( - speciesControlId, + speciesControlIdTxid, speciesControlIdGidx, oracle, - sireId, dameId, + sireIdTxid, sireIdGidx, + dameIdTxid, dameIdGidx, sireGenome, sireGenerationBE8, dameGenome, dameGenerationBE8, saltHash, @@ -207,9 +209,10 @@ contract BreedCommit( // 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, + bytes32 speciesControlIdTxid, int speciesControlIdGidx, pubkey oracle, - assetId sireId, assetId dameId, + bytes32 sireIdTxid, int sireIdGidx, + bytes32 dameIdTxid, int dameIdGidx, bytes32 sireGenome, bytes8 sireGenerationBE8, bytes32 dameGenome, bytes8 dameGenerationBE8, bytes32 saltHash, @@ -223,8 +226,9 @@ contract BreedReveal( // Oracle provides randomness and a signature oracleRand: bytes32, oracleSig: signature, - // The assetId of the new Kitty being created - newKittyId: assetId, + // The explicit Asset ID operands of the new Kitty being created + newKittyIdTxid: bytes32, + newKittyIdGidx: int, kittyOutputIndex: int, sireOutputIndex: int, dameOutputIndex: int, @@ -239,17 +243,17 @@ contract BreedReveal( 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); + let speciesGroup = tx.assetGroups.find(speciesControlIdTxid, speciesControlIdGidx); 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"); + require(tx.outputs[speciesControlOutputIndex].assets.lookup(speciesControlIdTxid, speciesControlIdGidx) == 1, "Species Control not in output"); // 4. Find the new Kitty's asset group - let newKittyGroup = tx.assetGroups.find(newKittyId); + let newKittyGroup = tx.assetGroups.find(newKittyIdTxid, newKittyIdGidx); 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"); + require(newKittyGroup.controlIs(speciesControlIdTxid, speciesControlIdGidx), "Child not Species-Controlled"); let newKittyOutput = tx.outputs[kittyOutputIndex]; - require(newKittyOutput.assets.lookup(newKittyId) == 1, "New Kitty not locked in output"); + require(newKittyOutput.assets.lookup(newKittyIdTxid, newKittyIdGidx) == 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 @@ -268,15 +272,15 @@ contract BreedReveal( 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].assets.lookup(sireIdTxid, sireIdGidx) == 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].assets.lookup(dameIdTxid, dameIdGidx) == 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); + let speciesGroup = tx.assetGroups.find(speciesControlIdTxid, speciesControlIdGidx); require(speciesGroup != null && speciesGroup.delta == 0, "Species Control must be retained"); - require(tx.outputs[speciesControlOutputIndex].assets.lookup(speciesControlId) == 1, "Species Control not in output"); + require(tx.outputs[speciesControlOutputIndex].assets.lookup(speciesControlIdTxid, speciesControlIdGidx) == 1, "Species Control not in output"); } } diff --git a/docs/arkade-primitives-spec.md b/docs/arkade-primitives-spec.md index c6f1806..ea0bae4 100644 --- a/docs/arkade-primitives-spec.md +++ b/docs/arkade-primitives-spec.md @@ -1,6 +1,6 @@ # Arkade Primitives: Compiler Specification -A single contract compiled to audit-grade opcodes, exercising every primitive in the Arkade compilation stack. The vehicle is `USDT0Bridge.receive()`: it touches recursive covenants, control asset gating, loop unrolling, streaming hashes, `checkSigFromStack`, asset lookup sentinels, type conversions, multi-group introspection, and branch normalization. +A single contract compiled to audit-grade opcodes, exercising every primitive in the Arkade compilation stack. The vehicle is `USDT0Bridge.receive()`: it touches recursive covenants, control asset gating, loop unrolling, streaming hashes, `checkSigFromStack`, asset lookup success flags, type conversions, multi-group introspection, and branch normalization. --- @@ -15,7 +15,7 @@ Each primitive gets a section number. The opcode listing references these. | P3 | Loop unrolling | `for index, value in array` → flat `OP_CHECKSIGFROMSTACK` sequence | | P4 | Streaming hash | `SHA256INITIALIZE / UPDATE / FINALIZE` for >520B | | P5 | `checkSigFromStack` | BIP340 Schnorr signature over arbitrary message | -| P6 | Sentinel handling | `-1` from asset lookup must branch before arithmetic | +| P6 | Asset lookup success flags | Consume the returned success flag with `OP_VERIFY` before arithmetic | | P7 | Type conversion | csn↔u64le↔u32le at every boundary | | P8 | Multi-group introspection | `INSPECTASSETGROUPSUM`, `INSPECTOUTASSETLOOKUP` | | P9 | Branch normalization | IF/ELSE arms leave identical stack depth/types | @@ -34,10 +34,10 @@ options { } contract USDT0Bridge( - bytes32 usdt0AssetId_txid, - int usdt0AssetId_gidx, - bytes32 ctrlAssetId_txid, - int ctrlAssetId_gidx, + bytes32 usdt0AssetIdTxid, + int usdt0AssetIdGidx, + bytes32 ctrlAssetIdTxid, + int ctrlAssetIdGidx, bytes32 thisArkId, pubkey issuerPk, pubkey serverPk, @@ -79,18 +79,18 @@ contract USDT0Bridge( require(valid >= dvnThreshold, "quorum failed"); // --- Control asset present --- - require(tx.inputs[0].assets.lookup(ctrlAssetId) > 0, "no ctrl"); + require(tx.inputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx) > 0, "no ctrl"); // --- Mint output correct --- - require(tx.outputs[1].assets.lookup(usdt0AssetId) >= amount, "mint short"); + require(tx.outputs[1].assets.lookup(usdt0AssetIdTxid, usdt0AssetIdGidx) >= amount, "mint short"); require(tx.outputs[1].scriptPubKey == new SingleSig(recipientPk), "wrong dest"); // --- Recursive covenant --- require(tx.outputs[0].scriptPubKey == tx.input.current.scriptPubKey, "broken"); // --- Control asset not leaked --- - require(tx.outputs[0].assets.lookup(ctrlAssetId) >= - tx.inputs[0].assets.lookup(ctrlAssetId), "ctrl leaked"); + require(tx.outputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx) >= + tx.inputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx), "ctrl leaked"); } } ``` @@ -105,8 +105,7 @@ contract USDT0Bridge( |---|---|---|---|---| | `csn` | 1-4 bytes | CScriptNum | Witness inputs, OP_0..16, OP_PICK | OP_PICK, OP_IF, OP_EQUAL, OP_VERIFY | | `u32le` | 4 bytes | Unsigned LE | OP_INSPECTLOCKTIME | OP_LE32TOLE64 | -| `u64le` | 8 bytes | Signed LE | INSPECTASSETGROUPSUM, ADD64, INSPECTINASSETLOOKUP (non-sentinel) | ADD64, GREATERTHAN64, EQUAL | -| `sentinel` | varies | CScriptNum `-1` | INSPECTINASSETLOOKUP (not found), INSPECTOUTASSETLOOKUP (not found) | Must branch before arithmetic | +| `u64le` | 8 bytes | Signed LE | INSPECTASSETGROUPSUM, ADD64, asset lookup result | ADD64, GREATERTHAN64, EQUAL | | `bytes32` | 32 bytes | Raw | SHA256FINALIZE, witness pushes | SHA256UPDATE, EQUAL, CHECKSIGFROMSTACK | | `pubkey` | 32 bytes | x-only (BIP340) | Witness, constructor literal | CHECKSIG, CHECKSIGFROMSTACK, SingleSig | | `signature` | 64 bytes | BIP340 Schnorr | Witness | CHECKSIG, CHECKSIGFROMSTACK | @@ -116,7 +115,6 @@ contract USDT0Bridge( - `csn → u64le`: `OP_SCRIPTNUMTOLE64` - `u32le → u64le`: `OP_LE32TOLE64` - `u64le → csn`: `OP_LE64TOSCRIPTNUM` -- `sentinel → u64le`: Illegal. Branch on `OP_1NEGATE OP_EQUAL` first. --- @@ -141,7 +139,7 @@ Server variant: serverSig on top. Non-server variant: absent. ## Opcode Listing: Server Variant Stack annotation: `// [bottom ... | top]` -Type suffixes: `(c)` = csn, `(u)` = u64le, `(4)` = u32le, `(s)` = sentinel-or-u64le, `(32)` = bytes32, `(pk)` = pubkey, `(sig)` = signature, `(hc)` = hash context +Type suffixes: `(c)` = csn, `(u)` = u64le, `(4)` = u32le, `(32)` = bytes32, `(pk)` = pubkey, `(sig)` = signature, `(hc)` = hash context Constructor literals shown as ``. These are byte-pushes baked into the tapscript. @@ -313,19 +311,13 @@ OP_VERIFY ; [srcId, burnTx, recip, sig0, sig1, sig ; ╔═══════════════════════════════════════════════════════════╗ ; ║ PHASE 6: CONTROL ASSET PRESENT IN INPUT [P6, P8] ║ ; ╚═══════════════════════════════════════════════════════════╝ -; require(tx.inputs[0].assets.lookup(ctrlAssetId) > 0) +; require(tx.inputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx) > 0) OP_0 ; input index 0 - ; 32-byte push - ; 2-byte push (u16 LE) -OP_INSPECTINASSETLOOKUP ; [.., msg, ctrlIn(s)] - -; SENTINEL GUARD: -1 means asset not found at this input -OP_DUP ; [.., ctrlIn(s), ctrlIn(s)] -OP_1NEGATE ; [.., ctrlIn(s), ctrlIn(s), -1(c)] -OP_EQUAL ; [.., ctrlIn(s), isMissing(c)] -OP_NOT ; [.., ctrlIn(s), isPresent(c)] -OP_VERIFY ; [.., ctrlIn(u)] -- now known to be u64le + ; 32-byte push + ; minimally encoded ScriptNum +OP_INSPECTINASSETLOOKUP ; [.., msg, ctrlIn(u), successFlag(c)] +OP_VERIFY ; [.., ctrlIn(u)] ; Save for Phase 9 (ctrl leak check) ; [srcId, burnTx, recip, sig0, sig1, sig2, ; amt(u), msg(32), ctrlIn(u)] @@ -333,20 +325,14 @@ OP_VERIFY ; [.., ctrlIn(u)] -- now known to be u6 ; ╔═══════════════════════════════════════════════════════════╗ ; ║ PHASE 7: MINT OUTPUT CORRECT [P6, P8] ║ ; ╚═══════════════════════════════════════════════════════════╝ -; require(tx.outputs[1].assets.lookup(usdt0AssetId) >= amount) +; require(tx.outputs[1].assets.lookup(usdt0AssetIdTxid, usdt0AssetIdGidx) >= amount) ; require(tx.outputs[1].scriptPubKey == new SingleSig(recipientPk)) ; Check mint amount OP_1 ; output index 1 - ; 32-byte push - ; 2-byte push -OP_INSPECTOUTASSETLOOKUP ; [.., ctrlIn, mintAmt(s)] - -; SENTINEL GUARD -OP_DUP -OP_1NEGATE -OP_EQUAL -OP_NOT + ; 32-byte push + ; minimally encoded ScriptNum +OP_INSPECTOUTASSETLOOKUP ; [.., ctrlIn, mintAmt(u), successFlag(c)] OP_VERIFY ; [.., ctrlIn, mintAmt(u)] ; mintAmt >= amount (both u64le) @@ -397,19 +383,13 @@ OP_VERIFY ; [srcId, burnTx, recip, sig0, sig1, sig ; ╔═══════════════════════════════════════════════════════════╗ ; ║ PHASE 9: CONTROL ASSET NOT LEAKED [P2, P6] ║ ; ╚═══════════════════════════════════════════════════════════╝ -; require(tx.outputs[0].assets.lookup(ctrlAssetId) >= tx.inputs[0].assets.lookup(ctrlAssetId)) +; require(tx.outputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx) >= tx.inputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx)) ; ctrlIn is already on the stack from Phase 6. OP_0 ; output index 0 - - -OP_INSPECTOUTASSETLOOKUP ; [.., msg, ctrlIn(u), ctrlOut(s)] - -; SENTINEL GUARD -OP_DUP -OP_1NEGATE -OP_EQUAL -OP_NOT + + +OP_INSPECTOUTASSETLOOKUP ; [.., msg, ctrlIn(u), ctrlOut(u), successFlag(c)] OP_VERIFY ; [.., msg, ctrlIn(u), ctrlOut(u)] ; ctrlOut >= ctrlIn @@ -446,14 +426,14 @@ The DVN valid counter uses standard `OP_ADD` on CScriptNum values (0 or 1, accum **Verified:** Correct. Using `OP_ADD64` here would be wasteful (extra overflow flag to consume) and require converting the bool result to u64le first. -### A3: Sentinel guard on every asset lookup +### A3: Success flag consumption on every asset lookup Three asset lookups in the contract: -1. Phase 6: `INSPECTINASSETLOOKUP` for ctrl in input 0 (guarded) -2. Phase 7: `INSPECTOUTASSETLOOKUP` for usdt0 in output 1 (guarded) -3. Phase 9: `INSPECTOUTASSETLOOKUP` for ctrl in output 0 (guarded) +1. Phase 6: `INSPECTINASSETLOOKUP` for ctrl in input 0 +2. Phase 7: `INSPECTOUTASSETLOOKUP` for usdt0 in output 1 +3. Phase 9: `INSPECTOUTASSETLOOKUP` for ctrl in output 0 -All three have the 5-opcode sentinel guard: `OP_DUP OP_1NEGATE OP_EQUAL OP_NOT OP_VERIFY`. Correct. +All three return `(result, successFlag)` and consume `successFlag` immediately with `OP_VERIFY`, leaving the typed result for the comparison. Correct. ### A4: Streaming hash argument order @@ -494,7 +474,7 @@ After Phase 6, the stack is: ``` [srcId, burnTx, recip, sig0, sig1, sig2, amt(u), msg(32), ctrlIn(u)] ``` -Phase 7 does `INSPECTOUTASSETLOOKUP` which pushes `mintAmt(s)`. After sentinel guard, stack is: +Phase 7 does `INSPECTOUTASSETLOOKUP`, which pushes `mintAmt(u)` and `successFlag(c)`. After `OP_VERIFY` consumes the flag, the stack is: ``` [srcId(9), burnTx(8), recip(7), sig0(6), sig1(5), sig2(4), amt(3), msg(2), ctrlIn(1), mintAmt(0)] ``` @@ -544,7 +524,7 @@ These apply to any Arkade contract, not just Bridge. 1. **Type boundary conversion.** Every u64le arithmetic operand is exactly 8 bytes. Insert `OP_SCRIPTNUMTOLE64` at csn→u64le boundaries. Insert `OP_LE32TOLE64` at u32le→u64le boundaries. Constructor constants used in u64le context are pre-converted at compile time. -2. **Sentinel branching.** Every `INSPECTINASSETLOOKUP` and `INSPECTOUTASSETLOOKUP` result is guarded with `OP_DUP OP_1NEGATE OP_EQUAL OP_NOT OP_VERIFY` before any arithmetic or comparison. +2. **Asset lookup success flag consumption.** Every `INSPECTINASSETLOOKUP` and `INSPECTOUTASSETLOOKUP` pushes `(result, successFlag)`. The compiler consumes `successFlag` immediately with `OP_VERIFY` before using the result in arithmetic or comparison. 3. **Overflow flag consumption.** Every `OP_ADD64`, `OP_SUB64`, `OP_MUL64`, `OP_DIV64` pushes result + overflow flag. The flag is consumed by `OP_VERIFY` immediately. The compiler never leaves an overflow flag on the stack across a branch boundary. diff --git a/docs/arkade-script-with-assets.md b/docs/arkade-script-with-assets.md index 70448e9..d38ccbc 100644 --- a/docs/arkade-script-with-assets.md +++ b/docs/arkade-script-with-assets.md @@ -10,16 +10,18 @@ For base opcodes (transaction introspection, arithmetic, cryptographic, etc.), s 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. +All Asset IDs are the canonical pair **`(asset_txid, asset_gidx)`** — two stack items. `asset_txid` (32 bytes) is the **issuance** transaction ID where the asset was minted. `asset_gidx` is the group index **at which the asset was issued**, a minimally encoded ScriptNum in `0..65535` (not a group's current packet position — the two coincide only for a fresh issuance). + +The lookup/find/control opcodes return a trailing **success flag**: `… 1` on success, `0 0` (or `empty 0 0` for control) on absence. The Arkade Script sugar consumes that flag for you — `lookup`/`find`/`controlIs` assert presence; `has`/`hasControl` expose it as a boolean. ### Packet & Groups | 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 | +| `OP_INSPECTASSETGROUPASSETID` `k` | → `asset_txid asset_gidx` | Canonical Asset ID of the group at packet position `k`. A fresh issuance resolves to `(this_txid, k)`. | +| `OP_INSPECTASSETGROUPCTRL` `k` | → `asset_txid asset_gidx 1` \| `empty 0 0` | Control Asset ID + success flag (`1` present, `0` absent) | +| `OP_FINDASSETGROUPBYASSETID` `asset_txid asset_gidx` | → `k 1` \| `0 0` | Resolved packet position `k` + success flag | ### Per-Group Metadata @@ -49,7 +51,7 @@ All Asset IDs are represented as **two stack items**: `(txid32, gidx_u16)`. `txi |--------|--------------|-------------| | `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 | +| `OP_INSPECTOUTASSETLOOKUP` `o_u32 asset_txid asset_gidx` | → `amount_u64 1` \| `0 0` | Amount of asset `(asset_txid, asset_gidx)` at output `o_u32` + success flag | ### Cross-Input (Packet-Declared) @@ -57,7 +59,7 @@ All Asset IDs are represented as **two stack items**: `(txid32, gidx_u16)`. `txi |--------|--------------|-------------| | `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 | +| `OP_INSPECTINASSETLOOKUP` `i_u32 asset_txid asset_gidx` | → `amount_u64 1` \| `0 0` | Declared amount for asset `(asset_txid, asset_gidx)` at input `i_u32` + success flag | --- @@ -70,19 +72,28 @@ The following API provides syntactic sugar for Arkade Script contracts. Each pro ```javascript 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.find(assetTxid, assetGidx) + // → assetTxid assetGidx OP_FINDASSETGROUPBYASSETID OP_VERIFY + // Asserts existence, leaves the resolved packet position k + +tx.assetGroups.has(assetTxid, assetGidx) + // → assetTxid assetGidx OP_FINDASSETGROUPBYASSETID OP_NIP + // Bool: true if a group with that Asset ID exists tx.assetGroups[k].assetId // → OP_INSPECTASSETGROUPASSETID k - // Returns: { txid: bytes32, gidx: int } + // Returns the canonical Asset ID (asset_txid, asset_gidx) tx.assetGroups[k].isFresh // → OP_INSPECTASSETGROUPASSETID k // OP_DROP OP_TXID OP_EQUAL - // True if assetId.txid == this_txid (new asset) + // True if asset_txid == this_txid (new asset) + +group.controlIs(ctrlTxid, ctrlGidx) + // → OP_INSPECTASSETGROUPCTRL OP_DROP + // OP_EQUAL OP_SWAP OP_EQUAL OP_BOOLAND + // Bool: full canonical control Asset ID equality -tx.assetGroups[k].control // → OP_INSPECTASSETGROUPCTRL k - // Returns: AssetId (txid32, gidx_u16), or -1 if no control +group.hasControl // → OP_INSPECTASSETGROUPCTRL OP_NIP OP_NIP + // Bool: true if a control asset is present // Metadata hash (immutable, set at genesis) tx.assetGroups[k].metadataHash @@ -156,9 +167,13 @@ 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.inputs[i].assets.lookup(assetTxid, assetGidx) + // → i assetTxid assetGidx OP_INSPECTINASSETLOOKUP OP_VERIFY + // Asserts presence, leaves amount + +tx.inputs[i].assets.has(assetTxid, assetGidx) + // → i assetTxid assetGidx OP_INSPECTINASSETLOOKUP OP_NIP + // Bool: true if the asset is present ``` ### Cross-Output Asset Lookups @@ -171,9 +186,13 @@ 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 +tx.outputs[o].assets.lookup(assetTxid, assetGidx) + // → o assetTxid assetGidx OP_INSPECTOUTASSETLOOKUP OP_VERIFY + // Asserts presence, leaves amount + +tx.outputs[o].assets.has(assetTxid, assetGidx) + // → o assetTxid assetGidx OP_INSPECTOUTASSETLOOKUP OP_NIP + // Bool: true if the asset is present ``` --- @@ -234,13 +253,15 @@ struct AssetOutputIntent { ### Checking Asset Presence ```javascript -// Check if an asset is present in transaction -let groupIndex = tx.assetGroups.find(assetId); -require(groupIndex != null, "Asset not found"); +// Assert an asset group exists (find aborts on absence, leaving k) +let groupIndex = tx.assetGroups.find(assetTxid, assetGidx); + +// Boolean presence without aborting +require(tx.assetGroups.has(assetTxid, assetGidx), "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"); +// Assert an asset is at a specific output (lookup aborts on absence) +let amount = tx.outputs[o].assets.lookup(assetTxid, assetGidx); +require(amount > 0, "Asset amount must be positive"); ``` ### Checking if Asset is Fresh (New Issuance) @@ -273,7 +294,6 @@ require(group.delta < 0, "Must be burn"); ### Verifying Control Asset ```javascript -let group = tx.assetGroups.find(assetId); -require(group != null, "Asset not found"); -require(group.control == expectedControlId, "Wrong control asset"); +let group = tx.assetGroups.find(assetTxid, assetGidx); +require(group.controlIs(expectedCtrlTxid, expectedCtrlGidx), "Wrong control asset"); ``` diff --git a/examples/arkade_kitties.ark b/examples/arkade_kitties.ark index 100bd0b..0719cc2 100644 --- a/examples/arkade_kitties.ark +++ b/examples/arkade_kitties.ark @@ -3,7 +3,7 @@ // // Key features demonstrated: // - group.isFresh: Detect newly minted kitties -// - group.control: Verify species membership +// - group.controlIs: Verify species membership // - group.metadataHash: Verify genome/attributes // - group.delta: Detect minting vs transfer // @@ -18,15 +18,19 @@ options { } contract ArkadeKitties( - bytes32 speciesControlId, + bytes32 speciesControlIdTxid, + int speciesControlIdGidx, pubkey oraclePk ) { // Breed two kitties to create a new one // Parents must be species-controlled, child must be fresh function breed( - bytes32 sireId, - bytes32 dameId, - bytes32 childId, + bytes32 sireIdTxid, + int sireIdGidx, + bytes32 dameIdTxid, + int dameIdGidx, + bytes32 childIdTxid, + int childIdGidx, bytes32 sireGenomeHash, bytes32 dameGenomeHash, bytes32 expectedChildMetadataHash, @@ -37,52 +41,52 @@ contract ArkadeKitties( int ctrlOutputIdx ) { // Verify sire is species-controlled with correct genome - let sireGroup = tx.assetGroups.find(sireId); - require(sireGroup.control == speciesControlId, "sire not species-controlled"); + let sireGroup = tx.assetGroups.find(sireIdTxid, sireIdGidx); + require(sireGroup.controlIs(speciesControlIdTxid, speciesControlIdGidx), "sire not species-controlled"); require(sireGroup.metadataHash == sireGenomeHash, "sire genome mismatch"); require(sireGroup.delta == 0, "sire must be retained"); // Verify dame is species-controlled with correct genome - let dameGroup = tx.assetGroups.find(dameId); - require(dameGroup.control == speciesControlId, "dame not species-controlled"); + let dameGroup = tx.assetGroups.find(dameIdTxid, dameIdGidx); + require(dameGroup.controlIs(speciesControlIdTxid, speciesControlIdGidx), "dame not species-controlled"); require(dameGroup.metadataHash == dameGenomeHash, "dame genome mismatch"); require(dameGroup.delta == 0, "dame must be retained"); // Verify child is fresh (new issuance in this tx) - let childGroup = tx.assetGroups.find(childId); + let childGroup = tx.assetGroups.find(childIdTxid, childIdGidx); require(childGroup.isFresh == 1, "child must be fresh"); require(childGroup.delta == 1, "must mint exactly 1 child"); - require(childGroup.control == speciesControlId, "child not species-controlled"); + require(childGroup.controlIs(speciesControlIdTxid, speciesControlIdGidx), "child not species-controlled"); require(childGroup.metadataHash == expectedChildMetadataHash, "child genome mismatch"); // Species control must be retained (delta == 0) - let ctrlGroup = tx.assetGroups.find(speciesControlId); + let ctrlGroup = tx.assetGroups.find(speciesControlIdTxid, speciesControlIdGidx); require(ctrlGroup.delta == 0, "species control must be retained"); // Oracle signature validates the breeding (genome combination) require(checkSig(oracleSig, oraclePk), "invalid oracle sig"); // Verify outputs contain all assets - require(tx.outputs[childOutputIdx].assets.lookup(childId) == 1, "child not in output"); - require(tx.outputs[sireOutputIdx].assets.lookup(sireId) == 1, "sire not returned"); - require(tx.outputs[dameOutputIdx].assets.lookup(dameId) == 1, "dame not returned"); - require(tx.outputs[ctrlOutputIdx].assets.lookup(speciesControlId) == 1, "ctrl not retained"); + require(tx.outputs[childOutputIdx].assets.lookup(childIdTxid, childIdGidx) == 1, "child not in output"); + require(tx.outputs[sireOutputIdx].assets.lookup(sireIdTxid, sireIdGidx) == 1, "sire not returned"); + require(tx.outputs[dameOutputIdx].assets.lookup(dameIdTxid, dameIdGidx) == 1, "dame not returned"); + require(tx.outputs[ctrlOutputIdx].assets.lookup(speciesControlIdTxid, speciesControlIdGidx) == 1, "ctrl not retained"); } // Transfer a kitty to a new owner - function transfer(bytes32 kittyId, pubkey newOwnerPk, signature ownerSig, pubkey ownerPk) { - let kittyGroup = tx.assetGroups.find(kittyId); + function transfer(bytes32 kittyIdTxid, int kittyIdGidx, pubkey newOwnerPk, signature ownerSig, pubkey ownerPk) { + let kittyGroup = tx.assetGroups.find(kittyIdTxid, kittyIdGidx); // Must be existing kitty (not fresh) require(kittyGroup.isFresh == 0, "must be existing kitty"); // Must be species-controlled - require(kittyGroup.control == speciesControlId, "not species-controlled"); + require(kittyGroup.controlIs(speciesControlIdTxid, speciesControlIdGidx), "not species-controlled"); // Must be transfer (delta == 0) require(kittyGroup.delta == 0, "must be transfer only"); - require(tx.outputs[0].assets.lookup(kittyId) == 1, "kitty not in output"); + require(tx.outputs[0].assets.lookup(kittyIdTxid, kittyIdGidx) == 1, "kitty not in output"); require(tx.outputs[0].scriptPubKey == new SingleSig(newOwnerPk), "wrong destination"); require(checkSig(ownerSig, ownerPk), "invalid owner sig"); } diff --git a/examples/arkade_kitties.hack b/examples/arkade_kitties.hack index 589d01b..86d5ab4 100644 --- a/examples/arkade_kitties.hack +++ b/examples/arkade_kitties.hack @@ -3,13 +3,19 @@ # # Function: breed (cooperative) - - + + OP_FINDASSETGROUPBYASSETID +OP_VERIFY OP_INSPECTASSETGROUPCTRL - +OP_DROP + +OP_EQUAL +OP_SWAP + OP_EQUAL +OP_BOOLAND OP_INSPECTASSETGROUPMETADATAHASH @@ -24,13 +30,19 @@ OP_SUB64 OP_VERIFY 0 OP_EQUAL - - + + OP_FINDASSETGROUPBYASSETID +OP_VERIFY OP_INSPECTASSETGROUPCTRL - +OP_DROP + OP_EQUAL +OP_SWAP + +OP_EQUAL +OP_BOOLAND OP_INSPECTASSETGROUPMETADATAHASH @@ -45,9 +57,10 @@ OP_SUB64 OP_VERIFY 0 OP_EQUAL - - + + OP_FINDASSETGROUPBYASSETID +OP_VERIFY OP_INSPECTASSETGROUPASSETID OP_DROP @@ -67,15 +80,21 @@ OP_VERIFY OP_EQUAL OP_INSPECTASSETGROUPCTRL - +OP_DROP + +OP_EQUAL +OP_SWAP + OP_EQUAL +OP_BOOLAND OP_INSPECTASSETGROUPMETADATAHASH OP_EQUAL - - + + OP_FINDASSETGROUPBYASSETID +OP_VERIFY OP_1 OP_INSPECTASSETGROUPSUM @@ -90,49 +109,33 @@ OP_EQUAL OP_CHECKSIG - - + + OP_INSPECTOUTASSETLOOKUP -OP_DUP -OP_1NEGATE -OP_EQUAL -OP_NOT OP_VERIFY 1 OP_EQUAL OP_VERIFY - - + + OP_INSPECTOUTASSETLOOKUP -OP_DUP -OP_1NEGATE -OP_EQUAL -OP_NOT OP_VERIFY 1 OP_EQUAL OP_VERIFY - - + + OP_INSPECTOUTASSETLOOKUP -OP_DUP -OP_1NEGATE -OP_EQUAL -OP_NOT OP_VERIFY 1 OP_EQUAL OP_VERIFY - - + + OP_INSPECTOUTASSETLOOKUP -OP_DUP -OP_1NEGATE -OP_EQUAL -OP_NOT OP_VERIFY 1 OP_EQUAL @@ -150,9 +153,10 @@ OP_CHECKSEQUENCEVERIFY OP_DROP # Function: transfer (cooperative) - - + + OP_FINDASSETGROUPBYASSETID +OP_VERIFY OP_INSPECTASSETGROUPASSETID OP_DROP @@ -162,8 +166,13 @@ OP_EQUAL OP_EQUAL OP_INSPECTASSETGROUPCTRL - +OP_DROP + OP_EQUAL +OP_SWAP + +OP_EQUAL +OP_BOOLAND OP_1 OP_INSPECTASSETGROUPSUM @@ -175,20 +184,16 @@ OP_VERIFY 0 OP_EQUAL 0 - - + + OP_INSPECTOUTASSETLOOKUP -OP_DUP -OP_1NEGATE -OP_EQUAL -OP_NOT OP_VERIFY 1 OP_EQUAL OP_VERIFY 0 OP_INSPECTOUTPUTSCRIPTPUBKEY - +)> OP_EQUAL diff --git a/examples/arkade_kitties.json b/examples/arkade_kitties.json index f7b5710..c774385 100644 --- a/examples/arkade_kitties.json +++ b/examples/arkade_kitties.json @@ -2,11 +2,11 @@ "contractName": "ArkadeKitties", "constructorInputs": [ { - "name": "speciesControlId_txid", + "name": "speciesControlIdTxid", "type": "bytes32" }, { - "name": "speciesControlId_gidx", + "name": "speciesControlIdGidx", "type": "int" }, { @@ -19,17 +19,29 @@ "name": "breed", "functionInputs": [ { - "name": "sireId", + "name": "sireIdTxid", "type": "bytes32" }, { - "name": "dameId", + "name": "sireIdGidx", + "type": "int" + }, + { + "name": "dameIdTxid", "type": "bytes32" }, { - "name": "childId", + "name": "dameIdGidx", + "type": "int" + }, + { + "name": "childIdTxid", "type": "bytes32" }, + { + "name": "childIdGidx", + "type": "int" + }, { "name": "sireGenomeHash", "type": "bytes32" @@ -65,20 +77,35 @@ ], "witnessSchema": [ { - "name": "sireId", + "name": "sireIdTxid", "type": "bytes32", "encoding": "raw-32" }, { - "name": "dameId", + "name": "sireIdGidx", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "dameIdTxid", "type": "bytes32", "encoding": "raw-32" }, { - "name": "childId", + "name": "dameIdGidx", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "childIdTxid", "type": "bytes32", "encoding": "raw-32" }, + { + "name": "childIdGidx", + "type": "int", + "encoding": "scriptnum" + }, { "name": "sireGenomeHash", "type": "bytes32", @@ -180,13 +207,19 @@ } ], "asm": [ - "", - "", + "", + "", "OP_FINDASSETGROUPBYASSETID", + "OP_VERIFY", "", "OP_INSPECTASSETGROUPCTRL", - "", + "OP_DROP", + "", "OP_EQUAL", + "OP_SWAP", + "", + "OP_EQUAL", + "OP_BOOLAND", "", "OP_INSPECTASSETGROUPMETADATAHASH", "", @@ -201,13 +234,19 @@ "OP_VERIFY", "0", "OP_EQUAL", - "", - "", + "", + "", "OP_FINDASSETGROUPBYASSETID", + "OP_VERIFY", "", "OP_INSPECTASSETGROUPCTRL", - "", + "OP_DROP", + "", + "OP_EQUAL", + "OP_SWAP", + "", "OP_EQUAL", + "OP_BOOLAND", "", "OP_INSPECTASSETGROUPMETADATAHASH", "", @@ -222,9 +261,10 @@ "OP_VERIFY", "0", "OP_EQUAL", - "", - "", + "", + "", "OP_FINDASSETGROUPBYASSETID", + "OP_VERIFY", "", "OP_INSPECTASSETGROUPASSETID", "OP_DROP", @@ -244,15 +284,21 @@ "OP_EQUAL", "", "OP_INSPECTASSETGROUPCTRL", - "", + "OP_DROP", + "", + "OP_EQUAL", + "OP_SWAP", + "", "OP_EQUAL", + "OP_BOOLAND", "", "OP_INSPECTASSETGROUPMETADATAHASH", "", "OP_EQUAL", - "", - "", + "", + "", "OP_FINDASSETGROUPBYASSETID", + "OP_VERIFY", "", "OP_1", "OP_INSPECTASSETGROUPSUM", @@ -267,49 +313,33 @@ "", "OP_CHECKSIG", "", - "", - "", + "", + "", "OP_INSPECTOUTASSETLOOKUP", - "OP_DUP", - "OP_1NEGATE", - "OP_EQUAL", - "OP_NOT", "OP_VERIFY", "1", "OP_EQUAL", "OP_VERIFY", "", - "", - "", + "", + "", "OP_INSPECTOUTASSETLOOKUP", - "OP_DUP", - "OP_1NEGATE", - "OP_EQUAL", - "OP_NOT", "OP_VERIFY", "1", "OP_EQUAL", "OP_VERIFY", "", - "", - "", + "", + "", "OP_INSPECTOUTASSETLOOKUP", - "OP_DUP", - "OP_1NEGATE", - "OP_EQUAL", - "OP_NOT", "OP_VERIFY", "1", "OP_EQUAL", "OP_VERIFY", "", - "", - "", + "", + "", "OP_INSPECTOUTASSETLOOKUP", - "OP_DUP", - "OP_1NEGATE", - "OP_EQUAL", - "OP_NOT", "OP_VERIFY", "1", "OP_EQUAL", @@ -323,17 +353,29 @@ "name": "breed", "functionInputs": [ { - "name": "sireId", + "name": "sireIdTxid", "type": "bytes32" }, { - "name": "dameId", + "name": "sireIdGidx", + "type": "int" + }, + { + "name": "dameIdTxid", "type": "bytes32" }, { - "name": "childId", + "name": "dameIdGidx", + "type": "int" + }, + { + "name": "childIdTxid", "type": "bytes32" }, + { + "name": "childIdGidx", + "type": "int" + }, { "name": "sireGenomeHash", "type": "bytes32" @@ -402,9 +444,13 @@ "name": "transfer", "functionInputs": [ { - "name": "kittyId", + "name": "kittyIdTxid", "type": "bytes32" }, + { + "name": "kittyIdGidx", + "type": "int" + }, { "name": "newOwnerPk", "type": "pubkey" @@ -420,10 +466,15 @@ ], "witnessSchema": [ { - "name": "kittyId", + "name": "kittyIdTxid", "type": "bytes32", "encoding": "raw-32" }, + { + "name": "kittyIdGidx", + "type": "int", + "encoding": "scriptnum" + }, { "name": "newOwnerPk", "type": "pubkey", @@ -470,9 +521,10 @@ } ], "asm": [ - "", - "", + "", + "", "OP_FINDASSETGROUPBYASSETID", + "OP_VERIFY", "", "OP_INSPECTASSETGROUPASSETID", "OP_DROP", @@ -482,8 +534,13 @@ "OP_EQUAL", "", "OP_INSPECTASSETGROUPCTRL", - "", + "OP_DROP", + "", + "OP_EQUAL", + "OP_SWAP", + "", "OP_EQUAL", + "OP_BOOLAND", "", "OP_1", "OP_INSPECTASSETGROUPSUM", @@ -495,13 +552,9 @@ "0", "OP_EQUAL", "0", - "", - "", + "", + "", "OP_INSPECTOUTASSETLOOKUP", - "OP_DUP", - "OP_1NEGATE", - "OP_EQUAL", - "OP_NOT", "OP_VERIFY", "1", "OP_EQUAL", @@ -522,9 +575,13 @@ "name": "transfer", "functionInputs": [ { - "name": "kittyId", + "name": "kittyIdTxid", "type": "bytes32" }, + { + "name": "kittyIdGidx", + "type": "int" + }, { "name": "newOwnerPk", "type": "pubkey" @@ -594,12 +651,12 @@ ] } ], - "source": "\noptions {\n server = server;\n exit = 576;\n}\n\ncontract ArkadeKitties(\n bytes32 speciesControlId,\n pubkey oraclePk\n) {\n function breed(\n bytes32 sireId,\n bytes32 dameId,\n bytes32 childId,\n bytes32 sireGenomeHash,\n bytes32 dameGenomeHash,\n bytes32 expectedChildMetadataHash,\n signature oracleSig,\n int childOutputIdx,\n int sireOutputIdx,\n int dameOutputIdx,\n int ctrlOutputIdx\n ) {\n let sireGroup = tx.assetGroups.find(sireId);\n require(sireGroup.control == speciesControlId, \"sire not species-controlled\");\n require(sireGroup.metadataHash == sireGenomeHash, \"sire genome mismatch\");\n require(sireGroup.delta == 0, \"sire must be retained\");\n\n let dameGroup = tx.assetGroups.find(dameId);\n require(dameGroup.control == speciesControlId, \"dame not species-controlled\");\n require(dameGroup.metadataHash == dameGenomeHash, \"dame genome mismatch\");\n require(dameGroup.delta == 0, \"dame must be retained\");\n\n let childGroup = tx.assetGroups.find(childId);\n require(childGroup.isFresh == 1, \"child must be fresh\");\n require(childGroup.delta == 1, \"must mint exactly 1 child\");\n require(childGroup.control == speciesControlId, \"child not species-controlled\");\n require(childGroup.metadataHash == expectedChildMetadataHash, \"child genome mismatch\");\n\n let ctrlGroup = tx.assetGroups.find(speciesControlId);\n require(ctrlGroup.delta == 0, \"species control must be retained\");\n\n require(checkSig(oracleSig, oraclePk), \"invalid oracle sig\");\n\n require(tx.outputs[childOutputIdx].assets.lookup(childId) == 1, \"child not in output\");\n require(tx.outputs[sireOutputIdx].assets.lookup(sireId) == 1, \"sire not returned\");\n require(tx.outputs[dameOutputIdx].assets.lookup(dameId) == 1, \"dame not returned\");\n require(tx.outputs[ctrlOutputIdx].assets.lookup(speciesControlId) == 1, \"ctrl not retained\");\n }\n\n function transfer(bytes32 kittyId, pubkey newOwnerPk, signature ownerSig, pubkey ownerPk) {\n let kittyGroup = tx.assetGroups.find(kittyId);\n\n require(kittyGroup.isFresh == 0, \"must be existing kitty\");\n\n require(kittyGroup.control == speciesControlId, \"not species-controlled\");\n\n require(kittyGroup.delta == 0, \"must be transfer only\");\n\n require(tx.outputs[0].assets.lookup(kittyId) == 1, \"kitty not in output\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(newOwnerPk), \"wrong destination\");\n require(checkSig(ownerSig, ownerPk), \"invalid owner sig\");\n }\n}", + "source": "\noptions {\n server = server;\n exit = 576;\n}\n\ncontract ArkadeKitties(\n bytes32 speciesControlIdTxid,\n int speciesControlIdGidx,\n pubkey oraclePk\n) {\n function breed(\n bytes32 sireIdTxid,\n int sireIdGidx,\n bytes32 dameIdTxid,\n int dameIdGidx,\n bytes32 childIdTxid,\n int childIdGidx,\n bytes32 sireGenomeHash,\n bytes32 dameGenomeHash,\n bytes32 expectedChildMetadataHash,\n signature oracleSig,\n int childOutputIdx,\n int sireOutputIdx,\n int dameOutputIdx,\n int ctrlOutputIdx\n ) {\n let sireGroup = tx.assetGroups.find(sireIdTxid, sireIdGidx);\n require(sireGroup.controlIs(speciesControlIdTxid, speciesControlIdGidx), \"sire not species-controlled\");\n require(sireGroup.metadataHash == sireGenomeHash, \"sire genome mismatch\");\n require(sireGroup.delta == 0, \"sire must be retained\");\n\n let dameGroup = tx.assetGroups.find(dameIdTxid, dameIdGidx);\n require(dameGroup.controlIs(speciesControlIdTxid, speciesControlIdGidx), \"dame not species-controlled\");\n require(dameGroup.metadataHash == dameGenomeHash, \"dame genome mismatch\");\n require(dameGroup.delta == 0, \"dame must be retained\");\n\n let childGroup = tx.assetGroups.find(childIdTxid, childIdGidx);\n require(childGroup.isFresh == 1, \"child must be fresh\");\n require(childGroup.delta == 1, \"must mint exactly 1 child\");\n require(childGroup.controlIs(speciesControlIdTxid, speciesControlIdGidx), \"child not species-controlled\");\n require(childGroup.metadataHash == expectedChildMetadataHash, \"child genome mismatch\");\n\n let ctrlGroup = tx.assetGroups.find(speciesControlIdTxid, speciesControlIdGidx);\n require(ctrlGroup.delta == 0, \"species control must be retained\");\n\n require(checkSig(oracleSig, oraclePk), \"invalid oracle sig\");\n\n require(tx.outputs[childOutputIdx].assets.lookup(childIdTxid, childIdGidx) == 1, \"child not in output\");\n require(tx.outputs[sireOutputIdx].assets.lookup(sireIdTxid, sireIdGidx) == 1, \"sire not returned\");\n require(tx.outputs[dameOutputIdx].assets.lookup(dameIdTxid, dameIdGidx) == 1, \"dame not returned\");\n require(tx.outputs[ctrlOutputIdx].assets.lookup(speciesControlIdTxid, speciesControlIdGidx) == 1, \"ctrl not retained\");\n }\n\n function transfer(bytes32 kittyIdTxid, int kittyIdGidx, pubkey newOwnerPk, signature ownerSig, pubkey ownerPk) {\n let kittyGroup = tx.assetGroups.find(kittyIdTxid, kittyIdGidx);\n\n require(kittyGroup.isFresh == 0, \"must be existing kitty\");\n\n require(kittyGroup.controlIs(speciesControlIdTxid, speciesControlIdGidx), \"not species-controlled\");\n\n require(kittyGroup.delta == 0, \"must be transfer only\");\n\n require(tx.outputs[0].assets.lookup(kittyIdTxid, kittyIdGidx) == 1, \"kitty not in output\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(newOwnerPk), \"wrong destination\");\n require(checkSig(ownerSig, ownerPk), \"invalid owner sig\");\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-08T15:43:10.958175096+00:00", + "updatedAt": "2026-06-15T12:19:43.981980+00:00", "warnings": [ "warning[type]: fn breed: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn breed: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", @@ -610,6 +667,27 @@ "warning[type]: fn breed: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn breed: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn transfer: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[type]: fn transfer: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" + "warning[type]: fn transfer: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'sireGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'sireGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'sireGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'sireGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'dameGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'dameGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'dameGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'dameGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'childGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'childGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'childGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'childGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'childGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'ctrlGroup'", + "warning[output-invariant]: fn 'breed' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'ctrlGroup'", + "warning[output-invariant]: fn 'transfer' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'kittyGroup'", + "warning[output-invariant]: fn 'transfer' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'kittyGroup'", + "warning[output-invariant]: fn 'transfer' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'kittyGroup'", + "warning[output-invariant]: fn 'transfer' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'kittyGroup'", + "warning[output-invariant]: fn 'transfer' (serverVariant=false): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newOwnerPk'", + "warning[output-invariant]: fn 'transfer' (serverVariant=false): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'ownerPk'" ] } \ No newline at end of file diff --git a/examples/bonds/bond_mint.ark b/examples/bonds/bond_mint.ark index c385484..c898dba 100644 --- a/examples/bonds/bond_mint.ark +++ b/examples/bonds/bond_mint.ark @@ -61,7 +61,7 @@ // // POOL-AUTHENTICATION CAVEAT // The "genuine pool not co-spent" check verifies only that an input holds -// the debit-control asset (`tx.inputs[poolIdx].assets.lookup(debitCtrlId) +// the debit-control asset (`tx.inputs[poolIdx].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) // >= 1`), not that the input's scriptPubKey is the canonical RepaymentPool // covenant. Safety therefore rests on the asset-registry invariant that // no pool function ever lets debitCtrlId leak from its output[0]. EVERY @@ -83,8 +83,10 @@ options { contract BondMint( pubkey borrowerPk, - bytes32 debitAssetId, - bytes32 debitCtrlId, + bytes32 debitAssetIdTxid, + int debitAssetIdGidx, + bytes32 debitCtrlIdTxid, + int debitCtrlIdGidx, int mintedAmount, int collateral, int maturity, @@ -99,9 +101,9 @@ contract BondMint( require(checkSig(borrowerSig, borrowerPk), "invalid borrower sig"); require(tx.time < maturity, "loan matured"); - require(tx.inputs[poolIdx].assets.lookup(debitCtrlId) >= 1, "genuine pool not co-spent"); + require(tx.inputs[poolIdx].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1, "genuine pool not co-spent"); - let debitGroup = tx.assetGroups.find(debitAssetId); + let debitGroup = tx.assetGroups.find(debitAssetIdTxid, debitAssetIdGidx); require(debitGroup.sumInputs == debitGroup.sumOutputs + mintedAmount, "debit not burned exactly mintedAmount"); require(tx.outputs[1].value >= collateral, "collateral not returned"); @@ -124,9 +126,9 @@ contract BondMint( function liquidate(int poolIdx, pubkey auctioneerPk) { require(tx.time < maturity, "matured; use auction"); - require(tx.inputs[poolIdx].assets.lookup(debitCtrlId) >= 1, "genuine pool not co-spent"); + require(tx.inputs[poolIdx].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1, "genuine pool not co-spent"); - let debitGroup = tx.assetGroups.find(debitAssetId); + let debitGroup = tx.assetGroups.find(debitAssetIdTxid, debitAssetIdGidx); require(debitGroup.sumInputs == debitGroup.sumOutputs + mintedAmount, "debit not burned exactly mintedAmount"); require(tx.outputs[1].value > 0, "auctioneer output empty"); @@ -143,9 +145,9 @@ contract BondMint( int windowEnd = maturity + auctionWindow; require(tx.time < windowEnd, "auction window closed"); - require(tx.inputs[poolIdx].assets.lookup(debitCtrlId) >= 1, "genuine pool not co-spent"); + require(tx.inputs[poolIdx].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1, "genuine pool not co-spent"); - let debitGroup = tx.assetGroups.find(debitAssetId); + let debitGroup = tx.assetGroups.find(debitAssetIdTxid, debitAssetIdGidx); require(debitGroup.sumInputs == debitGroup.sumOutputs + mintedAmount, "debit not burned exactly mintedAmount"); require(tx.outputs[1].value > 0, "auctioneer output empty"); @@ -168,9 +170,9 @@ contract BondMint( require(checkSig(borrowerSig, borrowerPk), "invalid borrower sig"); require(tx.time < maturity, "vault matured"); - require(tx.inputs[poolIdx].assets.lookup(debitCtrlId) >= 1, "genuine pool not co-spent"); + require(tx.inputs[poolIdx].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1, "genuine pool not co-spent"); - let debitGroup = tx.assetGroups.find(debitAssetId); + let debitGroup = tx.assetGroups.find(debitAssetIdTxid, debitAssetIdGidx); require(debitGroup.sumInputs == debitGroup.sumOutputs + mintedAmount, "debit not burned exactly mintedAmount"); } } diff --git a/examples/bonds/repayment_pool.ark b/examples/bonds/repayment_pool.ark index 41f9e8d..12c2d53 100644 --- a/examples/bonds/repayment_pool.ark +++ b/examples/bonds/repayment_pool.ark @@ -101,11 +101,16 @@ options { } contract RepaymentPool( - bytes32 usdtAssetId, - bytes32 creditAssetId, // fungible per-maturity credit (lender entitlement) - bytes32 creditCtrlId, // control asset gating credit mint - bytes32 debitAssetId, // fungible per-maturity debit (borrower obligation) - bytes32 debitCtrlId, // control asset gating debit mint + bytes32 usdtAssetIdTxid, + int usdtAssetIdGidx, + bytes32 creditAssetIdTxid, // fungible per-maturity credit (lender entitlement) + int creditAssetIdGidx, + bytes32 creditCtrlIdTxid, // control asset gating credit mint + int creditCtrlIdGidx, + bytes32 debitAssetIdTxid, // fungible per-maturity debit (borrower obligation) + int debitAssetIdGidx, + bytes32 debitCtrlIdTxid, // control asset gating debit mint + int debitCtrlIdGidx, bytes32 ticker, // oracle feed id (e.g. sha256("BTC/USDT")) pubkey oraclePk, // price oracle (Fuji-style signed feed) int oracleMaxAgeSeconds, // max oracle staleness, in SECONDS (tx.offchainTime); contrast with maturity/auctionWindow which are BLOCK HEIGHTS (tx.time) @@ -175,15 +180,15 @@ contract RepaymentPool( int required = (amount * initRatioBps + 9999) / 10000; require(collateralValue >= required, "undercollateralized at issuance"); - let creditGroup = tx.assetGroups.find(creditAssetId); + let creditGroup = tx.assetGroups.find(creditAssetIdTxid, creditAssetIdGidx); require(creditGroup.delta == amount, "credit delta mismatch"); - require(creditGroup.control == creditCtrlId, "wrong credit control"); - let debitGroup = tx.assetGroups.find(debitAssetId); + require(creditGroup.controlIs(creditCtrlIdTxid, creditCtrlIdGidx), "wrong credit control"); + let debitGroup = tx.assetGroups.find(debitAssetIdTxid, debitAssetIdGidx); require(debitGroup.delta == amount, "debit delta mismatch"); - require(debitGroup.control == debitCtrlId, "wrong debit control"); - let ccGroup = tx.assetGroups.find(creditCtrlId); + require(debitGroup.controlIs(debitCtrlIdTxid, debitCtrlIdGidx), "wrong debit control"); + let ccGroup = tx.assetGroups.find(creditCtrlIdTxid, creditCtrlIdGidx); require(ccGroup.delta == 0, "credit control changed"); - let dcGroup = tx.assetGroups.find(debitCtrlId); + let dcGroup = tx.assetGroups.find(debitCtrlIdTxid, debitCtrlIdGidx); require(dcGroup.delta == 0, "debit control changed"); int creditNext = totalCreditOutstanding + amount; @@ -191,27 +196,27 @@ contract RepaymentPool( require( tx.outputs[0].scriptPubKey == new RepaymentPool( - usdtAssetId, creditAssetId, creditCtrlId, debitAssetId, debitCtrlId, ticker, + usdtAssetIdTxid, usdtAssetIdGidx, creditAssetIdTxid, creditAssetIdGidx, creditCtrlIdTxid, creditCtrlIdGidx, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, ticker, oraclePk, oracleMaxAgeSeconds, initRatioBps, liqThresholdBps, maturity, auctionWindow, auctionDiscountBps, usdtBalance, creditNext, debitNext, exit ), "invalid pool recreation" ); - require(tx.outputs[0].assets.lookup(usdtAssetId) >= usdtBalance, "pool usdt short"); - require(tx.outputs[0].assets.lookup(creditCtrlId) >= 1, "credit control not retained"); - require(tx.outputs[0].assets.lookup(debitCtrlId) >= 1, "debit control not retained"); + require(tx.outputs[0].assets.lookup(usdtAssetIdTxid, usdtAssetIdGidx) >= usdtBalance, "pool usdt short"); + require(tx.outputs[0].assets.lookup(creditCtrlIdTxid, creditCtrlIdGidx) >= 1, "credit control not retained"); + require(tx.outputs[0].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1, "debit control not retained"); - require(tx.outputs[1].assets.lookup(creditAssetId) >= amount, "credit not delivered"); + require(tx.outputs[1].assets.lookup(creditAssetIdTxid, creditAssetIdGidx) >= amount, "credit not delivered"); require(tx.outputs[1].scriptPubKey == new SingleSig(borrowerPk), "credit dest not borrower"); require( tx.outputs[2].scriptPubKey == new BondMint( - borrowerPk, debitAssetId, debitCtrlId, amount, collateral, maturity, auctionWindow, exit + borrowerPk, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, amount, collateral, maturity, auctionWindow, exit ), "invalid vault" ); require(tx.outputs[2].value >= collateral, "collateral not locked"); - require(tx.outputs[2].assets.lookup(debitAssetId) >= amount, "debit not custodied in vault"); + require(tx.outputs[2].assets.lookup(debitAssetIdTxid, debitAssetIdGidx) >= amount, "debit not custodied in vault"); } // ACCEPT REPAYMENT — pre-maturity, co-spent with BondMint.repay. @@ -225,18 +230,18 @@ contract RepaymentPool( require( tx.inputs[vaultIdx].scriptPubKey == new BondMint( - borrowerPk, debitAssetId, debitCtrlId, mintedAmount, collateral, maturity, auctionWindow, exit + borrowerPk, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, mintedAmount, collateral, maturity, auctionWindow, exit ), "vault input mismatch" ); - let debitGroup = tx.assetGroups.find(debitAssetId); + let debitGroup = tx.assetGroups.find(debitAssetIdTxid, debitAssetIdGidx); require(debitGroup.sumInputs == debitGroup.sumOutputs + mintedAmount, "debit not burned exactly mintedAmount"); - let creditGroup = tx.assetGroups.find(creditAssetId); + let creditGroup = tx.assetGroups.find(creditAssetIdTxid, creditAssetIdGidx); require(creditGroup.delta == 0, "credit must not move on repayment"); - let ccGroup = tx.assetGroups.find(creditCtrlId); + let ccGroup = tx.assetGroups.find(creditCtrlIdTxid, creditCtrlIdGidx); require(ccGroup.delta == 0, "credit control changed"); - let dcGroup = tx.assetGroups.find(debitCtrlId); + let dcGroup = tx.assetGroups.find(debitCtrlIdTxid, debitCtrlIdGidx); require(dcGroup.delta == 0, "debit control changed"); int usdtNext = usdtBalance + mintedAmount; @@ -244,15 +249,15 @@ contract RepaymentPool( require( tx.outputs[0].scriptPubKey == new RepaymentPool( - usdtAssetId, creditAssetId, creditCtrlId, debitAssetId, debitCtrlId, ticker, + usdtAssetIdTxid, usdtAssetIdGidx, creditAssetIdTxid, creditAssetIdGidx, creditCtrlIdTxid, creditCtrlIdGidx, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, ticker, oraclePk, oracleMaxAgeSeconds, initRatioBps, liqThresholdBps, maturity, auctionWindow, auctionDiscountBps, usdtNext, totalCreditOutstanding, debitNext, exit ), "invalid pool recreation" ); - require(tx.outputs[0].assets.lookup(usdtAssetId) >= usdtNext, "repayment not received"); - require(tx.outputs[0].assets.lookup(creditCtrlId) >= 1, "credit control not retained"); - require(tx.outputs[0].assets.lookup(debitCtrlId) >= 1, "debit control not retained"); + require(tx.outputs[0].assets.lookup(usdtAssetIdTxid, usdtAssetIdGidx) >= usdtNext, "repayment not received"); + require(tx.outputs[0].assets.lookup(creditCtrlIdTxid, creditCtrlIdGidx) >= 1, "credit control not retained"); + require(tx.outputs[0].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1, "debit control not retained"); require(tx.outputs[1].value >= collateral, "collateral not returned"); require(tx.outputs[1].scriptPubKey == new SingleSig(borrowerPk), "collateral dest not borrower"); @@ -293,18 +298,18 @@ contract RepaymentPool( require( tx.inputs[vaultIdx].scriptPubKey == new BondMint( - borrowerPk, debitAssetId, debitCtrlId, oldMintedAmount, collateral, maturity, auctionWindow, exit + borrowerPk, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, oldMintedAmount, collateral, maturity, auctionWindow, exit ), "vault input mismatch" ); - let debitGroup = tx.assetGroups.find(debitAssetId); + let debitGroup = tx.assetGroups.find(debitAssetIdTxid, debitAssetIdGidx); require(debitGroup.sumInputs == debitGroup.sumOutputs + oldMintedAmount, "debit not burned exactly oldMintedAmount"); - let creditGroup = tx.assetGroups.find(creditAssetId); + let creditGroup = tx.assetGroups.find(creditAssetIdTxid, creditAssetIdGidx); require(creditGroup.delta == 0, "credit must not move during roll"); - let ccGroup = tx.assetGroups.find(creditCtrlId); + let ccGroup = tx.assetGroups.find(creditCtrlIdTxid, creditCtrlIdGidx); require(ccGroup.delta == 0, "credit control changed"); - let dcGroup = tx.assetGroups.find(debitCtrlId); + let dcGroup = tx.assetGroups.find(debitCtrlIdTxid, debitCtrlIdGidx); require(dcGroup.delta == 0, "debit control changed"); int usdtNext = usdtBalance + expectedDischarge; @@ -312,15 +317,15 @@ contract RepaymentPool( require( tx.outputs[outIdxPool].scriptPubKey == new RepaymentPool( - usdtAssetId, creditAssetId, creditCtrlId, debitAssetId, debitCtrlId, ticker, + usdtAssetIdTxid, usdtAssetIdGidx, creditAssetIdTxid, creditAssetIdGidx, creditCtrlIdTxid, creditCtrlIdGidx, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, ticker, oraclePk, oracleMaxAgeSeconds, initRatioBps, liqThresholdBps, maturity, auctionWindow, auctionDiscountBps, usdtNext, totalCreditOutstanding, debitNext, exit ), "invalid pool recreation" ); - require(tx.outputs[outIdxPool].assets.lookup(usdtAssetId) >= usdtNext, "discharge not received"); - require(tx.outputs[outIdxPool].assets.lookup(creditCtrlId) >= 1, "credit control not retained"); - require(tx.outputs[outIdxPool].assets.lookup(debitCtrlId) >= 1, "debit control not retained"); + require(tx.outputs[outIdxPool].assets.lookup(usdtAssetIdTxid, usdtAssetIdGidx) >= usdtNext, "discharge not received"); + require(tx.outputs[outIdxPool].assets.lookup(creditCtrlIdTxid, creditCtrlIdGidx) >= 1, "credit control not retained"); + require(tx.outputs[outIdxPool].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1, "debit control not retained"); } // ROLL IN — borrower opens a new vault on THIS pool (the next maturity). @@ -371,15 +376,15 @@ contract RepaymentPool( int required = (newMintedAmount * initRatioBps + 9999) / 10000; require(collateralValue >= required, "undercollateralized at issuance"); - let creditGroup = tx.assetGroups.find(creditAssetId); + let creditGroup = tx.assetGroups.find(creditAssetIdTxid, creditAssetIdGidx); require(creditGroup.delta == newMintedAmount, "credit delta mismatch"); - require(creditGroup.control == creditCtrlId, "wrong credit control"); - let debitGroup = tx.assetGroups.find(debitAssetId); + require(creditGroup.controlIs(creditCtrlIdTxid, creditCtrlIdGidx), "wrong credit control"); + let debitGroup = tx.assetGroups.find(debitAssetIdTxid, debitAssetIdGidx); require(debitGroup.delta == newMintedAmount, "debit delta mismatch"); - require(debitGroup.control == debitCtrlId, "wrong debit control"); - let ccGroup = tx.assetGroups.find(creditCtrlId); + require(debitGroup.controlIs(debitCtrlIdTxid, debitCtrlIdGidx), "wrong debit control"); + let ccGroup = tx.assetGroups.find(creditCtrlIdTxid, creditCtrlIdGidx); require(ccGroup.delta == 0, "credit control changed"); - let dcGroup = tx.assetGroups.find(debitCtrlId); + let dcGroup = tx.assetGroups.find(debitCtrlIdTxid, debitCtrlIdGidx); require(dcGroup.delta == 0, "debit control changed"); int creditNext = totalCreditOutstanding + newMintedAmount; @@ -387,29 +392,29 @@ contract RepaymentPool( require( tx.outputs[outIdxPool].scriptPubKey == new RepaymentPool( - usdtAssetId, creditAssetId, creditCtrlId, debitAssetId, debitCtrlId, ticker, + usdtAssetIdTxid, usdtAssetIdGidx, creditAssetIdTxid, creditAssetIdGidx, creditCtrlIdTxid, creditCtrlIdGidx, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, ticker, oraclePk, oracleMaxAgeSeconds, initRatioBps, liqThresholdBps, maturity, auctionWindow, auctionDiscountBps, usdtBalance, creditNext, debitNext, exit ), "invalid pool recreation" ); - require(tx.outputs[outIdxPool].assets.lookup(usdtAssetId) >= usdtBalance, "pool usdt short"); - require(tx.outputs[outIdxPool].assets.lookup(creditCtrlId) >= 1, "credit control not retained"); - require(tx.outputs[outIdxPool].assets.lookup(debitCtrlId) >= 1, "debit control not retained"); + require(tx.outputs[outIdxPool].assets.lookup(usdtAssetIdTxid, usdtAssetIdGidx) >= usdtBalance, "pool usdt short"); + require(tx.outputs[outIdxPool].assets.lookup(creditCtrlIdTxid, creditCtrlIdGidx) >= 1, "credit control not retained"); + require(tx.outputs[outIdxPool].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1, "debit control not retained"); - require(tx.outputs[outIdxCredit].assets.lookup(creditAssetId) >= newMintedAmount, "credit not delivered"); + require(tx.outputs[outIdxCredit].assets.lookup(creditAssetIdTxid, creditAssetIdGidx) >= newMintedAmount, "credit not delivered"); // No scriptPubKey pin: the borrower's Schnorr sighash covers all outputs, // so the credit destination IS implicitly committed by borrowerSig. This // is what lets rollIn compose with a non_interactive_swap covenant fill. require( tx.outputs[outIdxVault].scriptPubKey == new BondMint( - borrowerPk, debitAssetId, debitCtrlId, newMintedAmount, newCollateral, maturity, auctionWindow, exit + borrowerPk, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, newMintedAmount, newCollateral, maturity, auctionWindow, exit ), "invalid new vault" ); require(tx.outputs[outIdxVault].value >= newCollateral, "collateral not locked"); - require(tx.outputs[outIdxVault].assets.lookup(debitAssetId) >= newMintedAmount, "debit not custodied in vault"); + require(tx.outputs[outIdxVault].assets.lookup(debitAssetIdTxid, debitAssetIdGidx) >= newMintedAmount, "debit not custodied in vault"); } // LIQUIDATE — pre-maturity MARGIN CALL. Permissionless. Anyone can trigger @@ -441,18 +446,18 @@ contract RepaymentPool( require( tx.inputs[vaultIdx].scriptPubKey == new BondMint( - borrowerPk, debitAssetId, debitCtrlId, mintedAmount, collateral, maturity, auctionWindow, exit + borrowerPk, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, mintedAmount, collateral, maturity, auctionWindow, exit ), "vault input mismatch" ); - let debitGroup = tx.assetGroups.find(debitAssetId); + let debitGroup = tx.assetGroups.find(debitAssetIdTxid, debitAssetIdGidx); require(debitGroup.sumInputs == debitGroup.sumOutputs + mintedAmount, "debit not burned exactly mintedAmount"); - let creditGroup = tx.assetGroups.find(creditAssetId); + let creditGroup = tx.assetGroups.find(creditAssetIdTxid, creditAssetIdGidx); require(creditGroup.delta == 0, "credit must not move on liquidation"); - let ccGroup = tx.assetGroups.find(creditCtrlId); + let ccGroup = tx.assetGroups.find(creditCtrlIdTxid, creditCtrlIdGidx); require(ccGroup.delta == 0, "credit control changed"); - let dcGroup = tx.assetGroups.find(debitCtrlId); + let dcGroup = tx.assetGroups.find(debitCtrlIdTxid, debitCtrlIdGidx); require(dcGroup.delta == 0, "debit control changed"); int collateralValue = collateral * oraclePrice / 100000000; @@ -473,15 +478,15 @@ contract RepaymentPool( int usdtNext = usdtBalance + liquidationUsdt; require( tx.outputs[0].scriptPubKey == new RepaymentPool( - usdtAssetId, creditAssetId, creditCtrlId, debitAssetId, debitCtrlId, ticker, + usdtAssetIdTxid, usdtAssetIdGidx, creditAssetIdTxid, creditAssetIdGidx, creditCtrlIdTxid, creditCtrlIdGidx, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, ticker, oraclePk, oracleMaxAgeSeconds, initRatioBps, liqThresholdBps, maturity, auctionWindow, auctionDiscountBps, usdtNext, totalCreditOutstanding, debitNext, exit ), "invalid pool recreation" ); - require(tx.outputs[0].assets.lookup(usdtAssetId) >= usdtNext, "liquidation proceeds short"); - require(tx.outputs[0].assets.lookup(creditCtrlId) >= 1, "credit control not retained"); - require(tx.outputs[0].assets.lookup(debitCtrlId) >= 1, "debit control not retained"); + require(tx.outputs[0].assets.lookup(usdtAssetIdTxid, usdtAssetIdGidx) >= usdtNext, "liquidation proceeds short"); + require(tx.outputs[0].assets.lookup(creditCtrlIdTxid, creditCtrlIdGidx) >= 1, "credit control not retained"); + require(tx.outputs[0].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1, "debit control not retained"); require(tx.outputs[1].value >= collateralSold, "auctioneer underpaid"); require(tx.outputs[1].scriptPubKey == new SingleSig(auctioneerPk), "output 1 not auctioneer"); if (excess > 330) { @@ -493,15 +498,15 @@ contract RepaymentPool( int usdtNext = usdtBalance + liquidationUsdt; require( tx.outputs[0].scriptPubKey == new RepaymentPool( - usdtAssetId, creditAssetId, creditCtrlId, debitAssetId, debitCtrlId, ticker, + usdtAssetIdTxid, usdtAssetIdGidx, creditAssetIdTxid, creditAssetIdGidx, creditCtrlIdTxid, creditCtrlIdGidx, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, ticker, oraclePk, oracleMaxAgeSeconds, initRatioBps, liqThresholdBps, maturity, auctionWindow, auctionDiscountBps, usdtNext, totalCreditOutstanding, debitNext, exit ), "invalid pool recreation" ); - require(tx.outputs[0].assets.lookup(usdtAssetId) >= usdtNext, "liquidation proceeds short"); - require(tx.outputs[0].assets.lookup(creditCtrlId) >= 1, "credit control not retained"); - require(tx.outputs[0].assets.lookup(debitCtrlId) >= 1, "debit control not retained"); + require(tx.outputs[0].assets.lookup(usdtAssetIdTxid, usdtAssetIdGidx) >= usdtNext, "liquidation proceeds short"); + require(tx.outputs[0].assets.lookup(creditCtrlIdTxid, creditCtrlIdGidx) >= 1, "credit control not retained"); + require(tx.outputs[0].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1, "debit control not retained"); require(tx.outputs[1].value >= collateral, "auctioneer underpaid"); require(tx.outputs[1].scriptPubKey == new SingleSig(auctioneerPk), "output 1 not auctioneer"); } @@ -542,18 +547,18 @@ contract RepaymentPool( require( tx.inputs[vaultIdx].scriptPubKey == new BondMint( - borrowerPk, debitAssetId, debitCtrlId, mintedAmount, collateral, maturity, auctionWindow, exit + borrowerPk, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, mintedAmount, collateral, maturity, auctionWindow, exit ), "vault input mismatch" ); - let debitGroup = tx.assetGroups.find(debitAssetId); + let debitGroup = tx.assetGroups.find(debitAssetIdTxid, debitAssetIdGidx); require(debitGroup.sumInputs == debitGroup.sumOutputs + mintedAmount, "debit not burned exactly mintedAmount"); - let creditGroup = tx.assetGroups.find(creditAssetId); + let creditGroup = tx.assetGroups.find(creditAssetIdTxid, creditAssetIdGidx); require(creditGroup.delta == 0, "credit must not move on auction"); - let ccGroup = tx.assetGroups.find(creditCtrlId); + let ccGroup = tx.assetGroups.find(creditCtrlIdTxid, creditCtrlIdGidx); require(ccGroup.delta == 0, "credit control changed"); - let dcGroup = tx.assetGroups.find(debitCtrlId); + let dcGroup = tx.assetGroups.find(debitCtrlIdTxid, debitCtrlIdGidx); require(dcGroup.delta == 0, "debit control changed"); // FOLLOW-UP: same overflow ceiling as `issue` on `collateral * oraclePrice`; @@ -570,15 +575,15 @@ contract RepaymentPool( int usdtNext = usdtBalance + auctionUsdt; require( tx.outputs[0].scriptPubKey == new RepaymentPool( - usdtAssetId, creditAssetId, creditCtrlId, debitAssetId, debitCtrlId, ticker, + usdtAssetIdTxid, usdtAssetIdGidx, creditAssetIdTxid, creditAssetIdGidx, creditCtrlIdTxid, creditCtrlIdGidx, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, ticker, oraclePk, oracleMaxAgeSeconds, initRatioBps, liqThresholdBps, maturity, auctionWindow, auctionDiscountBps, usdtNext, totalCreditOutstanding, debitNext, exit ), "invalid pool recreation" ); - require(tx.outputs[0].assets.lookup(usdtAssetId) >= usdtNext, "auction proceeds short"); - require(tx.outputs[0].assets.lookup(creditCtrlId) >= 1, "credit control not retained"); - require(tx.outputs[0].assets.lookup(debitCtrlId) >= 1, "debit control not retained"); + require(tx.outputs[0].assets.lookup(usdtAssetIdTxid, usdtAssetIdGidx) >= usdtNext, "auction proceeds short"); + require(tx.outputs[0].assets.lookup(creditCtrlIdTxid, creditCtrlIdGidx) >= 1, "credit control not retained"); + require(tx.outputs[0].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1, "debit control not retained"); require(tx.outputs[1].value >= collateralSold, "auctioneer underpaid"); require(tx.outputs[1].scriptPubKey == new SingleSig(auctioneerPk), "output 1 not auctioneer"); if (excess > 330) { @@ -590,15 +595,15 @@ contract RepaymentPool( int usdtNext = usdtBalance + auctionUsdt; require( tx.outputs[0].scriptPubKey == new RepaymentPool( - usdtAssetId, creditAssetId, creditCtrlId, debitAssetId, debitCtrlId, ticker, + usdtAssetIdTxid, usdtAssetIdGidx, creditAssetIdTxid, creditAssetIdGidx, creditCtrlIdTxid, creditCtrlIdGidx, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, ticker, oraclePk, oracleMaxAgeSeconds, initRatioBps, liqThresholdBps, maturity, auctionWindow, auctionDiscountBps, usdtNext, totalCreditOutstanding, debitNext, exit ), "invalid pool recreation" ); - require(tx.outputs[0].assets.lookup(usdtAssetId) >= usdtNext, "auction proceeds short"); - require(tx.outputs[0].assets.lookup(creditCtrlId) >= 1, "credit control not retained"); - require(tx.outputs[0].assets.lookup(debitCtrlId) >= 1, "debit control not retained"); + require(tx.outputs[0].assets.lookup(usdtAssetIdTxid, usdtAssetIdGidx) >= usdtNext, "auction proceeds short"); + require(tx.outputs[0].assets.lookup(creditCtrlIdTxid, creditCtrlIdGidx) >= 1, "credit control not retained"); + require(tx.outputs[0].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1, "debit control not retained"); require(tx.outputs[1].value >= collateral, "auctioneer underpaid"); require(tx.outputs[1].scriptPubKey == new SingleSig(auctioneerPk), "output 1 not auctioneer"); } @@ -626,11 +631,11 @@ contract RepaymentPool( int payout = amount * usdtBalance / totalCreditOutstanding; require(payout > 0, "payout rounds to zero"); - let creditGroup = tx.assetGroups.find(creditAssetId); + let creditGroup = tx.assetGroups.find(creditAssetIdTxid, creditAssetIdGidx); require(creditGroup.sumInputs == creditGroup.sumOutputs + amount, "credit not burned exactly amount"); - let ccGroup = tx.assetGroups.find(creditCtrlId); + let ccGroup = tx.assetGroups.find(creditCtrlIdTxid, creditCtrlIdGidx); require(ccGroup.delta == 0, "credit control changed"); - let dcGroup = tx.assetGroups.find(debitCtrlId); + let dcGroup = tx.assetGroups.find(debitCtrlIdTxid, debitCtrlIdGidx); require(dcGroup.delta == 0, "debit control changed"); int usdtNext = usdtBalance - payout; @@ -638,17 +643,17 @@ contract RepaymentPool( require( tx.outputs[0].scriptPubKey == new RepaymentPool( - usdtAssetId, creditAssetId, creditCtrlId, debitAssetId, debitCtrlId, ticker, + usdtAssetIdTxid, usdtAssetIdGidx, creditAssetIdTxid, creditAssetIdGidx, creditCtrlIdTxid, creditCtrlIdGidx, debitAssetIdTxid, debitAssetIdGidx, debitCtrlIdTxid, debitCtrlIdGidx, ticker, oraclePk, oracleMaxAgeSeconds, initRatioBps, liqThresholdBps, maturity, auctionWindow, auctionDiscountBps, usdtNext, creditNext, totalDebitOutstanding, exit ), "invalid pool recreation" ); - require(tx.outputs[0].assets.lookup(usdtAssetId) >= usdtNext, "pool usdt short"); - require(tx.outputs[0].assets.lookup(creditCtrlId) >= 1, "credit control not retained"); - require(tx.outputs[0].assets.lookup(debitCtrlId) >= 1, "debit control not retained"); + require(tx.outputs[0].assets.lookup(usdtAssetIdTxid, usdtAssetIdGidx) >= usdtNext, "pool usdt short"); + require(tx.outputs[0].assets.lookup(creditCtrlIdTxid, creditCtrlIdGidx) >= 1, "credit control not retained"); + require(tx.outputs[0].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1, "debit control not retained"); - require(tx.outputs[1].assets.lookup(usdtAssetId) >= payout, "holder underpaid"); + require(tx.outputs[1].assets.lookup(usdtAssetIdTxid, usdtAssetIdGidx) >= payout, "holder underpaid"); require(tx.outputs[1].scriptPubKey == new SingleSig(holderPk), "redeem dest not holder"); } } diff --git a/examples/controlled_mint.ark b/examples/controlled_mint.ark index 50c0976..42c2a83 100644 --- a/examples/controlled_mint.ark +++ b/examples/controlled_mint.ark @@ -15,34 +15,36 @@ options { } contract ControlledMint( - bytes32 tokenAssetId, - bytes32 ctrlAssetId, + bytes32 tokenAssetIdTxid, + int tokenAssetIdGidx, + bytes32 ctrlAssetIdTxid, + int ctrlAssetIdGidx, pubkey issuerPk ) { // Mint: delta > 0, control asset present and retained function mint(int amount, pubkey recipientPk, signature issuerSig) { - let tokenGroup = tx.assetGroups.find(tokenAssetId); + let tokenGroup = tx.assetGroups.find(tokenAssetIdTxid, tokenAssetIdGidx); require(tokenGroup.delta == amount, "delta mismatch"); - require(tokenGroup.control == ctrlAssetId, "wrong control"); + require(tokenGroup.controlIs(ctrlAssetIdTxid, ctrlAssetIdGidx), "wrong control"); - let ctrlGroup = tx.assetGroups.find(ctrlAssetId); + let ctrlGroup = tx.assetGroups.find(ctrlAssetIdTxid, ctrlAssetIdGidx); require(ctrlGroup.delta == 0, "ctrl supply changed"); - require(tx.outputs[0].assets.lookup(tokenAssetId) >= amount, "mint short"); + require(tx.outputs[0].assets.lookup(tokenAssetIdTxid, tokenAssetIdGidx) >= amount, "mint short"); require(tx.outputs[0].scriptPubKey == new SingleSig(recipientPk), "wrong dest"); require(checkSig(issuerSig, issuerPk), "bad sig"); } // Burn: delta < 0, no control asset needed function burn(int amount, signature ownerSig, pubkey ownerPk) { - let tokenGroup = tx.assetGroups.find(tokenAssetId); + let tokenGroup = tx.assetGroups.find(tokenAssetIdTxid, tokenAssetIdGidx); require(tokenGroup.sumInputs >= tokenGroup.sumOutputs + amount, "burn short"); require(checkSig(ownerSig, ownerPk), "bad sig"); } // Lock supply forever: burn the control asset function lockSupply(signature issuerSig) { - let ctrlGroup = tx.assetGroups.find(ctrlAssetId); + let ctrlGroup = tx.assetGroups.find(ctrlAssetIdTxid, ctrlAssetIdGidx); require(ctrlGroup.sumOutputs == 0, "ctrl not burned"); require(checkSig(issuerSig, issuerPk), "bad sig"); } diff --git a/examples/fee_adapter.ark b/examples/fee_adapter.ark index f864ee3..c7d681e 100644 --- a/examples/fee_adapter.ark +++ b/examples/fee_adapter.ark @@ -17,7 +17,8 @@ contract FeeAdapter( pubkey senderPk, pubkey operatorPk, pubkey recipientPk, - bytes32 paymentAssetId, + bytes32 paymentAssetIdTxid, + int paymentAssetIdGidx, int minFee ) { // Execute: process payment with fee deduction @@ -27,10 +28,10 @@ contract FeeAdapter( require(fee >= minFee, "fee below minimum"); // Input must contain the payment asset - require(tx.inputs[0].assets.lookup(paymentAssetId) > 0, "no payment asset in input"); + require(tx.inputs[0].assets.lookup(paymentAssetIdTxid, paymentAssetIdGidx) > 0, "no payment asset in input"); // Output must contain the payment asset (minus fee) - require(tx.outputs[0].assets.lookup(paymentAssetId) > 0, "no payment asset in output"); + require(tx.outputs[0].assets.lookup(paymentAssetIdTxid, paymentAssetIdGidx) > 0, "no payment asset in output"); // Sender must sign require(checkSig(senderSig, senderPk), "invalid sender signature"); diff --git a/examples/nft_mint.ark b/examples/nft_mint.ark index d5fd3ec..87c72bd 100644 --- a/examples/nft_mint.ark +++ b/examples/nft_mint.ark @@ -1,28 +1,25 @@ -// NFT Minting Contract -// Demonstrates isFresh and assetId properties for unique asset creation +// NFT Mint Contract +// Demonstrates fresh-issuance enforcement and collection control via asset +// group introspection. // -// This contract enforces: -// 1. Each NFT is fresh (newly created in this transaction) -// 2. NFTs are controlled by a collection control asset -// 3. Only one unit per NFT (delta = 1) -// -// Key group properties used: -// - group.isFresh: Detects if asset was created in current transaction -// - group.delta: Ensures exactly 1 unit is minted -// - group.control: Verifies collection membership +// An Asset ID is the canonical pair (asset_txid, asset_gidx): +// - group.isFresh: asset issued in this tx (genesis) +// - group.delta: net change across inputs/outputs +// - group.controlIs(txid, gidx): collection control membership options { - server = server; - exit = 288; + server = server; + exit = 288; } contract NFTMint( - bytes32 collectionCtrlId, + bytes32 collectionCtrlIdTxid, + int collectionCtrlIdGidx, pubkey issuerPk ) { // Mint a new NFT: must be fresh with delta=1 - function mint(bytes32 nftAssetId, pubkey recipientPk, signature issuerSig) { - let nftGroup = tx.assetGroups.find(nftAssetId); + function mint(bytes32 nftAssetIdTxid, int nftAssetIdGidx, pubkey recipientPk, signature issuerSig) { + let nftGroup = tx.assetGroups.find(nftAssetIdTxid, nftAssetIdGidx); // Must be a brand new asset (genesis in this tx) require(nftGroup.isFresh == 1, "must be fresh"); @@ -31,22 +28,22 @@ contract NFTMint( require(nftGroup.delta == 1, "must mint exactly 1"); // Must be controlled by the collection - require(nftGroup.control == collectionCtrlId, "wrong collection"); + require(nftGroup.controlIs(collectionCtrlIdTxid, collectionCtrlIdGidx), "wrong collection"); // Control asset must be retained (delta == 0) - let ctrlGroup = tx.assetGroups.find(collectionCtrlId); + let ctrlGroup = tx.assetGroups.find(collectionCtrlIdTxid, collectionCtrlIdGidx); require(ctrlGroup.delta == 0, "control must be retained"); // NFT goes to recipient - require(tx.outputs[0].assets.lookup(nftAssetId) == 1, "NFT not in output"); + require(tx.outputs[0].assets.lookup(nftAssetIdTxid, nftAssetIdGidx) == 1, "NFT not in output"); require(tx.outputs[0].scriptPubKey == new SingleSig(recipientPk), "wrong recipient"); require(checkSig(issuerSig, issuerPk), "bad issuer sig"); } // Transfer existing NFT (not fresh) - function transfer(bytes32 nftAssetId, pubkey newOwnerPk, signature ownerSig, pubkey ownerPk) { - let nftGroup = tx.assetGroups.find(nftAssetId); + function transfer(bytes32 nftAssetIdTxid, int nftAssetIdGidx, pubkey newOwnerPk, signature ownerSig, pubkey ownerPk) { + let nftGroup = tx.assetGroups.find(nftAssetIdTxid, nftAssetIdGidx); // Must NOT be fresh (existing asset) require(nftGroup.isFresh == 0, "cannot be fresh"); @@ -55,18 +52,18 @@ contract NFTMint( require(nftGroup.delta == 0, "must be transfer"); // Must be controlled by the collection - require(nftGroup.control == collectionCtrlId, "wrong collection"); + require(nftGroup.controlIs(collectionCtrlIdTxid, collectionCtrlIdGidx), "wrong collection"); // NFT goes to new owner - require(tx.outputs[0].assets.lookup(nftAssetId) == 1, "NFT not in output"); + require(tx.outputs[0].assets.lookup(nftAssetIdTxid, nftAssetIdGidx) == 1, "NFT not in output"); require(tx.outputs[0].scriptPubKey == new SingleSig(newOwnerPk), "wrong dest"); require(checkSig(ownerSig, ownerPk), "bad owner sig"); } // Burn an NFT (delta = -1) - function burn(bytes32 nftAssetId, signature ownerSig, pubkey ownerPk) { - let nftGroup = tx.assetGroups.find(nftAssetId); + function burn(bytes32 nftAssetIdTxid, int nftAssetIdGidx, signature ownerSig, pubkey ownerPk) { + let nftGroup = tx.assetGroups.find(nftAssetIdTxid, nftAssetIdGidx); // Must be existing asset require(nftGroup.isFresh == 0, "cannot burn fresh asset"); diff --git a/examples/nft_mint.json b/examples/nft_mint.json index eb09c32..da0326b 100644 --- a/examples/nft_mint.json +++ b/examples/nft_mint.json @@ -2,11 +2,11 @@ "contractName": "NFTMint", "constructorInputs": [ { - "name": "collectionCtrlId_txid", + "name": "collectionCtrlIdTxid", "type": "bytes32" }, { - "name": "collectionCtrlId_gidx", + "name": "collectionCtrlIdGidx", "type": "int" }, { @@ -19,9 +19,13 @@ "name": "mint", "functionInputs": [ { - "name": "nftAssetId", + "name": "nftAssetIdTxid", "type": "bytes32" }, + { + "name": "nftAssetIdGidx", + "type": "int" + }, { "name": "recipientPk", "type": "pubkey" @@ -33,10 +37,15 @@ ], "witnessSchema": [ { - "name": "nftAssetId", + "name": "nftAssetIdTxid", "type": "bytes32", "encoding": "raw-32" }, + { + "name": "nftAssetIdGidx", + "type": "int", + "encoding": "scriptnum" + }, { "name": "recipientPk", "type": "pubkey", @@ -81,9 +90,10 @@ } ], "asm": [ - "", - "", + "", + "", "OP_FINDASSETGROUPBYASSETID", + "OP_VERIFY", "", "OP_INSPECTASSETGROUPASSETID", "OP_DROP", @@ -103,11 +113,17 @@ "OP_EQUAL", "", "OP_INSPECTASSETGROUPCTRL", - "", + "OP_DROP", + "", "OP_EQUAL", - "", - "", + "OP_SWAP", + "", + "OP_EQUAL", + "OP_BOOLAND", + "", + "", "OP_FINDASSETGROUPBYASSETID", + "OP_VERIFY", "", "OP_1", "OP_INSPECTASSETGROUPSUM", @@ -119,13 +135,9 @@ "0", "OP_EQUAL", "0", - "", - "", + "", + "", "OP_INSPECTOUTASSETLOOKUP", - "OP_DUP", - "OP_1NEGATE", - "OP_EQUAL", - "OP_NOT", "OP_VERIFY", "1", "OP_EQUAL", @@ -146,9 +158,13 @@ "name": "mint", "functionInputs": [ { - "name": "nftAssetId", + "name": "nftAssetIdTxid", "type": "bytes32" }, + { + "name": "nftAssetIdGidx", + "type": "int" + }, { "name": "recipientPk", "type": "pubkey" @@ -205,9 +221,13 @@ "name": "transfer", "functionInputs": [ { - "name": "nftAssetId", + "name": "nftAssetIdTxid", "type": "bytes32" }, + { + "name": "nftAssetIdGidx", + "type": "int" + }, { "name": "newOwnerPk", "type": "pubkey" @@ -223,10 +243,15 @@ ], "witnessSchema": [ { - "name": "nftAssetId", + "name": "nftAssetIdTxid", "type": "bytes32", "encoding": "raw-32" }, + { + "name": "nftAssetIdGidx", + "type": "int", + "encoding": "scriptnum" + }, { "name": "newOwnerPk", "type": "pubkey", @@ -273,9 +298,10 @@ } ], "asm": [ - "", - "", + "", + "", "OP_FINDASSETGROUPBYASSETID", + "OP_VERIFY", "", "OP_INSPECTASSETGROUPASSETID", "OP_DROP", @@ -295,16 +321,17 @@ "OP_EQUAL", "", "OP_INSPECTASSETGROUPCTRL", - "", + "OP_DROP", + "", "OP_EQUAL", + "OP_SWAP", + "", + "OP_EQUAL", + "OP_BOOLAND", "0", - "", - "", + "", + "", "OP_INSPECTOUTASSETLOOKUP", - "OP_DUP", - "OP_1NEGATE", - "OP_EQUAL", - "OP_NOT", "OP_VERIFY", "1", "OP_EQUAL", @@ -325,9 +352,13 @@ "name": "transfer", "functionInputs": [ { - "name": "nftAssetId", + "name": "nftAssetIdTxid", "type": "bytes32" }, + { + "name": "nftAssetIdGidx", + "type": "int" + }, { "name": "newOwnerPk", "type": "pubkey" @@ -400,9 +431,13 @@ "name": "burn", "functionInputs": [ { - "name": "nftAssetId", + "name": "nftAssetIdTxid", "type": "bytes32" }, + { + "name": "nftAssetIdGidx", + "type": "int" + }, { "name": "ownerSig", "type": "signature" @@ -414,10 +449,15 @@ ], "witnessSchema": [ { - "name": "nftAssetId", + "name": "nftAssetIdTxid", "type": "bytes32", "encoding": "raw-32" }, + { + "name": "nftAssetIdGidx", + "type": "int", + "encoding": "scriptnum" + }, { "name": "ownerSig", "type": "signature", @@ -450,9 +490,10 @@ } ], "asm": [ - "", - "", + "", + "", "OP_FINDASSETGROUPBYASSETID", + "OP_VERIFY", "", "OP_INSPECTASSETGROUPASSETID", "OP_DROP", @@ -482,9 +523,13 @@ "name": "burn", "functionInputs": [ { - "name": "nftAssetId", + "name": "nftAssetIdTxid", "type": "bytes32" }, + { + "name": "nftAssetIdGidx", + "type": "int" + }, { "name": "ownerSig", "type": "signature" @@ -538,17 +583,34 @@ ] } ], - "source": "\noptions {\n server = server;\n exit = 288;\n}\n\ncontract NFTMint(\n bytes32 collectionCtrlId,\n pubkey issuerPk\n) {\n function mint(bytes32 nftAssetId, pubkey recipientPk, signature issuerSig) {\n let nftGroup = tx.assetGroups.find(nftAssetId);\n\n require(nftGroup.isFresh == 1, \"must be fresh\");\n\n require(nftGroup.delta == 1, \"must mint exactly 1\");\n\n require(nftGroup.control == collectionCtrlId, \"wrong collection\");\n\n let ctrlGroup = tx.assetGroups.find(collectionCtrlId);\n require(ctrlGroup.delta == 0, \"control must be retained\");\n\n require(tx.outputs[0].assets.lookup(nftAssetId) == 1, \"NFT not in output\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(recipientPk), \"wrong recipient\");\n\n require(checkSig(issuerSig, issuerPk), \"bad issuer sig\");\n }\n\n function transfer(bytes32 nftAssetId, pubkey newOwnerPk, signature ownerSig, pubkey ownerPk) {\n let nftGroup = tx.assetGroups.find(nftAssetId);\n\n require(nftGroup.isFresh == 0, \"cannot be fresh\");\n\n require(nftGroup.delta == 0, \"must be transfer\");\n\n require(nftGroup.control == collectionCtrlId, \"wrong collection\");\n\n require(tx.outputs[0].assets.lookup(nftAssetId) == 1, \"NFT not in output\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(newOwnerPk), \"wrong dest\");\n\n require(checkSig(ownerSig, ownerPk), \"bad owner sig\");\n }\n\n function burn(bytes32 nftAssetId, signature ownerSig, pubkey ownerPk) {\n let nftGroup = tx.assetGroups.find(nftAssetId);\n\n require(nftGroup.isFresh == 0, \"cannot burn fresh asset\");\n\n require(nftGroup.sumInputs >= nftGroup.sumOutputs + 1, \"must burn exactly 1\");\n\n require(checkSig(ownerSig, ownerPk), \"bad owner sig\");\n }\n}", + "source": "\noptions {\n server = server;\n exit = 288;\n}\n\ncontract NFTMint(\n bytes32 collectionCtrlIdTxid,\n int collectionCtrlIdGidx,\n pubkey issuerPk\n) {\n function mint(bytes32 nftAssetIdTxid, int nftAssetIdGidx, pubkey recipientPk, signature issuerSig) {\n let nftGroup = tx.assetGroups.find(nftAssetIdTxid, nftAssetIdGidx);\n\n require(nftGroup.isFresh == 1, \"must be fresh\");\n\n require(nftGroup.delta == 1, \"must mint exactly 1\");\n\n require(nftGroup.controlIs(collectionCtrlIdTxid, collectionCtrlIdGidx), \"wrong collection\");\n\n let ctrlGroup = tx.assetGroups.find(collectionCtrlIdTxid, collectionCtrlIdGidx);\n require(ctrlGroup.delta == 0, \"control must be retained\");\n\n require(tx.outputs[0].assets.lookup(nftAssetIdTxid, nftAssetIdGidx) == 1, \"NFT not in output\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(recipientPk), \"wrong recipient\");\n\n require(checkSig(issuerSig, issuerPk), \"bad issuer sig\");\n }\n\n function transfer(bytes32 nftAssetIdTxid, int nftAssetIdGidx, pubkey newOwnerPk, signature ownerSig, pubkey ownerPk) {\n let nftGroup = tx.assetGroups.find(nftAssetIdTxid, nftAssetIdGidx);\n\n require(nftGroup.isFresh == 0, \"cannot be fresh\");\n\n require(nftGroup.delta == 0, \"must be transfer\");\n\n require(nftGroup.controlIs(collectionCtrlIdTxid, collectionCtrlIdGidx), \"wrong collection\");\n\n require(tx.outputs[0].assets.lookup(nftAssetIdTxid, nftAssetIdGidx) == 1, \"NFT not in output\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(newOwnerPk), \"wrong dest\");\n\n require(checkSig(ownerSig, ownerPk), \"bad owner sig\");\n }\n\n function burn(bytes32 nftAssetIdTxid, int nftAssetIdGidx, signature ownerSig, pubkey ownerPk) {\n let nftGroup = tx.assetGroups.find(nftAssetIdTxid, nftAssetIdGidx);\n\n require(nftGroup.isFresh == 0, \"cannot burn fresh asset\");\n\n require(nftGroup.sumInputs >= nftGroup.sumOutputs + 1, \"must burn exactly 1\");\n\n require(checkSig(ownerSig, ownerPk), \"bad owner sig\");\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-08T15:43:11.148171244+00:00", + "updatedAt": "2026-06-15T12:19:44.398758+00:00", "warnings": [ "warning[type]: fn mint: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn mint: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn mint: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn transfer: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[type]: fn transfer: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" + "warning[type]: fn transfer: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[output-invariant]: fn 'mint' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'nftGroup'", + "warning[output-invariant]: fn 'mint' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'nftGroup'", + "warning[output-invariant]: fn 'mint' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'nftGroup'", + "warning[output-invariant]: fn 'mint' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'nftGroup'", + "warning[output-invariant]: fn 'mint' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'ctrlGroup'", + "warning[output-invariant]: fn 'mint' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'ctrlGroup'", + "warning[output-invariant]: fn 'mint' (serverVariant=false): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'recipientPk'", + "warning[output-invariant]: fn 'transfer' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'nftGroup'", + "warning[output-invariant]: fn 'transfer' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'nftGroup'", + "warning[output-invariant]: fn 'transfer' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'nftGroup'", + "warning[output-invariant]: fn 'transfer' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'nftGroup'", + "warning[output-invariant]: fn 'transfer' (serverVariant=false): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newOwnerPk'", + "warning[output-invariant]: fn 'transfer' (serverVariant=false): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'ownerPk'", + "warning[output-invariant]: fn 'burn' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'nftGroup'", + "warning[output-invariant]: fn 'burn' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'nftGroup'", + "warning[output-invariant]: fn 'burn' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'nftGroup'", + "warning[output-invariant]: fn 'burn' (serverVariant=false): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'ownerPk'" ] } \ No newline at end of file diff --git a/examples/non_interactive_swap.ark b/examples/non_interactive_swap.ark index 80940de..d8e32ab 100644 --- a/examples/non_interactive_swap.ark +++ b/examples/non_interactive_swap.ark @@ -22,11 +22,13 @@ contract NonInteractiveSwap( // Maker's public key (creator of the swap offer) pubkey makerPk, // Asset ID the maker is offering (locked in this contract) - bytes32 offerAssetId, + bytes32 offerAssetIdTxid, + int offerAssetIdGidx, // Amount of offer asset locked int offerAmount, // Asset ID the maker wants to receive - bytes32 wantAssetId, + bytes32 wantAssetIdTxid, + int wantAssetIdGidx, // Amount of want asset required for the swap int wantAmount, // Expiration timestamp (block height) after which maker can cancel @@ -42,7 +44,7 @@ contract NonInteractiveSwap( // Introspection verifies the atomic exchange: // Output 0: maker receives wantAsset require( - tx.outputs[0].assets.lookup(wantAssetId) >= wantAmount, + tx.outputs[0].assets.lookup(wantAssetIdTxid, wantAssetIdGidx) >= wantAmount, "insufficient want asset for maker" ); require( @@ -52,7 +54,7 @@ contract NonInteractiveSwap( // Output 1: taker receives offerAsset require( - tx.outputs[1].assets.lookup(offerAssetId) >= offerAmount, + tx.outputs[1].assets.lookup(offerAssetIdTxid, offerAssetIdGidx) >= offerAmount, "insufficient offer asset for taker" ); require( diff --git a/examples/non_interactive_swap.hack b/examples/non_interactive_swap.hack index bc17724..fc257c6 100644 --- a/examples/non_interactive_swap.hack +++ b/examples/non_interactive_swap.hack @@ -7,36 +7,28 @@ OP_CHECKSIG 0 - - + + OP_INSPECTOUTASSETLOOKUP -OP_DUP -OP_1NEGATE -OP_EQUAL -OP_NOT OP_VERIFY OP_GREATERTHANOREQUAL64 OP_VERIFY 0 OP_INSPECTOUTPUTSCRIPTPUBKEY - +)> OP_EQUAL 1 - - + + OP_INSPECTOUTASSETLOOKUP -OP_DUP -OP_1NEGATE -OP_EQUAL -OP_NOT OP_VERIFY OP_GREATERTHANOREQUAL64 OP_VERIFY 1 OP_INSPECTOUTPUTSCRIPTPUBKEY - +)> OP_EQUAL diff --git a/examples/non_interactive_swap.json b/examples/non_interactive_swap.json index 4ef1252..253e0d3 100644 --- a/examples/non_interactive_swap.json +++ b/examples/non_interactive_swap.json @@ -6,11 +6,11 @@ "type": "pubkey" }, { - "name": "offerAssetId_txid", + "name": "offerAssetIdTxid", "type": "bytes32" }, { - "name": "offerAssetId_gidx", + "name": "offerAssetIdGidx", "type": "int" }, { @@ -18,11 +18,11 @@ "type": "int" }, { - "name": "wantAssetId_txid", + "name": "wantAssetIdTxid", "type": "bytes32" }, { - "name": "wantAssetId_gidx", + "name": "wantAssetIdGidx", "type": "int" }, { @@ -90,13 +90,9 @@ "", "OP_CHECKSIG", "0", - "", - "", + "", + "", "OP_INSPECTOUTASSETLOOKUP", - "OP_DUP", - "OP_1NEGATE", - "OP_EQUAL", - "OP_NOT", "OP_VERIFY", "", "OP_GREATERTHANOREQUAL64", @@ -106,13 +102,9 @@ ")>", "OP_EQUAL", "1", - "", - "", + "", + "", "OP_INSPECTOUTASSETLOOKUP", - "OP_DUP", - "OP_1NEGATE", - "OP_EQUAL", - "OP_NOT", "OP_VERIFY", "", "OP_GREATERTHANOREQUAL64", @@ -268,14 +260,15 @@ ] } ], - "source": "\noptions {\n server = server;\n exit = 144;\n}\n\ncontract NonInteractiveSwap(\n pubkey makerPk,\n bytes32 offerAssetId,\n int offerAmount,\n bytes32 wantAssetId,\n int wantAmount,\n int expirationTime\n) {\n function swap(pubkey takerPk, signature takerSig) {\n require(checkSig(takerSig, takerPk), \"invalid taker signature\");\n\n require(\n tx.outputs[0].assets.lookup(wantAssetId) >= wantAmount,\n \"insufficient want asset for maker\"\n );\n require(\n tx.outputs[0].scriptPubKey == new SingleSig(makerPk),\n \"output 0 not spendable by maker\"\n );\n\n require(\n tx.outputs[1].assets.lookup(offerAssetId) >= offerAmount,\n \"insufficient offer asset for taker\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SingleSig(takerPk),\n \"output 1 not spendable by taker\"\n );\n }\n\n function cancel(signature makerSig) {\n require(tx.time >= expirationTime, \"swap not expired\");\n require(checkSig(makerSig, makerPk), \"invalid maker signature\");\n }\n}", + "source": "\noptions {\n server = server;\n exit = 144;\n}\n\ncontract NonInteractiveSwap(\n pubkey makerPk,\n bytes32 offerAssetIdTxid,\n int offerAssetIdGidx,\n int offerAmount,\n bytes32 wantAssetIdTxid,\n int wantAssetIdGidx,\n int wantAmount,\n int expirationTime\n) {\n function swap(pubkey takerPk, signature takerSig) {\n require(checkSig(takerSig, takerPk), \"invalid taker signature\");\n\n require(\n tx.outputs[0].assets.lookup(wantAssetIdTxid, wantAssetIdGidx) >= wantAmount,\n \"insufficient want asset for maker\"\n );\n require(\n tx.outputs[0].scriptPubKey == new SingleSig(makerPk),\n \"output 0 not spendable by maker\"\n );\n\n require(\n tx.outputs[1].assets.lookup(offerAssetIdTxid, offerAssetIdGidx) >= offerAmount,\n \"insufficient offer asset for taker\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SingleSig(takerPk),\n \"output 1 not spendable by taker\"\n );\n }\n\n function cancel(signature makerSig) {\n require(tx.time >= expirationTime, \"swap not expired\");\n require(checkSig(makerSig, makerPk), \"invalid maker signature\");\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-08T15:43:11.207072223+00:00", + "updatedAt": "2026-06-15T12:19:43.984220+00:00", "warnings": [ "warning[type]: fn swap: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[type]: fn swap: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" + "warning[type]: fn swap: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[output-invariant]: fn 'swap' (serverVariant=false): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'takerPk'" ] } \ No newline at end of file diff --git a/examples/options/cash_secured_put.ark b/examples/options/cash_secured_put.ark index e7f42b6..9373bce 100644 --- a/examples/options/cash_secured_put.ark +++ b/examples/options/cash_secured_put.ark @@ -44,7 +44,8 @@ options { contract CashSecuredPut( pubkey sellerPk, // option writer; locks the stable pubkey buyerPk, // option holder - bytes32 stableAssetId, // USDT / USDC asset id + bytes32 stableAssetIdTxid, // USDT / USDC asset id (issuance txid) + int stableAssetIdGidx, // USDT / USDC asset id (issuance group index) int stableAmount, // seller's deposit (strike value) int btcSats, // BTC the buyer must deliver to exercise int expiryHeight, // exercise window opens at this block height @@ -70,7 +71,7 @@ contract CashSecuredPut( // Output 1: buyer receives the stablecoin. require( - tx.outputs[1].assets.lookup(stableAssetId) >= stableAmount, + tx.outputs[1].assets.lookup(stableAssetIdTxid, stableAssetIdGidx) >= stableAmount, "buyer underpaid" ); require( @@ -99,13 +100,13 @@ contract CashSecuredPut( require(checkSig(sellerSig, sellerPk), "invalid seller sig"); require( tx.outputs[0].scriptPubKey == new CashSecuredPut( - newSellerPk, buyerPk, stableAssetId, + newSellerPk, buyerPk, stableAssetIdTxid, stableAssetIdGidx, stableAmount, btcSats, expiryHeight, graceBlocks, exit ), "invalid transfer output" ); require( - tx.outputs[0].assets.lookup(stableAssetId) >= stableAmount, + tx.outputs[0].assets.lookup(stableAssetIdTxid, stableAssetIdGidx) >= stableAmount, "collateral not preserved" ); } @@ -120,13 +121,13 @@ contract CashSecuredPut( require(checkSig(buyerSig, buyerPk), "invalid buyer sig"); require( tx.outputs[0].scriptPubKey == new CashSecuredPut( - sellerPk, newBuyerPk, stableAssetId, + sellerPk, newBuyerPk, stableAssetIdTxid, stableAssetIdGidx, stableAmount, btcSats, expiryHeight, graceBlocks, exit ), "invalid transfer output" ); require( - tx.outputs[0].assets.lookup(stableAssetId) >= stableAmount, + tx.outputs[0].assets.lookup(stableAssetIdTxid, stableAssetIdGidx) >= stableAmount, "collateral not preserved" ); } diff --git a/examples/options/cash_secured_put.json b/examples/options/cash_secured_put.json index 378e93e..ecf91b3 100644 --- a/examples/options/cash_secured_put.json +++ b/examples/options/cash_secured_put.json @@ -10,11 +10,11 @@ "type": "pubkey" }, { - "name": "stableAssetId_txid", + "name": "stableAssetIdTxid", "type": "bytes32" }, { - "name": "stableAssetId_gidx", + "name": "stableAssetIdGidx", "type": "int" }, { @@ -101,13 +101,9 @@ ")>", "OP_EQUAL", "1", - "", - "", + "", + "", "OP_INSPECTOUTASSETLOOKUP", - "OP_DUP", - "OP_1NEGATE", - "OP_EQUAL", - "OP_NOT", "OP_VERIFY", "", "OP_GREATERTHANOREQUAL64", @@ -326,16 +322,12 @@ "OP_CHECKSIG", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,,)>", + ",,,,,,,,)>", "OP_EQUAL", "0", - "", - "", + "", + "", "OP_INSPECTOUTASSETLOOKUP", - "OP_DUP", - "OP_1NEGATE", - "OP_EQUAL", - "OP_NOT", "OP_VERIFY", "", "OP_GREATERTHANOREQUAL64", @@ -468,16 +460,12 @@ "OP_CHECKSIG", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,,)>", + ",,,,,,,,)>", "OP_EQUAL", "0", - "", - "", + "", + "", "OP_INSPECTOUTASSETLOOKUP", - "OP_DUP", - "OP_1NEGATE", - "OP_EQUAL", - "OP_NOT", "OP_VERIFY", "", "OP_GREATERTHANOREQUAL64", @@ -555,12 +543,12 @@ ] } ], - "source": "\nimport \"single_sig.ark\";\n\noptions {\n server = server;\n exit = exit;\n}\n\ncontract CashSecuredPut(\n pubkey sellerPk,\n pubkey buyerPk,\n bytes32 stableAssetId,\n int stableAmount,\n int btcSats,\n int expiryHeight,\n int graceBlocks,\n int exit\n) {\n\n function exercise(signature buyerSig) {\n require(tx.time >= expiryHeight, \"before expiry\");\n require(checkSig(buyerSig, buyerPk), \"invalid buyer sig\");\n\n require(tx.outputs[0].value >= btcSats, \"seller underpaid\");\n require(\n tx.outputs[0].scriptPubKey == new SingleSig(sellerPk),\n \"output 0 not seller\"\n );\n\n require(\n tx.outputs[1].assets.lookup(stableAssetId) >= stableAmount,\n \"buyer underpaid\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SingleSig(buyerPk),\n \"output 1 not buyer\"\n );\n }\n\n function reclaim(signature sellerSig) {\n int reclaimHeight = expiryHeight + graceBlocks;\n require(tx.time >= reclaimHeight, \"reclaim window not open\");\n require(checkSig(sellerSig, sellerPk), \"invalid seller sig\");\n }\n\n function transferSeller(signature sellerSig, pubkey newSellerPk) {\n require(tx.time < expiryHeight, \"no transfers after expiry\");\n require(checkSig(sellerSig, sellerPk), \"invalid seller sig\");\n require(\n tx.outputs[0].scriptPubKey == new CashSecuredPut(\n newSellerPk, buyerPk, stableAssetId,\n stableAmount, btcSats, expiryHeight, graceBlocks, exit\n ),\n \"invalid transfer output\"\n );\n require(\n tx.outputs[0].assets.lookup(stableAssetId) >= stableAmount,\n \"collateral not preserved\"\n );\n }\n\n function transferBuyer(signature buyerSig, pubkey newBuyerPk) {\n require(tx.time < expiryHeight, \"no transfers after expiry\");\n require(checkSig(buyerSig, buyerPk), \"invalid buyer sig\");\n require(\n tx.outputs[0].scriptPubKey == new CashSecuredPut(\n sellerPk, newBuyerPk, stableAssetId,\n stableAmount, btcSats, expiryHeight, graceBlocks, exit\n ),\n \"invalid transfer output\"\n );\n require(\n tx.outputs[0].assets.lookup(stableAssetId) >= stableAmount,\n \"collateral not preserved\"\n );\n }\n}", + "source": "\nimport \"single_sig.ark\";\n\noptions {\n server = server;\n exit = exit;\n}\n\ncontract CashSecuredPut(\n pubkey sellerPk,\n pubkey buyerPk,\n bytes32 stableAssetIdTxid,\n int stableAssetIdGidx,\n int stableAmount,\n int btcSats,\n int expiryHeight,\n int graceBlocks,\n int exit\n) {\n\n function exercise(signature buyerSig) {\n require(tx.time >= expiryHeight, \"before expiry\");\n require(checkSig(buyerSig, buyerPk), \"invalid buyer sig\");\n\n require(tx.outputs[0].value >= btcSats, \"seller underpaid\");\n require(\n tx.outputs[0].scriptPubKey == new SingleSig(sellerPk),\n \"output 0 not seller\"\n );\n\n require(\n tx.outputs[1].assets.lookup(stableAssetIdTxid, stableAssetIdGidx) >= stableAmount,\n \"buyer underpaid\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SingleSig(buyerPk),\n \"output 1 not buyer\"\n );\n }\n\n function reclaim(signature sellerSig) {\n int reclaimHeight = expiryHeight + graceBlocks;\n require(tx.time >= reclaimHeight, \"reclaim window not open\");\n require(checkSig(sellerSig, sellerPk), \"invalid seller sig\");\n }\n\n function transferSeller(signature sellerSig, pubkey newSellerPk) {\n require(tx.time < expiryHeight, \"no transfers after expiry\");\n require(checkSig(sellerSig, sellerPk), \"invalid seller sig\");\n require(\n tx.outputs[0].scriptPubKey == new CashSecuredPut(\n newSellerPk, buyerPk, stableAssetIdTxid, stableAssetIdGidx,\n stableAmount, btcSats, expiryHeight, graceBlocks, exit\n ),\n \"invalid transfer output\"\n );\n require(\n tx.outputs[0].assets.lookup(stableAssetIdTxid, stableAssetIdGidx) >= stableAmount,\n \"collateral not preserved\"\n );\n }\n\n function transferBuyer(signature buyerSig, pubkey newBuyerPk) {\n require(tx.time < expiryHeight, \"no transfers after expiry\");\n require(checkSig(buyerSig, buyerPk), \"invalid buyer sig\");\n require(\n tx.outputs[0].scriptPubKey == new CashSecuredPut(\n sellerPk, newBuyerPk, stableAssetIdTxid, stableAssetIdGidx,\n stableAmount, btcSats, expiryHeight, graceBlocks, exit\n ),\n \"invalid transfer output\"\n );\n require(\n tx.outputs[0].assets.lookup(stableAssetIdTxid, stableAssetIdGidx) >= stableAmount,\n \"collateral not preserved\"\n );\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-22T14:44:53.800848519+00:00", + "updatedAt": "2026-06-15T12:19:44.980102+00:00", "warnings": [ "warning[type]: fn exercise: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn exercise: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", diff --git a/examples/options/covered_call.ark b/examples/options/covered_call.ark index ff9a2df..a71ad0d 100644 --- a/examples/options/covered_call.ark +++ b/examples/options/covered_call.ark @@ -52,7 +52,8 @@ options { contract CoveredCall( pubkey sellerPk, // option writer; locks the BTC pubkey buyerPk, // option holder - bytes32 stableAssetId, // USDT / USDC asset id + bytes32 stableAssetIdTxid, // USDT / USDC asset id (issuance txid) + int stableAssetIdGidx, // USDT / USDC asset id (issuance group index) int btcSats, // seller's deposit int strikeAmount, // total stablecoin the buyer must pay to exercise int expiryHeight, // exercise window opens at this block height @@ -73,7 +74,7 @@ contract CoveredCall( // Output 0: seller receives the strike payment in stablecoin. require( - tx.outputs[0].assets.lookup(stableAssetId) >= strikeAmount, + tx.outputs[0].assets.lookup(stableAssetIdTxid, stableAssetIdGidx) >= strikeAmount, "seller underpaid" ); require( @@ -113,7 +114,7 @@ contract CoveredCall( require(checkSig(sellerSig, sellerPk), "invalid seller sig"); require( tx.outputs[0].scriptPubKey == new CoveredCall( - newSellerPk, buyerPk, stableAssetId, + newSellerPk, buyerPk, stableAssetIdTxid, stableAssetIdGidx, btcSats, strikeAmount, expiryHeight, graceBlocks, exit ), "invalid transfer output" @@ -131,7 +132,7 @@ contract CoveredCall( require(checkSig(buyerSig, buyerPk), "invalid buyer sig"); require( tx.outputs[0].scriptPubKey == new CoveredCall( - sellerPk, newBuyerPk, stableAssetId, + sellerPk, newBuyerPk, stableAssetIdTxid, stableAssetIdGidx, btcSats, strikeAmount, expiryHeight, graceBlocks, exit ), "invalid transfer output" diff --git a/examples/options/covered_call.json b/examples/options/covered_call.json index 375e3ef..c1d3821 100644 --- a/examples/options/covered_call.json +++ b/examples/options/covered_call.json @@ -10,11 +10,11 @@ "type": "pubkey" }, { - "name": "stableAssetId_txid", + "name": "stableAssetIdTxid", "type": "bytes32" }, { - "name": "stableAssetId_gidx", + "name": "stableAssetIdGidx", "type": "int" }, { @@ -92,13 +92,9 @@ "", "OP_CHECKSIG", "0", - "", - "", + "", + "", "OP_INSPECTOUTASSETLOOKUP", - "OP_DUP", - "OP_1NEGATE", - "OP_EQUAL", - "OP_NOT", "OP_VERIFY", "", "OP_GREATERTHANOREQUAL64", @@ -326,7 +322,7 @@ "OP_CHECKSIG", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,,)>", + ",,,,,,,,)>", "OP_EQUAL", "0", "OP_INSPECTOUTPUTVALUE", @@ -461,7 +457,7 @@ "OP_CHECKSIG", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,,)>", + ",,,,,,,,)>", "OP_EQUAL", "0", "OP_INSPECTOUTPUTVALUE", @@ -541,12 +537,12 @@ ] } ], - "source": "\nimport \"single_sig.ark\";\n\noptions {\n server = server;\n exit = exit;\n}\n\ncontract CoveredCall(\n pubkey sellerPk,\n pubkey buyerPk,\n bytes32 stableAssetId,\n int btcSats,\n int strikeAmount,\n int expiryHeight,\n int graceBlocks,\n int exit\n) {\n\n function exercise(signature buyerSig) {\n require(tx.time >= expiryHeight, \"before expiry\");\n require(checkSig(buyerSig, buyerPk), \"invalid buyer sig\");\n\n require(\n tx.outputs[0].assets.lookup(stableAssetId) >= strikeAmount,\n \"seller underpaid\"\n );\n require(\n tx.outputs[0].scriptPubKey == new SingleSig(sellerPk),\n \"output 0 not seller\"\n );\n\n require(tx.outputs[1].value >= btcSats, \"buyer underpaid\");\n require(\n tx.outputs[1].scriptPubKey == new SingleSig(buyerPk),\n \"output 1 not buyer\"\n );\n }\n\n function reclaim(signature sellerSig) {\n int reclaimHeight = expiryHeight + graceBlocks;\n require(tx.time >= reclaimHeight, \"reclaim window not open\");\n require(checkSig(sellerSig, sellerPk), \"invalid seller sig\");\n }\n\n function transferSeller(signature sellerSig, pubkey newSellerPk) {\n require(tx.time < expiryHeight, \"no transfers after expiry\");\n require(checkSig(sellerSig, sellerPk), \"invalid seller sig\");\n require(\n tx.outputs[0].scriptPubKey == new CoveredCall(\n newSellerPk, buyerPk, stableAssetId,\n btcSats, strikeAmount, expiryHeight, graceBlocks, exit\n ),\n \"invalid transfer output\"\n );\n require(tx.outputs[0].value >= btcSats, \"collateral not preserved\");\n }\n\n function transferBuyer(signature buyerSig, pubkey newBuyerPk) {\n require(tx.time < expiryHeight, \"no transfers after expiry\");\n require(checkSig(buyerSig, buyerPk), \"invalid buyer sig\");\n require(\n tx.outputs[0].scriptPubKey == new CoveredCall(\n sellerPk, newBuyerPk, stableAssetId,\n btcSats, strikeAmount, expiryHeight, graceBlocks, exit\n ),\n \"invalid transfer output\"\n );\n require(tx.outputs[0].value >= btcSats, \"collateral not preserved\");\n }\n}", + "source": "\nimport \"single_sig.ark\";\n\noptions {\n server = server;\n exit = exit;\n}\n\ncontract CoveredCall(\n pubkey sellerPk,\n pubkey buyerPk,\n bytes32 stableAssetIdTxid,\n int stableAssetIdGidx,\n int btcSats,\n int strikeAmount,\n int expiryHeight,\n int graceBlocks,\n int exit\n) {\n\n function exercise(signature buyerSig) {\n require(tx.time >= expiryHeight, \"before expiry\");\n require(checkSig(buyerSig, buyerPk), \"invalid buyer sig\");\n\n require(\n tx.outputs[0].assets.lookup(stableAssetIdTxid, stableAssetIdGidx) >= strikeAmount,\n \"seller underpaid\"\n );\n require(\n tx.outputs[0].scriptPubKey == new SingleSig(sellerPk),\n \"output 0 not seller\"\n );\n\n require(tx.outputs[1].value >= btcSats, \"buyer underpaid\");\n require(\n tx.outputs[1].scriptPubKey == new SingleSig(buyerPk),\n \"output 1 not buyer\"\n );\n }\n\n function reclaim(signature sellerSig) {\n int reclaimHeight = expiryHeight + graceBlocks;\n require(tx.time >= reclaimHeight, \"reclaim window not open\");\n require(checkSig(sellerSig, sellerPk), \"invalid seller sig\");\n }\n\n function transferSeller(signature sellerSig, pubkey newSellerPk) {\n require(tx.time < expiryHeight, \"no transfers after expiry\");\n require(checkSig(sellerSig, sellerPk), \"invalid seller sig\");\n require(\n tx.outputs[0].scriptPubKey == new CoveredCall(\n newSellerPk, buyerPk, stableAssetIdTxid, stableAssetIdGidx,\n btcSats, strikeAmount, expiryHeight, graceBlocks, exit\n ),\n \"invalid transfer output\"\n );\n require(tx.outputs[0].value >= btcSats, \"collateral not preserved\");\n }\n\n function transferBuyer(signature buyerSig, pubkey newBuyerPk) {\n require(tx.time < expiryHeight, \"no transfers after expiry\");\n require(checkSig(buyerSig, buyerPk), \"invalid buyer sig\");\n require(\n tx.outputs[0].scriptPubKey == new CoveredCall(\n sellerPk, newBuyerPk, stableAssetIdTxid, stableAssetIdGidx,\n btcSats, strikeAmount, expiryHeight, graceBlocks, exit\n ),\n \"invalid transfer output\"\n );\n require(tx.outputs[0].value >= btcSats, \"collateral not preserved\");\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-22T14:44:53.734362334+00:00", + "updatedAt": "2026-06-15T12:19:44.694861+00:00", "warnings": [ "warning[type]: fn exercise: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn exercise: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", diff --git a/examples/threshold_oracle.ark b/examples/threshold_oracle.ark index dc694d0..aff2b0b 100644 --- a/examples/threshold_oracle.ark +++ b/examples/threshold_oracle.ark @@ -16,8 +16,10 @@ options { } contract ThresholdOracle( - bytes32 tokenAssetId, - bytes32 ctrlAssetId, + bytes32 tokenAssetIdTxid, + int tokenAssetIdGidx, + bytes32 ctrlAssetIdTxid, + int ctrlAssetIdGidx, pubkey[] oracles, int threshold ) { @@ -37,9 +39,9 @@ contract ThresholdOracle( } require(valid >= threshold, "quorum failed"); - require(tx.inputs[0].assets.lookup(ctrlAssetId) > 0, "no ctrl"); - require(tx.outputs[1].assets.lookup(tokenAssetId) >= amount, "short"); + require(tx.inputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx) > 0, "no ctrl"); + require(tx.outputs[1].assets.lookup(tokenAssetIdTxid, tokenAssetIdGidx) >= amount, "short"); require(tx.outputs[1].scriptPubKey == new SingleSig(recipientPk), "wrong dest"); - require(tx.outputs[0].scriptPubKey == new ThresholdOracle(tokenAssetId, ctrlAssetId, oracles, threshold), "broken"); + require(tx.outputs[0].scriptPubKey == new ThresholdOracle(tokenAssetIdTxid, tokenAssetIdGidx, ctrlAssetIdTxid, ctrlAssetIdGidx, oracles, threshold), "broken"); } } diff --git a/examples/token_vault.ark b/examples/token_vault.ark index f43a763..9cdd736 100644 --- a/examples/token_vault.ark +++ b/examples/token_vault.ark @@ -15,22 +15,24 @@ options { contract TokenVault( pubkey ownerPk, - bytes32 tokenAssetId, - bytes32 ctrlAssetId + bytes32 tokenAssetIdTxid, + int tokenAssetIdGidx, + bytes32 ctrlAssetIdTxid, + int ctrlAssetIdGidx ) { // Deposit: ensure the control asset stays in the output // and output token balance >= input token balance function deposit(signature ownerSig) { // Control asset must be present in input 0 - require(tx.inputs[0].assets.lookup(ctrlAssetId) > 0, "no ctrl in input"); + require(tx.inputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx) > 0, "no ctrl in input"); // Control asset must be present in output 0 - require(tx.outputs[0].assets.lookup(ctrlAssetId) > 0, "no ctrl in output"); + require(tx.outputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx) > 0, "no ctrl in output"); // Output tokens >= input tokens (no leaking tokens) require( - tx.outputs[0].assets.lookup(tokenAssetId) >= - tx.inputs[0].assets.lookup(tokenAssetId), + tx.outputs[0].assets.lookup(tokenAssetIdTxid, tokenAssetIdGidx) >= + tx.inputs[0].assets.lookup(tokenAssetIdTxid, tokenAssetIdGidx), "token balance decreased" ); @@ -41,7 +43,7 @@ contract TokenVault( // Withdraw: owner withdraws tokens cooperatively with server function withdraw(signature ownerSig, int amount) { // Control asset must stay in the output - require(tx.outputs[0].assets.lookup(ctrlAssetId) > 0, "no ctrl in output"); + require(tx.outputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx) > 0, "no ctrl in output"); // Owner must sign require(checkSig(ownerSig, ownerPk), "invalid owner signature"); diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 38eb50e..6e19607 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -4,23 +4,23 @@ use crate::models::{ WitnessElement, DEFAULT_ARRAY_LENGTH, }; use crate::opcodes::{ - OP_0, OP_1, OP_1NEGATE, OP_ADD64, OP_CAT, OP_CHECKLOCKTIMEVERIFY, OP_CHECKSEQUENCEVERIFY, + OP_0, OP_1, OP_ADD64, OP_BOOLAND, OP_CAT, OP_CHECKLOCKTIMEVERIFY, OP_CHECKSEQUENCEVERIFY, OP_CHECKSIG, OP_CHECKSIGADD, OP_CHECKSIGFROMSTACK, OP_CHECKSIGFROMSTACKVERIFY, - OP_CHECKSIGVERIFY, OP_DIV64, OP_DROP, OP_DUP, OP_ECMULSCALARVERIFY, OP_ELSE, OP_ENDIF, - OP_EQUAL, OP_FALSE, OP_FINDASSETGROUPBYASSETID, OP_GREATERTHAN, OP_GREATERTHAN64, - OP_GREATERTHANOREQUAL, OP_GREATERTHANOREQUAL64, OP_IF, OP_INPUTBYTECODE, OP_INPUTOUTPOINT, - OP_INPUTSEQUENCE, OP_INPUTVALUE, OP_INSPECTASSETGROUP, OP_INSPECTASSETGROUPASSETID, - OP_INSPECTASSETGROUPCTRL, OP_INSPECTASSETGROUPMETADATAHASH, OP_INSPECTASSETGROUPNUM, - OP_INSPECTASSETGROUPSUM, OP_INSPECTINASSETAT, OP_INSPECTINASSETCOUNT, OP_INSPECTINASSETLOOKUP, - OP_INSPECTINPUTISSUANCE, OP_INSPECTINPUTOUTPOINT, OP_INSPECTINPUTSCRIPTPUBKEY, - OP_INSPECTINPUTSEQUENCE, OP_INSPECTINPUTVALUE, OP_INSPECTLOCKTIME, OP_INSPECTNUMASSETGROUPS, - OP_INSPECTNUMINPUTS, OP_INSPECTNUMOUTPUTS, OP_INSPECTOUTASSETAT, OP_INSPECTOUTASSETCOUNT, - OP_INSPECTOUTASSETLOOKUP, OP_INSPECTOUTPUTNONCE, OP_INSPECTOUTPUTSCRIPTPUBKEY, - OP_INSPECTOUTPUTVALUE, OP_INSPECTVERSION, OP_LE32TOLE64, OP_LE64TOSCRIPTNUM, OP_LESSTHAN, - OP_LESSTHAN64, OP_LESSTHANOREQUAL, OP_LESSTHANOREQUAL64, OP_MUL64, OP_NEG64, OP_NIP, OP_NOT, - OP_NUMEQUAL, OP_PUSHCURRENTINPUTINDEX, OP_SCRIPTNUMTOLE64, OP_SHA256, OP_SHA256FINALIZE, - OP_SHA256INITIALIZE, OP_SHA256UPDATE, OP_SUB64, OP_TWEAKVERIFY, OP_TXHASH, OP_TXWEIGHT, - OP_VERIFY, + OP_CHECKSIGVERIFY, OP_DIV64, OP_DROP, OP_ECMULSCALARVERIFY, OP_ELSE, OP_ENDIF, OP_EQUAL, + OP_FALSE, OP_FINDASSETGROUPBYASSETID, OP_GREATERTHAN, OP_GREATERTHAN64, OP_GREATERTHANOREQUAL, + OP_GREATERTHANOREQUAL64, OP_IF, OP_INPUTBYTECODE, OP_INPUTOUTPOINT, OP_INPUTSEQUENCE, + OP_INPUTVALUE, OP_INSPECTASSETGROUP, OP_INSPECTASSETGROUPASSETID, OP_INSPECTASSETGROUPCTRL, + OP_INSPECTASSETGROUPMETADATAHASH, OP_INSPECTASSETGROUPNUM, OP_INSPECTASSETGROUPSUM, + OP_INSPECTINASSETAT, OP_INSPECTINASSETCOUNT, OP_INSPECTINASSETLOOKUP, OP_INSPECTINPUTISSUANCE, + OP_INSPECTINPUTOUTPOINT, OP_INSPECTINPUTSCRIPTPUBKEY, OP_INSPECTINPUTSEQUENCE, + OP_INSPECTINPUTVALUE, OP_INSPECTLOCKTIME, OP_INSPECTNUMASSETGROUPS, OP_INSPECTNUMINPUTS, + OP_INSPECTNUMOUTPUTS, OP_INSPECTOUTASSETAT, OP_INSPECTOUTASSETCOUNT, OP_INSPECTOUTASSETLOOKUP, + OP_INSPECTOUTPUTNONCE, OP_INSPECTOUTPUTSCRIPTPUBKEY, OP_INSPECTOUTPUTVALUE, OP_INSPECTVERSION, + OP_LE32TOLE64, OP_LE64TOSCRIPTNUM, OP_LESSTHAN, OP_LESSTHAN64, OP_LESSTHANOREQUAL, + OP_LESSTHANOREQUAL64, OP_MUL64, OP_NEG64, OP_NIP, OP_NOT, OP_NUMEQUAL, + OP_PUSHCURRENTINPUTINDEX, OP_SCRIPTNUMTOLE64, OP_SHA256, OP_SHA256FINALIZE, + OP_SHA256INITIALIZE, OP_SHA256UPDATE, OP_SUB64, OP_SWAP, OP_TWEAKVERIFY, OP_TXHASH, + OP_TXWEIGHT, OP_VERIFY, }; use crate::parser; use crate::typechecker::{self, ArkType}; @@ -84,10 +84,13 @@ fn expression_uses_introspection(expr: &Expression) -> bool { Expression::InputIntrospection { .. } => true, Expression::OutputIntrospection { .. } => true, Expression::AssetLookup { .. } => true, + Expression::AssetHas { .. } => true, Expression::AssetCount { .. } => true, Expression::AssetAt { .. } => true, Expression::GroupFind { .. } => true, + Expression::GroupHas { .. } => true, Expression::GroupProperty { .. } => true, + Expression::GroupControlIs { .. } => true, Expression::AssetGroupsLength => true, Expression::GroupSum { .. } => true, Expression::GroupNumIO { .. } => true, @@ -166,7 +169,9 @@ fn collect_pubkey_usage_in_expr( | Expression::CheckSigFromStackVerify { pubkey, .. } => { data_sigs.insert(pubkey.clone()); } - Expression::AssetLookup { index, .. } | Expression::AssetCount { index, .. } => { + Expression::AssetLookup { index, .. } + | Expression::AssetHas { index, .. } + | Expression::AssetCount { index, .. } => { collect_pubkey_usage_in_expr(index, tx_sigs, data_sigs); } Expression::AssetAt { @@ -253,7 +258,9 @@ fn collect_pubkey_usage_in_expr( | Expression::CurrentInput(_) | Expression::TxIntrospection { .. } | Expression::GroupFind { .. } + | Expression::GroupHas { .. } | Expression::GroupProperty { .. } + | Expression::GroupControlIs { .. } | Expression::AssetGroupsLength | Expression::ArrayLength(_) => {} } @@ -432,11 +439,9 @@ pub fn compile(source_code: &str) -> Result { // The Arkade operator key is always injected externally (via getInfo()). // It is never a constructor parameter — options.server is a boolean flag only. - // Collect asset IDs used in lookups for constructor param decomposition - let lookup_asset_ids = collect_lookup_asset_ids(&contract); - - // Build constructor inputs with asset ID decomposition - let parameters = decompose_constructor_params(&contract.parameters, &lookup_asset_ids); + // Build constructor inputs. Asset IDs are now ordinary scalar params + // (explicit `bytes32 fooTxid` + `int fooGidx`); only array params expand. + let parameters = decompose_constructor_params(&contract.parameters); let mut json = ContractJson { name: contract.name.clone(), @@ -479,95 +484,16 @@ pub fn compile(source_code: &str) -> Result { Ok(json) } -/// Collect all asset ID parameter names used in AssetLookup expressions -pub(crate) fn collect_lookup_asset_ids(contract: &crate::models::Contract) -> Vec { - let mut ids = Vec::new(); - for function in &contract.functions { - for stmt in &function.statements { - collect_asset_ids_from_statement(stmt, &mut ids); - } - } - ids.sort(); - ids.dedup(); - ids -} - -fn collect_asset_ids_from_statement(stmt: &Statement, ids: &mut Vec) { - match stmt { - Statement::Require(req) => { - collect_asset_ids_from_requirement(req, ids); - } - Statement::IfElse { - condition, - then_body, - else_body, - } => { - collect_asset_ids_from_expression(condition, ids); - for s in then_body { - collect_asset_ids_from_statement(s, ids); - } - if let Some(else_stmts) = else_body { - for s in else_stmts { - collect_asset_ids_from_statement(s, ids); - } - } - } - Statement::ForIn { body, .. } => { - for s in body { - collect_asset_ids_from_statement(s, ids); - } - } - Statement::LetBinding { value, .. } | Statement::VarAssign { value, .. } => { - collect_asset_ids_from_expression(value, ids); - } - } -} - -fn collect_asset_ids_from_requirement(req: &Requirement, ids: &mut Vec) { - match req { - Requirement::Comparison { left, op: _, right } => { - collect_asset_ids_from_expression(left, ids); - collect_asset_ids_from_expression(right, ids); - } - _ => {} - } -} - -fn collect_asset_ids_from_expression(expr: &Expression, ids: &mut Vec) { - match expr { - Expression::AssetLookup { asset_id, .. } => { - ids.push(asset_id.clone()); - } - Expression::BinaryOp { left, right, .. } => { - collect_asset_ids_from_expression(left, ids); - collect_asset_ids_from_expression(right, ids); - } - Expression::GroupFind { asset_id } => { - ids.push(asset_id.clone()); - } - _ => {} - } -} - -/// Decompose constructor params: bytes32 params used in asset lookups become _txid + _gidx pairs -/// Array types (e.g., pubkey[]) are flattened to name_0, name_1, name_2, etc. +/// Expand constructor params for emission. Array types (e.g., `pubkey[]`) are +/// flattened to `name_0`, `name_1`, `name_2`, …; every other param passes +/// through unchanged. Asset IDs are no longer special-cased: an Asset ID is +/// authored as two ordinary scalar params (`bytes32 fooTxid` + `int fooGidx`). pub(crate) fn decompose_constructor_params( params: &[crate::models::Parameter], - lookup_asset_ids: &[String], ) -> Vec { let mut result = Vec::new(); for param in params { - if lookup_asset_ids.contains(¶m.name) && param.param_type == "bytes32" { - // Decompose into txid (bytes32) + gidx (int) - result.push(crate::models::Parameter { - name: format!("{}_txid", param.name), - param_type: "bytes32".to_string(), - }); - result.push(crate::models::Parameter { - name: format!("{}_gidx", param.name), - param_type: "int".to_string(), - }); - } else if param.param_type.ends_with("[]") { + if param.param_type.ends_with("[]") { // Array type: flatten to name_0, name_1, name_2, etc. let base_type = param.param_type.trim_end_matches("[]"); for i in 0..DEFAULT_ARRAY_LENGTH { @@ -825,15 +751,19 @@ fn generate_requirements(function: &Function) -> Vec { } fn contains_asset_lookup(expr: &Expression) -> bool { - matches!(expr, Expression::AssetLookup { .. }) - || matches!(expr, Expression::BinaryOp { left, .. } if contains_asset_lookup(left)) + matches!( + expr, + Expression::AssetLookup { .. } | Expression::AssetHas { .. } + ) || matches!(expr, Expression::BinaryOp { left, .. } if contains_asset_lookup(left)) } fn contains_group_expression(expr: &Expression) -> bool { matches!( expr, Expression::GroupFind { .. } + | Expression::GroupHas { .. } | Expression::GroupProperty { .. } + | Expression::GroupControlIs { .. } | Expression::GroupSum { .. } | Expression::AssetGroupsLength ) @@ -1300,9 +1230,18 @@ fn generate_expression_asm(expr: &Expression, asm: &mut Vec) { Expression::AssetLookup { source, index, - asset_id, + asset_txid, + asset_gidx, } => { - emit_asset_lookup_asm(source, index, asset_id, asm); + emit_asset_lookup_asm(source, index, asset_txid, asset_gidx, asm); + } + Expression::AssetHas { + source, + index, + asset_txid, + asset_gidx, + } => { + emit_asset_has_asm(source, index, asset_txid, asset_gidx, asm); } Expression::AssetCount { source, index } => { emit_asset_count_asm(source, index, asm); @@ -1324,10 +1263,24 @@ fn generate_expression_asm(expr: &Expression, asm: &mut Vec) { Expression::OutputIntrospection { index, property } => { emit_output_introspection_asm(index, property, asm); } - Expression::GroupFind { asset_id } => { - asm.push(format!("<{}_txid>", asset_id)); - asm.push(format!("<{}_gidx>", asset_id)); - asm.push(OP_FINDASSETGROUPBYASSETID.to_string()); + Expression::GroupFind { + asset_txid, + asset_gidx, + } => { + emit_group_find_asm(asset_txid, asset_gidx, asm); + } + Expression::GroupHas { + asset_txid, + asset_gidx, + } => { + emit_group_has_asm(asset_txid, asset_gidx, asm); + } + Expression::GroupControlIs { + group, + asset_txid, + asset_gidx, + } => { + emit_group_control_is_asm(group, asset_txid, asset_gidx, asm); } Expression::GroupProperty { group, property } => { emit_group_property_asm(group, property, asm); @@ -1575,7 +1528,23 @@ fn emit_comparison_asm(left: &Expression, op: &str, right: &Expression, asm: &mu if op == "==" { if let Expression::Literal(val) = right { if val == "true" { - // This is a dummy comparison wrapping an introspection expression + // Bare `tx.assetGroups.find(...)`: the find already asserts + // existence via its internal OP_VERIFY and leaves the resolved + // packet position k. k is NOT a boolean (k == 0 is a valid + // successful find), so drop it and leave an explicit OP_1 as the + // requirement's true result. + if let Expression::GroupFind { + asset_txid, + asset_gidx, + } = left + { + emit_group_find_asm(asset_txid, asset_gidx, asm); + asm.push(OP_DROP.to_string()); + asm.push(OP_1.to_string()); + return; + } + // Other dummy-wrapped expressions (has/controlIs/checkSig/…) + // already leave a boolean — emit directly. emit_expression_asm(left, asm); return; } @@ -1638,9 +1607,18 @@ fn emit_expression_asm(expr: &Expression, asm: &mut Vec) { Expression::AssetLookup { source, index, - asset_id, + asset_txid, + asset_gidx, } => { - emit_asset_lookup_asm(source, index, asset_id, asm); + emit_asset_lookup_asm(source, index, asset_txid, asset_gidx, asm); + } + Expression::AssetHas { + source, + index, + asset_txid, + asset_gidx, + } => { + emit_asset_has_asm(source, index, asset_txid, asset_gidx, asm); } Expression::AssetCount { source, index } => { emit_asset_count_asm(source, index, asm); @@ -1665,11 +1643,24 @@ fn emit_expression_asm(expr: &Expression, asm: &mut Vec) { Expression::BinaryOp { left, op, right } => { emit_binary_op_asm(left, op, right, asm); } - Expression::GroupFind { asset_id } => { - // tx.assetGroups.find(assetId) → OP_FINDASSETGROUPBYASSETID - asm.push(format!("<{}_txid>", asset_id)); - asm.push(format!("<{}_gidx>", asset_id)); - asm.push(OP_FINDASSETGROUPBYASSETID.to_string()); + Expression::GroupFind { + asset_txid, + asset_gidx, + } => { + emit_group_find_asm(asset_txid, asset_gidx, asm); + } + Expression::GroupHas { + asset_txid, + asset_gidx, + } => { + emit_group_has_asm(asset_txid, asset_gidx, asm); + } + Expression::GroupControlIs { + group, + asset_txid, + asset_gidx, + } => { + emit_group_control_is_asm(group, asset_txid, asset_gidx, asm); } Expression::GroupProperty { group, property } => { emit_group_property_asm(group, property, asm); @@ -1899,39 +1890,91 @@ fn emit_contract_instance_asm(contract_name: &str, args: &[Expression], asm: &mu asm.push(format!("", contract_name, args_str)); } -/// Emit assembly for an asset lookup: tx.inputs[i].assets.lookup(assetId) +/// Push the lookup operands and the source-appropriate lookup opcode. /// -/// Emits the lookup opcode followed by sentinel guard pattern. -/// The sentinel guard verifies the result is not -1 (asset not found). -fn emit_asset_lookup_asm( +/// Push order follows the opcode (`popAssetID` pops gidx top, then txid; then +/// the io index `o`/`i`): index, then txid, then gidx. Stack after the opcode is +/// `[amount, success_flag]` (flag on top). +fn emit_asset_lookup_operands( source: &AssetLookupSource, index: &Expression, - asset_id: &str, + asset_txid: &Expression, + asset_gidx: &Expression, asm: &mut Vec, ) { - // Push the index emit_expression_asm(index, asm); - - // Push decomposed asset ID (txid + gidx) - asm.push(format!("<{}_txid>", asset_id)); - asm.push(format!("<{}_gidx>", asset_id)); - - // Emit the appropriate lookup opcode + emit_expression_asm(asset_txid, asm); // -> + emit_expression_asm(asset_gidx, asm); // -> or pushed literal match source { - AssetLookupSource::Input => { - asm.push(OP_INSPECTINASSETLOOKUP.to_string()); - } - AssetLookupSource::Output => { - asm.push(OP_INSPECTOUTASSETLOOKUP.to_string()); - } + AssetLookupSource::Input => asm.push(OP_INSPECTINASSETLOOKUP.to_string()), + AssetLookupSource::Output => asm.push(OP_INSPECTOUTASSETLOOKUP.to_string()), } +} - // Sentinel guard: verify result is not -1 (asset not found) - asm.push(OP_DUP.to_string()); - asm.push(OP_1NEGATE.to_string()); - asm.push(OP_EQUAL.to_string()); - asm.push(OP_NOT.to_string()); - asm.push(OP_VERIFY.to_string()); +/// Emit `tx.{inputs,outputs}[i].assets.lookup(txid, gidx)`: assert the asset is +/// present (consume the success flag with OP_VERIFY) and leave the typed amount. +fn emit_asset_lookup_asm( + source: &AssetLookupSource, + index: &Expression, + asset_txid: &Expression, + asset_gidx: &Expression, + asm: &mut Vec, +) { + emit_asset_lookup_operands(source, index, asset_txid, asset_gidx, asm); + asm.push(OP_VERIFY.to_string()); // consume success flag, leave amount +} + +/// Emit `tx.{inputs,outputs}[i].assets.has(txid, gidx)`: boolean presence — +/// keep the success flag, drop the amount below it with OP_NIP. +fn emit_asset_has_asm( + source: &AssetLookupSource, + index: &Expression, + asset_txid: &Expression, + asset_gidx: &Expression, + asm: &mut Vec, +) { + emit_asset_lookup_operands(source, index, asset_txid, asset_gidx, asm); + asm.push(OP_NIP.to_string()); // drop amount, leave success flag (Bool) +} + +/// Emit `tx.assetGroups.find(txid, gidx)`: assert existence (consume the success +/// flag with OP_VERIFY) and leave the resolved packet position k. +fn emit_group_find_asm(asset_txid: &Expression, asset_gidx: &Expression, asm: &mut Vec) { + emit_expression_asm(asset_txid, asm); + emit_expression_asm(asset_gidx, asm); + asm.push(OP_FINDASSETGROUPBYASSETID.to_string()); + asm.push(OP_VERIFY.to_string()); // consume success flag, leave k; fail if absent +} + +/// Emit `tx.assetGroups.has(txid, gidx)`: boolean presence — keep the success +/// flag, drop the resolved position k below it with OP_NIP. +fn emit_group_has_asm(asset_txid: &Expression, asset_gidx: &Expression, asm: &mut Vec) { + emit_expression_asm(asset_txid, asm); + emit_expression_asm(asset_gidx, asm); + asm.push(OP_FINDASSETGROUPBYASSETID.to_string()); + asm.push(OP_NIP.to_string()); // drop k, leave success flag (Bool) +} + +/// Emit `group.controlIs(txid, gidx)`: boolean equality over the complete +/// canonical control Asset ID. Stack after OP_INSPECTASSETGROUPCTRL is +/// `[ctrl_txid, ctrl_gidx, flag]`; drop the flag, compare gidx then txid (no +/// OP_EQUALVERIFY), and AND the two booleans. The absent tuple +/// `[empty_bytes, 0, 0]` cannot equal a valid bytes32 txid, so absence is false. +fn emit_group_control_is_asm( + group: &str, + asset_txid: &Expression, + asset_gidx: &Expression, + asm: &mut Vec, +) { + asm.push(format!("<{}>", group)); + asm.push(OP_INSPECTASSETGROUPCTRL.to_string()); + asm.push(OP_DROP.to_string()); // drop success flag -> [ctrl_txid, ctrl_gidx] + emit_expression_asm(asset_gidx, asm); + asm.push(OP_EQUAL.to_string()); // ctrl_gidx == gidx -> [ctrl_txid, bool] + asm.push(OP_SWAP.to_string()); // -> [bool, ctrl_txid] + emit_expression_asm(asset_txid, asm); + asm.push(OP_EQUAL.to_string()); // ctrl_txid == txid -> [bool, bool] + asm.push(OP_BOOLAND.to_string()); // combine -> Bool } /// Emit assembly for asset count: tx.inputs[i].assets.length or tx.outputs[o].assets.length @@ -1983,7 +2026,12 @@ fn emit_asset_at_asm( // Extract based on property match property { "assetId" => { - // Drop the amount, keep txid32 and gidx_u16 + // Drop the amount, keep the canonical Asset ID (asset_txid, asset_gidx). + // TODO(asset-id-struct): this intentionally leaves TWO stack items, so + // `.assetId` needs a composite `AssetId` struct return type before it + // can be destructured (.txid/.gidx) or compared safely. Deferred to a + // separate PR — see + // docs/superpowers/specs/2026-06-11-asset-lookup-explicit-txid-gidx-design.md asm.push(OP_DROP.to_string()); } "amount" => { @@ -2148,16 +2196,25 @@ fn emit_group_property_asm(group: &str, property: &str, asm: &mut Vec) { asm.push(OP_SUB64.to_string()); asm.push(OP_VERIFY.to_string()); } - "control" => { + "hasControl" => { + // group.hasControl: presence only. + // [ctrl_txid, ctrl_gidx, flag] -> OP_NIP OP_NIP -> flag (Bool) asm.push(format!("<{}>", group)); asm.push(OP_INSPECTASSETGROUPCTRL.to_string()); + asm.push(OP_NIP.to_string()); + asm.push(OP_NIP.to_string()); } "metadataHash" => { asm.push(format!("<{}>", group)); asm.push(OP_INSPECTASSETGROUPMETADATAHASH.to_string()); } "assetId" => { - // Returns (txid32, gidx_u16) tuple on stack + // Returns the canonical Asset ID (asset_txid, asset_gidx) — TWO stack items. + // TODO(asset-id-struct): like asset_at `.assetId`, this needs a composite + // `AssetId` struct return type before it can be destructured (.txid/.gidx) + // or compared with `==` (a single OP_EQUAL only sees the top item, the + // gidx). Deferred to a separate PR — see + // docs/superpowers/specs/2026-06-11-asset-lookup-explicit-txid-gidx-design.md asm.push(format!("<{}>", group)); asm.push(OP_INSPECTASSETGROUPASSETID.to_string()); } @@ -2521,6 +2578,82 @@ fn substitute_expression( .map(|a| substitute_expression(a, index_var, value_var, k, array_name)) .collect(), }, + // Asset lookups/has: substitute the io index and both Asset ID operands + // (e.g. `tx.outputs[i].assets.lookup(assetTxid, i)` unrolls i -> 0,1,2…). + Expression::AssetLookup { + source, + index, + asset_txid, + asset_gidx, + } => Expression::AssetLookup { + source: source.clone(), + index: Box::new(substitute_expression( + index, index_var, value_var, k, array_name, + )), + asset_txid: Box::new(substitute_expression( + asset_txid, index_var, value_var, k, array_name, + )), + asset_gidx: Box::new(substitute_expression( + asset_gidx, index_var, value_var, k, array_name, + )), + }, + Expression::AssetHas { + source, + index, + asset_txid, + asset_gidx, + } => Expression::AssetHas { + source: source.clone(), + index: Box::new(substitute_expression( + index, index_var, value_var, k, array_name, + )), + asset_txid: Box::new(substitute_expression( + asset_txid, index_var, value_var, k, array_name, + )), + asset_gidx: Box::new(substitute_expression( + asset_gidx, index_var, value_var, k, array_name, + )), + }, + Expression::GroupFind { + asset_txid, + asset_gidx, + } => Expression::GroupFind { + asset_txid: Box::new(substitute_expression( + asset_txid, index_var, value_var, k, array_name, + )), + asset_gidx: Box::new(substitute_expression( + asset_gidx, index_var, value_var, k, array_name, + )), + }, + Expression::GroupHas { + asset_txid, + asset_gidx, + } => Expression::GroupHas { + asset_txid: Box::new(substitute_expression( + asset_txid, index_var, value_var, k, array_name, + )), + asset_gidx: Box::new(substitute_expression( + asset_gidx, index_var, value_var, k, array_name, + )), + }, + Expression::GroupControlIs { + group, + asset_txid, + asset_gidx, + } => Expression::GroupControlIs { + // Replace the group name with the loop index when iterating groups. + group: if group == value_var { + k.to_string() + } else { + group.clone() + }, + asset_txid: Box::new(substitute_expression( + asset_txid, index_var, value_var, k, array_name, + )), + asset_gidx: Box::new(substitute_expression( + asset_gidx, index_var, value_var, k, array_name, + )), + }, // All other expressions are returned as-is _ => expr.clone(), } diff --git a/src/models/mod.rs b/src/models/mod.rs index 1cf5733..4d95d8a 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -260,11 +260,23 @@ pub enum Expression { Property(String), /// Current input access (tx.input.current) CurrentInput(Option), - /// Asset lookup: tx.inputs[i].assets.lookup(assetId) or tx.outputs[o].assets.lookup(assetId) + /// Asset lookup: tx.inputs[i].assets.lookup(txid, gidx) or + /// tx.outputs[o].assets.lookup(txid, gidx). Asserts the asset is present + /// (consumes the opcode success flag with OP_VERIFY) and leaves its amount. AssetLookup { source: AssetLookupSource, index: Box, - asset_id: String, + asset_txid: Box, // bytes32 reference + asset_gidx: Box, // int reference or literal (0..65535) + }, + /// Asset presence predicate: tx.inputs[i].assets.has(txid, gidx) or + /// tx.outputs[o].assets.has(txid, gidx). Boolean — true when the asset is + /// present, false when absent (keeps the opcode success flag, drops amount). + AssetHas { + source: AssetLookupSource, + index: Box, + asset_txid: Box, + asset_gidx: Box, }, /// Asset count: tx.inputs[i].assets.length or tx.outputs[o].assets.length AssetCount { @@ -296,10 +308,29 @@ pub enum Expression { op: String, right: Box, }, - /// Asset group find: tx.assetGroups.find(assetId) → csn index - GroupFind { asset_id: String }, + /// Asset group find: tx.assetGroups.find(txid, gidx) → resolved packet + /// position k. Asserts existence (consumes the success flag with OP_VERIFY). + GroupFind { + asset_txid: Box, + asset_gidx: Box, + }, + /// Asset group presence predicate: tx.assetGroups.has(txid, gidx). Boolean — + /// true when a group with that Asset ID exists, false otherwise. + GroupHas { + asset_txid: Box, + asset_gidx: Box, + }, /// Asset group property: group.sumInputs, group.delta, etc. GroupProperty { group: String, property: String }, + /// Boolean equality over the complete canonical control Asset ID: + /// group.controlIs(txid, gidx). False when control is absent or either + /// component differs. `group.hasControl` (presence only) is modeled as a + /// plain `GroupProperty { property: "hasControl" }`. + GroupControlIs { + group: String, + asset_txid: Box, + asset_gidx: Box, + }, /// Asset groups length: tx.assetGroups.length → csn AssetGroupsLength, /// Asset group sum with explicit index: tx.assetGroups[k].sumInputs/sumOutputs diff --git a/src/opcodes/mod.rs b/src/opcodes/mod.rs index 9715025..07842ad 100644 --- a/src/opcodes/mod.rs +++ b/src/opcodes/mod.rs @@ -55,6 +55,7 @@ pub const OP_CAT: &str = "OP_CAT"; pub const OP_DROP: &str = "OP_DROP"; pub const OP_DUP: &str = "OP_DUP"; pub const OP_NIP: &str = "OP_NIP"; +pub const OP_SWAP: &str = "OP_SWAP"; // Type conversions pub const OP_LE64TOSCRIPTNUM: &str = "OP_LE64TOSCRIPTNUM"; @@ -66,6 +67,7 @@ pub const OP_ECMULSCALARVERIFY: &str = "OP_ECMULSCALARVERIFY"; pub const OP_TWEAKVERIFY: &str = "OP_TWEAKVERIFY"; // Conditionals +pub const OP_BOOLAND: &str = "OP_BOOLAND"; pub const OP_NOT: &str = "OP_NOT"; pub const OP_FALSE: &str = "OP_FALSE"; pub const OP_IF: &str = "OP_IF"; diff --git a/src/parser/grammar.pest b/src/parser/grammar.pest index ad41562..389c919 100644 --- a/src/parser/grammar.pest +++ b/src/parser/grammar.pest @@ -140,10 +140,12 @@ primary_expr = { tweak_verify | asset_at | asset_count | + asset_has | asset_lookup | input_introspection | output_introspection | tx_introspection | + group_control_is | tx_property_access | this_property_access | constructor | @@ -176,10 +178,12 @@ complex_expression = _{ hash_comparison | asset_lookup_comparison | asset_count_comparison | + asset_has_comparison | asset_at_comparison | input_introspection_comparison | output_introspection_comparison | tx_introspection_comparison | + group_control_is_comparison | group_property_comparison | property_comparison | identifier_comparison | @@ -194,10 +198,12 @@ complex_expression = _{ tweak_verify | asset_at | asset_count | + asset_has | asset_lookup | input_introspection | output_introspection | tx_introspection | + group_control_is | asset_group_access | constructor | tx_property_access | @@ -210,9 +216,17 @@ complex_expression = _{ // ─── Asset Lookups ───────────────────────────────────────────────────────────── -// Asset lookup on inputs/outputs: tx.inputs[i].assets.lookup(assetId) +// Asset lookup on inputs/outputs: tx.inputs[i].assets.lookup(txid, gidx) +// txid is a bytes32 identifier; gidx is an int identifier or a 0..65535 literal. asset_lookup = { - "tx" ~ "." ~ asset_lookup_source ~ array_access ~ "." ~ "assets" ~ "." ~ "lookup" ~ "(" ~ identifier ~ ")" + "tx" ~ "." ~ asset_lookup_source ~ array_access ~ "." ~ "assets" + ~ "." ~ "lookup" ~ "(" ~ identifier ~ "," ~ (identifier | number_literal) ~ ")" +} + +// Asset presence predicate: tx.inputs[i].assets.has(txid, gidx) → Bool +asset_has = { + "tx" ~ "." ~ asset_lookup_source ~ array_access ~ "." ~ "assets" + ~ "." ~ "has" ~ "(" ~ identifier ~ "," ~ (identifier | number_literal) ~ ")" } // Asset count: tx.inputs[i].assets.length or tx.outputs[o].assets.length @@ -246,6 +260,12 @@ asset_count_comparison = { asset_count ~ binary_operator ~ (identifier | number_literal) } +// Asset has comparison: asset_has op expression +// Handles: tx.outputs[0].assets.has(txid, gidx) == 0 (assert absent) +asset_has_comparison = { + asset_has ~ binary_operator ~ (identifier | number_literal) +} + // Asset at comparison: asset_at op expression // Handles: tx.outputs[0].assets[0].amount >= minAmount asset_at_comparison = { @@ -306,7 +326,8 @@ output_introspection_comparison = { // tx.assetGroups[k].sumInputs, etc. asset_group_access = { "tx" ~ "." ~ "assetGroups" ~ ( - "." ~ "find" ~ "(" ~ identifier ~ ")" | + "." ~ "find" ~ "(" ~ identifier ~ "," ~ (identifier | number_literal) ~ ")" | + "." ~ "has" ~ "(" ~ identifier ~ "," ~ (identifier | number_literal) ~ ")" | "." ~ "length" | array_access ~ "." ~ group_property ) @@ -314,8 +335,22 @@ asset_group_access = { // Group property names — atomic to prevent partial matches // numInputs/numOutputs must come before sumInputs/sumOutputs to prevent partial matches +// `control` was removed: a canonical control Asset ID is two items + a flag, so +// use `controlIs(txid, gidx)` / `hasControl` instead. group_property = @{ - "numInputs" | "numOutputs" | "sumInputs" | "sumOutputs" | "delta" | "control" | "metadataHash" | "assetId" | "isFresh" + "numInputs" | "numOutputs" | "sumInputs" | "sumOutputs" | "delta" | "hasControl" | "metadataHash" | "assetId" | "isFresh" +} + +// Group control predicates (struct-free, replace the old `.control ==`): +// group.controlIs(txid, gidx) → Bool (full canonical control Asset ID equality) +// group.hasControl → Bool (handled as a plain group_property) +group_control_is = { + identifier ~ "." ~ "controlIs" ~ "(" ~ identifier ~ "," ~ (identifier | number_literal) ~ ")" +} + +// controlIs comparison: group.controlIs(txid, gidx) == 0 (assert absent/unequal) +group_control_is_comparison = { + group_control_is ~ binary_operator ~ (identifier | number_literal) } // Group property comparison: variable.property op expression @@ -391,9 +426,10 @@ tx_property_access = { tx_property_body = { // Input.current syntax - first-class support for current input introspection ("input" ~ "." ~ "current" ~ ("." ~ identifier)*) | - // Asset groups access: find, length, or indexed property access + // Asset groups access: find, has, length, or indexed property access ("assetGroups" ~ ( - "." ~ "find" ~ "(" ~ identifier ~ ")" | + "." ~ "find" ~ "(" ~ identifier ~ "," ~ (identifier | number_literal) ~ ")" | + "." ~ "has" ~ "(" ~ identifier ~ "," ~ (identifier | number_literal) ~ ")" | "." ~ "length" | array_access ~ ("." ~ asset_group_property)? )?) | @@ -408,7 +444,7 @@ tx_property_suffix = { // Asset group properties asset_group_property = { - "numInputs" | "numOutputs" | "sumInputs" | "sumOutputs" | "delta" | "control" | "metadataHash" | "assetId" | "isFresh" | "length" | "find" + "numInputs" | "numOutputs" | "sumInputs" | "sumOutputs" | "delta" | "hasControl" | "metadataHash" | "assetId" | "isFresh" | "length" | "find" } // This property access diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 4093bf0..2e9c367 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -493,8 +493,10 @@ fn parse_primary_expr(pair: Pair) -> Result { Rule::tweak_verify => parse_tweak_verify(pair), Rule::check_sig_from_stack_verify => parse_check_sig_from_stack_verify_expr(pair), Rule::asset_lookup => parse_asset_lookup_to_expression(pair), + Rule::asset_has => parse_asset_has_to_expression(pair), Rule::asset_count => parse_asset_count_to_expression(pair), Rule::asset_at => parse_asset_at_to_expression(pair), + Rule::group_control_is => parse_group_control_is_to_expression(pair), Rule::input_introspection => parse_input_introspection_to_expression(pair), Rule::output_introspection => parse_output_introspection_to_expression(pair), Rule::tx_introspection => parse_tx_introspection_to_expression(pair), @@ -522,6 +524,8 @@ fn parse_complex_expression(pair: Pair) -> Result { Rule::binary_operation => parse_binary_operation(pair), Rule::asset_lookup_comparison => parse_asset_lookup_comparison(pair), Rule::asset_count_comparison => parse_asset_count_comparison(pair), + Rule::asset_has_comparison => parse_asset_has_comparison(pair), + Rule::group_control_is_comparison => parse_group_control_is_comparison(pair), Rule::asset_at_comparison => parse_asset_at_comparison(pair), Rule::input_introspection_comparison => parse_input_introspection_comparison(pair), Rule::output_introspection_comparison => parse_output_introspection_comparison(pair), @@ -530,9 +534,11 @@ fn parse_complex_expression(pair: Pair) -> Result { Rule::output_introspection => parse_standalone_output_introspection(pair), Rule::tx_introspection => parse_standalone_tx_introspection(pair), Rule::asset_lookup => parse_standalone_asset_lookup(pair), + Rule::asset_has => parse_standalone_asset_has(pair), Rule::asset_count => parse_standalone_asset_count(pair), Rule::asset_at => parse_standalone_asset_at(pair), Rule::asset_group_access => parse_asset_group_access(pair), + Rule::group_control_is => parse_standalone_group_control_is(pair), Rule::group_property_comparison => parse_group_property_comparison(pair), // Streaming SHA256 Rule::sha256_initialize => { @@ -773,6 +779,9 @@ fn parse_property_comparison(pair: Pair) -> Result { .to_string(); let right_expr = inner.next().ok_or("Missing right side expression")?; + reject_malformed_asset_call(left_expr.as_str())?; + reject_malformed_asset_call(right_expr.as_str())?; + let left = match left_expr.as_rule() { Rule::tx_property_access | Rule::this_property_access => { parse_tx_property_to_expression(left_expr) @@ -878,8 +887,62 @@ fn parse_standalone_asset_lookup(pair: Pair) -> Result) -> Result { +/// Parse an asset-id txid operand (a bytes32 identifier). +fn parse_asset_id_txid(pair: Pair) -> Expression { + Expression::Variable(pair.as_str().to_string()) +} + +/// Parse an asset-id gidx operand (an int identifier or a numeric literal). +fn parse_asset_id_gidx(pair: Pair) -> Expression { + match pair.as_rule() { + Rule::number_literal => Expression::Literal(pair.as_str().to_string()), + _ => Expression::Variable(pair.as_str().to_string()), + } +} + +/// Parse the two Asset ID operands from an asset-group access pair. +fn parse_asset_group_id_operands(pair: Pair) -> Result<(Expression, Expression), String> { + let operand_parent = match pair.as_rule() { + Rule::tx_property_access => { + let mut inner = pair.into_inner(); + let body = inner + .next() + .ok_or("asset id requires (txid, gidx) operands")?; + if body.as_rule() != Rule::tx_property_body || inner.next().is_some() { + return Err("asset id requires (txid, gidx) operands".to_string()); + } + body + } + Rule::tx_property_body | Rule::asset_group_access => pair, + rule => return Err(format!("unexpected asset group operand parent: {rule:?}")), + }; + + let mut operands = operand_parent.into_inner(); + let txid_pair = operands + .next() + .ok_or("asset id requires (txid, gidx) operands")?; + let gidx_pair = operands + .next() + .ok_or("asset id requires (txid, gidx) operands")?; + + if txid_pair.as_rule() != Rule::identifier + || !matches!(gidx_pair.as_rule(), Rule::identifier | Rule::number_literal) + || operands.next().is_some() + { + return Err("asset id requires (txid, gidx) operands".to_string()); + } + + Ok(( + parse_asset_id_txid(txid_pair), + parse_asset_id_gidx(gidx_pair), + )) +} + +/// Shared parse for `tx.{inputs,outputs}[i].assets.{lookup,has}(txid, gidx)`: +/// returns the source, the input/output index, and the two Asset ID operands. +fn parse_asset_lookup_operands( + pair: Pair, +) -> Result<(AssetLookupSource, Expression, Expression, Expression), String> { let mut inner = pair.into_inner(); // Parse source: "inputs" or "outputs" @@ -907,13 +970,32 @@ fn parse_asset_lookup_to_expression(pair: Pair) -> Result Expression::Literal(index_pair.as_str().to_string()), }; - // Parse asset ID - let asset_id = inner.next().ok_or("Missing asset ID")?.as_str().to_string(); + // Parse the canonical Asset ID operands: txid (bytes32) then gidx (int). + let asset_txid = parse_asset_id_txid(inner.next().ok_or("Missing asset txid")?); + let asset_gidx = parse_asset_id_gidx(inner.next().ok_or("Missing asset gidx")?); + Ok((source, index, asset_txid, asset_gidx)) +} + +/// Parse an asset_lookup pair into an Expression::AssetLookup +fn parse_asset_lookup_to_expression(pair: Pair) -> Result { + let (source, index, asset_txid, asset_gidx) = parse_asset_lookup_operands(pair)?; Ok(Expression::AssetLookup { source, index: Box::new(index), - asset_id, + asset_txid: Box::new(asset_txid), + asset_gidx: Box::new(asset_gidx), + }) +} + +/// Parse an asset_has pair into an Expression::AssetHas +fn parse_asset_has_to_expression(pair: Pair) -> Result { + let (source, index, asset_txid, asset_gidx) = parse_asset_lookup_operands(pair)?; + Ok(Expression::AssetHas { + source, + index: Box::new(index), + asset_txid: Box::new(asset_txid), + asset_gidx: Box::new(asset_gidx), }) } @@ -1053,6 +1135,99 @@ fn parse_asset_count_comparison(pair: Pair) -> Result Ok(Requirement::Comparison { left, op, right }) } +/// Parse a standalone asset_has (require-bare): leaves the presence flag. +fn parse_standalone_asset_has(pair: Pair) -> Result { + let expr = parse_asset_has_to_expression(pair)?; + Ok(Requirement::Comparison { + left: expr, + op: "==".to_string(), + right: Expression::Literal("true".to_string()), + }) +} + +/// Parse asset_has_comparison: asset_has op (identifier | number_literal) +fn parse_asset_has_comparison(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + + let left_pair = inner.next().ok_or("Missing left asset has")?; + let left = parse_asset_has_to_expression(left_pair)?; + + let op = inner + .next() + .ok_or("Missing comparison operator")? + .as_str() + .to_string(); + + let right_pair = inner.next().ok_or("Missing right expression")?; + let right = match right_pair.as_rule() { + Rule::identifier => Expression::Variable(right_pair.as_str().to_string()), + Rule::number_literal => Expression::Literal(right_pair.as_str().to_string()), + _ => { + return Err(format!( + "Unexpected right side in asset has comparison: {:?}", + right_pair.as_rule() + )) + } + }; + + Ok(Requirement::Comparison { left, op, right }) +} + +/// Parse a group_control_is pair: `group.controlIs(txid, gidx)` → GroupControlIs. +fn parse_group_control_is_to_expression(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let group = inner + .next() + .ok_or("Missing group in controlIs")? + .as_str() + .to_string(); + let asset_txid = parse_asset_id_txid(inner.next().ok_or("Missing controlIs txid")?); + let asset_gidx = parse_asset_id_gidx(inner.next().ok_or("Missing controlIs gidx")?); + Ok(Expression::GroupControlIs { + group, + asset_txid: Box::new(asset_txid), + asset_gidx: Box::new(asset_gidx), + }) +} + +/// Parse a standalone group_control_is (require-bare): leaves the boolean. +fn parse_standalone_group_control_is(pair: Pair) -> Result { + let expr = parse_group_control_is_to_expression(pair)?; + Ok(Requirement::Comparison { + left: expr, + op: "==".to_string(), + right: Expression::Literal("true".to_string()), + }) +} + +/// Parse group_control_is_comparison: group.controlIs(...) op (identifier | number_literal) +fn parse_group_control_is_comparison(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + + let left_pair = inner.next().ok_or("Missing left controlIs")?; + let left = parse_group_control_is_to_expression(left_pair)?; + + let op = inner + .next() + .ok_or("Missing comparison operator")? + .as_str() + .to_string(); + + let right_pair = inner.next().ok_or("Missing right expression")?; + let right = match right_pair.as_rule() { + Rule::identifier => Expression::Variable(right_pair.as_str().to_string()), + Rule::number_literal => Expression::Literal(right_pair.as_str().to_string()), + _ => { + return Err(format!( + "Unexpected right side in controlIs comparison: {:?}", + right_pair.as_rule() + )) + } + }; + + Ok(Requirement::Comparison { left, op, right }) +} + /// Parse asset_at_comparison: asset_at op expression fn parse_asset_at_comparison(pair: Pair) -> Result { let mut inner = pair.into_inner(); @@ -1335,18 +1510,27 @@ fn parse_arith_expr_to_expression(pair: Pair) -> Result) -> Result { let text = pair.as_str(); - let mut inner = pair.into_inner(); // Determine which variant of asset group access if text.contains(".find(") { - // tx.assetGroups.find(assetId) - let asset_id = inner - .next() - .ok_or("Missing asset ID in group find")? - .as_str() - .to_string(); + // tx.assetGroups.find(txid, gidx) + let (asset_txid, asset_gidx) = parse_asset_group_id_operands(pair)?; Ok(Requirement::Comparison { - left: Expression::GroupFind { asset_id }, + left: Expression::GroupFind { + asset_txid: Box::new(asset_txid), + asset_gidx: Box::new(asset_gidx), + }, + op: "==".to_string(), + right: Expression::Literal("true".to_string()), + }) + } else if text.contains(".has(") { + // tx.assetGroups.has(txid, gidx) + let (asset_txid, asset_gidx) = parse_asset_group_id_operands(pair)?; + Ok(Requirement::Comparison { + left: Expression::GroupHas { + asset_txid: Box::new(asset_txid), + asset_gidx: Box::new(asset_gidx), + }, op: "==".to_string(), right: Expression::Literal("true".to_string()), }) @@ -1359,6 +1543,7 @@ fn parse_asset_group_access(pair: Pair) -> Result { }) } else { // tx.assetGroups[k].property + let mut inner = pair.into_inner(); let array_access = inner.next().ok_or("Missing group index")?; let index_pair = array_access .into_inner() @@ -1798,15 +1983,46 @@ fn parse_constructor_args(pair: Pair) -> Result, String> { /// Parse tx_property_access into the appropriate Expression type /// Handles special patterns like tx.assetGroups[idx].sumInputs/sumOutputs +/// Reject malformed asset-API calls that fell through to the generic property +/// path. A well-formed `.assets.lookup`/`.assets.has` matches the dedicated +/// `asset_lookup`/`asset_has` rules (which require exactly two operands) before +/// any property fallback, so seeing one of these method names in a property +/// string means a legacy single-argument or otherwise malformed call. +fn reject_malformed_asset_call(text: &str) -> Result<(), String> { + if text.contains(".assets.lookup(") { + return Err(format!( + "asset lookup requires two operands `lookup(txid, gidx)`: {text}" + )); + } + if text.contains(".assets.has(") { + return Err(format!( + "asset presence check requires two operands `has(txid, gidx)`: {text}" + )); + } + Ok(()) +} + fn parse_tx_property_to_expr(pair: Pair) -> Result { let text = pair.as_str(); - // Handle tx.assetGroups.find(assetId) - if text.starts_with("tx.assetGroups.find(") && text.ends_with(")") { - let start = "tx.assetGroups.find(".len(); - let end = text.len() - 1; - let asset_id = text[start..end].to_string(); - return Ok(Expression::GroupFind { asset_id }); + reject_malformed_asset_call(text)?; + + // Handle tx.assetGroups.find(txid, gidx) + if text.starts_with("tx.assetGroups.find(") && text.ends_with(')') { + let (asset_txid, asset_gidx) = parse_asset_group_id_operands(pair)?; + return Ok(Expression::GroupFind { + asset_txid: Box::new(asset_txid), + asset_gidx: Box::new(asset_gidx), + }); + } + + // Handle tx.assetGroups.has(txid, gidx) + if text.starts_with("tx.assetGroups.has(") && text.ends_with(')') { + let (asset_txid, asset_gidx) = parse_asset_group_id_operands(pair)?; + return Ok(Expression::GroupHas { + asset_txid: Box::new(asset_txid), + asset_gidx: Box::new(asset_gidx), + }); } // Handle tx.assetGroups.length diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 93f14f9..6d6ffc9 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -446,23 +446,38 @@ pub fn infer_type(expr: &Expression, scope: &Scope) -> ArkType { // Asset introspection Expression::AssetLookup { .. } => ArkType::Uint64Le, + Expression::AssetHas { .. } => ArkType::Bool, Expression::AssetCount { .. } => ArkType::Int, Expression::AssetAt { property, .. } => match property.as_str() { "amount" => ArkType::Uint64Le, + // TODO(asset-id-struct): `.assetId` is really a two-item canonical + // Asset ID (asset_txid, asset_gidx), NOT a single bytes32. Typed as + // Bytes32 only as a stopgap until the composite `AssetId` struct + // return type lands (separate PR). See + // docs/superpowers/specs/2026-06-11-asset-lookup-explicit-txid-gidx-design.md + // ("Value side / asset_at" + "Out of scope"). "assetId" => ArkType::Bytes32, _ => ArkType::Unknown, }, // Asset group introspection Expression::GroupFind { .. } => ArkType::Int, + Expression::GroupHas { .. } => ArkType::Bool, + Expression::GroupControlIs { .. } => ArkType::Bool, Expression::GroupSum { .. } => ArkType::Uint64Le, Expression::GroupNumIO { .. } => ArkType::Int, Expression::AssetGroupsLength => ArkType::Int, Expression::GroupProperty { property, .. } => match property.as_str() { "sumInputs" | "sumOutputs" | "delta" => ArkType::Uint64Le, "numInputs" | "numOutputs" => ArkType::Int, - "control" | "metadataHash" | "assetId" => ArkType::Bytes32, - "isFresh" => ArkType::Bool, + // TODO(asset-id-struct): `assetId` is really a two-item canonical + // Asset ID (asset_txid, asset_gidx), not a single bytes32; typed + // Bytes32 only as a stopgap until the composite `AssetId` struct + // lands, so `==` over it is unsound until then (see spec + // 2026-06-11-asset-lookup-explicit-txid-gidx-design.md). `metadataHash` + // is genuinely a 32-byte hash and is correct. + "metadataHash" | "assetId" => ArkType::Bytes32, + "isFresh" | "hasControl" => ArkType::Bool, _ => ArkType::Unknown, }, Expression::GroupIOAccess { property, .. } => match property.as_deref() { diff --git a/src/validator/mod.rs b/src/validator/mod.rs index a09e674..5781118 100644 --- a/src/validator/mod.rs +++ b/src/validator/mod.rs @@ -19,7 +19,8 @@ //! Issues are returned as a `Vec`. Use [`has_errors`] to check //! whether any are fatal. -use crate::models::{Contract, ContractJson, Parameter, Statement}; +use crate::models::{Contract, ContractJson, Expression, Parameter, Requirement, Statement}; +use crate::typechecker::{build_scope, infer_type, ArkType, Scope}; use std::collections::HashMap; use std::collections::HashSet; @@ -181,10 +182,196 @@ pub fn validate_ast(contract: &Contract) -> Vec { check_shadowing(contract, &mut issues); check_expanded_namespace(contract, &mut issues); + check_asset_id_operands(contract, &mut issues); issues } +// ─── Asset ID operand validation (fatal) ─────────────────────────────────────── + +/// Reject malformed canonical Asset ID operands at compile time instead of +/// relying on the emulator's runtime `popAssetID` check. For every +/// `lookup`/`find`/`has`/`controlIs` operand: +/// - `asset_txid` must resolve to `Bytes32` (rejects `Unknown`/swapped types), +/// - `asset_gidx` must resolve to `Int` (rejects `Unknown`); a numeric literal +/// must additionally be in `0..=65535`. +/// +/// Scope-aware: seeds constructor + function params, infers `let`/assignment +/// values, binds a `for` loop's index variable as `Int`. The loop value +/// variable stays `Unknown` (no iterable-element typing yet) and is therefore +/// not accepted as an Asset ID component. +fn check_asset_id_operands(contract: &Contract, issues: &mut Vec) { + let ctor_scope = build_scope(&contract.parameters); + for func in &contract.functions { + let mut scope = ctor_scope.clone(); + scope.extend(build_scope(&func.parameters)); + walk_asset_id_stmts(&func.statements, &mut scope, &func.name, issues); + } +} + +fn walk_asset_id_stmts( + stmts: &[Statement], + scope: &mut Scope, + fname: &str, + issues: &mut Vec, +) { + for stmt in stmts { + match stmt { + Statement::Require(req) => { + if let Requirement::Comparison { left, right, .. } = req { + check_asset_id_expr(left, scope, fname, issues); + check_asset_id_expr(right, scope, fname, issues); + } + } + Statement::LetBinding { name, value } => { + check_asset_id_expr(value, scope, fname, issues); + let t = infer_type(value, scope); + scope.insert(name.clone(), t); + } + Statement::VarAssign { name, value } => { + check_asset_id_expr(value, scope, fname, issues); + let t = infer_type(value, scope); + scope.insert(name.clone(), t); + } + Statement::IfElse { + condition, + then_body, + else_body, + } => { + check_asset_id_expr(condition, scope, fname, issues); + walk_asset_id_stmts(then_body, &mut scope.clone(), fname, issues); + if let Some(eb) = else_body { + walk_asset_id_stmts(eb, &mut scope.clone(), fname, issues); + } + } + Statement::ForIn { + index_var, + value_var, + body, + .. + } => { + let mut loop_scope = scope.clone(); + loop_scope.insert(index_var.clone(), ArkType::Int); + loop_scope.insert(value_var.clone(), ArkType::Unknown); + walk_asset_id_stmts(body, &mut loop_scope, fname, issues); + } + } + } +} + +/// Walk an expression, validating the operands of every Asset ID construct and +/// recursing through compound expressions that can nest one. +fn check_asset_id_expr( + expr: &Expression, + scope: &Scope, + fname: &str, + issues: &mut Vec, +) { + match expr { + Expression::AssetLookup { + index, + asset_txid, + asset_gidx, + .. + } + | Expression::AssetHas { + index, + asset_txid, + asset_gidx, + .. + } => { + check_asset_id_expr(index, scope, fname, issues); + validate_asset_id(asset_txid, asset_gidx, scope, fname, issues); + } + Expression::GroupFind { + asset_txid, + asset_gidx, + } + | Expression::GroupHas { + asset_txid, + asset_gidx, + } + | Expression::GroupControlIs { + asset_txid, + asset_gidx, + .. + } => { + validate_asset_id(asset_txid, asset_gidx, scope, fname, issues); + } + Expression::BinaryOp { left, right, .. } | Expression::Concat { left, right, .. } => { + check_asset_id_expr(left, scope, fname, issues); + check_asset_id_expr(right, scope, fname, issues); + } + Expression::AssetAt { + io_index, + asset_index, + .. + } => { + check_asset_id_expr(io_index, scope, fname, issues); + check_asset_id_expr(asset_index, scope, fname, issues); + } + Expression::InputIntrospection { index, .. } + | Expression::OutputIntrospection { index, .. } + | Expression::AssetCount { index, .. } => { + check_asset_id_expr(index, scope, fname, issues); + } + _ => {} + } +} + +/// Validate one `(asset_txid, asset_gidx)` pair. +fn validate_asset_id( + asset_txid: &Expression, + asset_gidx: &Expression, + scope: &Scope, + fname: &str, + issues: &mut Vec, +) { + let txid_type = infer_type(asset_txid, scope); + if txid_type != ArkType::Bytes32 { + issues.push(ValidationIssue::error(format!( + "function '{}': asset id txid operand {} must be bytes32, got {}", + fname, + describe_operand(asset_txid), + txid_type.as_str() + ))); + } + + // gidx: a numeric literal is range-checked directly; anything else must + // resolve to Int through the scope. + if let Expression::Literal(lit) = asset_gidx { + match lit.parse::() { + Ok(v) if (0..=65535).contains(&v) => {} + Ok(v) => issues.push(ValidationIssue::error(format!( + "function '{}': asset id gidx literal {} is out of range 0..65535", + fname, v + ))), + Err(_) => issues.push(ValidationIssue::error(format!( + "function '{}': asset id gidx literal '{}' is not a valid integer", + fname, lit + ))), + } + } else { + let gidx_type = infer_type(asset_gidx, scope); + if gidx_type != ArkType::Int { + issues.push(ValidationIssue::error(format!( + "function '{}': asset id gidx operand {} must be int (0..65535), got {}", + fname, + describe_operand(asset_gidx), + gidx_type.as_str() + ))); + } + } +} + +fn describe_operand(expr: &Expression) -> String { + match expr { + Expression::Variable(v) => format!("'{}'", v), + Expression::Literal(l) => format!("'{}'", l), + _ => "".to_string(), + } +} + // ─── AST helpers ───────────────────────────────────────────────────────────── /// Returns `true` if any statement in the slice contains a `Require` (recursing @@ -355,14 +542,13 @@ fn walk_scope( } /// Check 2: the names a function's parameters and the constructor's parameters -/// contribute to the *emitted* placeholder namespace — after array flattening, -/// asset decomposition, and reserved generated names — must be unique. Distinct -/// source names can still collide here (e.g. `int[] xs` vs `int xs_0`). +/// contribute to the *emitted* placeholder namespace — after array flattening +/// and reserved generated names — must be unique. Distinct source names can +/// still collide here (e.g. `int[] xs` vs `int xs_0`). fn check_expanded_namespace(contract: &Contract, issues: &mut Vec) { - let lookup_ids = crate::compiler::collect_lookup_asset_ids(contract); - // Constructor params expanded exactly as the emitter decomposes them. - let ctor_expanded = - crate::compiler::decompose_constructor_params(&contract.parameters, &lookup_ids); + // Constructor params expanded exactly as the emitter expands them + // (array flattening only; asset IDs are now ordinary scalar params). + let ctor_expanded = crate::compiler::decompose_constructor_params(&contract.parameters); for func in contract.functions.iter().filter(|f| !f.is_internal) { let mut seen: HashSet = HashSet::new(); diff --git a/tests/asset_id_explicit_test.rs b/tests/asset_id_explicit_test.rs new file mode 100644 index 0000000..a8c82df --- /dev/null +++ b/tests/asset_id_explicit_test.rs @@ -0,0 +1,306 @@ +//! Tests for the explicit canonical `(asset_txid, asset_gidx)` Asset ID design: +//! two-operand lookup/find, the `has`/`controlIs`/`hasControl` predicates, the +//! `(result, success_flag)` opcode ABI handling, fatal operand validation, and +//! parser rejection of the legacy single-argument forms. + +use arkade_compiler::compile; + +const PREAMBLE: &str = "options { server = server; exit = 144; }\n"; + +fn server_asm(src: &str, func: &str) -> String { + let out = compile(src).unwrap_or_else(|e| panic!("compile failed: {e:?}")); + let f = out + .functions + .iter() + .find(|f| f.name == func && f.server_variant) + .unwrap_or_else(|| panic!("{func} server variant not found")); + f.asm.join(" ") +} + +// ─── Result + success flag ABI ────────────────────────────────────────────── + +#[test] +fn lookup_consumes_success_flag_with_single_verify() { + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, int fooGidx, pubkey pk) {{ + function f(signature sig) {{ + require(tx.outputs[0].assets.lookup(fooTxid, fooGidx) >= 1); + require(checkSig(sig, pk)); + }} + }}" + ); + let asm = server_asm(&src, "f"); + // lookup opcode is immediately followed by OP_VERIFY (flag consume) — and + // the stale -1 sentinel guard (OP_1NEGATE/OP_DUP) is gone. + assert!(asm.contains("OP_INSPECTOUTASSETLOOKUP OP_VERIFY"), "{asm}"); + assert!( + !asm.contains("OP_1NEGATE"), + "sentinel guard must be gone: {asm}" + ); +} + +#[test] +fn find_leaves_k_via_verify_in_let_binding() { + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, int fooGidx, pubkey pk) {{ + function f(signature sig) {{ + let g = tx.assetGroups.find(fooTxid, fooGidx); + require(g.delta == 0); + require(checkSig(sig, pk)); + }} + }}" + ); + let asm = server_asm(&src, "f"); + assert!( + asm.contains("OP_FINDASSETGROUPBYASSETID OP_VERIFY"), + "find must consume the success flag with OP_VERIFY: {asm}" + ); +} + +#[test] +fn general_expression_parses_group_find_with_literal_gidx_in_let_binding() { + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, pubkey pk) {{ + function f(signature sig) {{ + let g = tx.assetGroups.find(fooTxid, 0); + require(g.delta == 0); + require(checkSig(sig, pk)); + }} + }}" + ); + let asm = server_asm(&src, "f"); + assert!( + asm.contains(" 0 OP_FINDASSETGROUPBYASSETID OP_VERIFY"), + "{asm}" + ); +} + +#[test] +fn bare_find_requirement_drops_k_and_pushes_true() { + // k == 0 is a valid successful find but false as a Script boolean, so the + // dummy `== true` path must emit `find … OP_DROP OP_1`, not leave k. + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, int fooGidx, pubkey pk) {{ + function f(signature sig) {{ + require(tx.assetGroups.find(fooTxid, fooGidx)); + require(checkSig(sig, pk)); + }} + }}" + ); + let asm = server_asm(&src, "f"); + assert!( + asm.contains("OP_FINDASSETGROUPBYASSETID OP_VERIFY OP_DROP OP_1"), + "bare find must emit find + OP_DROP OP_1: {asm}" + ); +} + +// ─── has predicate (presence Bool) ────────────────────────────────────────── + +#[test] +fn asset_has_keeps_flag_drops_amount() { + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, int fooGidx, pubkey pk) {{ + function f(signature sig) {{ + require(tx.outputs[0].assets.has(fooTxid, fooGidx)); + require(tx.inputs[0].assets.has(fooTxid, fooGidx) == 0); + require(checkSig(sig, pk)); + }} + }}" + ); + let asm = server_asm(&src, "f"); + assert!(asm.contains("OP_INSPECTOUTASSETLOOKUP OP_NIP"), "{asm}"); + assert!(asm.contains("OP_INSPECTINASSETLOOKUP OP_NIP"), "{asm}"); +} + +#[test] +fn group_has_keeps_flag_drops_k() { + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, int fooGidx, pubkey pk) {{ + function f(signature sig) {{ + require(tx.assetGroups.has(fooTxid, fooGidx)); + require(checkSig(sig, pk)); + }} + }}" + ); + let asm = server_asm(&src, "f"); + assert!(asm.contains("OP_FINDASSETGROUPBYASSETID OP_NIP"), "{asm}"); +} + +#[test] +fn general_expression_parses_group_has_in_if_condition() { + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, int fooGidx, pubkey pk) {{ + function f(signature sig) {{ + if (tx.assetGroups.has(fooTxid, fooGidx)) {{ + require(checkSig(sig, pk)); + }} + }} + }}" + ); + let asm = server_asm(&src, "f"); + assert!( + asm.contains("OP_FINDASSETGROUPBYASSETID OP_NIP OP_IF"), + "{asm}" + ); +} + +// ─── controlIs / hasControl ───────────────────────────────────────────────── + +#[test] +fn control_is_compares_both_components_without_equalverify() { + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, int fooGidx, pubkey pk) {{ + function f(signature sig) {{ + let g = tx.assetGroups.find(fooTxid, fooGidx); + require(g.controlIs(fooTxid, fooGidx)); + require(checkSig(sig, pk)); + }} + }}" + ); + let asm = server_asm(&src, "f"); + // [ctrl_txid, ctrl_gidx, flag] OP_DROP, then compare both, then OP_BOOLAND. + assert!( + asm.contains("OP_INSPECTASSETGROUPCTRL OP_DROP"), + "controlIs must drop the success flag: {asm}" + ); + assert!(asm.contains("OP_SWAP"), "{asm}"); + assert!(asm.contains("OP_BOOLAND"), "{asm}"); + assert!( + !asm.contains("OP_EQUALVERIFY"), + "controlIs equality must not abort on mismatch: {asm}" + ); +} + +#[test] +fn has_control_is_presence_only() { + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, int fooGidx, pubkey pk) {{ + function f(signature sig) {{ + let g = tx.assetGroups.find(fooTxid, fooGidx); + require(g.hasControl == 1); + require(checkSig(sig, pk)); + }} + }}" + ); + let asm = server_asm(&src, "f"); + assert!( + asm.contains("OP_INSPECTASSETGROUPCTRL OP_NIP OP_NIP"), + "{asm}" + ); +} + +#[test] +fn legacy_control_property_is_rejected() { + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, int fooGidx, pubkey pk) {{ + function f(signature sig) {{ + let g = tx.assetGroups.find(fooTxid, fooGidx); + require(g.control == fooTxid); + require(checkSig(sig, pk)); + }} + }}" + ); + assert!( + compile(&src).is_err(), + "legacy `.control` must no longer parse" + ); +} + +// ─── Minimal ScriptNum encoding for a literal gidx ────────────────────────── + +#[test] +fn literal_zero_gidx_is_minimal() { + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, pubkey pk) {{ + function f(signature sig) {{ + require(tx.outputs[0].assets.lookup(fooTxid, 0) >= 1); + require(checkSig(sig, pk)); + }} + }}" + ); + let asm = server_asm(&src, "f"); + // gidx pushes a single minimal "0" token, not a padded "00 00". + assert!( + asm.contains(" 0 OP_INSPECTOUTASSETLOOKUP"), + "{asm}" + ); + assert!(!asm.contains("00 00"), "{asm}"); +} + +// ─── Parser rejection of legacy / malformed call forms ────────────────────── + +fn rejects(body: &str) { + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, int fooGidx, pubkey pk) {{ + function f(signature sig) {{ {body} require(checkSig(sig, pk)); }} + }}" + ); + assert!(compile(&src).is_err(), "expected rejection for: {body}"); +} + +#[test] +fn rejects_legacy_single_arg_lookup() { + rejects("require(tx.outputs[0].assets.lookup(fooTxid) >= 1);"); +} + +#[test] +fn rejects_single_arg_find() { + rejects("let g = tx.assetGroups.find(fooTxid); require(g.delta == 0);"); +} + +#[test] +fn rejects_extra_arg_lookup() { + rejects("require(tx.outputs[0].assets.lookup(fooTxid, fooGidx, fooGidx) >= 1);"); +} + +// ─── Fatal operand validation ─────────────────────────────────────────────── + +#[test] +fn rejects_swapped_txid_gidx_operands() { + // txid is an int param, gidx is a bytes32 param -> both wrong. + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, int fooGidx, pubkey pk) {{ + function f(signature sig) {{ + require(tx.outputs[0].assets.lookup(fooGidx, fooTxid) >= 1); + require(checkSig(sig, pk)); + }} + }}" + ); + assert!(compile(&src).is_err(), "swapped operands must be rejected"); +} + +#[test] +fn rejects_out_of_range_literal_gidx() { + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, pubkey pk) {{ + function f(signature sig) {{ + require(tx.outputs[0].assets.lookup(fooTxid, 70000) >= 1); + require(checkSig(sig, pk)); + }} + }}" + ); + let err = compile(&src) + .expect_err("out-of-range gidx must be rejected") + .to_string(); + assert!(err.contains("out of range"), "unexpected error: {err}"); +} + +#[test] +fn accepts_loop_index_as_gidx() { + // The loop index variable is statically Int, so it is a valid gidx operand. + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, pubkey pk) {{ + function f(signature sig, signature[] sigs) {{ + for (i, s) in sigs {{ + require(tx.outputs[i].assets.lookup(fooTxid, i) >= 1); + }} + require(checkSig(sig, pk)); + }} + }}" + ); + assert!( + compile(&src).is_ok(), + "loop index as gidx should compile: {:?}", + compile(&src).err() + ); +} diff --git a/tests/beacon_test.rs b/tests/beacon_test.rs index 0f1a7a3..a722cf8 100644 --- a/tests/beacon_test.rs +++ b/tests/beacon_test.rs @@ -16,7 +16,7 @@ options { } contract PriceBeacon( - bytes32 ctrlAssetId, + bytes32 ctrlAssetIdTxid, int ctrlAssetIdGidx, pubkey oraclePk, pubkey oracleServerPk, int numGroups @@ -30,7 +30,7 @@ contract PriceBeacon( } function update(signature oracleSig) { - require(tx.inputs[0].assets.lookup(ctrlAssetId) > 0, "no ctrl"); + require(tx.inputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx) > 0, "no ctrl"); require(tx.outputs[0].scriptPubKey == new PriceBeacon(ctrlAssetId, oraclePk, oracleServerPk, numGroups), "broken"); require(checkSig(oracleSig, oraclePk), "bad sig"); } @@ -49,8 +49,8 @@ options { } contract PriceBeacon( - bytes32 ticker, - bytes32 clock, + bytes32 tickerTxid, int tickerGidx, + bytes32 clockTxid, int clockGidx, pubkey oraclePk, int exit ) { @@ -58,7 +58,7 @@ contract PriceBeacon( require(checkSig(oracleSig, oraclePk), "invalid oracle signature"); require(newPrice > 0, "price must be positive"); - int currentHeight = tx.inputs[0].assets.lookup(clock); + int currentHeight = tx.inputs[0].assets.lookup(clockTxid, clockGidx); require(newBlockHeight >= currentHeight, "block height must not regress"); require( @@ -66,11 +66,11 @@ contract PriceBeacon( "beacon script must survive" ); require( - tx.outputs[0].assets.lookup(ticker) == newPrice, + tx.outputs[0].assets.lookup(tickerTxid, tickerGidx) == newPrice, "price not updated correctly" ); require( - tx.outputs[0].assets.lookup(clock) == newBlockHeight, + tx.outputs[0].assets.lookup(clockTxid, clockGidx) == newBlockHeight, "block height not updated correctly" ); } @@ -81,15 +81,15 @@ contract PriceBeacon( "beacon script must survive" ); - int currentPrice = tx.inputs[0].assets.lookup(ticker); + int currentPrice = tx.inputs[0].assets.lookup(tickerTxid, tickerGidx); require( - tx.outputs[0].assets.lookup(ticker) >= currentPrice, + tx.outputs[0].assets.lookup(tickerTxid, tickerGidx) >= currentPrice, "price asset must survive" ); - int currentHeight = tx.inputs[0].assets.lookup(clock); + int currentHeight = tx.inputs[0].assets.lookup(clockTxid, clockGidx); require( - tx.outputs[0].assets.lookup(clock) >= currentHeight, + tx.outputs[0].assets.lookup(clockTxid, clockGidx) >= currentHeight, "clock asset must survive" ); } @@ -97,19 +97,19 @@ contract PriceBeacon( function migrate(signature oracleSig, pubkey newOraclePk) { require(checkSig(oracleSig, oraclePk), "invalid oracle signature"); - int currentPrice = tx.inputs[0].assets.lookup(ticker); - int currentHeight = tx.inputs[0].assets.lookup(clock); + int currentPrice = tx.inputs[0].assets.lookup(tickerTxid, tickerGidx); + int currentHeight = tx.inputs[0].assets.lookup(clockTxid, clockGidx); require( tx.outputs[0].scriptPubKey == new PriceBeacon(ticker, clock, newOraclePk, exit), "invalid new beacon" ); require( - tx.outputs[0].assets.lookup(ticker) == currentPrice, + tx.outputs[0].assets.lookup(tickerTxid, tickerGidx) == currentPrice, "price must be preserved" ); require( - tx.outputs[0].assets.lookup(clock) == currentHeight, + tx.outputs[0].assets.lookup(clockTxid, clockGidx) == currentHeight, "block height must be preserved" ); } diff --git a/tests/bond_mint_test.rs b/tests/bond_mint_test.rs index a32c381..0bd28ba 100644 --- a/tests/bond_mint_test.rs +++ b/tests/bond_mint_test.rs @@ -18,11 +18,12 @@ fn test_bond_mint_compiles() { assert_eq!(output.functions.len(), 8, "expected 8 functions"); let names: Vec<&str> = output.parameters.iter().map(|p| p.name.as_str()).collect(); + // Asset IDs are authored as explicit (Txid, Gidx) param pairs — no implicit decomposition. for id in ["debitAssetId", "debitCtrlId"] { assert!( - names.contains(&format!("{id}_txid").as_str()) - && names.contains(&format!("{id}_gidx").as_str()), - "{id} not decomposed, got: {names:?}" + names.contains(&format!("{id}Txid").as_str()) + && names.contains(&format!("{id}Gidx").as_str()), + "{id} not present as explicit Txid/Gidx params, got: {names:?}" ); } assert!( diff --git a/tests/cash_secured_put_test.rs b/tests/cash_secured_put_test.rs index 585aca3..ba2c7b0 100644 --- a/tests/cash_secured_put_test.rs +++ b/tests/cash_secured_put_test.rs @@ -16,7 +16,7 @@ options { contract CashSecuredPut( pubkey sellerPk, pubkey buyerPk, - bytes32 stableAssetId, + bytes32 stableAssetIdTxid, int stableAssetIdGidx, int stableAmount, int btcSats, int expiryHeight, @@ -34,7 +34,7 @@ contract CashSecuredPut( ); require( - tx.outputs[1].assets.lookup(stableAssetId) >= stableAmount, + tx.outputs[1].assets.lookup(stableAssetIdTxid, stableAssetIdGidx) >= stableAmount, "buyer underpaid" ); require( @@ -60,7 +60,7 @@ contract CashSecuredPut( "invalid transfer output" ); require( - tx.outputs[0].assets.lookup(stableAssetId) >= stableAmount, + tx.outputs[0].assets.lookup(stableAssetIdTxid, stableAssetIdGidx) >= stableAmount, "collateral not preserved" ); } @@ -76,7 +76,7 @@ contract CashSecuredPut( "invalid transfer output" ); require( - tx.outputs[0].assets.lookup(stableAssetId) >= stableAmount, + tx.outputs[0].assets.lookup(stableAssetIdTxid, stableAssetIdGidx) >= stableAmount, "collateral not preserved" ); } @@ -185,21 +185,24 @@ fn test_reclaim_is_seller_only_with_cltv() { } #[test] -fn test_asset_id_decomposes_to_txid_and_gidx() { +fn test_asset_id_is_two_explicit_params() { let out = compile(PUT_CODE).unwrap(); + let txid = out + .parameters + .iter() + .find(|p| p.name == "stableAssetIdTxid") + .expect("constructorInputs must include explicit stableAssetIdTxid"); + assert_eq!(txid.param_type, "bytes32"); + let gidx = out + .parameters + .iter() + .find(|p| p.name == "stableAssetIdGidx") + .expect("constructorInputs must include explicit stableAssetIdGidx"); + assert_eq!(gidx.param_type, "int"); + // No implicit decomposition: the old generated `_txid`/`_gidx` names are gone. let names: Vec<&str> = out.parameters.iter().map(|p| p.name.as_str()).collect(); - assert!( - names.contains(&"stableAssetId_txid"), - "constructorInputs must include stableAssetId_txid" - ); - assert!( - names.contains(&"stableAssetId_gidx"), - "constructorInputs must include stableAssetId_gidx" - ); - assert!( - !names.contains(&"stableAssetId"), - "raw bytes32 stableAssetId should not appear in ABI" - ); + assert!(!names.contains(&"stableAssetId_txid")); + assert!(!names.contains(&"stableAssetId_gidx")); } #[test] diff --git a/tests/controlled_mint_test.rs b/tests/controlled_mint_test.rs index 3733c6e..1849235 100644 --- a/tests/controlled_mint_test.rs +++ b/tests/controlled_mint_test.rs @@ -20,26 +20,24 @@ fn test_controlled_mint_contract() { let param_names: Vec<&str> = output.parameters.iter().map(|p| p.name.as_str()).collect(); assert!(param_names.contains(&"issuerPk"), "missing issuerPk"); - // tokenAssetId (bytes32 used in lookups) should be decomposed into _txid + _gidx + // Asset IDs are authored as explicit (Txid, Gidx) param pairs. assert!( - param_names.contains(&"tokenAssetId_txid"), - "missing tokenAssetId_txid decomposition, got: {:?}", + param_names.contains(&"tokenAssetIdTxid"), + "missing explicit tokenAssetIdTxid, got: {:?}", param_names ); assert!( - param_names.contains(&"tokenAssetId_gidx"), - "missing tokenAssetId_gidx decomposition" + param_names.contains(&"tokenAssetIdGidx"), + "missing explicit tokenAssetIdGidx" ); - - // ctrlAssetId should also be decomposed (used in find and lookup) assert!( - param_names.contains(&"ctrlAssetId_txid"), - "missing ctrlAssetId_txid decomposition, got: {:?}", + param_names.contains(&"ctrlAssetIdTxid"), + "missing explicit ctrlAssetIdTxid, got: {:?}", param_names ); assert!( - param_names.contains(&"ctrlAssetId_gidx"), - "missing ctrlAssetId_gidx decomposition" + param_names.contains(&"ctrlAssetIdGidx"), + "missing explicit ctrlAssetIdGidx" ); // Verify functions: 3 functions x 2 variants = 6 diff --git a/tests/covered_call_test.rs b/tests/covered_call_test.rs index 3f11f1d..0446cb6 100644 --- a/tests/covered_call_test.rs +++ b/tests/covered_call_test.rs @@ -17,7 +17,7 @@ options { contract CoveredCall( pubkey sellerPk, pubkey buyerPk, - bytes32 stableAssetId, + bytes32 stableAssetIdTxid, int stableAssetIdGidx, int btcSats, int strikeAmount, int expiryHeight, @@ -29,7 +29,7 @@ contract CoveredCall( require(checkSig(buyerSig, buyerPk), "invalid buyer sig"); require( - tx.outputs[0].assets.lookup(stableAssetId) >= strikeAmount, + tx.outputs[0].assets.lookup(stableAssetIdTxid, stableAssetIdGidx) >= strikeAmount, "seller underpaid" ); require( @@ -184,21 +184,24 @@ fn test_reclaim_is_seller_only_with_cltv() { } #[test] -fn test_asset_id_decomposes_to_txid_and_gidx() { +fn test_asset_id_is_two_explicit_params() { let out = compile(CALL_CODE).unwrap(); + let txid = out + .parameters + .iter() + .find(|p| p.name == "stableAssetIdTxid") + .expect("constructorInputs must include explicit stableAssetIdTxid"); + assert_eq!(txid.param_type, "bytes32"); + let gidx = out + .parameters + .iter() + .find(|p| p.name == "stableAssetIdGidx") + .expect("constructorInputs must include explicit stableAssetIdGidx"); + assert_eq!(gidx.param_type, "int"); + // No implicit decomposition: the old generated `_txid`/`_gidx` names are gone. let names: Vec<&str> = out.parameters.iter().map(|p| p.name.as_str()).collect(); - assert!( - names.contains(&"stableAssetId_txid"), - "constructorInputs must include stableAssetId_txid" - ); - assert!( - names.contains(&"stableAssetId_gidx"), - "constructorInputs must include stableAssetId_gidx" - ); - assert!( - !names.contains(&"stableAssetId"), - "raw bytes32 stableAssetId should not appear in ABI" - ); + assert!(!names.contains(&"stableAssetId_txid")); + assert!(!names.contains(&"stableAssetId_gidx")); } #[test] diff --git a/tests/epoch_limiter_test.rs b/tests/epoch_limiter_test.rs index 1d7304b..62fc751 100644 --- a/tests/epoch_limiter_test.rs +++ b/tests/epoch_limiter_test.rs @@ -20,7 +20,7 @@ options { contract EpochLimiter( bytes32 epochStartAssetId, bytes32 epochTotalAssetId, - bytes32 ctrlAssetId, + bytes32 ctrlAssetIdTxid, int ctrlAssetIdGidx, int epochLimit, int epochBlocks, pubkey adminPk, @@ -32,7 +32,7 @@ contract EpochLimiter( let epochStart = tx.assetGroups[epochStartIdx].sumInputs; let epochTotal = tx.assetGroups[epochTotalIdx].sumInputs; - require(tx.inputs[0].assets.lookup(ctrlAssetId) > 0, "no ctrl"); + require(tx.inputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx) > 0, "no ctrl"); if (tx.time >= epochStart + epochBlocks) { let newStart = tx.time; @@ -46,7 +46,7 @@ contract EpochLimiter( require(newTotal <= epochLimit, "exceeds limit"); } - require(tx.outputs[0].assets.lookup(ctrlAssetId) >= tx.inputs[0].assets.lookup(ctrlAssetId), "ctrl leaked"); + require(tx.outputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx) >= tx.inputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx), "ctrl leaked"); require(tx.outputs[0].scriptPubKey == tx.input.current.scriptPubKey, "broken"); } } diff --git a/tests/fee_adapter_test.rs b/tests/fee_adapter_test.rs index 28861cb..10da4be 100644 --- a/tests/fee_adapter_test.rs +++ b/tests/fee_adapter_test.rs @@ -1,7 +1,7 @@ use arkade_compiler::compile; use arkade_compiler::opcodes::{ - OP_1NEGATE, OP_CHECKSEQUENCEVERIFY, OP_CHECKSIG, OP_DUP, OP_EQUAL, OP_GREATERTHAN64, - OP_GREATERTHANOREQUAL, OP_INSPECTINASSETLOOKUP, OP_INSPECTOUTASSETLOOKUP, OP_NOT, OP_VERIFY, + OP_CHECKSEQUENCEVERIFY, OP_CHECKSIG, OP_GREATERTHAN64, OP_GREATERTHANOREQUAL, + OP_INSPECTINASSETLOOKUP, OP_INSPECTOUTASSETLOOKUP, OP_VERIFY, }; #[test] @@ -23,15 +23,15 @@ fn test_fee_adapter_contract() { assert!(param_names.contains(&"recipientPk")); assert!(param_names.contains(&"minFee")); - // paymentAssetId (bytes32 used in lookup) should be decomposed + // paymentAssetId is authored as explicit (Txid, Gidx) params. assert!( - param_names.contains(&"paymentAssetId_txid"), - "missing paymentAssetId_txid decomposition, got: {:?}", + param_names.contains(&"paymentAssetIdTxid"), + "missing explicit paymentAssetIdTxid, got: {:?}", param_names ); assert!( - param_names.contains(&"paymentAssetId_gidx"), - "missing paymentAssetId_gidx decomposition" + param_names.contains(&"paymentAssetIdGidx"), + "missing explicit paymentAssetIdGidx" ); // Verify functions: 2 functions x 2 variants = 4 @@ -84,11 +84,16 @@ fn test_fee_adapter_contract() { execute_asm ); - // Should have sentinel guard pattern - let sentinel_guard = format!("{OP_DUP} {OP_1NEGATE} {OP_EQUAL} {OP_NOT} {OP_VERIFY}"); + // Lookups assert presence by consuming the opcode success flag with a + // single OP_VERIFY (replaces the old -1 sentinel guard). assert!( - execute_asm.contains(&sentinel_guard), - "missing sentinel guard in execute: {}", + execute_asm.contains(&format!("{OP_INSPECTINASSETLOOKUP} {OP_VERIFY}")), + "input lookup must be followed by OP_VERIFY flag-consume: {}", + execute_asm + ); + assert!( + execute_asm.contains(&format!("{OP_INSPECTOUTASSETLOOKUP} {OP_VERIFY}")), + "output lookup must be followed by OP_VERIFY flag-consume: {}", execute_asm ); diff --git a/tests/group_properties_test.rs b/tests/group_properties_test.rs index 49d8cea..26f7b5b 100644 --- a/tests/group_properties_test.rs +++ b/tests/group_properties_test.rs @@ -14,10 +14,10 @@ fn test_group_asset_id_basic() { exit = 144; } - contract AssetIdTest(pubkey serverKey, bytes32 tokenAssetId, bytes32 expectedAssetId) { + contract AssetIdTest(pubkey serverKey, bytes32 tokenAssetIdTxid, int tokenAssetIdGidx, bytes32 expectedAssetId) { function checkAssetId(signature ownerSig, pubkey owner) { require(checkSig(ownerSig, owner)); - let tokenGroup = tx.assetGroups.find(tokenAssetId); + let tokenGroup = tx.assetGroups.find(tokenAssetIdTxid, tokenAssetIdGidx); require(tokenGroup.assetId == expectedAssetId, "asset id mismatch"); } } @@ -55,10 +55,10 @@ fn test_group_is_fresh_basic() { exit = 144; } - contract FreshAssetTest(pubkey serverKey, bytes32 newAssetId) { + contract FreshAssetTest(pubkey serverKey, bytes32 newAssetIdTxid, int newAssetIdGidx) { function verifyFresh(signature ownerSig, pubkey owner) { require(checkSig(ownerSig, owner)); - let group = tx.assetGroups.find(newAssetId); + let group = tx.assetGroups.find(newAssetIdTxid, newAssetIdGidx); require(group.isFresh == 1, "must be fresh"); } } @@ -105,13 +105,13 @@ fn test_is_fresh_with_delta_combo() { exit = 144; } - contract NFTMintTest(pubkey serverKey, bytes32 nftAssetId, bytes32 ctrlAssetId) { + contract NFTMintTest(pubkey serverKey, bytes32 nftAssetIdTxid, int nftAssetIdGidx, bytes32 ctrlAssetIdTxid, int ctrlAssetIdGidx) { function mintNFT(signature issuerSig, pubkey issuer) { require(checkSig(issuerSig, issuer)); - let nftGroup = tx.assetGroups.find(nftAssetId); + let nftGroup = tx.assetGroups.find(nftAssetIdTxid, nftAssetIdGidx); require(nftGroup.isFresh == 1, "must be new asset"); require(nftGroup.delta == 1, "must mint exactly 1"); - require(nftGroup.control == ctrlAssetId, "wrong control"); + require(nftGroup.controlIs(ctrlAssetIdTxid, ctrlAssetIdGidx), "wrong control"); } } "#; @@ -161,10 +161,10 @@ fn test_is_fresh_zero_for_existing_asset() { exit = 144; } - contract ExistingAssetTest(pubkey serverKey, bytes32 assetId) { + contract ExistingAssetTest(pubkey serverKey, bytes32 assetIdTxid, int assetIdGidx) { function transferExisting(signature ownerSig, pubkey owner) { require(checkSig(ownerSig, owner)); - let group = tx.assetGroups.find(assetId); + let group = tx.assetGroups.find(assetIdTxid, assetIdGidx); require(group.isFresh == 0, "must be existing asset"); require(group.delta == 0, "must be transfer only"); } @@ -205,10 +205,10 @@ fn test_group_metadata_hash() { exit = 144; } - contract MetadataTest(pubkey serverKey, bytes32 assetId, bytes32 expectedHash) { + contract MetadataTest(pubkey serverKey, bytes32 assetIdTxid, int assetIdGidx, bytes32 expectedHash) { function verifyMetadata(signature ownerSig, pubkey owner) { require(checkSig(ownerSig, owner)); - let group = tx.assetGroups.find(assetId); + let group = tx.assetGroups.find(assetIdTxid, assetIdGidx); require(group.metadataHash == expectedHash, "metadata mismatch"); } } @@ -244,18 +244,18 @@ fn test_all_group_properties() { contract AllPropertiesTest( pubkey serverKey, - bytes32 assetId, - bytes32 ctrlAssetId, + bytes32 assetIdTxid, int assetIdGidx, + bytes32 ctrlAssetIdTxid, int ctrlAssetIdGidx, bytes32 expectedMetadata ) { function fullCheck(signature sig, pubkey pk, int expectedDelta) { require(checkSig(sig, pk)); - let group = tx.assetGroups.find(assetId); + let group = tx.assetGroups.find(assetIdTxid, assetIdGidx); // Test all group properties require(group.isFresh == 1, "not fresh"); require(group.delta == expectedDelta, "wrong delta"); - require(group.control == ctrlAssetId, "wrong control"); + require(group.controlIs(ctrlAssetIdTxid, ctrlAssetIdGidx), "wrong control"); require(group.metadataHash == expectedMetadata, "wrong metadata"); require(group.sumOutputs >= group.sumInputs, "outputs < inputs"); } @@ -321,10 +321,10 @@ fn test_group_num_inputs() { exit = 144; } - contract NumInputsTest(pubkey serverKey, bytes32 assetId) { + contract NumInputsTest(pubkey serverKey, bytes32 assetIdTxid, int assetIdGidx) { function checkInputCount(signature sig, pubkey pk) { require(checkSig(sig, pk)); - let group = tx.assetGroups.find(assetId); + let group = tx.assetGroups.find(assetIdTxid, assetIdGidx); require(group.numInputs >= 1, "need at least one input"); } } @@ -364,10 +364,10 @@ fn test_group_num_outputs() { exit = 144; } - contract NumOutputsTest(pubkey serverKey, bytes32 assetId) { + contract NumOutputsTest(pubkey serverKey, bytes32 assetIdTxid, int assetIdGidx) { function checkOutputCount(signature sig, pubkey pk) { require(checkSig(sig, pk)); - let group = tx.assetGroups.find(assetId); + let group = tx.assetGroups.find(assetIdTxid, assetIdGidx); require(group.numOutputs >= 2, "need at least two outputs"); } } @@ -407,10 +407,10 @@ fn test_group_num_io_together() { exit = 144; } - contract NumIOTest(pubkey serverKey, bytes32 assetId) { + contract NumIOTest(pubkey serverKey, bytes32 assetIdTxid, int assetIdGidx) { function checkCounts(signature sig, pubkey pk) { require(checkSig(sig, pk)); - let group = tx.assetGroups.find(assetId); + let group = tx.assetGroups.find(assetIdTxid, assetIdGidx); require(group.numInputs >= 1, "need inputs"); require(group.numOutputs >= 1, "need outputs"); require(group.numOutputs >= group.numInputs, "outputs must be >= inputs"); diff --git a/tests/no_shadowing_test.rs b/tests/no_shadowing_test.rs index 36a0a4f..25c6c2b 100644 --- a/tests/no_shadowing_test.rs +++ b/tests/no_shadowing_test.rs @@ -191,24 +191,11 @@ contract Demo(int[] xs) { ); } -#[test] -fn rejects_asset_decomposition_collision() { - // `foo` is used in a lookup -> decomposes to foo_txid, foo_gidx; param foo_txid collides. - let src = r#" -contract Demo(bytes32 foo) { - function f(bytes32 foo_txid) { - require(tx.inputs[0].assets.lookup(foo) > 0); - } -} -"#; - let err = compile(src) - .expect_err("expected a namespace collision error") - .to_string(); - assert!( - err.contains("collide in the emitted namespace"), - "unexpected error: {err}" - ); -} +// NOTE: the former `rejects_asset_decomposition_collision` test was removed. +// Asset IDs are no longer implicitly decomposed into `_txid`/`_gidx` generated +// names; they are authored as ordinary explicit scalar params, so that +// collision class no longer exists. Array-flattening collisions are still +// covered by the tests above. #[test] fn rejects_param_colliding_with_server_signature() { diff --git a/tests/repayment_pool_test.rs b/tests/repayment_pool_test.rs index 4f8843a..a6dda53 100644 --- a/tests/repayment_pool_test.rs +++ b/tests/repayment_pool_test.rs @@ -30,9 +30,9 @@ fn test_repayment_pool_compiles() { "debitCtrlId", ] { assert!( - names.contains(&format!("{id}_txid").as_str()) - && names.contains(&format!("{id}_gidx").as_str()), - "{id} not decomposed, got: {names:?}" + names.contains(&format!("{id}Txid").as_str()) + && names.contains(&format!("{id}Gidx").as_str()), + "{id} not present as explicit Txid/Gidx params, got: {names:?}" ); } // Phased timeline + margin-call threshold + auction-incentive surface. @@ -126,13 +126,13 @@ fn test_all_burn_checks_are_strict_equality() { #[test] fn test_pool_retains_debit_ctrl_in_every_function() { // SECURITY: BondMint authenticates "genuine pool co-spent" only by - // `tx.inputs[poolIdx].assets.lookup(debitCtrlId) >= 1` — no scriptPubKey + // `tx.inputs[poolIdx].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) >= 1` — no scriptPubKey // reconstruction. The safety of every BondMint settlement therefore // depends on the asset-registry invariant that NO RepaymentPool function // ever lets debitCtrlId leak from its output[0] (or outIdxPool for // variable-output functions). // - // If a future function omits the `tx.outputs[*].assets.lookup(debitCtrlId) + // If a future function omits the `tx.outputs[*].assets.lookup(debitCtrlIdTxid, debitCtrlIdGidx) // >= 1` retention check, the control asset can migrate into a malicious // covenant that the BondMint will then accept as "the pool" on its next // settlement, redirecting collateral to the attacker. diff --git a/tests/threshold_oracle_test.rs b/tests/threshold_oracle_test.rs index 5690232..442e1b5 100644 --- a/tests/threshold_oracle_test.rs +++ b/tests/threshold_oracle_test.rs @@ -17,8 +17,8 @@ options { } contract ThresholdOracle( - bytes32 tokenAssetId, - bytes32 ctrlAssetId, + bytes32 tokenAssetIdTxid, int tokenAssetIdGidx, + bytes32 ctrlAssetIdTxid, int ctrlAssetIdGidx, pubkey[] oracles, int threshold ) { @@ -38,8 +38,8 @@ contract ThresholdOracle( } require(valid >= threshold, "quorum failed"); - require(tx.inputs[0].assets.lookup(ctrlAssetId) > 0, "no ctrl"); - require(tx.outputs[1].assets.lookup(tokenAssetId) >= amount, "short"); + require(tx.inputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx) > 0, "no ctrl"); + require(tx.outputs[1].assets.lookup(tokenAssetIdTxid, tokenAssetIdGidx) >= amount, "short"); require(tx.outputs[1].scriptPubKey == new SingleSig(recipientPk), "wrong dest"); require(tx.outputs[0].scriptPubKey == new ThresholdOracle(tokenAssetId, ctrlAssetId, oracles, threshold), "broken"); } diff --git a/tests/token_vault_test.rs b/tests/token_vault_test.rs index dbf44d3..6402f0a 100644 --- a/tests/token_vault_test.rs +++ b/tests/token_vault_test.rs @@ -1,7 +1,7 @@ use arkade_compiler::compile; use arkade_compiler::opcodes::{ - OP_1NEGATE, OP_CHECKSEQUENCEVERIFY, OP_CHECKSIG, OP_DUP, OP_EQUAL, OP_GREATERTHAN64, - OP_GREATERTHANOREQUAL64, OP_INSPECTINASSETLOOKUP, OP_INSPECTOUTASSETLOOKUP, OP_NOT, OP_VERIFY, + OP_CHECKSEQUENCEVERIFY, OP_CHECKSIG, OP_GREATERTHAN64, OP_GREATERTHANOREQUAL64, + OP_INSPECTINASSETLOOKUP, OP_INSPECTOUTASSETLOOKUP, OP_VERIFY, }; #[test] @@ -16,27 +16,25 @@ fn test_token_vault_contract() { // Verify contract name assert_eq!(output.name, "TokenVault"); - // Verify parameters - bytes32 params used in lookups should be decomposed - // ownerPk (pubkey, no decomposition) - // tokenAssetId (bytes32 → _txid + _gidx) - // ctrlAssetId (bytes32 → _txid + _gidx) + // Verify parameters, including explicit Asset ID (Txid, Gidx) pairs. + // ownerPk remains a scalar pubkey parameter. let param_names: Vec<&str> = output.parameters.iter().map(|p| p.name.as_str()).collect(); assert!(param_names.contains(&"ownerPk"), "missing ownerPk"); assert!( - param_names.contains(&"tokenAssetId_txid"), - "missing tokenAssetId_txid decomposition" + param_names.contains(&"tokenAssetIdTxid"), + "missing explicit tokenAssetIdTxid" ); assert!( - param_names.contains(&"tokenAssetId_gidx"), - "missing tokenAssetId_gidx decomposition" + param_names.contains(&"tokenAssetIdGidx"), + "missing explicit tokenAssetIdGidx" ); assert!( - param_names.contains(&"ctrlAssetId_txid"), - "missing ctrlAssetId_txid decomposition" + param_names.contains(&"ctrlAssetIdTxid"), + "missing explicit ctrlAssetIdTxid" ); assert!( - param_names.contains(&"ctrlAssetId_gidx"), - "missing ctrlAssetId_gidx decomposition" + param_names.contains(&"ctrlAssetIdGidx"), + "missing explicit ctrlAssetIdGidx" ); // Verify functions: 2 functions x 2 variants = 4 @@ -66,11 +64,16 @@ fn test_token_vault_contract() { deposit_asm ); - // Check sentinel guard pattern (DUP, 1NEGATE, EQUAL, NOT, VERIFY) - let sentinel_guard = format!("{OP_DUP} {OP_1NEGATE} {OP_EQUAL} {OP_NOT} {OP_VERIFY}"); + // Lookups assert presence by consuming the opcode success flag with a + // single OP_VERIFY. assert!( - deposit_asm.contains(&sentinel_guard), - "missing sentinel guard pattern in deposit asm: {}", + deposit_asm.contains(&format!("{OP_INSPECTINASSETLOOKUP} {OP_VERIFY}")), + "input lookup must be followed by OP_VERIFY flag-consume: {}", + deposit_asm + ); + assert!( + deposit_asm.contains(&format!("{OP_INSPECTOUTASSETLOOKUP} {OP_VERIFY}")), + "output lookup must be followed by OP_VERIFY flag-consume: {}", deposit_asm ); @@ -170,6 +173,6 @@ fn test_token_vault_cli() { assert!(json_output.contains("\"contractName\": \"TokenVault\"")); assert!(json_output.contains(OP_INSPECTINASSETLOOKUP)); assert!(json_output.contains(OP_INSPECTOUTASSETLOOKUP)); - assert!(json_output.contains("tokenAssetId_txid")); - assert!(json_output.contains("ctrlAssetId_txid")); + assert!(json_output.contains("tokenAssetIdTxid")); + assert!(json_output.contains("ctrlAssetIdTxid")); } From 3e08aa17a4a87e27c035004b714494b8d0d55ab6 Mon Sep 17 00:00:00 2001 From: msinkec Date: Mon, 15 Jun 2026 21:39:02 +0200 Subject: [PATCH 2/9] Remove unused const + update doc --- docs/arkade-primitives-spec.md | 19 +++++++------------ src/opcodes/mod.rs | 2 -- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/docs/arkade-primitives-spec.md b/docs/arkade-primitives-spec.md index ea0bae4..0499b8f 100644 --- a/docs/arkade-primitives-spec.md +++ b/docs/arkade-primitives-spec.md @@ -337,13 +337,8 @@ OP_VERIFY ; [.., ctrlIn, mintAmt(u)] ; mintAmt >= amount (both u64le) OP_3 OP_PICK ; copy amt(u) - ; depth from top: ctrlIn=1, msg=2, amt=3... - ; Recount: - ; [srcId(8), burnTx(7), recip(6), sig0(5), sig1(4), - ; sig2(3), amt(2), msg(1), ctrlIn(0), mintAmt(top)] - ; Wait, mintAmt IS top. amt at depth 3 from mintAmt. - ; But OP_PICK index counts from top-1. - ; top=mintAmt(0), ctrlIn(1), msg(2), amt(3). + ; depth from top: mintAmt=0, ctrlIn=1, + ; msg=2, amt=3 ; OP_3 OP_PICK copies amt. Correct. OP_SWAP ; [.., amt(u), mintAmt(u)] OP_GREATERTHANOREQUAL64 ; [.., flag(c)] @@ -501,14 +496,14 @@ After Phase 9, stack is: | Phase 4: streaming hash | 16 | 32 | 0 | | Phase 5: DVN quorum (3 iters) | 24 | 96 (3 x 32-byte pubkeys) | 0 | | Phase 5: quorum check | 3 | 1 (threshold) | 0 | -| Phase 6: ctrl present | 9 | 34 | 0 | -| Phase 7: mint output | 14 | 36 | 0 | +| Phase 6: ctrl present | 5 | 34 | 0 | +| Phase 7: mint output | 10 | 36 | 0 | | Phase 8: recursive covenant | 5 | 0 | 0 | -| Phase 9: ctrl not leaked | 12 | 34 | 0 | +| Phase 9: ctrl not leaked | 8 | 34 | 0 | | Phase 10: cleanup | 5 | 0 | 0 | -| **Total** | **~102** | **~305** | **1** | +| **Total** | **~90** | **~305** | **1** | -**Script size:** ~102 opcodes + ~305 bytes push data + ~20 push-length prefixes = **~427 bytes**. +**Script size:** ~90 opcodes + ~305 bytes push data + ~20 push-length prefixes = **~415 bytes**. **Sigops budget:** 50 + witness_size. Witness = 1 serverSig (64B) + 3 dvnSigs (192B) + 1 amount (~4B) + 1 sourceArkId (32B) + 1 burnTxId (32B) + 1 recipientPk (32B) + CompactSize prefixes (~8B) = ~364B. Budget = 50 + 364 = **414**. Cost = 50 (one CHECKSIGVERIFY). Passes with 364 remaining. diff --git a/src/opcodes/mod.rs b/src/opcodes/mod.rs index 07842ad..bfc8e74 100644 --- a/src/opcodes/mod.rs +++ b/src/opcodes/mod.rs @@ -16,7 +16,6 @@ pub const OP_13: &str = "OP_13"; pub const OP_14: &str = "OP_14"; pub const OP_15: &str = "OP_15"; pub const OP_16: &str = "OP_16"; -pub const OP_1NEGATE: &str = "OP_1NEGATE"; // Absolute and relative timelock verification pub const OP_CHECKLOCKTIMEVERIFY: &str = "OP_CHECKLOCKTIMEVERIFY"; @@ -53,7 +52,6 @@ pub const OP_CAT: &str = "OP_CAT"; // Stack manipulation pub const OP_DROP: &str = "OP_DROP"; -pub const OP_DUP: &str = "OP_DUP"; pub const OP_NIP: &str = "OP_NIP"; pub const OP_SWAP: &str = "OP_SWAP"; From d048dbed666e1b9ed52d94af0fc31eb5433c9ea9 Mon Sep 17 00:00:00 2001 From: msinkec Date: Mon, 15 Jun 2026 23:33:11 +0200 Subject: [PATCH 3/9] remove redundant comments and asserts --- src/compiler/mod.rs | 6 ++---- src/parser/grammar.pest | 3 +-- src/validator/mod.rs | 3 +-- tests/asset_id_explicit_test.rs | 12 ++---------- tests/bond_mint_test.rs | 2 +- tests/cash_secured_put_test.rs | 4 ---- tests/covered_call_test.rs | 4 ---- tests/fee_adapter_test.rs | 2 +- tests/no_shadowing_test.rs | 6 ------ 9 files changed, 8 insertions(+), 34 deletions(-) diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 6e19607..81fc14c 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -439,8 +439,7 @@ pub fn compile(source_code: &str) -> Result { // The Arkade operator key is always injected externally (via getInfo()). // It is never a constructor parameter — options.server is a boolean flag only. - // Build constructor inputs. Asset IDs are now ordinary scalar params - // (explicit `bytes32 fooTxid` + `int fooGidx`); only array params expand. + // Build constructor inputs (array params expand to indexed scalars). let parameters = decompose_constructor_params(&contract.parameters); let mut json = ContractJson { @@ -486,8 +485,7 @@ pub fn compile(source_code: &str) -> Result { /// Expand constructor params for emission. Array types (e.g., `pubkey[]`) are /// flattened to `name_0`, `name_1`, `name_2`, …; every other param passes -/// through unchanged. Asset IDs are no longer special-cased: an Asset ID is -/// authored as two ordinary scalar params (`bytes32 fooTxid` + `int fooGidx`). +/// through unchanged. pub(crate) fn decompose_constructor_params( params: &[crate::models::Parameter], ) -> Vec { diff --git a/src/parser/grammar.pest b/src/parser/grammar.pest index 389c919..f073278 100644 --- a/src/parser/grammar.pest +++ b/src/parser/grammar.pest @@ -335,8 +335,7 @@ asset_group_access = { // Group property names — atomic to prevent partial matches // numInputs/numOutputs must come before sumInputs/sumOutputs to prevent partial matches -// `control` was removed: a canonical control Asset ID is two items + a flag, so -// use `controlIs(txid, gidx)` / `hasControl` instead. +// Control membership is tested with `controlIs(txid, gidx)` / `hasControl`. group_property = @{ "numInputs" | "numOutputs" | "sumInputs" | "sumOutputs" | "delta" | "hasControl" | "metadataHash" | "assetId" | "isFresh" } diff --git a/src/validator/mod.rs b/src/validator/mod.rs index 5781118..f2adc86 100644 --- a/src/validator/mod.rs +++ b/src/validator/mod.rs @@ -546,8 +546,7 @@ fn walk_scope( /// and reserved generated names — must be unique. Distinct source names can /// still collide here (e.g. `int[] xs` vs `int xs_0`). fn check_expanded_namespace(contract: &Contract, issues: &mut Vec) { - // Constructor params expanded exactly as the emitter expands them - // (array flattening only; asset IDs are now ordinary scalar params). + // Constructor params expanded exactly as the emitter expands them (array flattening). let ctor_expanded = crate::compiler::decompose_constructor_params(&contract.parameters); for func in contract.functions.iter().filter(|f| !f.is_internal) { diff --git a/tests/asset_id_explicit_test.rs b/tests/asset_id_explicit_test.rs index a8c82df..b3367a7 100644 --- a/tests/asset_id_explicit_test.rs +++ b/tests/asset_id_explicit_test.rs @@ -30,13 +30,8 @@ fn lookup_consumes_success_flag_with_single_verify() { }}" ); let asm = server_asm(&src, "f"); - // lookup opcode is immediately followed by OP_VERIFY (flag consume) — and - // the stale -1 sentinel guard (OP_1NEGATE/OP_DUP) is gone. + // lookup opcode is immediately followed by OP_VERIFY to consume the flag. assert!(asm.contains("OP_INSPECTOUTASSETLOOKUP OP_VERIFY"), "{asm}"); - assert!( - !asm.contains("OP_1NEGATE"), - "sentinel guard must be gone: {asm}" - ); } #[test] @@ -200,10 +195,7 @@ fn legacy_control_property_is_rejected() { }} }}" ); - assert!( - compile(&src).is_err(), - "legacy `.control` must no longer parse" - ); + assert!(compile(&src).is_err(), "`.control` is not valid syntax"); } // ─── Minimal ScriptNum encoding for a literal gidx ────────────────────────── diff --git a/tests/bond_mint_test.rs b/tests/bond_mint_test.rs index 0bd28ba..cf5f11b 100644 --- a/tests/bond_mint_test.rs +++ b/tests/bond_mint_test.rs @@ -18,7 +18,7 @@ fn test_bond_mint_compiles() { assert_eq!(output.functions.len(), 8, "expected 8 functions"); let names: Vec<&str> = output.parameters.iter().map(|p| p.name.as_str()).collect(); - // Asset IDs are authored as explicit (Txid, Gidx) param pairs — no implicit decomposition. + // Asset IDs are authored as explicit (Txid, Gidx) param pairs. for id in ["debitAssetId", "debitCtrlId"] { assert!( names.contains(&format!("{id}Txid").as_str()) diff --git a/tests/cash_secured_put_test.rs b/tests/cash_secured_put_test.rs index ba2c7b0..df1fc74 100644 --- a/tests/cash_secured_put_test.rs +++ b/tests/cash_secured_put_test.rs @@ -199,10 +199,6 @@ fn test_asset_id_is_two_explicit_params() { .find(|p| p.name == "stableAssetIdGidx") .expect("constructorInputs must include explicit stableAssetIdGidx"); assert_eq!(gidx.param_type, "int"); - // No implicit decomposition: the old generated `_txid`/`_gidx` names are gone. - let names: Vec<&str> = out.parameters.iter().map(|p| p.name.as_str()).collect(); - assert!(!names.contains(&"stableAssetId_txid")); - assert!(!names.contains(&"stableAssetId_gidx")); } #[test] diff --git a/tests/covered_call_test.rs b/tests/covered_call_test.rs index 0446cb6..c4ab500 100644 --- a/tests/covered_call_test.rs +++ b/tests/covered_call_test.rs @@ -198,10 +198,6 @@ fn test_asset_id_is_two_explicit_params() { .find(|p| p.name == "stableAssetIdGidx") .expect("constructorInputs must include explicit stableAssetIdGidx"); assert_eq!(gidx.param_type, "int"); - // No implicit decomposition: the old generated `_txid`/`_gidx` names are gone. - let names: Vec<&str> = out.parameters.iter().map(|p| p.name.as_str()).collect(); - assert!(!names.contains(&"stableAssetId_txid")); - assert!(!names.contains(&"stableAssetId_gidx")); } #[test] diff --git a/tests/fee_adapter_test.rs b/tests/fee_adapter_test.rs index 10da4be..d381d9a 100644 --- a/tests/fee_adapter_test.rs +++ b/tests/fee_adapter_test.rs @@ -85,7 +85,7 @@ fn test_fee_adapter_contract() { ); // Lookups assert presence by consuming the opcode success flag with a - // single OP_VERIFY (replaces the old -1 sentinel guard). + // single OP_VERIFY. assert!( execute_asm.contains(&format!("{OP_INSPECTINASSETLOOKUP} {OP_VERIFY}")), "input lookup must be followed by OP_VERIFY flag-consume: {}", diff --git a/tests/no_shadowing_test.rs b/tests/no_shadowing_test.rs index 25c6c2b..023908e 100644 --- a/tests/no_shadowing_test.rs +++ b/tests/no_shadowing_test.rs @@ -191,12 +191,6 @@ contract Demo(int[] xs) { ); } -// NOTE: the former `rejects_asset_decomposition_collision` test was removed. -// Asset IDs are no longer implicitly decomposed into `_txid`/`_gidx` generated -// names; they are authored as ordinary explicit scalar params, so that -// collision class no longer exists. Array-flattening collisions are still -// covered by the tests above. - #[test] fn rejects_param_colliding_with_server_signature() { let src = r#" From 92faabc69f9123d225f466331dae36bf2a92ecbf Mon Sep 17 00:00:00 2001 From: msinkec Date: Thu, 18 Jun 2026 23:43:14 +0200 Subject: [PATCH 4/9] remove redundant references --- src/compiler/mod.rs | 6 ++---- src/typechecker/mod.rs | 7 ++----- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 81fc14c..e4e8bf8 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -2028,8 +2028,7 @@ fn emit_asset_at_asm( // TODO(asset-id-struct): this intentionally leaves TWO stack items, so // `.assetId` needs a composite `AssetId` struct return type before it // can be destructured (.txid/.gidx) or compared safely. Deferred to a - // separate PR — see - // docs/superpowers/specs/2026-06-11-asset-lookup-explicit-txid-gidx-design.md + // separate PR. asm.push(OP_DROP.to_string()); } "amount" => { @@ -2211,8 +2210,7 @@ fn emit_group_property_asm(group: &str, property: &str, asm: &mut Vec) { // TODO(asset-id-struct): like asset_at `.assetId`, this needs a composite // `AssetId` struct return type before it can be destructured (.txid/.gidx) // or compared with `==` (a single OP_EQUAL only sees the top item, the - // gidx). Deferred to a separate PR — see - // docs/superpowers/specs/2026-06-11-asset-lookup-explicit-txid-gidx-design.md + // gidx). Deferred to a separate PR. asm.push(format!("<{}>", group)); asm.push(OP_INSPECTASSETGROUPASSETID.to_string()); } diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 6d6ffc9..70211af 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -453,9 +453,7 @@ pub fn infer_type(expr: &Expression, scope: &Scope) -> ArkType { // TODO(asset-id-struct): `.assetId` is really a two-item canonical // Asset ID (asset_txid, asset_gidx), NOT a single bytes32. Typed as // Bytes32 only as a stopgap until the composite `AssetId` struct - // return type lands (separate PR). See - // docs/superpowers/specs/2026-06-11-asset-lookup-explicit-txid-gidx-design.md - // ("Value side / asset_at" + "Out of scope"). + // return type lands (separate PR). "assetId" => ArkType::Bytes32, _ => ArkType::Unknown, }, @@ -473,8 +471,7 @@ pub fn infer_type(expr: &Expression, scope: &Scope) -> ArkType { // TODO(asset-id-struct): `assetId` is really a two-item canonical // Asset ID (asset_txid, asset_gidx), not a single bytes32; typed // Bytes32 only as a stopgap until the composite `AssetId` struct - // lands, so `==` over it is unsound until then (see spec - // 2026-06-11-asset-lookup-explicit-txid-gidx-design.md). `metadataHash` + // lands, so `==` over it is unsound until then. `metadataHash` // is genuinely a 32-byte hash and is correct. "metadataHash" | "assetId" => ArkType::Bytes32, "isFresh" | "hasControl" => ArkType::Bool, From c255875c81c5978715bd7993b11c7c47fb68cb26 Mon Sep 17 00:00:00 2001 From: msinkec Date: Fri, 19 Jun 2026 09:35:02 +0200 Subject: [PATCH 5/9] harden to comparison operator --- src/parser/grammar.pest | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parser/grammar.pest b/src/parser/grammar.pest index f073278..bcf8c1b 100644 --- a/src/parser/grammar.pest +++ b/src/parser/grammar.pest @@ -263,7 +263,7 @@ asset_count_comparison = { // Asset has comparison: asset_has op expression // Handles: tx.outputs[0].assets.has(txid, gidx) == 0 (assert absent) asset_has_comparison = { - asset_has ~ binary_operator ~ (identifier | number_literal) + asset_has ~ comparison_operator ~ (identifier | number_literal) } // Asset at comparison: asset_at op expression @@ -349,7 +349,7 @@ group_control_is = { // controlIs comparison: group.controlIs(txid, gidx) == 0 (assert absent/unequal) group_control_is_comparison = { - group_control_is ~ binary_operator ~ (identifier | number_literal) + group_control_is ~ comparison_operator ~ (identifier | number_literal) } // Group property comparison: variable.property op expression From bc284472e605e505dfc56ec17fe6db4ed3f87c64 Mon Sep 17 00:00:00 2001 From: msinkec Date: Fri, 19 Jun 2026 10:30:41 +0200 Subject: [PATCH 6/9] make the asset-ID walker exhaustive for nested expressions --- src/validator/mod.rs | 116 +++++++++++++++++++++++++++----- tests/asset_id_explicit_test.rs | 39 +++++++++++ 2 files changed, 137 insertions(+), 18 deletions(-) diff --git a/src/validator/mod.rs b/src/validator/mod.rs index f2adc86..c4886f8 100644 --- a/src/validator/mod.rs +++ b/src/validator/mod.rs @@ -260,30 +260,33 @@ fn walk_asset_id_stmts( } /// Walk an expression, validating the operands of every Asset ID construct and -/// recursing through compound expressions that can nest one. +/// recursing through every sub-expression that can nest one. +/// +/// Traversal and validation are deliberately split: this function validates the +/// Asset ID operands of the constructs that carry them, then recurses into *all* +/// direct sub-expressions via [`child_exprs`]. Because `child_exprs` is an +/// exhaustive match with no wildcard, any future `Expression` variant forces a +/// decision there and can never silently bypass this validation by falling +/// through a catch-all. fn check_asset_id_expr( expr: &Expression, scope: &Scope, fname: &str, issues: &mut Vec, ) { + // Variant-specific Asset ID operand validation. match expr { Expression::AssetLookup { - index, asset_txid, asset_gidx, .. } | Expression::AssetHas { - index, asset_txid, asset_gidx, .. - } => { - check_asset_id_expr(index, scope, fname, issues); - validate_asset_id(asset_txid, asset_gidx, scope, fname, issues); } - Expression::GroupFind { + | Expression::GroupFind { asset_txid, asset_gidx, } @@ -298,24 +301,101 @@ fn check_asset_id_expr( } => { validate_asset_id(asset_txid, asset_gidx, scope, fname, issues); } - Expression::BinaryOp { left, right, .. } | Expression::Concat { left, right, .. } => { - check_asset_id_expr(left, scope, fname, issues); - check_asset_id_expr(right, scope, fname, issues); + _ => {} + } + + // Generic recursion through every sub-expression. + for child in child_exprs(expr) { + check_asset_id_expr(child, scope, fname, issues); + } +} + +/// Return the direct sub-expressions of `expr`. +/// +/// This is the single source of truth for expression-tree traversal in the +/// validator. The match is intentionally exhaustive (no `_` arm): adding a new +/// [`Expression`] variant will fail to compile here until its nested +/// expressions — if any — are declared, guaranteeing that walkers built on top +/// of this (e.g. [`check_asset_id_expr`]) cover every new construct. +fn child_exprs(expr: &Expression) -> Vec<&Expression> { + match expr { + // Leaf nodes: no nested expressions. + Expression::Variable(_) + | Expression::Literal(_) + | Expression::Property(_) + | Expression::CurrentInput(_) + | Expression::TxIntrospection { .. } + | Expression::GroupProperty { .. } + | Expression::AssetGroupsLength + | Expression::ArrayLength(_) + | Expression::CheckSigExpr { .. } + | Expression::CheckSigFromStackExpr { .. } + | Expression::CheckSigFromStackVerify { .. } => vec![], + + Expression::AssetLookup { + index, + asset_txid, + asset_gidx, + .. } + | Expression::AssetHas { + index, + asset_txid, + asset_gidx, + .. + } => vec![index, asset_txid, asset_gidx], + Expression::AssetCount { index, .. } + | Expression::InputIntrospection { index, .. } + | Expression::OutputIntrospection { index, .. } + | Expression::GroupSum { index, .. } + | Expression::GroupNumIO { index, .. } => vec![index], Expression::AssetAt { io_index, asset_index, .. - } => { - check_asset_id_expr(io_index, scope, fname, issues); - check_asset_id_expr(asset_index, scope, fname, issues); + } => vec![io_index, asset_index], + Expression::BinaryOp { left, right, .. } | Expression::Concat { left, right, .. } => { + vec![left, right] } - Expression::InputIntrospection { index, .. } - | Expression::OutputIntrospection { index, .. } - | Expression::AssetCount { index, .. } => { - check_asset_id_expr(index, scope, fname, issues); + Expression::GroupFind { + asset_txid, + asset_gidx, } - _ => {} + | Expression::GroupHas { + asset_txid, + asset_gidx, + } => vec![asset_txid, asset_gidx], + Expression::GroupControlIs { + asset_txid, + asset_gidx, + .. + } => vec![asset_txid, asset_gidx], + Expression::GroupIOAccess { + group_index, + io_index, + .. + } => vec![group_index, io_index], + Expression::ArrayIndex { array, index } => vec![array, index], + Expression::Sha256 { data } | Expression::Sha256Initialize { data } => vec![data], + Expression::Sha256Update { context, chunk } => vec![context, chunk], + Expression::Sha256Finalize { + context, + last_chunk, + } => vec![context, last_chunk], + Expression::Neg64 { value } + | Expression::Le64ToScriptNum { value } + | Expression::Le32ToLe64 { value } => vec![value], + Expression::EcMulScalarVerify { + scalar, + point_p, + point_q, + } => vec![scalar, point_p, point_q], + Expression::TweakVerify { + point_p, + tweak, + point_q, + } => vec![point_p, tweak, point_q], + Expression::ContractInstance { args, .. } => args.iter().collect(), } } diff --git a/tests/asset_id_explicit_test.rs b/tests/asset_id_explicit_test.rs index b3367a7..4dd6aa1 100644 --- a/tests/asset_id_explicit_test.rs +++ b/tests/asset_id_explicit_test.rs @@ -277,6 +277,45 @@ fn rejects_out_of_range_literal_gidx() { assert!(err.contains("out of range"), "unexpected error: {err}"); } +#[test] +fn rejects_bad_gidx_in_right_hand_comparison_operand() { + // The Asset ID walker must validate *both* sides of a comparison, not just + // the left. The left operand is a valid lookup; the malformed one (gidx + // 70000) is the right-hand operand. + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, pubkey pk) {{ + function f(signature sig) {{ + require(tx.inputs[0].assets.lookup(fooTxid, 5) + >= tx.outputs[0].assets.lookup(fooTxid, 70000)); + require(checkSig(sig, pk)); + }} + }}" + ); + let err = compile(&src) + .expect_err("out-of-range gidx in RHS operand must be rejected") + .to_string(); + assert!(err.contains("out of range"), "unexpected error: {err}"); +} + +#[test] +fn rejects_bad_gidx_in_if_condition_predicate() { + // A `has()` predicate nested in an `if` condition must still be validated. + let src = format!( + "{PREAMBLE}contract C(bytes32 fooTxid, pubkey pk) {{ + function f(signature sig) {{ + if (tx.outputs[0].assets.has(fooTxid, 70000)) {{ + require(checkSig(sig, pk)); + }} + require(checkSig(sig, pk)); + }} + }}" + ); + let err = compile(&src) + .expect_err("out-of-range gidx in if-condition must be rejected") + .to_string(); + assert!(err.contains("out of range"), "unexpected error: {err}"); +} + #[test] fn accepts_loop_index_as_gidx() { // The loop index variable is statically Int, so it is a valid gidx operand. From 3347dae0050cb60d41a2625ef3d1164227d2467c Mon Sep 17 00:00:00 2001 From: msinkec Date: Fri, 19 Jun 2026 10:54:27 +0200 Subject: [PATCH 7/9] fix var references --- tests/beacon_test.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/beacon_test.rs b/tests/beacon_test.rs index a722cf8..4c55000 100644 --- a/tests/beacon_test.rs +++ b/tests/beacon_test.rs @@ -22,7 +22,7 @@ contract PriceBeacon( int numGroups ) { function passthrough() { - require(tx.outputs[0].scriptPubKey == new PriceBeacon(ctrlAssetId, oraclePk, oracleServerPk, numGroups), "broken"); + require(tx.outputs[0].scriptPubKey == new PriceBeacon(ctrlAssetIdTxid, ctrlAssetIdGidx, oraclePk, oracleServerPk, numGroups), "broken"); for (k, group) in tx.assetGroups { require(group.sumOutputs >= group.sumInputs, "drained"); @@ -31,7 +31,7 @@ contract PriceBeacon( function update(signature oracleSig) { require(tx.inputs[0].assets.lookup(ctrlAssetIdTxid, ctrlAssetIdGidx) > 0, "no ctrl"); - require(tx.outputs[0].scriptPubKey == new PriceBeacon(ctrlAssetId, oraclePk, oracleServerPk, numGroups), "broken"); + require(tx.outputs[0].scriptPubKey == new PriceBeacon(ctrlAssetIdTxid, ctrlAssetIdGidx, oraclePk, oracleServerPk, numGroups), "broken"); require(checkSig(oracleSig, oraclePk), "bad sig"); } } @@ -62,7 +62,7 @@ contract PriceBeacon( require(newBlockHeight >= currentHeight, "block height must not regress"); require( - tx.outputs[0].scriptPubKey == new PriceBeacon(ticker, clock, oraclePk, exit), + tx.outputs[0].scriptPubKey == new PriceBeacon(tickerTxid, tickerGidx, clockTxid, clockGidx, oraclePk, exit), "beacon script must survive" ); require( @@ -77,7 +77,7 @@ contract PriceBeacon( function passthrough() { require( - tx.outputs[0].scriptPubKey == new PriceBeacon(ticker, clock, oraclePk, exit), + tx.outputs[0].scriptPubKey == new PriceBeacon(tickerTxid, tickerGidx, clockTxid, clockGidx, oraclePk, exit), "beacon script must survive" ); @@ -101,7 +101,7 @@ contract PriceBeacon( int currentHeight = tx.inputs[0].assets.lookup(clockTxid, clockGidx); require( - tx.outputs[0].scriptPubKey == new PriceBeacon(ticker, clock, newOraclePk, exit), + tx.outputs[0].scriptPubKey == new PriceBeacon(tickerTxid, tickerGidx, clockTxid, clockGidx, newOraclePk, exit), "invalid new beacon" ); require( From b2db23bfcc911c140f42c3ff117327523ea4185e Mon Sep 17 00:00:00 2001 From: msinkec Date: Fri, 19 Jun 2026 11:03:21 +0200 Subject: [PATCH 8/9] validate gidx ABI fields and recurse into nested examples --- tests/no_shadowing_test.rs | 31 ++++++++++++++++++++++--------- tests/token_vault_test.rs | 2 ++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/tests/no_shadowing_test.rs b/tests/no_shadowing_test.rs index 023908e..1fbb3c0 100644 --- a/tests/no_shadowing_test.rs +++ b/tests/no_shadowing_test.rs @@ -242,19 +242,29 @@ contract Demo(pubkey[] ks) { use std::fs; use std::path::Path; +/// Recursively collect every `.ark` file under `dir`, including subdirectories +/// like `examples/bonds/` and `examples/options/`. +fn collect_ark_files(dir: &Path, out: &mut Vec) { + for entry in fs::read_dir(dir).expect("read examples dir") { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + collect_ark_files(&path, out); + } else if path.extension().and_then(|s| s.to_str()) == Some("ark") { + out.push(path); + } + } +} + /// Every bundled example must still compile cleanly. A new failure here means /// either a real latent shadowing/collision bug in the example (fix the example /// per the spec) or a false positive in the rule (fix the rule). #[test] fn all_examples_still_compile() { let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples"); - let mut checked = 0; - for entry in fs::read_dir(&dir).expect("read examples dir") { - let path = entry.expect("dir entry").path(); - if path.extension().and_then(|s| s.to_str()) != Some("ark") { - continue; - } - let src = fs::read_to_string(&path).expect("read example"); + let mut files = Vec::new(); + collect_ark_files(&dir, &mut files); + for path in &files { + let src = fs::read_to_string(path).expect("read example"); let result = compile(&src); assert!( result.is_ok(), @@ -262,7 +272,10 @@ fn all_examples_still_compile() { path.display(), result.err() ); - checked += 1; } - assert!(checked > 0, "no .ark examples found in {}", dir.display()); + assert!( + !files.is_empty(), + "no .ark examples found in {}", + dir.display() + ); } diff --git a/tests/token_vault_test.rs b/tests/token_vault_test.rs index 6402f0a..765ef51 100644 --- a/tests/token_vault_test.rs +++ b/tests/token_vault_test.rs @@ -175,4 +175,6 @@ fn test_token_vault_cli() { assert!(json_output.contains(OP_INSPECTOUTASSETLOOKUP)); assert!(json_output.contains("tokenAssetIdTxid")); assert!(json_output.contains("ctrlAssetIdTxid")); + assert!(json_output.contains("tokenAssetIdGidx")); + assert!(json_output.contains("ctrlAssetIdGidx")); } From 732fbde808c0faed2c8aef3a83f0c7085b2748fd Mon Sep 17 00:00:00 2001 From: msinkec Date: Fri, 19 Jun 2026 11:10:24 +0200 Subject: [PATCH 9/9] drop obsolete null guards, fix controlIs grouping and u64le sign --- docs/ArkadeKitties.md | 8 +++----- docs/arkade-primitives-spec.md | 2 +- docs/arkade-script-with-assets.md | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/ArkadeKitties.md b/docs/ArkadeKitties.md index 753858a..5e37fc0 100644 --- a/docs/ArkadeKitties.md +++ b/docs/ArkadeKitties.md @@ -170,7 +170,6 @@ contract BreedCommit( // 2. Verify parent assets are present and valid let sireGroup = tx.assetGroups.find(sireIdTxid, sireIdGidx); let dameGroup = tx.assetGroups.find(dameIdTxid, dameIdGidx); - require(sireGroup != null && dameGroup != null, "Sire and Dame assets must be spent"); require(sireGroup.controlIs(speciesControlIdTxid, speciesControlIdGidx), "Sire not Species-Controlled"); require(dameGroup.controlIs(speciesControlIdTxid, speciesControlIdGidx), "Dame not Species-Controlled"); require(sireGroup.metadataHash == computeKittyMetadataRoot(sireGenome, sireGenerationBE8), "Sire metadata hash mismatch"); @@ -178,7 +177,7 @@ contract BreedCommit( // 2. Verify Species Control asset is present and retained let speciesGroup = tx.assetGroups.find(speciesControlIdTxid, speciesControlIdGidx); - require(speciesGroup != null && speciesGroup.delta == 0, "Species Control must be present and retained"); + require(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 @@ -244,12 +243,11 @@ contract BreedReveal( // 3. Verify Species Control is present and retained (delta == 0) let speciesGroup = tx.assetGroups.find(speciesControlIdTxid, speciesControlIdGidx); - require(speciesGroup != null && speciesGroup.delta == 0, "Species Control must be present and retained"); + require(speciesGroup.delta == 0, "Species Control must be present and retained"); require(tx.outputs[speciesControlOutputIndex].assets.lookup(speciesControlIdTxid, speciesControlIdGidx) == 1, "Species Control not in output"); // 4. Find the new Kitty's asset group let newKittyGroup = tx.assetGroups.find(newKittyIdTxid, newKittyIdGidx); - require(newKittyGroup != null, "New Kitty asset group not found"); require(newKittyGroup.isFresh && newKittyGroup.delta == 1, "Child must be a fresh NFT"); require(newKittyGroup.controlIs(speciesControlIdTxid, speciesControlIdGidx), "Child not Species-Controlled"); let newKittyOutput = tx.outputs[kittyOutputIndex]; @@ -279,7 +277,7 @@ contract BreedReveal( // 3. Verify Species Control is retained (delta == 0) let speciesGroup = tx.assetGroups.find(speciesControlIdTxid, speciesControlIdGidx); - require(speciesGroup != null && speciesGroup.delta == 0, "Species Control must be retained"); + require(speciesGroup.delta == 0, "Species Control must be retained"); require(tx.outputs[speciesControlOutputIndex].assets.lookup(speciesControlIdTxid, speciesControlIdGidx) == 1, "Species Control not in output"); } diff --git a/docs/arkade-primitives-spec.md b/docs/arkade-primitives-spec.md index 0499b8f..d09dd4b 100644 --- a/docs/arkade-primitives-spec.md +++ b/docs/arkade-primitives-spec.md @@ -105,7 +105,7 @@ contract USDT0Bridge( |---|---|---|---|---| | `csn` | 1-4 bytes | CScriptNum | Witness inputs, OP_0..16, OP_PICK | OP_PICK, OP_IF, OP_EQUAL, OP_VERIFY | | `u32le` | 4 bytes | Unsigned LE | OP_INSPECTLOCKTIME | OP_LE32TOLE64 | -| `u64le` | 8 bytes | Signed LE | INSPECTASSETGROUPSUM, ADD64, asset lookup result | ADD64, GREATERTHAN64, EQUAL | +| `u64le` | 8 bytes | Unsigned LE | INSPECTASSETGROUPSUM, ADD64, asset lookup result | ADD64, GREATERTHAN64, EQUAL | | `bytes32` | 32 bytes | Raw | SHA256FINALIZE, witness pushes | SHA256UPDATE, EQUAL, CHECKSIGFROMSTACK | | `pubkey` | 32 bytes | x-only (BIP340) | Witness, constructor literal | CHECKSIG, CHECKSIGFROMSTACK, SingleSig | | `signature` | 64 bytes | BIP340 Schnorr | Witness | CHECKSIG, CHECKSIGFROMSTACK | diff --git a/docs/arkade-script-with-assets.md b/docs/arkade-script-with-assets.md index d38ccbc..a400763 100644 --- a/docs/arkade-script-with-assets.md +++ b/docs/arkade-script-with-assets.md @@ -12,7 +12,7 @@ These opcodes provide access to the Arkade Asset V1 packet embedded in the trans All Asset IDs are the canonical pair **`(asset_txid, asset_gidx)`** — two stack items. `asset_txid` (32 bytes) is the **issuance** transaction ID where the asset was minted. `asset_gidx` is the group index **at which the asset was issued**, a minimally encoded ScriptNum in `0..65535` (not a group's current packet position — the two coincide only for a fresh issuance). -The lookup/find/control opcodes return a trailing **success flag**: `… 1` on success, `0 0` (or `empty 0 0` for control) on absence. The Arkade Script sugar consumes that flag for you — `lookup`/`find`/`controlIs` assert presence; `has`/`hasControl` expose it as a boolean. +The lookup/find/control opcodes return a trailing **success flag**: `… 1` on success, `0 0` (or `empty 0 0` for control) on absence. The Arkade Script sugar consumes that flag for you — `lookup`/`find` assert presence; `has`/`hasControl`/`controlIs` expose it as a boolean. ### Packet & Groups