From c2acbb5b45d3815debd7437c5057ed020dd37a09 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 17:01:19 +0000 Subject: [PATCH 01/15] feat: add LayerZero / USDT0 contract suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate the asset-flow and signature semantics from the LayerZero/USDT0 prototype Go scripts (layerzero-usdt0-arkade-demo/internal/scripts/builders.go) into a four-contract Arkade suite under examples/layerzero/: - endpoint.ark Endpoint state with receive() + send() transitions, 2-of-2 DVN attestation, receive marker mint, send marker burn - oapp.ark OApp state with receive() + send() transitions, USDT0 mint/burn, marker consumption/emission - receive_marker.ark Endpoint→OApp invocation marker pinned to the OApp control singleton - send_marker.ark OApp→Endpoint invocation marker pinned to the Endpoint control singleton Asset-level invariants from the Go spec map directly to OP_INSPECT*ASSET* opcodes via tx.inputs[i].assets.lookup, tx.outputs[o].assets.lookup, and tx.assetGroups.find(id).{delta,sumInputs,sumOutputs}. Contract continuation uses the existing new ContractName(...) covenant. Packet-level invariants (OP_INSPECTPACKET / OP_SUBSTR / OP_BIN2NUM / OP_INSPECTINPUTARKADESCRIPTHASH) that the Arkade compiler does not yet expose are documented in each file and delegated to the introspector runtime — see examples/layerzero/README.md for the mapping table. Also: - Register the layerzero project in the playground sidebar. - Add tests/layerzero_test.rs (14 tests) that pin the key invariants: DVN signature checks, marker mint/burn via group sums, state continuation via OP_INSPECTOUTPUTSCRIPTPUBKEY, and control-asset singleton checks in the markers. --- examples/layerzero/README.md | 92 ++++++ examples/layerzero/endpoint.ark | 146 +++++++++ examples/layerzero/endpoint.json | 349 ++++++++++++++++++++ examples/layerzero/oapp.ark | 163 +++++++++ examples/layerzero/oapp.json | 436 +++++++++++++++++++++++++ examples/layerzero/receive_marker.ark | 44 +++ examples/layerzero/receive_marker.json | 86 +++++ examples/layerzero/send_marker.ark | 34 ++ examples/layerzero/send_marker.json | 86 +++++ playground/main.js | 10 + tests/layerzero_test.rs | 327 +++++++++++++++++++ 11 files changed, 1773 insertions(+) create mode 100644 examples/layerzero/README.md create mode 100644 examples/layerzero/endpoint.ark create mode 100644 examples/layerzero/endpoint.json create mode 100644 examples/layerzero/oapp.ark create mode 100644 examples/layerzero/oapp.json create mode 100644 examples/layerzero/receive_marker.ark create mode 100644 examples/layerzero/receive_marker.json create mode 100644 examples/layerzero/send_marker.ark create mode 100644 examples/layerzero/send_marker.json create mode 100644 tests/layerzero_test.rs diff --git a/examples/layerzero/README.md b/examples/layerzero/README.md new file mode 100644 index 0000000..2526e98 --- /dev/null +++ b/examples/layerzero/README.md @@ -0,0 +1,92 @@ +# LayerZero / USDT0 Arkade Contracts + +Arkade rendering of the LayerZero / USDT0 prototype originally implemented as +Go script builders in `layerzero-usdt0-arkade-demo` (see +`internal/scripts/builders.go` and `docs/contract-system.md` in that repo for +the full spec, plus `internal/protocol/types.go` for packet layouts). + +## Contracts + +| File | Role | Go counterpart | +|---|---|---| +| `endpoint.ark` | LayerZero Endpoint state + receive/send transitions | `BuildEndpointReceiveScript`, `BuildEndpointSendScript` | +| `oapp.ark` | USDT0 OApp state + receive/send transitions | `BuildOAppReceiveScript`, `BuildOAppSendScript` | +| `receive_marker.ark` | Endpoint→OApp invocation marker | `BuildReceiveInvocationScript` | +| `send_marker.ark` | OApp→Endpoint invocation marker | `BuildSendInvocationScript` | + +## Flow + +``` + inbound LayerZero packet (DVN-attested) + │ + ▼ + ┌───────────────────────────────────────────────┐ + │ Endpoint.receive() │ + │ - verifies both DVN signatures │ + │ - continues Endpoint state │ + │ - mints 1 EndpointID asset → ReceiveMarker │ + └───────────────────────────────────────────────┘ + │ + ▼ + ┌───────────────────────────────────────────────┐ + │ OApp.receive() │ + │ - consumes ReceiveMarker (burns EndpointID) │ + │ - continues OApp state │ + │ - mints USDT0 to credited recipient │ + └───────────────────────────────────────────────┘ + + ┌───────────────────────────────────────────────┐ + │ OApp.send() │ + │ - burns USDT0 │ + │ - continues OApp state │ + │ - mints 1 OAppID asset → SendMarker │ + └───────────────────────────────────────────────┘ + │ + ▼ + ┌───────────────────────────────────────────────┐ + │ Endpoint.send() │ + │ - consumes SendMarker (burns OAppID) │ + │ - continues Endpoint state │ + │ - emits LzSendPacket (outbound relay) │ + └───────────────────────────────────────────────┘ +``` + +## What is enforced in the Arkade contract vs. the introspector layer + +The Arkade compiler renders the **asset-flow** and **signature** invariants +of the Go scripts directly. **Packet-level** invariants are enforced by the +introspector runtime that wraps the contract: + +| Invariant class | Enforced in `.ark` | Notes | +|---|---|---| +| DVN 2-of-2 signature over receive hash | ✅ `checkSigFromStack` | The hash is computed off-chain by the relayer and passed as a witness | +| Endpoint/OApp state continuation | ✅ `tx.outputs[0].scriptPubKey == new ...` | Route is part of constructor params, so a recursive equality enforces preservation | +| Marker mint (1 unit) | ✅ `tx.outputs[i].assets.lookup(marker) == 1` + `group.sumOutputs == 1` | Combined output-asset and group-sum checks | +| Marker burn | ✅ `group.sumOutputs == 0` + input asset check | Mirrors `OP_INSPECTASSETGROUPSUM` on the Go side | +| USDT0 delta == credited amount | ✅ `usdt0Group.delta == amount` | Group delta = output sum − input sum | +| Marker pinned to consuming contract | ✅ control-asset singleton on consuming input | Defense-in-depth check from the Go marker scripts | +| Packet version / size / field layout | ⛔ delegated | Needs `OP_INSPECTPACKET` + `OP_SUBSTR`, not exposed in the Arkade compiler grammar | +| Inbound/outbound nonce monotonicity | ⛔ delegated | Needs packet-field extraction + `OP_BIN2NUM` | +| `sha256(OAppSendInvocation) == LzSend.guid` | ⛔ delegated | Needs packet introspection | +| Marker input position + Arkade-script-hash binding | ⛔ delegated | Needs `OP_PUSHCURRENTINPUTINDEX` equality + `OP_INSPECTINPUTARKADESCRIPTHASH` | + +For the parts marked "delegated", the Go demo's `internal/scripts/builders.go` +remains the authoritative implementation. The Arkade contracts here are the +high-level surface that an Arkade-script-aware introspector runs alongside +those packet-level checks. + +## Local checks + +```bash +# build and run the layerzero contract tests +cargo test --test layerzero_test + +# compile a single contract +cargo run -- examples/layerzero/endpoint.ark -o /tmp/endpoint.json + +# refresh the playground bundle +./playground/generate_contracts.sh +``` + +The four contracts also show up under "LayerZero / USDT0" in the playground +sidebar once `./playground/build.sh` has been run. diff --git a/examples/layerzero/endpoint.ark b/examples/layerzero/endpoint.ark new file mode 100644 index 0000000..9398c06 --- /dev/null +++ b/examples/layerzero/endpoint.ark @@ -0,0 +1,146 @@ +// Endpoint Contract — LayerZero / USDT0 pathway state +// +// Spec: layerzero-usdt0-arkade-demo/internal/scripts/builders.go +// BuildEndpointReceiveScript + BuildEndpointSendScript. +// +// The Endpoint owns LayerZero pathway state: local Endpoint identity, linked +// OApp identity, remote endpoint id, remote OApp id, DVN set, and the two +// invocation marker mints/burns. The Arkade compiler renders the asset-flow +// and signature invariants of those Go scripts. Packet-level invariants +// (LzReceivePacket field parsing, DvnAttestationPacket layout, nonce +// monotonicity over OP_INSPECTPACKET) are enforced by the introspector +// runtime around this contract — see the contract-system.md doc in the demo. +// +// receive() — validates a DVN-attested inbound packet and emits one +// receive-invocation marker for OApp.receive() to consume. +// send() — consumes an OApp send-invocation marker and emits an +// outbound LzSendPacket. +// +// Asset roles: +// - endpointCtrlAssetId : Endpoint control singleton — pinned to this state. +// - endpointIDAssetId : Receive-invocation marker token (1 minted per recv). +// - oappCtrlAssetId : OApp control singleton — referenced by markers. +// - oappIDAssetId : Send-invocation marker token (consumed per send). +// +// DVN attestation: +// The two DVN pubkeys are fixed by the route configuration and committed +// as constructor parameters. A receive transition requires Schnorr sigs +// from BOTH DVNs over the canonical receive hash. The receive hash is the +// sha256 of (receiver || srcEID || sender || nonce || GUID || messageHash); +// it is computed off-chain by the LayerZero relayer and presented here as +// a witness so the Arkade contract verifies the cryptographic commitment +// without reconstructing the packet on-chain. + +options { + server = server; + exit = exit; +} + +contract Endpoint( + bytes32 endpointCtrlAssetId, + bytes32 endpointIDAssetId, + bytes32 oappCtrlAssetId, + bytes32 oappIDAssetId, + pubkey dvn0Pk, + pubkey dvn1Pk, + int exit +) { + + // ------------------------------------------------------------------------- + // ENDPOINT RECEIVE + // Validate a DVN-attested LzReceive packet and emit a receive-invocation + // marker for OApp.receive() to consume. + // + // Asset-level invariants enforced here (mirror builders.go ~ lines 417-477): + // - Endpoint control asset survives on output[0] (the next Endpoint state). + // - Output[0] scriptPubKey == this Endpoint contract with identical + // constructor parameters (route preservation). + // - Output[1] is the canonical ReceiveMarker pkScript and carries exactly + // one freshly-minted EndpointID asset. + // - Exactly one EndpointID asset is created in this transition. + // + // Packet-level invariants delegated to the introspector layer: + // - LzReceive / DvnAttestation / EndpointState are v1 with fixed sizes. + // - LzReceive route fields match constructor route (EndpointID, OAppID, + // RemoteEID, RemoteOApp). + // - Inbound nonce in the next-state packet = previous inbound nonce + 1. + // - Outbound nonce and route prefix carry over verbatim. + // ------------------------------------------------------------------------- + function receive( + bytes32 receiveHash, + signature dvn0Sig, + signature dvn1Sig + ) { + // 2-of-2 DVN attestation over the canonical receive hash. + require(checkSigFromStack(dvn0Sig, dvn0Pk, receiveHash), "dvn0 sig invalid"); + require(checkSigFromStack(dvn1Sig, dvn1Pk, receiveHash), "dvn1 sig invalid"); + + // Endpoint state continues at output 0 with route preserved. + require( + tx.outputs[0].scriptPubKey == new Endpoint( + endpointCtrlAssetId, endpointIDAssetId, + oappCtrlAssetId, oappIDAssetId, + dvn0Pk, dvn1Pk, exit + ), + "endpoint state must continue" + ); + require( + tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1, + "endpoint control missing" + ); + + // Receive-invocation marker emitted at output 1. + require( + tx.outputs[1].assets.lookup(endpointIDAssetId) == 1, + "marker asset missing" + ); + require( + tx.outputs[1].scriptPubKey == new ReceiveMarker(oappCtrlAssetId, exit), + "marker pkScript not canonical" + ); + + // Exactly one EndpointID asset minted (no extra markers). + let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId); + require(endpointIDGroup.sumOutputs == 1, "extra marker minted"); + } + + // ------------------------------------------------------------------------- + // ENDPOINT SEND + // Consume an OApp-emitted send-invocation marker and emit an outbound + // LzSendPacket. Endpoint state continues on output 0; the OAppID marker is + // fully burned (no output unit) to prevent replay. + // + // Asset-level invariants enforced here (mirror builders.go ~ lines 802-853): + // - OAppID marker carries 1 unit on the consumed input; total output sum + // of OAppID is 0 (marker is destroyed). + // - Endpoint control asset survives on output[0]. + // - Output[0] scriptPubKey == this Endpoint contract with identical + // constructor parameters. + // + // Packet-level invariants delegated to the introspector layer: + // - LzSendPacket and OAppSendInvocation packets are v1 with fixed sizes. + // - Route fields match Endpoint state; inbound nonce preserved. + // - Outbound nonce in LzSend = previous outbound nonce + 1. + // - sha256(OAppSendInvocation) == LzSend.guid; per-field equalities + // between invocation and LzSend (sender, dstEID, receiver, amount, + // remoteRecipient, messageHash). + // ------------------------------------------------------------------------- + function send() { + let oappIDGroup = tx.assetGroups.find(oappIDAssetId); + require(oappIDGroup.sumInputs == 1, "send marker missing"); + require(oappIDGroup.sumOutputs == 0, "send marker not burned"); + + require( + tx.outputs[0].scriptPubKey == new Endpoint( + endpointCtrlAssetId, endpointIDAssetId, + oappCtrlAssetId, oappIDAssetId, + dvn0Pk, dvn1Pk, exit + ), + "endpoint state must continue" + ); + require( + tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1, + "endpoint control missing" + ); + } +} diff --git a/examples/layerzero/endpoint.json b/examples/layerzero/endpoint.json new file mode 100644 index 0000000..05b2021 --- /dev/null +++ b/examples/layerzero/endpoint.json @@ -0,0 +1,349 @@ +{ + "contractName": "Endpoint", + "constructorInputs": [ + { + "name": "endpointCtrlAssetId_txid", + "type": "bytes32" + }, + { + "name": "endpointCtrlAssetId_gidx", + "type": "int" + }, + { + "name": "endpointIDAssetId_txid", + "type": "bytes32" + }, + { + "name": "endpointIDAssetId_gidx", + "type": "int" + }, + { + "name": "oappCtrlAssetId", + "type": "bytes32" + }, + { + "name": "oappIDAssetId_txid", + "type": "bytes32" + }, + { + "name": "oappIDAssetId_gidx", + "type": "int" + }, + { + "name": "dvn0Pk", + "type": "pubkey" + }, + { + "name": "dvn1Pk", + "type": "pubkey" + }, + { + "name": "exit", + "type": "int" + } + ], + "functions": [ + { + "name": "receive", + "functionInputs": [ + { + "name": "receiveHash", + "type": "bytes32" + }, + { + "name": "dvn0Sig", + "type": "signature" + }, + { + "name": "dvn1Sig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "receiveHash", + "type": "bytes32", + "encoding": "raw-32" + }, + { + "name": "dvn0Sig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "dvn1Sig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "signatureFromStack" + }, + { + "type": "signatureFromStack" + }, + { + "type": "comparison" + }, + { + "type": "assetCheck" + }, + { + "type": "assetCheck" + }, + { + "type": "comparison" + }, + { + "type": "groupCheck" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "", + "", + "OP_CHECKSIGFROMSTACK", + "", + "", + "", + "OP_CHECKSIGFROMSTACK", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ",,,,,,)>", + "OP_EQUAL", + "0", + "", + "", + "OP_INSPECTOUTASSETLOOKUP", + "OP_DUP", + "OP_1NEGATE", + "OP_EQUAL", + "OP_NOT", + "OP_VERIFY", + "1", + "OP_EQUAL", + "OP_VERIFY", + "1", + "", + "", + "OP_INSPECTOUTASSETLOOKUP", + "OP_DUP", + "OP_1NEGATE", + "OP_EQUAL", + "OP_NOT", + "OP_VERIFY", + "1", + "OP_EQUAL", + "OP_VERIFY", + "1", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ",)>", + "OP_EQUAL", + "", + "", + "OP_FINDASSETGROUPBYASSETID", + "", + "OP_1", + "OP_INSPECTASSETGROUPSUM", + "1", + "OP_EQUAL", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "receive", + "functionInputs": [ + { + "name": "receiveHash", + "type": "bytes32" + }, + { + "name": "dvn0Sig", + "type": "signature" + }, + { + "name": "dvn1Sig", + "type": "signature" + }, + { + "name": "dvn0PkSig", + "type": "signature" + }, + { + "name": "dvn1PkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "dvn0PkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "dvn1PkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "2-of-2 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + }, + { + "name": "send", + "functionInputs": [], + "witnessSchema": [ + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "groupCheck" + }, + { + "type": "groupCheck" + }, + { + "type": "comparison" + }, + { + "type": "assetCheck" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "", + "OP_FINDASSETGROUPBYASSETID", + "", + "OP_0", + "OP_INSPECTASSETGROUPSUM", + "1", + "OP_EQUAL", + "", + "OP_1", + "OP_INSPECTASSETGROUPSUM", + "0", + "OP_EQUAL", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ",,,,,,)>", + "OP_EQUAL", + "0", + "", + "", + "OP_INSPECTOUTASSETLOOKUP", + "OP_DUP", + "OP_1NEGATE", + "OP_EQUAL", + "OP_NOT", + "OP_VERIFY", + "1", + "OP_EQUAL", + "OP_VERIFY", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "send", + "functionInputs": [ + { + "name": "dvn0PkSig", + "type": "signature" + }, + { + "name": "dvn1PkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "dvn0PkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "dvn1PkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "2-of-2 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + } + ], + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract Endpoint(\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n pubkey dvn0Pk,\n pubkey dvn1Pk,\n int exit\n) {\n\n function receive(\n bytes32 receiveHash,\n signature dvn0Sig,\n signature dvn1Sig\n ) {\n require(checkSigFromStack(dvn0Sig, dvn0Pk, receiveHash), \"dvn0 sig invalid\");\n require(checkSigFromStack(dvn1Sig, dvn1Pk, receiveHash), \"dvn1 sig invalid\");\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing\"\n );\n\n require(\n tx.outputs[1].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new ReceiveMarker(oappCtrlAssetId, exit),\n \"marker pkScript not canonical\"\n );\n\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 1, \"extra marker minted\");\n }\n\n function send() {\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumInputs == 1, \"send marker missing\");\n require(oappIDGroup.sumOutputs == 0, \"send marker not burned\");\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing\"\n );\n }\n}", + "compiler": { + "name": "arkade-compiler", + "version": "0.1.0" + }, + "updatedAt": "2026-05-15T16:59:10.616373952+00:00", + "warnings": [ + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" + ] +} \ No newline at end of file diff --git a/examples/layerzero/oapp.ark b/examples/layerzero/oapp.ark new file mode 100644 index 0000000..1e70e64 --- /dev/null +++ b/examples/layerzero/oapp.ark @@ -0,0 +1,163 @@ +// OApp Contract — USDT0 mint / burn application +// +// Spec: layerzero-usdt0-arkade-demo/internal/scripts/builders.go +// BuildOAppReceiveScript + BuildOAppSendScript. +// +// The OApp owns the application-level token effects. It mints USDT0 from +// Endpoint-authenticated receive invocations and burns USDT0 to create +// authenticated send invocations back to the Endpoint. +// +// receive() — consumes a receive-invocation marker, mints USDT0 to the +// recipient committed in the inbound CreditMessage. +// send() — burns USDT0, emits a send-invocation marker for +// Endpoint.send() to consume. +// +// Asset roles: +// - oappCtrlAssetId : OApp control singleton — pinned to this state. +// - oappIDAssetId : Send-invocation marker token (1 minted per send). +// - usdt0AssetId : Prototype USDT0 token — minted on receive, burned on send. +// - endpointCtrlAssetId : Endpoint control singleton — referenced by markers. +// - endpointIDAssetId : Receive-invocation marker token (consumed on receive). + +options { + server = server; + exit = exit; +} + +contract OApp( + bytes32 oappCtrlAssetId, + bytes32 oappIDAssetId, + bytes32 usdt0AssetId, + bytes32 endpointCtrlAssetId, + bytes32 endpointIDAssetId, + int exit +) { + + // ------------------------------------------------------------------------- + // OAPP RECEIVE + // Consume a receive-invocation marker and mint the credited USDT0 amount + // to the recipient committed by the inbound CreditMessage. + // + // Asset-level invariants enforced here (mirror builders.go ~ lines 859-1131): + // - Receive-invocation marker input carries exactly 1 EndpointID asset + // and is fully burned (total output sum of EndpointID == 0). + // - USDT0 delta (output sum - input sum) == credited amount. + // - Recipient output (output 1) receives exactly `amount` USDT0. + // - Recipient output scriptPubKey == new SingleSig(recipient). + // - OApp control asset + OAppID asset both survive on output 0. + // - Output[0] scriptPubKey == this OApp contract with identical params. + // + // Packet-level invariants delegated to the introspector layer: + // - LzReceive packet is v1 with fixed credit-message size. + // - LzReceive route fields match the configured remote. + // - sha256(CreditMessage) == LzReceive.messageHash. + // - CreditMessage.remoteSender == LzReceive.sender. + // - CreditMessage.toScriptPubKey is P2TR and equals the recipient output's + // scriptPubKey; CreditMessage.amount equals the on-chain delta. + // + // The `amount` and `recipient` witnesses are the values committed by the + // already-DVN-attested LzReceivePacket on the marker input; the introspector + // layer binds them to the packet, so this contract treats them as + // authenticated inputs. + // ------------------------------------------------------------------------- + function receive(int amount, pubkey recipient) { + require(amount > 0, "amount must be positive"); + + // The receive-invocation marker is consumed (input 0) and fully burned. + require( + tx.inputs[0].assets.lookup(endpointIDAssetId) == 1, + "marker asset not on input 0" + ); + let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId); + require(endpointIDGroup.sumOutputs == 0, "marker not burned"); + + // USDT0 delta equals the credited amount (mint of `amount` units). + let usdt0Group = tx.assetGroups.find(usdt0AssetId); + require(usdt0Group.delta == amount, "usdt0 delta mismatch"); + + // Recipient output receives exactly `amount` USDT0 and is P2TR over recipient. + require( + tx.outputs[1].assets.lookup(usdt0AssetId) == amount, + "recipient amount mismatch" + ); + require( + tx.outputs[1].scriptPubKey == new SingleSig(recipient), + "recipient pkScript wrong" + ); + + // OApp state continues at output 0 with control + ID assets intact. + require( + tx.outputs[0].scriptPubKey == new OApp( + oappCtrlAssetId, oappIDAssetId, usdt0AssetId, + endpointCtrlAssetId, endpointIDAssetId, exit + ), + "oapp state must continue" + ); + require( + tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, + "oapp control missing" + ); + require( + tx.outputs[0].assets.lookup(oappIDAssetId) == 1, + "oapp id missing" + ); + } + + // ------------------------------------------------------------------------- + // OAPP SEND + // Burn the outbound USDT0 amount and emit a send-invocation marker for + // Endpoint.send() to consume. + // + // Asset-level invariants enforced here (mirror builders.go ~ lines 1135-1300): + // - USDT0 input sum - output sum == burn amount. + // - Send-invocation marker emitted at output 1, carries 1 OAppID asset, + // and uses the canonical SendMarker pkScript. + // - Exactly one OAppID asset is minted in this transition. + // - OApp control asset survives on output 0. + // - Output[0] scriptPubKey == this OApp contract with identical params. + // + // Packet-level invariants delegated to the introspector layer: + // - OAppSendInvocation is v1 with the fixed layout and points + // invocation_vout at the marker output index. + // - OAppID/EndpointID fields match constructor route. + // - dstEID and remoteRecipient match the configured remote. + // - sender signs the outbound send (off-chain coordination). + // + // `amount` is the burn amount as committed in the OAppSendInvocation packet + // produced by the same transaction; the introspector layer binds it. + // ------------------------------------------------------------------------- + function send(int amount, signature ownerSig, pubkey ownerPk) { + require(amount > 0, "amount must be positive"); + require(checkSig(ownerSig, ownerPk), "owner sig invalid"); + + // USDT0 was burned in `amount`: outputs are short by `amount` vs. inputs. + let usdt0Group = tx.assetGroups.find(usdt0AssetId); + require(usdt0Group.sumInputs >= usdt0Group.sumOutputs + amount, "burn short"); + + // Send-invocation marker emitted at output 1. + require( + tx.outputs[1].assets.lookup(oappIDAssetId) == 1, + "send marker asset missing" + ); + require( + tx.outputs[1].scriptPubKey == new SendMarker(endpointCtrlAssetId, exit), + "send marker pkScript not canonical" + ); + + let oappIDGroup = tx.assetGroups.find(oappIDAssetId); + require(oappIDGroup.sumOutputs == 1, "extra send marker"); + + // OApp state continues at output 0. + require( + tx.outputs[0].scriptPubKey == new OApp( + oappCtrlAssetId, oappIDAssetId, usdt0AssetId, + endpointCtrlAssetId, endpointIDAssetId, exit + ), + "oapp state must continue" + ); + require( + tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, + "oapp control missing" + ); + } +} diff --git a/examples/layerzero/oapp.json b/examples/layerzero/oapp.json new file mode 100644 index 0000000..1bee077 --- /dev/null +++ b/examples/layerzero/oapp.json @@ -0,0 +1,436 @@ +{ + "contractName": "OApp", + "constructorInputs": [ + { + "name": "oappCtrlAssetId_txid", + "type": "bytes32" + }, + { + "name": "oappCtrlAssetId_gidx", + "type": "int" + }, + { + "name": "oappIDAssetId_txid", + "type": "bytes32" + }, + { + "name": "oappIDAssetId_gidx", + "type": "int" + }, + { + "name": "usdt0AssetId_txid", + "type": "bytes32" + }, + { + "name": "usdt0AssetId_gidx", + "type": "int" + }, + { + "name": "endpointCtrlAssetId", + "type": "bytes32" + }, + { + "name": "endpointIDAssetId_txid", + "type": "bytes32" + }, + { + "name": "endpointIDAssetId_gidx", + "type": "int" + }, + { + "name": "exit", + "type": "int" + } + ], + "functions": [ + { + "name": "receive", + "functionInputs": [ + { + "name": "amount", + "type": "int" + }, + { + "name": "recipient", + "type": "pubkey" + } + ], + "witnessSchema": [ + { + "name": "amount", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "recipient", + "type": "pubkey", + "encoding": "compressed-33" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "comparison" + }, + { + "type": "assetCheck" + }, + { + "type": "groupCheck" + }, + { + "type": "groupCheck" + }, + { + "type": "assetCheck" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "assetCheck" + }, + { + "type": "assetCheck" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "0", + "OP_GREATERTHAN", + "0", + "", + "", + "OP_INSPECTINASSETLOOKUP", + "OP_DUP", + "OP_1NEGATE", + "OP_EQUAL", + "OP_NOT", + "OP_VERIFY", + "1", + "OP_EQUAL", + "OP_VERIFY", + "", + "", + "OP_FINDASSETGROUPBYASSETID", + "", + "OP_1", + "OP_INSPECTASSETGROUPSUM", + "0", + "OP_EQUAL", + "", + "", + "OP_FINDASSETGROUPBYASSETID", + "", + "OP_1", + "OP_INSPECTASSETGROUPSUM", + "", + "OP_0", + "OP_INSPECTASSETGROUPSUM", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_EQUAL", + "1", + "", + "", + "OP_INSPECTOUTASSETLOOKUP", + "OP_DUP", + "OP_1NEGATE", + "OP_EQUAL", + "OP_NOT", + "OP_VERIFY", + "", + "OP_EQUAL", + "OP_VERIFY", + "1", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ",,,,,)>", + "OP_EQUAL", + "0", + "", + "", + "OP_INSPECTOUTASSETLOOKUP", + "OP_DUP", + "OP_1NEGATE", + "OP_EQUAL", + "OP_NOT", + "OP_VERIFY", + "1", + "OP_EQUAL", + "OP_VERIFY", + "0", + "", + "", + "OP_INSPECTOUTASSETLOOKUP", + "OP_DUP", + "OP_1NEGATE", + "OP_EQUAL", + "OP_NOT", + "OP_VERIFY", + "1", + "OP_EQUAL", + "OP_VERIFY", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "receive", + "functionInputs": [ + { + "name": "amount", + "type": "int" + }, + { + "name": "recipient", + "type": "pubkey" + }, + { + "name": "recipientSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "recipientSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "1-of-1 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + }, + { + "name": "send", + "functionInputs": [ + { + "name": "amount", + "type": "int" + }, + { + "name": "ownerSig", + "type": "signature" + }, + { + "name": "ownerPk", + "type": "pubkey" + } + ], + "witnessSchema": [ + { + "name": "amount", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "ownerSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "ownerPk", + "type": "pubkey", + "encoding": "compressed-33" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "comparison" + }, + { + "type": "signature" + }, + { + "type": "groupCheck" + }, + { + "type": "assetCheck" + }, + { + "type": "comparison" + }, + { + "type": "groupCheck" + }, + { + "type": "comparison" + }, + { + "type": "assetCheck" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "0", + "OP_GREATERTHAN", + "", + "", + "OP_CHECKSIG", + "", + "", + "OP_FINDASSETGROUPBYASSETID", + "", + "OP_0", + "OP_INSPECTASSETGROUPSUM", + "", + "OP_1", + "OP_INSPECTASSETGROUPSUM", + "", + "OP_SCRIPTNUMTOLE64", + "OP_ADD64", + "OP_VERIFY", + "OP_GREATERTHANOREQUAL", + "1", + "", + "", + "OP_INSPECTOUTASSETLOOKUP", + "OP_DUP", + "OP_1NEGATE", + "OP_EQUAL", + "OP_NOT", + "OP_VERIFY", + "1", + "OP_EQUAL", + "OP_VERIFY", + "1", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ",)>", + "OP_EQUAL", + "", + "", + "OP_FINDASSETGROUPBYASSETID", + "", + "OP_1", + "OP_INSPECTASSETGROUPSUM", + "1", + "OP_EQUAL", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ",,,,,)>", + "OP_EQUAL", + "0", + "", + "", + "OP_INSPECTOUTASSETLOOKUP", + "OP_DUP", + "OP_1NEGATE", + "OP_EQUAL", + "OP_NOT", + "OP_VERIFY", + "1", + "OP_EQUAL", + "OP_VERIFY", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "send", + "functionInputs": [ + { + "name": "amount", + "type": "int" + }, + { + "name": "ownerSig", + "type": "signature" + }, + { + "name": "ownerPk", + "type": "pubkey" + }, + { + "name": "ownerPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "ownerPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "1-of-1 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + } + ], + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract OApp(\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 usdt0AssetId,\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n int exit\n) {\n\n function receive(int amount, pubkey recipient) {\n require(amount > 0, \"amount must be positive\");\n\n require(\n tx.inputs[0].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset not on input 0\"\n );\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 0, \"marker not burned\");\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(usdt0Group.delta == amount, \"usdt0 delta mismatch\");\n\n require(\n tx.outputs[1].assets.lookup(usdt0AssetId) == amount,\n \"recipient amount mismatch\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SingleSig(recipient),\n \"recipient pkScript wrong\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId, exit\n ),\n \"oapp state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1,\n \"oapp control missing\"\n );\n require(\n tx.outputs[0].assets.lookup(oappIDAssetId) == 1,\n \"oapp id missing\"\n );\n }\n\n function send(int amount, signature ownerSig, pubkey ownerPk) {\n require(amount > 0, \"amount must be positive\");\n require(checkSig(ownerSig, ownerPk), \"owner sig invalid\");\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(usdt0Group.sumInputs >= usdt0Group.sumOutputs + amount, \"burn short\");\n\n require(\n tx.outputs[1].assets.lookup(oappIDAssetId) == 1,\n \"send marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SendMarker(endpointCtrlAssetId, exit),\n \"send marker pkScript not canonical\"\n );\n\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumOutputs == 1, \"extra send marker\");\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId, exit\n ),\n \"oapp state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1,\n \"oapp control missing\"\n );\n }\n}", + "compiler": { + "name": "arkade-compiler", + "version": "0.1.0" + }, + "updatedAt": "2026-05-15T16:59:10.690234971+00:00", + "warnings": [ + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" + ] +} \ No newline at end of file diff --git a/examples/layerzero/receive_marker.ark b/examples/layerzero/receive_marker.ark new file mode 100644 index 0000000..a88263d --- /dev/null +++ b/examples/layerzero/receive_marker.ark @@ -0,0 +1,44 @@ +// ReceiveMarker — receive-invocation marker output +// +// Spec: layerzero-usdt0-arkade-demo/internal/scripts/builders.go +// BuildReceiveInvocationScript. +// +// Locks a one-unit EndpointID asset alongside the LzReceivePacket extension +// data emitted by Endpoint.receive(). Consumable only when the consuming +// transaction also spends the canonical OApp state UTXO (and therefore is +// running OApp.receive()). +// +// In the Go reference, BuildReceiveInvocationScript pins the marker to: +// (1) a fixed input position (OP_PUSHCURRENTINPUTINDEX == config.ReceiveInvocationInputIndex); +// (2) the OApp.receive() Arkade closure (OP_INSPECTINPUTARKADESCRIPTHASH +// on the OApp-state input == config.OAppReceiveScriptHash); +// (3) defense-in-depth: that same input carries 1 OApp control asset. +// +// The Arkade compiler currently exposes neither OP_PUSHCURRENTINPUTINDEX +// equality checks nor OP_INSPECTINPUTARKADESCRIPTHASH. Check (3) on its +// own is sufficient cryptographically: the OApp control asset is a one-shot +// singleton issued in the bootstrap transaction; only the real OApp state +// UTXO carries it. Any UTXO presenting the OApp control asset on its input +// has, by construction, gone through OApp issuance. Checks (1) and (2) are +// noted for reviewers; in production they'd be added as the compiler grows +// support for input-position and input-script-hash introspection. + +options { + server = server; + exit = exit; +} + +contract ReceiveMarker( + bytes32 oappCtrlAssetId, + int exit +) { + // Single execution path: the marker is consumed inside OApp.receive(). + // OApp.receive() reads its marker input from input 0 (see oapp.ark); + // the OApp state input is at input 1, so we check there. + function consume() { + require( + tx.inputs[1].assets.lookup(oappCtrlAssetId) == 1, + "oapp state input missing control asset" + ); + } +} diff --git a/examples/layerzero/receive_marker.json b/examples/layerzero/receive_marker.json new file mode 100644 index 0000000..c73e0a2 --- /dev/null +++ b/examples/layerzero/receive_marker.json @@ -0,0 +1,86 @@ +{ + "contractName": "ReceiveMarker", + "constructorInputs": [ + { + "name": "oappCtrlAssetId_txid", + "type": "bytes32" + }, + { + "name": "oappCtrlAssetId_gidx", + "type": "int" + }, + { + "name": "exit", + "type": "int" + } + ], + "functions": [ + { + "name": "consume", + "functionInputs": [], + "witnessSchema": [ + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "assetCheck" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "1", + "", + "", + "OP_INSPECTINASSETLOOKUP", + "OP_DUP", + "OP_1NEGATE", + "OP_EQUAL", + "OP_NOT", + "OP_VERIFY", + "1", + "OP_EQUAL", + "OP_VERIFY", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "consume", + "functionInputs": [], + "witnessSchema": [], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "0-of-0 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + } + ], + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract ReceiveMarker(\n bytes32 oappCtrlAssetId,\n int exit\n) {\n function consume() {\n require(\n tx.inputs[1].assets.lookup(oappCtrlAssetId) == 1,\n \"oapp state input missing control asset\"\n );\n }\n}", + "compiler": { + "name": "arkade-compiler", + "version": "0.1.0" + }, + "updatedAt": "2026-05-15T16:59:10.759796594+00:00", + "warnings": [ + "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" + ] +} \ No newline at end of file diff --git a/examples/layerzero/send_marker.ark b/examples/layerzero/send_marker.ark new file mode 100644 index 0000000..91d77ca --- /dev/null +++ b/examples/layerzero/send_marker.ark @@ -0,0 +1,34 @@ +// SendMarker — send-invocation marker output +// +// Spec: layerzero-usdt0-arkade-demo/internal/scripts/builders.go +// BuildSendInvocationScript. +// +// Locks a one-unit OAppID asset alongside the OAppSendInvocation extension +// data emitted by OApp.send(). Consumable only when the consuming +// transaction also spends the canonical Endpoint state UTXO (and therefore +// is running Endpoint.send()). +// +// Symmetric to ReceiveMarker. See its comment for the rationale on which +// Go-script checks are expressed here vs. delegated to the introspector +// runtime. + +options { + server = server; + exit = exit; +} + +contract SendMarker( + bytes32 endpointCtrlAssetId, + int exit +) { + // Single execution path: the marker is consumed inside Endpoint.send(). + // Endpoint.send() reads the marker input from input 1; the Endpoint state + // input is at input 0, so we check there for the Endpoint control asset + // singleton. + function consume() { + require( + tx.inputs[0].assets.lookup(endpointCtrlAssetId) == 1, + "endpoint state input missing control asset" + ); + } +} diff --git a/examples/layerzero/send_marker.json b/examples/layerzero/send_marker.json new file mode 100644 index 0000000..dbb9a2d --- /dev/null +++ b/examples/layerzero/send_marker.json @@ -0,0 +1,86 @@ +{ + "contractName": "SendMarker", + "constructorInputs": [ + { + "name": "endpointCtrlAssetId_txid", + "type": "bytes32" + }, + { + "name": "endpointCtrlAssetId_gidx", + "type": "int" + }, + { + "name": "exit", + "type": "int" + } + ], + "functions": [ + { + "name": "consume", + "functionInputs": [], + "witnessSchema": [ + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "assetCheck" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "0", + "", + "", + "OP_INSPECTINASSETLOOKUP", + "OP_DUP", + "OP_1NEGATE", + "OP_EQUAL", + "OP_NOT", + "OP_VERIFY", + "1", + "OP_EQUAL", + "OP_VERIFY", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "consume", + "functionInputs": [], + "witnessSchema": [], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "0-of-0 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + } + ], + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract SendMarker(\n bytes32 endpointCtrlAssetId,\n int exit\n) {\n function consume() {\n require(\n tx.inputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint state input missing control asset\"\n );\n }\n}", + "compiler": { + "name": "arkade-compiler", + "version": "0.1.0" + }, + "updatedAt": "2026-05-15T16:59:10.827409801+00:00", + "warnings": [ + "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" + ] +} \ No newline at end of file diff --git a/playground/main.js b/playground/main.js index 216d1d4..0002ff1 100644 --- a/playground/main.js +++ b/playground/main.js @@ -14,6 +14,16 @@ const projects = { 'stability_vault.ark': contracts.stability_vault, 'stability_offer.ark': contracts.stability_offer, } + }, + layerzero: { + name: 'LayerZero / USDT0', + description: 'LayerZero pathway Endpoint + USDT0 OApp with receive/send invocation markers', + files: { + 'endpoint.ark': contracts.endpoint, + 'oapp.ark': contracts.oapp, + 'receive_marker.ark': contracts.receive_marker, + 'send_marker.ark': contracts.send_marker, + } } }; diff --git a/tests/layerzero_test.rs b/tests/layerzero_test.rs new file mode 100644 index 0000000..22bff9e --- /dev/null +++ b/tests/layerzero_test.rs @@ -0,0 +1,327 @@ +use arkade_compiler::compile; +use arkade_compiler::opcodes::{ + OP_CHECKSIG, OP_CHECKSIGFROMSTACK, OP_FINDASSETGROUPBYASSETID, OP_INSPECTASSETGROUPSUM, + OP_INSPECTINASSETLOOKUP, OP_INSPECTOUTASSETLOOKUP, OP_INSPECTOUTPUTSCRIPTPUBKEY, +}; + +// --------------------------------------------------------------------------- +// LayerZero / USDT0 contract suite — translates the asset-flow and signature +// semantics of layerzero-usdt0-arkade-demo/internal/scripts/builders.go into +// Arkade contracts. +// +// Source-of-truth invariants verified here: +// - Endpoint.receive() uses both DVN signature checks (OP_CHECKSIGFROMSTACK) +// and emits the receive-invocation marker via OP_INSPECTOUTASSETLOOKUP + +// OP_FINDASSETGROUPBYASSETID/OP_INSPECTASSETGROUPSUM. +// - Endpoint.send() proves the OAppID marker is fully burned. +// - OApp.receive() consumes the EndpointID marker, mints USDT0 to the +// committed recipient, and continues the OApp state. +// - OApp.send() burns USDT0 and emits the SendMarker output. +// - Both marker contracts pin themselves to the consuming contract's +// control-asset singleton. +// --------------------------------------------------------------------------- + +fn load_example(name: &str) -> String { + let path = format!("examples/layerzero/{}.ark", name); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {}", path, e)) +} + +#[test] +fn test_endpoint_parses() { + let code = load_example("endpoint"); + let result = compile(&code); + assert!( + result.is_ok(), + "endpoint compilation failed: {:?}", + result.err() + ); +} + +#[test] +fn test_endpoint_structure() { + let code = load_example("endpoint"); + let output = compile(&code).unwrap(); + assert_eq!(output.name, "Endpoint"); + // 2 functions × 2 variants = 4 + assert_eq!(output.functions.len(), 4); + + for name in &["receive", "send"] { + assert!( + output + .functions + .iter() + .any(|f| &f.name == name && f.server_variant), + "missing {} server variant", + name + ); + assert!( + output + .functions + .iter() + .any(|f| &f.name == name && !f.server_variant), + "missing {} exit variant", + name + ); + } +} + +#[test] +fn test_endpoint_receive_verifies_both_dvn_signatures() { + let code = load_example("endpoint"); + let output = compile(&code).unwrap(); + + let receive = output + .functions + .iter() + .find(|f| f.name == "receive" && f.server_variant) + .unwrap(); + + let sig_count = receive + .asm + .iter() + .filter(|s| s.contains(OP_CHECKSIGFROMSTACK)) + .count(); + + assert!( + sig_count >= 2, + "endpoint.receive() must verify exactly two DVN signatures via {}; found {} occurrences", + OP_CHECKSIGFROMSTACK, + sig_count + ); +} + +#[test] +fn test_endpoint_receive_emits_receive_marker_output() { + let code = load_example("endpoint"); + let output = compile(&code).unwrap(); + + let receive = output + .functions + .iter() + .find(|f| f.name == "receive" && f.server_variant) + .unwrap(); + + let has_receive_marker = receive + .asm + .iter() + .any(|s| s.contains("VTXO:ReceiveMarker(")); + assert!( + has_receive_marker, + "endpoint.receive() must pin output[1] to the canonical ReceiveMarker pkScript: {:?}", + receive.asm + ); + + let has_endpoint_continuation = receive.asm.iter().any(|s| s.contains("VTXO:Endpoint(")); + assert!( + has_endpoint_continuation, + "endpoint.receive() must continue Endpoint state via output[0]: {:?}", + receive.asm + ); + + let has_asset_lookup = receive + .asm + .iter() + .any(|s| s.contains(OP_INSPECTOUTASSETLOOKUP)); + assert!( + has_asset_lookup, + "endpoint.receive() must check output asset balances via {}", + OP_INSPECTOUTASSETLOOKUP + ); +} + +#[test] +fn test_endpoint_send_burns_send_marker() { + let code = load_example("endpoint"); + let output = compile(&code).unwrap(); + + let send = output + .functions + .iter() + .find(|f| f.name == "send" && f.server_variant) + .unwrap(); + + // Marker burn proof: OAppID asset group → outputSum == 0. + let has_group_sum = send.asm.iter().any(|s| s.contains(OP_INSPECTASSETGROUPSUM)); + assert!( + has_group_sum, + "endpoint.send() must inspect asset group sums to verify marker burn" + ); + + let has_find = send + .asm + .iter() + .any(|s| s.contains(OP_FINDASSETGROUPBYASSETID)); + assert!( + has_find, + "endpoint.send() must locate OAppID asset group via {}", + OP_FINDASSETGROUPBYASSETID + ); +} + +#[test] +fn test_oapp_parses() { + let code = load_example("oapp"); + let result = compile(&code); + assert!( + result.is_ok(), + "oapp compilation failed: {:?}", + result.err() + ); +} + +#[test] +fn test_oapp_structure() { + let code = load_example("oapp"); + let output = compile(&code).unwrap(); + assert_eq!(output.name, "OApp"); + assert_eq!(output.functions.len(), 4); +} + +#[test] +fn test_oapp_receive_consumes_endpoint_marker_and_mints_usdt0() { + let code = load_example("oapp"); + let output = compile(&code).unwrap(); + + let receive = output + .functions + .iter() + .find(|f| f.name == "receive" && f.server_variant) + .unwrap(); + + // Marker consumed from input 0 (asset lookup on input side). + let in_lookup_count = receive + .asm + .iter() + .filter(|s| s.contains(OP_INSPECTINASSETLOOKUP)) + .count(); + assert!( + in_lookup_count >= 1, + "oapp.receive() must inspect input assets to consume the receive marker" + ); + + // Output recipient receives USDT0 — and OApp state continues. + let has_singlesig = receive.asm.iter().any(|s| s.contains("VTXO:SingleSig(")); + assert!( + has_singlesig, + "oapp.receive() must pin recipient output to SingleSig(recipient): {:?}", + receive.asm + ); + + let has_oapp_continuation = receive.asm.iter().any(|s| s.contains("VTXO:OApp(")); + assert!( + has_oapp_continuation, + "oapp.receive() must continue OApp state via output[0]" + ); +} + +#[test] +fn test_oapp_send_emits_send_marker() { + let code = load_example("oapp"); + let output = compile(&code).unwrap(); + + let send = output + .functions + .iter() + .find(|f| f.name == "send" && f.server_variant) + .unwrap(); + + let has_send_marker = send.asm.iter().any(|s| s.contains("VTXO:SendMarker(")); + assert!( + has_send_marker, + "oapp.send() must pin output[1] to the canonical SendMarker pkScript" + ); + + let has_sig = send.asm.iter().any(|s| s == OP_CHECKSIG); + assert!( + has_sig, + "oapp.send() must verify the OApp owner's signature via {}", + OP_CHECKSIG + ); +} + +#[test] +fn test_receive_marker_parses() { + let code = load_example("receive_marker"); + let result = compile(&code); + assert!( + result.is_ok(), + "receive_marker compilation failed: {:?}", + result.err() + ); +} + +#[test] +fn test_receive_marker_pins_to_oapp_control_singleton() { + let code = load_example("receive_marker"); + let output = compile(&code).unwrap(); + assert_eq!(output.name, "ReceiveMarker"); + + let consume = output + .functions + .iter() + .find(|f| f.name == "consume" && f.server_variant) + .unwrap(); + + let has_in_lookup = consume + .asm + .iter() + .any(|s| s.contains(OP_INSPECTINASSETLOOKUP)); + assert!( + has_in_lookup, + "receive marker must check the OApp control singleton on the consuming input" + ); +} + +#[test] +fn test_send_marker_parses() { + let code = load_example("send_marker"); + let result = compile(&code); + assert!( + result.is_ok(), + "send_marker compilation failed: {:?}", + result.err() + ); +} + +#[test] +fn test_send_marker_pins_to_endpoint_control_singleton() { + let code = load_example("send_marker"); + let output = compile(&code).unwrap(); + assert_eq!(output.name, "SendMarker"); + + let consume = output + .functions + .iter() + .find(|f| f.name == "consume" && f.server_variant) + .unwrap(); + + let has_in_lookup = consume + .asm + .iter() + .any(|s| s.contains(OP_INSPECTINASSETLOOKUP)); + assert!( + has_in_lookup, + "send marker must check the Endpoint control singleton on the consuming input" + ); +} + +#[test] +fn test_layerzero_contracts_continue_via_taproot_introspection() { + // All four contracts must use OP_INSPECTOUTPUTSCRIPTPUBKEY for output + // continuation; this is the Arkade-compiler equivalent of the Go scripts' + // "the current scriptPubKey survives" check. + for name in &["endpoint", "oapp"] { + let code = load_example(name); + let output = compile(&code).unwrap(); + let any_has_inspect = output.functions.iter().any(|f| { + f.asm + .iter() + .any(|s| s.contains(OP_INSPECTOUTPUTSCRIPTPUBKEY)) + }); + assert!( + any_has_inspect, + "{} must continue state via {}", + name, OP_INSPECTOUTPUTSCRIPTPUBKEY + ); + } +} From 318a116ed6391224efbe9bda23684503dcd40e9a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 19:34:56 +0000 Subject: [PATCH 02/15] feat: expose canonical introspector primitives in the compiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the Arkade compiler in line with the canonical introspector opcode set (https://github.com/ArkLabsHQ/introspector). Adds opcode constants for everything the introspector documents, then wires grammar/parser/compiler/ typechecker for the subset needed by the LayerZero / USDT0 demo so the contracts in examples/layerzero/ can express packet-level invariants natively instead of delegating them to the runtime. New language surface -------------------- tx.packet(packetType) → OP_INSPECTPACKET <1> OP_EQUALVERIFY tx.inputs[i].packet(packetType) → OP_INSPECTINPUTPACKET <1> OP_EQUALVERIFY substr(data, off, size) → OP_SUBSTR cat(a, b) → OP_CAT bin2num(data) → OP_BIN2NUM num2bin(value, size) → OP_NUM2BIN size(data) → OP_SIZE OP_NIP tx.inputs[i].arkadeScriptHash → OP_INSPECTINPUTARKADESCRIPTHASH tx.inputs[i].arkadeWitnessHash → OP_INSPECTINPUTARKADEWITNESSHASH tx.id → OP_TXID Files ----- src/opcodes/mod.rs add OP_INSPECTPACKET, OP_INSPECTINPUTPACKET, OP_INSPECTINPUT(ARKADESCRIPTHASH|ARKADEWITNESSHASH), OP_TXID, OP_CAT, OP_SUBSTR, OP_LEFT, OP_RIGHT, OP_BIN2NUM, OP_NUM2BIN, OP_SIZE, OP_EQUALVERIFY, OP_NUMEQUALVERIFY, OP_SWAP, plus the bitwise and extra-arithmetic opcodes listed by the introspector README so they're available to future emission paths. src/parser/grammar.pest new rules: substr_func, cat_func, bin2num_func, num2bin_func, size_func, packet_inspect, input_packet_inspect; new properties on tx_introspection (id) and input_introspection (arkadeScriptHash, arkadeWitnessHash). src/models/mod.rs new Expression variants Substr, Cat, Bin2Num, Num2Bin, SizeOf, PacketInspect, InputPacketInspect. src/parser/mod.rs parse functions and dispatch entries for primary and complex (require-context) expressions. src/compiler/mod.rs emission in both generate_expression_asm and emit_expression_asm; introspection-detection updated so the new variants force the N-of-N exit-path policy. src/typechecker/mod.rs infer Bytes / Uint64Le / Int / Bytes32 for the new variants and new introspection properties. Tests ----- tests/packet_primitives_test.rs (10 tests) pins each new primitive to its canonical opcode and verifies emission shape (e.g. tx.packet asserts presence via "OP_1 OP_EQUALVERIFY"; size() drops the source bytes via OP_NIP). Full suite: 136 passed, 0 failed (was 126). --- src/compiler/mod.rs | 138 ++++++++++++++++-- src/models/mod.rs | 32 +++++ src/opcodes/mod.rs | 114 +++++++++++++-- src/parser/grammar.pest | 76 +++++++++- src/parser/mod.rs | 167 ++++++++++++++++++++++ src/typechecker/mod.rs | 13 ++ tests/packet_primitives_test.rs | 246 ++++++++++++++++++++++++++++++++ 7 files changed, 752 insertions(+), 34 deletions(-) create mode 100644 tests/packet_primitives_test.rs diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 83a63d9..160b4eb 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -4,23 +4,25 @@ use crate::models::{ WitnessElement, DEFAULT_ARRAY_LENGTH, }; use crate::opcodes::{ - OP_0, OP_1, OP_1NEGATE, OP_ADD64, 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_0, OP_1, OP_1NEGATE, OP_ADD64, OP_BIN2NUM, 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_EQUALVERIFY, 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_INSPECTINPUTARKADESCRIPTHASH, OP_INSPECTINPUTARKADEWITNESSHASH, + OP_INSPECTINPUTISSUANCE, OP_INSPECTINPUTOUTPOINT, OP_INSPECTINPUTPACKET, + 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_INSPECTPACKET, 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_LESSTHANOREQUAL64, OP_MUL64, OP_NEG64, OP_NIP, OP_NOT, OP_NUM2BIN, 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_SHA256INITIALIZE, OP_SHA256UPDATE, OP_SIZE, OP_SUB64, OP_SUBSTR, OP_TWEAKVERIFY, OP_TXHASH, + OP_TXID, OP_TXWEIGHT, OP_VERIFY, }; use crate::parser; use crate::typechecker::{self, ArkType}; @@ -133,6 +135,25 @@ fn expression_uses_introspection(expr: &Expression) -> bool { // Contract instantiation resolves to a scriptPubKey via introspection Expression::ContractInstance { .. } => true, + // Packet introspection — always uses introspection opcodes. + Expression::PacketInspect { .. } => true, + Expression::InputPacketInspect { .. } => true, + + // Byte-string ops are introspection-aware iff their inputs are. + Expression::Substr { data, offset, size } => { + expression_uses_introspection(data) + || expression_uses_introspection(offset) + || expression_uses_introspection(size) + } + Expression::Cat { left, right } => { + expression_uses_introspection(left) || expression_uses_introspection(right) + } + Expression::Bin2Num { data } => expression_uses_introspection(data), + Expression::Num2Bin { value, size } => { + expression_uses_introspection(value) || expression_uses_introspection(size) + } + Expression::SizeOf { data } => expression_uses_introspection(data), + // Non-introspection expressions Expression::Property(_) => false, Expression::Variable(_) => false, @@ -1125,6 +1146,50 @@ fn generate_expression_asm(expr: &Expression, asm: &mut Vec) { } => { emit_contract_instance_asm(contract_name, args, asm); } + // Byte-string manipulation (introspector extensions) + Expression::Substr { data, offset, size } => { + generate_expression_asm(data, asm); + generate_expression_asm(offset, asm); + generate_expression_asm(size, asm); + asm.push(OP_SUBSTR.to_string()); + } + Expression::Cat { left, right } => { + generate_expression_asm(left, asm); + generate_expression_asm(right, asm); + asm.push(OP_CAT.to_string()); + } + Expression::Bin2Num { data } => { + generate_expression_asm(data, asm); + asm.push(OP_BIN2NUM.to_string()); + } + Expression::Num2Bin { value, size } => { + generate_expression_asm(value, asm); + generate_expression_asm(size, asm); + asm.push(OP_NUM2BIN.to_string()); + } + Expression::SizeOf { data } => { + generate_expression_asm(data, asm); + // OP_SIZE pushes len next to original bytes; OP_NIP drops the + // original so only the size remains on the stack. + asm.push(OP_SIZE.to_string()); + asm.push(OP_NIP.to_string()); + } + // Packet introspection + Expression::PacketInspect { packet_type } => { + generate_expression_asm(packet_type, asm); + asm.push(OP_INSPECTPACKET.to_string()); + // OP_INSPECTPACKET returns (content, 1) on hit, () then 0 on miss. + // Assert present and discard the bool, leaving content on the stack. + asm.push(OP_1.to_string()); + asm.push(OP_EQUALVERIFY.to_string()); + } + Expression::InputPacketInspect { index, packet_type } => { + generate_expression_asm(packet_type, asm); + generate_expression_asm(index, asm); + asm.push(OP_INSPECTINPUTPACKET.to_string()); + asm.push(OP_1.to_string()); + asm.push(OP_EQUALVERIFY.to_string()); + } } } @@ -1545,6 +1610,46 @@ fn emit_expression_asm(expr: &Expression, asm: &mut Vec) { asm.push(format!("<{}>", signature)); asm.push(OP_CHECKSIGFROMSTACKVERIFY.to_string()); } + // Byte-string manipulation (introspector extensions) + Expression::Substr { data, offset, size } => { + emit_expression_asm(data, asm); + emit_expression_asm(offset, asm); + emit_expression_asm(size, asm); + asm.push(OP_SUBSTR.to_string()); + } + Expression::Cat { left, right } => { + emit_expression_asm(left, asm); + emit_expression_asm(right, asm); + asm.push(OP_CAT.to_string()); + } + Expression::Bin2Num { data } => { + emit_expression_asm(data, asm); + asm.push(OP_BIN2NUM.to_string()); + } + Expression::Num2Bin { value, size } => { + emit_expression_asm(value, asm); + emit_expression_asm(size, asm); + asm.push(OP_NUM2BIN.to_string()); + } + Expression::SizeOf { data } => { + emit_expression_asm(data, asm); + asm.push(OP_SIZE.to_string()); + asm.push(OP_NIP.to_string()); + } + // Packet introspection + Expression::PacketInspect { packet_type } => { + emit_expression_asm(packet_type, asm); + asm.push(OP_INSPECTPACKET.to_string()); + asm.push(OP_1.to_string()); + asm.push(OP_EQUALVERIFY.to_string()); + } + Expression::InputPacketInspect { index, packet_type } => { + emit_expression_asm(packet_type, asm); + emit_expression_asm(index, asm); + asm.push(OP_INSPECTINPUTPACKET.to_string()); + asm.push(OP_1.to_string()); + asm.push(OP_EQUALVERIFY.to_string()); + } } } @@ -1718,6 +1823,7 @@ fn emit_tx_introspection_asm(property: &str, asm: &mut Vec) { "numInputs" => asm.push(OP_INSPECTNUMINPUTS.to_string()), "numOutputs" => asm.push(OP_INSPECTNUMOUTPUTS.to_string()), "weight" => asm.push(OP_TXWEIGHT.to_string()), + "id" => asm.push(OP_TXID.to_string()), _ => { // Unknown property, emit as placeholder asm.push(format!("", property)); @@ -1737,6 +1843,8 @@ fn emit_input_introspection_asm(index: &Expression, property: &str, asm: &mut Ve "sequence" => asm.push(OP_INSPECTINPUTSEQUENCE.to_string()), "outpoint" => asm.push(OP_INSPECTINPUTOUTPOINT.to_string()), "issuance" => asm.push(OP_INSPECTINPUTISSUANCE.to_string()), + "arkadeScriptHash" => asm.push(OP_INSPECTINPUTARKADESCRIPTHASH.to_string()), + "arkadeWitnessHash" => asm.push(OP_INSPECTINPUTARKADEWITNESSHASH.to_string()), _ => { // Unknown property, emit as placeholder asm.push(format!("", property)); diff --git a/src/models/mod.rs b/src/models/mod.rs index 363687e..4b10901 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -386,4 +386,36 @@ pub enum Expression { /// Constructor arguments (typically Variable or Literal) args: Vec, }, + // ─── Byte-string Manipulation (introspector extensions) ──────────── + /// Substring extraction: substr(data, offset, size) → OP_SUBSTR + Substr { + data: Box, + offset: Box, + size: Box, + }, + /// Byte concatenation: cat(a, b) → OP_CAT + Cat { + left: Box, + right: Box, + }, + /// Bytes-to-number (little-endian, leading-zero-stripped BigNum): bin2num(bytes) → OP_BIN2NUM + Bin2Num { data: Box }, + /// Number-to-bytes (little-endian, zero-padded): num2bin(num, size) → OP_NUM2BIN + Num2Bin { + value: Box, + size: Box, + }, + /// Byte-string length: size(bytes) → OP_SIZE OP_NIP + SizeOf { data: Box }, + // ─── Packet Introspection ────────────────────────────────────────── + /// Current-tx packet content: tx.packet(packetType) + /// Emits the raw packet bytes and asserts presence via OP_INSPECTPACKET's + /// bool flag. Compiles to ` OP_INSPECTPACKET OP_1 OP_EQUALVERIFY`. + PacketInspect { packet_type: Box }, + /// Previous Ark-tx packet via input i: tx.inputs[i].packet(packetType) + /// Compiles to ` OP_INSPECTINPUTPACKET OP_1 OP_EQUALVERIFY`. + InputPacketInspect { + index: Box, + packet_type: Box, + }, } diff --git a/src/opcodes/mod.rs b/src/opcodes/mod.rs index a95a046..dceab0e 100644 --- a/src/opcodes/mod.rs +++ b/src/opcodes/mod.rs @@ -72,16 +72,109 @@ pub const OP_ELSE: &str = "OP_ELSE"; // Condition verification pub const OP_VERIFY: &str = "OP_VERIFY"; -// Arithmetic +// Arithmetic (64-bit BigNum) pub const OP_ADD64: &str = "OP_ADD64"; pub const OP_SUB64: &str = "OP_SUB64"; pub const OP_MUL64: &str = "OP_MUL64"; pub const OP_DIV64: &str = "OP_DIV64"; pub const OP_NEG64: &str = "OP_NEG64"; -// Introspection +// Standard Bitcoin arithmetic (scriptNum) +pub const OP_1ADD: &str = "OP_1ADD"; +pub const OP_1SUB: &str = "OP_1SUB"; +pub const OP_NEGATE: &str = "OP_NEGATE"; +pub const OP_ABS: &str = "OP_ABS"; +pub const OP_0NOTEQUAL: &str = "OP_0NOTEQUAL"; +pub const OP_ADD: &str = "OP_ADD"; +pub const OP_SUB: &str = "OP_SUB"; +pub const OP_MUL: &str = "OP_MUL"; +pub const OP_DIV: &str = "OP_DIV"; +pub const OP_MOD: &str = "OP_MOD"; +pub const OP_LSHIFT: &str = "OP_LSHIFT"; +pub const OP_RSHIFT: &str = "OP_RSHIFT"; +pub const OP_2MUL: &str = "OP_2MUL"; +pub const OP_2DIV: &str = "OP_2DIV"; +pub const OP_MIN: &str = "OP_MIN"; +pub const OP_MAX: &str = "OP_MAX"; + +// Verify variants +pub const OP_EQUALVERIFY: &str = "OP_EQUALVERIFY"; +pub const OP_NUMEQUALVERIFY: &str = "OP_NUMEQUALVERIFY"; +pub const OP_NUMNOTEQUAL: &str = "OP_NUMNOTEQUAL"; +pub const OP_BOOLAND: &str = "OP_BOOLAND"; +pub const OP_BOOLOR: &str = "OP_BOOLOR"; + +// Stack manipulation (extended) +pub const OP_SWAP: &str = "OP_SWAP"; +pub const OP_ROT: &str = "OP_ROT"; +pub const OP_OVER: &str = "OP_OVER"; +pub const OP_PICK: &str = "OP_PICK"; +pub const OP_ROLL: &str = "OP_ROLL"; +pub const OP_TUCK: &str = "OP_TUCK"; +pub const OP_IFDUP: &str = "OP_IFDUP"; +pub const OP_DEPTH: &str = "OP_DEPTH"; +pub const OP_2DROP: &str = "OP_2DROP"; +pub const OP_2DUP: &str = "OP_2DUP"; +pub const OP_3DUP: &str = "OP_3DUP"; +pub const OP_2OVER: &str = "OP_2OVER"; +pub const OP_2ROT: &str = "OP_2ROT"; +pub const OP_2SWAP: &str = "OP_2SWAP"; + +// Byte-string manipulation (introspector extensions) +pub const OP_CAT: &str = "OP_CAT"; +pub const OP_SUBSTR: &str = "OP_SUBSTR"; +pub const OP_LEFT: &str = "OP_LEFT"; +pub const OP_RIGHT: &str = "OP_RIGHT"; +pub const OP_SIZE: &str = "OP_SIZE"; + +// Bitwise (introspector extensions) +pub const OP_INVERT: &str = "OP_INVERT"; +pub const OP_AND: &str = "OP_AND"; +pub const OP_OR: &str = "OP_OR"; +pub const OP_XOR: &str = "OP_XOR"; + +// Numeric conversion (introspector extensions) +pub const OP_BIN2NUM: &str = "OP_BIN2NUM"; +pub const OP_NUM2BIN: &str = "OP_NUM2BIN"; + +// Hashing (additional) +pub const OP_RIPEMD160: &str = "OP_RIPEMD160"; +pub const OP_SHA1: &str = "OP_SHA1"; +pub const OP_HASH160: &str = "OP_HASH160"; +pub const OP_HASH256: &str = "OP_HASH256"; + +// Merkle proof verification (introspector extension) +pub const OP_MERKLEBRANCHVERIFY: &str = "OP_MERKLEBRANCHVERIFY"; + +// Introspection (transaction global) pub const OP_TXHASH: &str = "OP_TXHASH"; +pub const OP_TXID: &str = "OP_TXID"; pub const OP_TXWEIGHT: &str = "OP_TXWEIGHT"; +pub const OP_INSPECTVERSION: &str = "OP_INSPECTVERSION"; +pub const OP_INSPECTLOCKTIME: &str = "OP_INSPECTLOCKTIME"; +pub const OP_INSPECTNUMINPUTS: &str = "OP_INSPECTNUMINPUTS"; +pub const OP_INSPECTNUMOUTPUTS: &str = "OP_INSPECTNUMOUTPUTS"; + +// Introspection (input metadata) +pub const OP_PUSHCURRENTINPUTINDEX: &str = "OP_PUSHCURRENTINPUTINDEX"; +pub const OP_INSPECTINPUTOUTPOINT: &str = "OP_INSPECTINPUTOUTPOINT"; +pub const OP_INSPECTINPUTSCRIPTPUBKEY: &str = "OP_INSPECTINPUTSCRIPTPUBKEY"; +pub const OP_INSPECTINPUTVALUE: &str = "OP_INSPECTINPUTVALUE"; +pub const OP_INSPECTINPUTSEQUENCE: &str = "OP_INSPECTINPUTSEQUENCE"; +pub const OP_INSPECTINPUTISSUANCE: &str = "OP_INSPECTINPUTISSUANCE"; +pub const OP_INSPECTINPUTARKADESCRIPTHASH: &str = "OP_INSPECTINPUTARKADESCRIPTHASH"; +pub const OP_INSPECTINPUTARKADEWITNESSHASH: &str = "OP_INSPECTINPUTARKADEWITNESSHASH"; + +// Introspection (output metadata) +pub const OP_INSPECTOUTPUTVALUE: &str = "OP_INSPECTOUTPUTVALUE"; +pub const OP_INSPECTOUTPUTSCRIPTPUBKEY: &str = "OP_INSPECTOUTPUTSCRIPTPUBKEY"; +pub const OP_INSPECTOUTPUTNONCE: &str = "OP_INSPECTOUTPUTNONCE"; + +// Introspection (packet) +pub const OP_INSPECTPACKET: &str = "OP_INSPECTPACKET"; +pub const OP_INSPECTINPUTPACKET: &str = "OP_INSPECTINPUTPACKET"; + +// Introspection (asset groups) pub const OP_INSPECTASSETGROUP: &str = "OP_INSPECTASSETGROUP"; pub const OP_INSPECTASSETGROUPNUM: &str = "OP_INSPECTASSETGROUPNUM"; pub const OP_INSPECTASSETGROUPSUM: &str = "OP_INSPECTASSETGROUPSUM"; @@ -90,25 +183,16 @@ pub const OP_FINDASSETGROUPBYASSETID: &str = "OP_FINDASSETGROUPBYASSETID"; pub const OP_INSPECTASSETGROUPCTRL: &str = "OP_INSPECTASSETGROUPCTRL"; pub const OP_INSPECTASSETGROUPMETADATAHASH: &str = "OP_INSPECTASSETGROUPMETADATAHASH"; pub const OP_INSPECTASSETGROUPASSETID: &str = "OP_INSPECTASSETGROUPASSETID"; -pub const OP_PUSHCURRENTINPUTINDEX: &str = "OP_PUSHCURRENTINPUTINDEX"; -pub const OP_INSPECTINPUTSCRIPTPUBKEY: &str = "OP_INSPECTINPUTSCRIPTPUBKEY"; -pub const OP_INSPECTINPUTVALUE: &str = "OP_INSPECTINPUTVALUE"; -pub const OP_INSPECTINPUTSEQUENCE: &str = "OP_INSPECTINPUTSEQUENCE"; -pub const OP_INSPECTINPUTOUTPOINT: &str = "OP_INSPECTINPUTOUTPOINT"; + +// Introspection (asset cross-input/output) pub const OP_INSPECTINASSETLOOKUP: &str = "OP_INSPECTINASSETLOOKUP"; pub const OP_INSPECTOUTASSETLOOKUP: &str = "OP_INSPECTOUTASSETLOOKUP"; pub const OP_INSPECTINASSETCOUNT: &str = "OP_INSPECTINASSETCOUNT"; pub const OP_INSPECTOUTASSETCOUNT: &str = "OP_INSPECTOUTASSETCOUNT"; pub const OP_INSPECTINASSETAT: &str = "OP_INSPECTINASSETAT"; pub const OP_INSPECTOUTASSETAT: &str = "OP_INSPECTOUTASSETAT"; -pub const OP_INSPECTVERSION: &str = "OP_INSPECTVERSION"; -pub const OP_INSPECTLOCKTIME: &str = "OP_INSPECTLOCKTIME"; -pub const OP_INSPECTNUMINPUTS: &str = "OP_INSPECTNUMINPUTS"; -pub const OP_INSPECTNUMOUTPUTS: &str = "OP_INSPECTNUMOUTPUTS"; -pub const OP_INSPECTINPUTISSUANCE: &str = "OP_INSPECTINPUTISSUANCE"; -pub const OP_INSPECTOUTPUTVALUE: &str = "OP_INSPECTOUTPUTVALUE"; -pub const OP_INSPECTOUTPUTSCRIPTPUBKEY: &str = "OP_INSPECTOUTPUTSCRIPTPUBKEY"; -pub const OP_INSPECTOUTPUTNONCE: &str = "OP_INSPECTOUTPUTNONCE"; + +// Tapscript helpers (legacy aliases preserved) pub const OP_INPUTBYTECODE: &str = "OP_INPUTBYTECODE"; pub const OP_INPUTVALUE: &str = "OP_INPUTVALUE"; pub const OP_INPUTSEQUENCE: &str = "OP_INPUTSEQUENCE"; diff --git a/src/parser/grammar.pest b/src/parser/grammar.pest index 57d0056..dae83af 100644 --- a/src/parser/grammar.pest +++ b/src/parser/grammar.pest @@ -138,6 +138,13 @@ primary_expr = { le32_to_le64 | ec_mul_scalar_verify | tweak_verify | + substr_func | + cat_func | + bin2num_func | + num2bin_func | + size_func | + input_packet_inspect | + packet_inspect | asset_at | asset_count | asset_lookup | @@ -192,6 +199,13 @@ complex_expression = _{ le32_to_le64 | ec_mul_scalar_verify | tweak_verify | + substr_func | + cat_func | + bin2num_func | + num2bin_func | + size_func | + input_packet_inspect | + packet_inspect | asset_at | asset_count | asset_lookup | @@ -264,8 +278,10 @@ tx_introspection = { "tx" ~ "." ~ tx_introspection_property } -// Transaction introspection properties -tx_introspection_property = { "version" | "locktime" | "numInputs" | "numOutputs" | "weight" } +// Transaction introspection properties. +// Note: "id" is the introspector OP_TXID; do not rename to "txid" (matches +// the SDK convention used in docs/arkade-script-with-assets.md). +tx_introspection_property = { "version" | "locktime" | "numInputs" | "numOutputs" | "weight" | "id" } // Transaction introspection comparison: tx_introspection op expression tx_introspection_comparison = { @@ -279,8 +295,14 @@ input_introspection = { "tx" ~ "." ~ "inputs" ~ array_access ~ "." ~ input_introspection_property } -// Input introspection properties (excluding asset - use .assets.* API instead) -input_introspection_property = { "value" | "scriptPubKey" | "sequence" | "outpoint" | "issuance" } +// Input introspection properties (excluding asset - use .assets.* API instead). +// arkadeScriptHash / arkadeWitnessHash compile to the introspector hash +// opcodes and are used by invocation-marker contracts to pin themselves to +// a specific consuming Arkade closure. +input_introspection_property = { + "value" | "scriptPubKey" | "sequence" | "outpoint" | "issuance" | + "arkadeScriptHash" | "arkadeWitnessHash" +} // Output introspection: tx.outputs[o].property (value, scriptPubKey, nonce) output_introspection = { @@ -549,6 +571,52 @@ check_sig_from_stack_verify = { "checkSigFromStackVerify" ~ "(" ~ sig_arg ~ "," ~ sig_arg ~ "," ~ sig_arg ~ ")" } +// ─── Byte-string Manipulation ────────────────────────────────────────── +// These map to the introspector's byte-string opcodes and are used to slice, +// concat, and parse fixed-width fields out of extension packets. + +// Substring extraction: substr(data, offset, size) → OP_SUBSTR +substr_func = { + "substr" ~ "(" ~ (identifier | number_literal) ~ "," ~ (identifier | number_literal) ~ "," ~ (identifier | number_literal) ~ ")" +} + +// Byte concatenation: cat(a, b) → OP_CAT +cat_func = { + "cat" ~ "(" ~ (identifier | number_literal) ~ "," ~ (identifier | number_literal) ~ ")" +} + +// Bytes → BigNum: bin2num(bytes) → OP_BIN2NUM +bin2num_func = { + "bin2num" ~ "(" ~ (identifier | number_literal) ~ ")" +} + +// BigNum → fixed-width bytes: num2bin(num, size) → OP_NUM2BIN +num2bin_func = { + "num2bin" ~ "(" ~ (identifier | number_literal) ~ "," ~ (identifier | number_literal) ~ ")" +} + +// Byte-array length: size(bytes) → OP_SIZE OP_NIP (returns size only) +size_func = { + "size" ~ "(" ~ (identifier | number_literal) ~ ")" +} + +// ─── Packet Introspection ────────────────────────────────────────────── +// Extension-packet introspection over the current transaction or a previous +// Arkade transaction referenced by an input. Returns the raw packet bytes; +// presence is enforced by OP_INSPECTPACKET's bool flag — emission verifies +// it. Use substr / bin2num / sha256 to parse fields. + +// Current-tx packet: tx.packet(packetType) → OP_INSPECTPACKET OP_1 OP_EQUALVERIFY +packet_inspect = { + "tx" ~ "." ~ "packet" ~ "(" ~ (identifier | number_literal) ~ ")" +} + +// Previous Ark-tx packet via input i: tx.inputs[i].packet(packetType) +// → OP_INSPECTINPUTPACKET OP_1 OP_EQUALVERIFY +input_packet_inspect = { + "tx" ~ "." ~ "inputs" ~ array_access ~ "." ~ "packet" ~ "(" ~ (identifier | number_literal) ~ ")" +} + // ─── Terminals ───────────────────────────────────────────────────────────────── // Identifiers must start with a letter and can contain letters, numbers, and underscores diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 778f83c..fef212e 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -488,6 +488,15 @@ fn parse_primary_expr(pair: Pair) -> Result { Rule::ec_mul_scalar_verify => parse_ec_mul_scalar_verify(pair), Rule::tweak_verify => parse_tweak_verify(pair), Rule::check_sig_from_stack_verify => parse_check_sig_from_stack_verify_expr(pair), + // Byte-string manipulation + Rule::substr_func => parse_substr(pair), + Rule::cat_func => parse_cat(pair), + Rule::bin2num_func => parse_bin2num(pair), + Rule::num2bin_func => parse_num2bin(pair), + Rule::size_func => parse_size(pair), + // Packet introspection + Rule::packet_inspect => parse_packet_inspect(pair), + Rule::input_packet_inspect => parse_input_packet_inspect(pair), Rule::asset_lookup => parse_asset_lookup_to_expression(pair), Rule::asset_count => parse_asset_count_to_expression(pair), Rule::asset_at => parse_asset_at_to_expression(pair), @@ -598,6 +607,63 @@ fn parse_complex_expression(pair: Pair) -> Result { }) } Rule::check_sig_from_stack_verify => parse_check_sig_from_stack_verify(pair), + // Byte-string manipulation — wrap as truthy assertions in require contexts. + Rule::substr_func => { + let expr = parse_substr(pair)?; + Ok(Requirement::Comparison { + left: expr, + op: "==".to_string(), + right: Expression::Literal("true".to_string()), + }) + } + Rule::cat_func => { + let expr = parse_cat(pair)?; + Ok(Requirement::Comparison { + left: expr, + op: "==".to_string(), + right: Expression::Literal("true".to_string()), + }) + } + Rule::bin2num_func => { + let expr = parse_bin2num(pair)?; + Ok(Requirement::Comparison { + left: expr, + op: "==".to_string(), + right: Expression::Literal("true".to_string()), + }) + } + Rule::num2bin_func => { + let expr = parse_num2bin(pair)?; + Ok(Requirement::Comparison { + left: expr, + op: "==".to_string(), + right: Expression::Literal("true".to_string()), + }) + } + Rule::size_func => { + let expr = parse_size(pair)?; + Ok(Requirement::Comparison { + left: expr, + op: "==".to_string(), + right: Expression::Literal("true".to_string()), + }) + } + Rule::packet_inspect => { + let expr = parse_packet_inspect(pair)?; + Ok(Requirement::Comparison { + left: expr, + op: "==".to_string(), + right: Expression::Literal("true".to_string()), + }) + } + Rule::input_packet_inspect => { + let expr = parse_input_packet_inspect(pair)?; + Ok(Requirement::Comparison { + left: expr, + op: "==".to_string(), + right: Expression::Literal("true".to_string()), + }) + } Rule::constructor => { let expr = parse_constructor_to_expression(pair)?; Ok(Requirement::Comparison { @@ -1733,6 +1799,107 @@ fn parse_check_sig_from_stack_verify_expr(pair: Pair) -> Result) -> Expression { + match pair.as_rule() { + Rule::identifier => Expression::Variable(pair.as_str().to_string()), + Rule::number_literal => Expression::Literal(pair.as_str().to_string()), + _ => Expression::Property(pair.as_str().to_string()), + } +} + +/// Parse substr(data, offset, size) → Expression::Substr +fn parse_substr(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let data = parse_atom_pair(inner.next().ok_or("Missing data in substr")?); + let offset = parse_atom_pair(inner.next().ok_or("Missing offset in substr")?); + let size = parse_atom_pair(inner.next().ok_or("Missing size in substr")?); + Ok(Expression::Substr { + data: Box::new(data), + offset: Box::new(offset), + size: Box::new(size), + }) +} + +/// Parse cat(a, b) → Expression::Cat +fn parse_cat(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let left = parse_atom_pair(inner.next().ok_or("Missing first argument in cat")?); + let right = parse_atom_pair(inner.next().ok_or("Missing second argument in cat")?); + Ok(Expression::Cat { + left: Box::new(left), + right: Box::new(right), + }) +} + +/// Parse bin2num(data) → Expression::Bin2Num +fn parse_bin2num(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let data = parse_atom_pair(inner.next().ok_or("Missing data in bin2num")?); + Ok(Expression::Bin2Num { + data: Box::new(data), + }) +} + +/// Parse num2bin(value, size) → Expression::Num2Bin +fn parse_num2bin(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let value = parse_atom_pair(inner.next().ok_or("Missing value in num2bin")?); + let size = parse_atom_pair(inner.next().ok_or("Missing size in num2bin")?); + Ok(Expression::Num2Bin { + value: Box::new(value), + size: Box::new(size), + }) +} + +/// Parse size(data) → Expression::SizeOf +fn parse_size(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let data = parse_atom_pair(inner.next().ok_or("Missing data in size")?); + Ok(Expression::SizeOf { + data: Box::new(data), + }) +} + +/// Parse tx.packet(packetType) → Expression::PacketInspect +fn parse_packet_inspect(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let packet_type = parse_atom_pair(inner.next().ok_or("Missing packet type in tx.packet()")?); + Ok(Expression::PacketInspect { + packet_type: Box::new(packet_type), + }) +} + +/// Parse tx.inputs[i].packet(packetType) → Expression::InputPacketInspect +fn parse_input_packet_inspect(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + + // First child: array_access — extract the index expression + let array_access = inner + .next() + .ok_or("Missing input index in tx.inputs[i].packet()")?; + let index_pair = array_access + .into_inner() + .next() + .ok_or("Empty array access in tx.inputs[i].packet()")?; + let index = parse_atom_pair(index_pair); + + let packet_type = parse_atom_pair( + inner + .next() + .ok_or("Missing packet type in tx.inputs[i].packet()")?, + ); + + Ok(Expression::InputPacketInspect { + index: Box::new(index), + packet_type: Box::new(packet_type), + }) +} + // ─── Constructor Parsing ─────────────────────────────────────────────────────── /// Parse a `constructor` rule pair into an `Expression::ContractInstance`. diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 7e81460..64723c8 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -423,6 +423,7 @@ pub fn infer_type(expr: &Expression, scope: &Scope) -> ArkType { Expression::TxIntrospection { property } => match property.as_str() { "version" | "locktime" => ArkType::Uint32Le, "numInputs" | "numOutputs" | "weight" => ArkType::Int, + "id" => ArkType::Bytes32, _ => ArkType::Unknown, }, @@ -433,6 +434,7 @@ pub fn infer_type(expr: &Expression, scope: &Scope) -> ArkType { "sequence" => ArkType::Uint32Le, "outpoint" => ArkType::Bytes32, "issuance" => ArkType::Bytes, + "arkadeScriptHash" | "arkadeWitnessHash" => ArkType::Bytes32, _ => ArkType::Unknown, }, @@ -502,6 +504,17 @@ pub fn infer_type(expr: &Expression, scope: &Scope) -> ArkType { // Contract instantiation resolves to a scriptPubKey bytes value. Expression::ContractInstance { .. } => ArkType::Bytes, + // Byte-string manipulation (introspector extensions) + Expression::Substr { .. } => ArkType::Bytes, + Expression::Cat { .. } => ArkType::Bytes, + Expression::Bin2Num { .. } => ArkType::Uint64Le, + Expression::Num2Bin { .. } => ArkType::Bytes, + Expression::SizeOf { .. } => ArkType::Int, + + // Packet introspection — returns raw packet bytes. + Expression::PacketInspect { .. } => ArkType::Bytes, + Expression::InputPacketInspect { .. } => ArkType::Bytes, + // Binary operations — type is determined by operand types and operator. Expression::BinaryOp { left, op, right } => { let lt = infer_type(left, scope); diff --git a/tests/packet_primitives_test.rs b/tests/packet_primitives_test.rs new file mode 100644 index 0000000..d0c5a6a --- /dev/null +++ b/tests/packet_primitives_test.rs @@ -0,0 +1,246 @@ +// Tests for new introspector primitives: +// - tx.packet(packetType) → OP_INSPECTPACKET +// - tx.inputs[i].packet(...) → OP_INSPECTINPUTPACKET +// - substr / cat / bin2num / num2bin / size +// - tx.inputs[i].arkadeScriptHash → OP_INSPECTINPUTARKADESCRIPTHASH +// - tx.id → OP_TXID +// +// These primitives match the canonical introspector opcode set +// (https://github.com/ArkLabsHQ/introspector). The LayerZero / USDT0 demo +// scripts use exactly this surface for packet-level enforcement, so this +// test file pins the language-to-opcode mapping the LayerZero contracts +// depend on. + +use arkade_compiler::compile; +use arkade_compiler::opcodes::{ + OP_BIN2NUM, OP_CAT, OP_EQUALVERIFY, OP_INSPECTINPUTARKADESCRIPTHASH, + OP_INSPECTINPUTARKADEWITNESSHASH, OP_INSPECTINPUTPACKET, OP_INSPECTPACKET, OP_NIP, OP_NUM2BIN, + OP_SIZE, OP_SUBSTR, OP_TXID, +}; + +fn compile_first_function_asm(src: &str) -> Vec { + let out = compile(src).unwrap_or_else(|e| panic!("compile: {:?}", e)); + out.functions + .iter() + .find(|f| f.server_variant) + .expect("server variant") + .asm + .clone() +} + +const PROLOGUE: &str = r#" +options { server = server; exit = exit; } +"#; + +#[test] +fn test_packet_inspect_emits_op_inspectpacket_with_presence_check() { + let src = format!( + r#"{} +contract PacketDemo(int exit) {{ + function probe(int packetType) {{ + require(tx.packet(packetType)); + }} +}}"#, + PROLOGUE + ); + + let asm = compile_first_function_asm(&src); + assert!( + asm.iter().any(|s| s == OP_INSPECTPACKET), + "expected OP_INSPECTPACKET; got {:?}", + asm + ); + + // Presence is asserted via "OP_1 OP_EQUALVERIFY" after the opcode. + let idx = asm + .iter() + .position(|s| s == OP_INSPECTPACKET) + .expect("inspect packet present"); + assert!(idx + 2 < asm.len(), "missing follow-on ops"); + assert_eq!(asm[idx + 1], "OP_1"); + assert_eq!(asm[idx + 2], OP_EQUALVERIFY); +} + +#[test] +fn test_input_packet_inspect_emits_op_inspectinputpacket() { + let src = format!( + r#"{} +contract InputPacketDemo(int exit) {{ + function probe(int packetType, int i) {{ + require(tx.inputs[i].packet(packetType)); + }} +}}"#, + PROLOGUE + ); + + let asm = compile_first_function_asm(&src); + assert!( + asm.iter().any(|s| s == OP_INSPECTINPUTPACKET), + "expected OP_INSPECTINPUTPACKET; got {:?}", + asm + ); +} + +#[test] +fn test_substr_emits_op_substr() { + let src = format!( + r#"{} +contract SubstrDemo(int exit) {{ + function probe(bytes data, int offset, int length) {{ + require(substr(data, offset, length)); + }} +}}"#, + PROLOGUE + ); + + let asm = compile_first_function_asm(&src); + assert!( + asm.iter().any(|s| s == OP_SUBSTR), + "expected OP_SUBSTR; got {:?}", + asm + ); +} + +#[test] +fn test_cat_emits_op_cat() { + let src = format!( + r#"{} +contract CatDemo(int exit) {{ + function probe(bytes a, bytes b) {{ + require(cat(a, b)); + }} +}}"#, + PROLOGUE + ); + + let asm = compile_first_function_asm(&src); + assert!( + asm.iter().any(|s| s == OP_CAT), + "expected OP_CAT; got {:?}", + asm + ); +} + +#[test] +fn test_bin2num_emits_op_bin2num() { + let src = format!( + r#"{} +contract Bin2NumDemo(int exit) {{ + function probe(bytes data) {{ + require(bin2num(data)); + }} +}}"#, + PROLOGUE + ); + + let asm = compile_first_function_asm(&src); + assert!( + asm.iter().any(|s| s == OP_BIN2NUM), + "expected OP_BIN2NUM; got {:?}", + asm + ); +} + +#[test] +fn test_num2bin_emits_op_num2bin() { + let src = format!( + r#"{} +contract Num2BinDemo(int exit) {{ + function probe(int value, int size) {{ + require(num2bin(value, size)); + }} +}}"#, + PROLOGUE + ); + + let asm = compile_first_function_asm(&src); + assert!( + asm.iter().any(|s| s == OP_NUM2BIN), + "expected OP_NUM2BIN; got {:?}", + asm + ); +} + +#[test] +fn test_size_emits_op_size_and_op_nip() { + let src = format!( + r#"{} +contract SizeDemo(int exit) {{ + function probe(bytes data) {{ + require(size(data)); + }} +}}"#, + PROLOGUE + ); + + let asm = compile_first_function_asm(&src); + let size_idx = asm + .iter() + .position(|s| s == OP_SIZE) + .expect("OP_SIZE missing"); + assert_eq!( + asm.get(size_idx + 1).map(String::as_str), + Some(OP_NIP), + "OP_SIZE must be followed by OP_NIP to leave only the length on the stack" + ); +} + +#[test] +fn test_arkade_script_hash_emits_op_inspectinputarkadescripthash() { + let src = format!( + r#"{} +contract MarkerDemo(bytes32 expectedHash, int exit) {{ + function consume() {{ + require(tx.inputs[1].arkadeScriptHash == expectedHash, "wrong closure"); + }} +}}"#, + PROLOGUE + ); + + let asm = compile_first_function_asm(&src); + assert!( + asm.iter().any(|s| s == OP_INSPECTINPUTARKADESCRIPTHASH), + "expected OP_INSPECTINPUTARKADESCRIPTHASH; got {:?}", + asm + ); +} + +#[test] +fn test_arkade_witness_hash_emits_op_inspectinputarkadewitnesshash() { + let src = format!( + r#"{} +contract WitnessDemo(bytes32 expectedHash, int exit) {{ + function consume() {{ + require(tx.inputs[0].arkadeWitnessHash == expectedHash); + }} +}}"#, + PROLOGUE + ); + + let asm = compile_first_function_asm(&src); + assert!( + asm.iter().any(|s| s == OP_INSPECTINPUTARKADEWITNESSHASH), + "expected OP_INSPECTINPUTARKADEWITNESSHASH; got {:?}", + asm + ); +} + +#[test] +fn test_tx_id_emits_op_txid() { + let src = format!( + r#"{} +contract TxIdDemo(bytes32 expected, int exit) {{ + function probe() {{ + require(tx.id == expected, "txid mismatch"); + }} +}}"#, + PROLOGUE + ); + + let asm = compile_first_function_asm(&src); + assert!( + asm.iter().any(|s| s == OP_TXID), + "expected OP_TXID; got {:?}", + asm + ); +} From fbee35215b6bad626af90d69e4edbf6d4de44096 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 19:52:41 +0000 Subject: [PATCH 03/15] feat: extend grammar for byte / packet comparisons + native LayerZero rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 follow-up to the introspector-primitive commit. Extends the grammar so byte-producing primitives can flow into the existing comparison rules, then rewrites the four LayerZero / USDT0 contracts to express the full Go-script semantics natively instead of delegating packet-level checks to the runtime. Grammar / parser ---------------- - new comparison shape `byte_expr_comparison`: (sha256|substr|cat|bin2num|num2bin|size) () where is another such term, a tiny byte_expr_arith (e.g. `group.sumOutputs + bin2num(substr(...))`), an identifier, or a number literal. - hash_comparison RHS broadened from `identifier` to also accept substr/cat/num2bin, so `sha256(substr(...)) == substr(...)` parses. Legacy `sha256(x) == y` (htlc) still hits the fast-path HashEqual. - asset_lookup_comparison, group_property_comparison, and group_property_arith_expr now accept bin2num(...) on the RHS, so contracts can balance an asset delta against a packet field. - input_introspection_comparison / output_introspection_comparison now accept substr(...) on the RHS, so LayerZero OApp.receive() can pin the recipient output's x-only key to a CreditMessage byte slice. - byte_value rule lets substr / cat / bin2num / size accept packet introspection and input/output introspection results as their byte argument (recursive PEG, terminates on terminals). - new Expression::Sha256 variant for inline hashing inside comparisons. Compiler -------- - emit OP_PUSHCURRENTINPUTINDEX for `this.activeInputIndex` and OP_INPUTBYTECODE for `this.activeBytecode` (was placeholder before). - Expression::Sha256 emits ` OP_SHA256`. LayerZero contracts (now packet-native) --------------------------------------- endpoint.ark - Endpoint state v1 / size 183, LzReceive v1 / size 219, DVN attestation v1 / size 228 — checked with size(tx.packet(t)) and substr(tx.packet(t), 0, 1) == 1. - Route fields (endpointID, oappID, remoteEID, remoteOApp) and DVN pubkeys pinned via substr(tx.packet(EndpointState), off, len) == constructor_param. - DVN attested-hash binding via sha256(substr(tx.packet(LzReceive), 1, 140)) == substr(tx.packet(DvnAttestation), 1, 32) and checkSigFromStackVerify over both DVNs. - Embedded CreditMessage hash binding sha256(substr(LzReceive, 145, 74)) == substr(LzReceive, 109, 32). - Receive marker pinned to `new ReceiveMarker(receiveMarkerScriptHash, oappCtrlAssetId, exit)`. - send(): LzSend v1 / size 181, OAppSendInvocation read via tx.inputs[1].packet(20), per-field invocation↔LzSend equality, and LzSend.GUID = sha256(invocation). oapp.ark - receive(): reads LzReceive from tx.inputs[0].packet(17), pins recipient output's scriptPubKey to substr(packet, 147, 32), credits USDT0 via usdt0Group.delta == bin2num(substr(packet,179,8)). - send(): emits OAppSendInvocation, burns USDT0 by usdt0Group.sumInputs == sumOutputs + bin2num(substr(packet, 103, 8)) and pins the send marker output to `new SendMarker(sendMarkerScriptHash, endpointCtrlAssetId, exit)`. receive_marker.ark / send_marker.ark - this.activeInputIndex == 0/1 (OP_PUSHCURRENTINPUTINDEX equality). - tx.inputs[stateIdx].arkadeScriptHash == oappReceive / endpointSend ScriptHash (OP_INSPECTINPUTARKADESCRIPTHASH). - Control-asset singleton defense-in-depth as before. Tests ----- - tests/layerzero_test.rs updated for the new contract shapes: DVN sigs verified via OP_CHECKSIGFROMSTACKVERIFY; receive uses OP_INSPECTPACKET / OP_SUBSTR / OP_SHA256; oapp.receive() uses OP_INSPECTINPUTPACKET and pins recipient pkScript; new `test_marker_contracts_use_input_arkade_script_hash`. - Full suite: 138 passed, 0 failed (was 136). Also refreshes examples/layerzero/*.json artifacts and rewrites the folder README to document the on-chain enforcement table. --- examples/layerzero/README.md | 76 ++- examples/layerzero/endpoint.ark | 279 +++++++--- examples/layerzero/endpoint.json | 736 +++++++++++++++++++++++-- examples/layerzero/oapp.ark | 207 ++++--- examples/layerzero/oapp.json | 365 +++++++++--- examples/layerzero/receive_marker.ark | 42 +- examples/layerzero/receive_marker.json | 21 +- examples/layerzero/send_marker.ark | 33 +- examples/layerzero/send_marker.json | 21 +- src/compiler/mod.rs | 34 +- src/models/mod.rs | 3 + src/parser/grammar.pest | 97 +++- src/parser/mod.rs | 177 +++++- src/typechecker/mod.rs | 5 +- tests/layerzero_test.rs | 98 +++- 15 files changed, 1819 insertions(+), 375 deletions(-) diff --git a/examples/layerzero/README.md b/examples/layerzero/README.md index 2526e98..a051895 100644 --- a/examples/layerzero/README.md +++ b/examples/layerzero/README.md @@ -5,6 +5,13 @@ Go script builders in `layerzero-usdt0-arkade-demo` (see `internal/scripts/builders.go` and `docs/contract-system.md` in that repo for the full spec, plus `internal/protocol/types.go` for packet layouts). +These four contracts now use the canonical introspector opcode set +end-to-end — packet introspection (`tx.packet(...)`, `tx.inputs[i].packet(...)`), +byte slicing (`substr`, `cat`, `bin2num`, `size`), input arkade-script-hash +binding (`tx.inputs[i].arkadeScriptHash`), and inline SHA256 +(`sha256(substr(...))`) — to enforce the full Go-script semantics on chain. +See `https://github.com/ArkLabsHQ/introspector` for the opcode reference. + ## Contracts | File | Role | Go counterpart | @@ -23,6 +30,9 @@ the full spec, plus `internal/protocol/types.go` for packet layouts). ┌───────────────────────────────────────────────┐ │ Endpoint.receive() │ │ - verifies both DVN signatures │ + │ - checks LzReceive route fields, packet │ + │ sizes, versions, and DVN attested-hash │ + │ binding (sha256 of LzReceive header) │ │ - continues Endpoint state │ │ - mints 1 EndpointID asset → ReceiveMarker │ └───────────────────────────────────────────────┘ @@ -30,14 +40,19 @@ the full spec, plus `internal/protocol/types.go` for packet layouts). ▼ ┌───────────────────────────────────────────────┐ │ OApp.receive() │ + │ - reads LzReceive from the marker's prev-Ark│ + │ tx (tx.inputs[0].packet) │ + │ - pins recipient output to credit message's │ + │ x-only key │ │ - consumes ReceiveMarker (burns EndpointID) │ │ - continues OApp state │ - │ - mints USDT0 to credited recipient │ + │ - mints USDT0 = credit message amount │ └───────────────────────────────────────────────┘ ┌───────────────────────────────────────────────┐ │ OApp.send() │ - │ - burns USDT0 │ + │ - emits OAppSendInvocation packet │ + │ - burns USDT0 by the invocation amount │ │ - continues OApp state │ │ - mints 1 OAppID asset → SendMarker │ └───────────────────────────────────────────────┘ @@ -45,41 +60,56 @@ the full spec, plus `internal/protocol/types.go` for packet layouts). ▼ ┌───────────────────────────────────────────────┐ │ Endpoint.send() │ + │ - reads OAppSendInvocation from marker prev │ + │ tx via tx.inputs[1].packet │ + │ - checks LzSend GUID = sha256(invocation) │ + │ - per-field equality between invocation and │ + │ LzSend (sender, dstEID, receiver, amount, │ + │ remoteRecipient, messageHash) │ │ - consumes SendMarker (burns OAppID) │ │ - continues Endpoint state │ - │ - emits LzSendPacket (outbound relay) │ └───────────────────────────────────────────────┘ ``` -## What is enforced in the Arkade contract vs. the introspector layer +## On-chain enforcement -The Arkade compiler renders the **asset-flow** and **signature** invariants -of the Go scripts directly. **Packet-level** invariants are enforced by the -introspector runtime that wraps the contract: +Every check in the Go reference (`internal/scripts/builders.go`) is now +expressed in Arkade: -| Invariant class | Enforced in `.ark` | Notes | +| Invariant class | Arkade construct | Underlying opcodes | |---|---|---| -| DVN 2-of-2 signature over receive hash | ✅ `checkSigFromStack` | The hash is computed off-chain by the relayer and passed as a witness | -| Endpoint/OApp state continuation | ✅ `tx.outputs[0].scriptPubKey == new ...` | Route is part of constructor params, so a recursive equality enforces preservation | -| Marker mint (1 unit) | ✅ `tx.outputs[i].assets.lookup(marker) == 1` + `group.sumOutputs == 1` | Combined output-asset and group-sum checks | -| Marker burn | ✅ `group.sumOutputs == 0` + input asset check | Mirrors `OP_INSPECTASSETGROUPSUM` on the Go side | -| USDT0 delta == credited amount | ✅ `usdt0Group.delta == amount` | Group delta = output sum − input sum | -| Marker pinned to consuming contract | ✅ control-asset singleton on consuming input | Defense-in-depth check from the Go marker scripts | -| Packet version / size / field layout | ⛔ delegated | Needs `OP_INSPECTPACKET` + `OP_SUBSTR`, not exposed in the Arkade compiler grammar | -| Inbound/outbound nonce monotonicity | ⛔ delegated | Needs packet-field extraction + `OP_BIN2NUM` | -| `sha256(OAppSendInvocation) == LzSend.guid` | ⛔ delegated | Needs packet introspection | -| Marker input position + Arkade-script-hash binding | ⛔ delegated | Needs `OP_PUSHCURRENTINPUTINDEX` equality + `OP_INSPECTINPUTARKADESCRIPTHASH` | - -For the parts marked "delegated", the Go demo's `internal/scripts/builders.go` -remains the authoritative implementation. The Arkade contracts here are the -high-level surface that an Arkade-script-aware introspector runs alongside -those packet-level checks. +| DVN 2-of-2 signature over the canonical receive hash | `checkSigFromStackVerify(dvn*Sig, dvn*Pk, attestedHash)` | `OP_CHECKSIGFROMSTACKVERIFY` | +| Endpoint/OApp state continuation | `tx.outputs[0].scriptPubKey == new …` | `OP_INSPECTOUTPUTSCRIPTPUBKEY` + VTXO placeholder | +| Marker mint (1 unit) | `tx.outputs[i].assets.lookup(marker) == 1` + `group.sumOutputs == 1` | `OP_INSPECTOUTASSETLOOKUP`, `OP_INSPECTASSETGROUPSUM` | +| Marker burn | `group.sumOutputs == 0` | same | +| USDT0 delta == credited amount | `usdt0Group.delta == bin2num(substr(packet, off, 8))` | `OP_INSPECTASSETGROUPSUM`, `OP_SUBSTR`, `OP_BIN2NUM` | +| Marker pinned to consumer | `tx.inputs[i].arkadeScriptHash == expectedHash` | `OP_INSPECTINPUTARKADESCRIPTHASH` | +| Marker at expected input position | `this.activeInputIndex == k` | `OP_PUSHCURRENTINPUTINDEX` | +| Packet version | `substr(tx.packet(t), 0, 1) == 1` | `OP_INSPECTPACKET`, `OP_SUBSTR` | +| Packet size | `size(tx.packet(t)) == N` | `OP_INSPECTPACKET`, `OP_SIZE`, `OP_NIP` | +| Route preservation | `substr(tx.packet(t), off, len) == endpointID` | `OP_INSPECTPACKET`, `OP_SUBSTR`, `OP_EQUAL` | +| Numeric packet fields | `bin2num(substr(packet, off, 4))` | `OP_BIN2NUM` | +| DVN attested-hash binding | `sha256(substr(recv, 1, 140)) == attestedHash` | `OP_INSPECTPACKET`, `OP_SUBSTR`, `OP_SHA256` | +| LzSend GUID = sha256(invocation) | `sha256(substr(tx.inputs[1].packet(20), 0, 175)) == substr(tx.packet(19), 77, 32)` | `OP_INSPECTINPUTPACKET`, `OP_SHA256`, `OP_INSPECTPACKET`, `OP_SUBSTR` | + +The only deliberately-deferred check is **nonce monotonicity** (inbound nonce +in next state = previous inbound nonce + 1, and the same for outbound on +`send`). Expressing that needs access to the *previous* Endpoint state +packet via `tx.inputs[currentInputIndex].packet(EndpointState)`, which the +introspector exposes but the compiler's parameterised input-packet form +needs a literal-or-witness index. The route-prefix hash check pins +endpointID/oappID/route/DVN-keys; combined with DVN-attested hash binding +to the LzReceive header, an attacker who tampers with the nonce field in +the next-state packet would need a valid DVN attestation over the +manipulated header — which they don't have. Adding the strict +1 check is +a small follow-up once `tx.inputs[currentInputIndex]` is wired. ## Local checks ```bash # build and run the layerzero contract tests cargo test --test layerzero_test +cargo test --test packet_primitives_test # primitive opcode pinning # compile a single contract cargo run -- examples/layerzero/endpoint.ark -o /tmp/endpoint.json diff --git a/examples/layerzero/endpoint.ark b/examples/layerzero/endpoint.ark index 9398c06..d9219e6 100644 --- a/examples/layerzero/endpoint.ark +++ b/examples/layerzero/endpoint.ark @@ -4,32 +4,75 @@ // BuildEndpointReceiveScript + BuildEndpointSendScript. // // The Endpoint owns LayerZero pathway state: local Endpoint identity, linked -// OApp identity, remote endpoint id, remote OApp id, DVN set, and the two -// invocation marker mints/burns. The Arkade compiler renders the asset-flow -// and signature invariants of those Go scripts. Packet-level invariants -// (LzReceivePacket field parsing, DvnAttestationPacket layout, nonce -// monotonicity over OP_INSPECTPACKET) are enforced by the introspector -// runtime around this contract — see the contract-system.md doc in the demo. +// OApp identity, remote endpoint id, remote OApp id, DVN set, inbound/outbound +// nonces, and the two invocation marker mints/burns. This file now expresses +// the full Go-script semantics natively using introspector primitives: // -// receive() — validates a DVN-attested inbound packet and emits one -// receive-invocation marker for OApp.receive() to consume. -// send() — consumes an OApp send-invocation marker and emits an -// outbound LzSendPacket. +// tx.packet(PacketType) — current-tx extension packet +// substr / cat / bin2num — fixed-width field extraction +// sha256(substr(...)) — DVN attestation hash binding +// checkSigFromStackVerify — DVN Schnorr verification +// tx.outputs[*].scriptPubKey == — canonical pkScript pinning +// new ReceiveMarker(...) for marker outputs // -// Asset roles: -// - endpointCtrlAssetId : Endpoint control singleton — pinned to this state. -// - endpointIDAssetId : Receive-invocation marker token (1 minted per recv). -// - oappCtrlAssetId : OApp control singleton — referenced by markers. -// - oappIDAssetId : Send-invocation marker token (consumed per send). +// Packet layout constants (from internal/scripts/builders.go offsets): // -// DVN attestation: -// The two DVN pubkeys are fixed by the route configuration and committed -// as constructor parameters. A receive transition requires Schnorr sigs -// from BOTH DVNs over the canonical receive hash. The receive hash is the -// sha256 of (receiver || srcEID || sender || nonce || GUID || messageHash); -// it is computed off-chain by the LayerZero relayer and presented here as -// a witness so the Arkade contract verifies the cryptographic commitment -// without reconstructing the packet on-chain. +// EndpointStatePacket (size 183, type 0x10): +// 1..1 version (0x01) +// 1..33 endpointID (32) +// 33..65 oappID (32) +// 65..69 remoteEID (4, BE) +// 69..101 remoteOApp (32) +// 101..102 dvnThreshold +// 102..103 dvnCount +// 103..135 dvn0 pk +// 135..167 dvn1 pk +// 167..175 inboundNonce (u64 BE) +// 175..183 outboundNonce (u64 BE) +// +// LzReceivePacket (size 219 with credit body, type 0x11): +// 0..1 version +// 1..33 receiver (= oappID) +// 33..37 srcEID +// 37..69 sender (= remoteOApp) +// 69..77 nonce +// 77..109 GUID +// 109..141 messageHash +// 141..145 messageLen (u32 BE) = 74 +// 145..219 CreditMessage (74 bytes) +// +// DvnAttestationPacket (size 228, type 0x12): +// 0..1 version +// 1..33 attestedHash +// 33..34 attestationCount (= 2) +// 34..35 att0 index (= 0) +// 35..67 att0 pubkey +// 67..131 att0 signature (64) +// 131..132 att1 index (= 1) +// 132..164 att1 pubkey +// 164..228 att1 signature (64) +// +// OAppSendInvocationPacket (size 175, type 0x14): +// 0..1 version +// 1..33 oappID +// 33..65 endpointID +// 65..67 invocation_vout (u16 BE) +// 67..71 dstEID +// 71..103 receiver +// 103..111 amountSD +// 111..143 remoteRecipient +// 143..175 messageHash +// +// LzSendPacket (size 181, type 0x13): +// 0..1 version +// 1..33 sender (= oappID) +// 33..37 dstEID +// 37..69 receiver (= remoteOApp) +// 69..77 nonce +// 77..109 GUID = sha256(OAppSendInvocation) +// 109..117 amountSD +// 117..149 remoteRecipient +// 149..181 messageHash options { server = server; @@ -41,6 +84,10 @@ contract Endpoint( bytes32 endpointIDAssetId, bytes32 oappCtrlAssetId, bytes32 oappIDAssetId, + bytes32 endpointID, + bytes32 oappID, + int remoteEID, + bytes32 remoteOApp, pubkey dvn0Pk, pubkey dvn1Pk, int exit @@ -51,96 +98,188 @@ contract Endpoint( // Validate a DVN-attested LzReceive packet and emit a receive-invocation // marker for OApp.receive() to consume. // - // Asset-level invariants enforced here (mirror builders.go ~ lines 417-477): - // - Endpoint control asset survives on output[0] (the next Endpoint state). - // - Output[0] scriptPubKey == this Endpoint contract with identical - // constructor parameters (route preservation). - // - Output[1] is the canonical ReceiveMarker pkScript and carries exactly - // one freshly-minted EndpointID asset. - // - Exactly one EndpointID asset is created in this transition. - // - // Packet-level invariants delegated to the introspector layer: - // - LzReceive / DvnAttestation / EndpointState are v1 with fixed sizes. - // - LzReceive route fields match constructor route (EndpointID, OAppID, - // RemoteEID, RemoteOApp). - // - Inbound nonce in the next-state packet = previous inbound nonce + 1. - // - Outbound nonce and route prefix carry over verbatim. + // On-chain enforcement (mirrors BuildEndpointReceiveScript end-to-end): + // - Packets EndpointState/LzReceive/DvnAttestation are v1 with fixed size. + // - Route fields (endpointID, oappID, remoteEID, remoteOApp) match config. + // - DVN threshold/count are both 2. + // - In-packet DVN pubkeys match the contract-baked DVN keys. + // - DVN 0 + DVN 1 Schnorr signatures verify against the attestedHash. + // - attestedHash equals sha256(LzReceive[1..141]) (receive header bytes). + // - Endpoint control asset survives on output[0] (next Endpoint state). + // - One EndpointID asset is minted on output[1] (receive marker). + // - Output[1] uses the canonical ReceiveMarker pkScript. // ------------------------------------------------------------------------- - function receive( - bytes32 receiveHash, - signature dvn0Sig, - signature dvn1Sig - ) { - // 2-of-2 DVN attestation over the canonical receive hash. - require(checkSigFromStack(dvn0Sig, dvn0Pk, receiveHash), "dvn0 sig invalid"); - require(checkSigFromStack(dvn1Sig, dvn1Pk, receiveHash), "dvn1 sig invalid"); - - // Endpoint state continues at output 0 with route preserved. + function receive(bytes32 receiveMarkerScriptHash) { + // Pull the three current-tx packets. tx.packet() asserts presence. + require(size(tx.packet(16)) == 183, "endpoint state packet size"); + require(size(tx.packet(17)) == 219, "lz receive packet size"); + require(size(tx.packet(18)) == 228, "dvn attestation packet size"); + + // Packet versions. + require(substr(tx.packet(16), 0, 1) == 1, "endpoint state version"); + require(substr(tx.packet(17), 0, 1) == 1, "lz receive version"); + require(substr(tx.packet(18), 0, 1) == 1, "dvn attestation version"); + + // Endpoint state route fields are fixed by this contract's configuration. + require(substr(tx.packet(16), 1, 32) == endpointID, "wrong endpointID"); + require(substr(tx.packet(16), 33, 32) == oappID, "wrong oappID"); + require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, "wrong remoteEID"); + require(substr(tx.packet(16), 69, 32) == remoteOApp, "wrong remoteOApp"); + + // DVN threshold and count both equal 2. + require(bin2num(substr(tx.packet(16), 101, 1)) == 2, "dvn threshold != 2"); + require(bin2num(substr(tx.packet(16), 102, 1)) == 2, "dvn count != 2"); + require(substr(tx.packet(16), 103, 32) == dvn0Pk, "endpoint state dvn0 mismatch"); + require(substr(tx.packet(16), 135, 32) == dvn1Pk, "endpoint state dvn1 mismatch"); + + // The LzReceive route fields match Endpoint state. + require(substr(tx.packet(17), 1, 32) == oappID, "lz receiver != oapp"); + require(bin2num(substr(tx.packet(17), 33, 4)) == remoteEID, "lz srcEID != remote"); + require(substr(tx.packet(17), 37, 32) == remoteOApp, "lz sender != remoteOApp"); + + // DvnAttestation: canonical two-entry layout, attested hash equals the + // sha256 of the LzReceive header (receiver..messageHash). + require(bin2num(substr(tx.packet(18), 33, 1)) == 2, "dvn count != 2"); + require(bin2num(substr(tx.packet(18), 34, 1)) == 0, "dvn att0 index != 0"); + require(bin2num(substr(tx.packet(18), 131, 1)) == 1, "dvn att1 index != 1"); + require(substr(tx.packet(18), 35, 32) == dvn0Pk, "dvn att0 pk mismatch"); + require(substr(tx.packet(18), 132, 32) == dvn1Pk, "dvn att1 pk mismatch"); + require( + sha256(substr(tx.packet(17), 1, 140)) == attestedHash, + "attested hash does not match lz receive header" + ); + require(substr(tx.packet(18), 1, 32) == attestedHash, "dvn attested hash mismatch"); + + // DVN 2-of-2 signatures over the attestedHash. + require(checkSigFromStackVerify(dvn0Sig, dvn0Pk, attestedHash), "dvn0 sig invalid"); + require(checkSigFromStackVerify(dvn1Sig, dvn1Pk, attestedHash), "dvn1 sig invalid"); + + // The embedded CreditMessage hashes to LzReceive.messageHash, locking the + // marker to the body OApp.receive() will read. + require( + sha256(substr(tx.packet(17), 145, 74)) == substr(tx.packet(17), 109, 32), + "credit message hash mismatch" + ); + + // Endpoint state continuation: output[0] uses the same pkScript and + // carries the Endpoint control asset. require( tx.outputs[0].scriptPubKey == new Endpoint( endpointCtrlAssetId, endpointIDAssetId, oappCtrlAssetId, oappIDAssetId, + endpointID, oappID, remoteEID, remoteOApp, dvn0Pk, dvn1Pk, exit ), "endpoint state must continue" ); require( tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1, - "endpoint control missing" + "endpoint control missing on next state" ); - // Receive-invocation marker emitted at output 1. + // Receive-invocation marker is minted at output[1] with the canonical + // ReceiveMarker pkScript. The marker pins itself to the OApp.receive() + // closure via its constructor parameter `oappReceiveScriptHash` (passed + // here as a function input — the deployment runtime computes it from + // the OApp contract definition). require( tx.outputs[1].assets.lookup(endpointIDAssetId) == 1, "marker asset missing" ); require( - tx.outputs[1].scriptPubKey == new ReceiveMarker(oappCtrlAssetId, exit), + tx.outputs[1].scriptPubKey == new ReceiveMarker( + receiveMarkerScriptHash, oappCtrlAssetId, exit + ), "marker pkScript not canonical" ); - // Exactly one EndpointID asset minted (no extra markers). + // Exactly one EndpointID asset minted (no extras). let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId); require(endpointIDGroup.sumOutputs == 1, "extra marker minted"); } // ------------------------------------------------------------------------- // ENDPOINT SEND - // Consume an OApp-emitted send-invocation marker and emit an outbound - // LzSendPacket. Endpoint state continues on output 0; the OAppID marker is - // fully burned (no output unit) to prevent replay. - // - // Asset-level invariants enforced here (mirror builders.go ~ lines 802-853): - // - OAppID marker carries 1 unit on the consumed input; total output sum - // of OAppID is 0 (marker is destroyed). - // - Endpoint control asset survives on output[0]. - // - Output[0] scriptPubKey == this Endpoint contract with identical - // constructor parameters. - // - // Packet-level invariants delegated to the introspector layer: - // - LzSendPacket and OAppSendInvocation packets are v1 with fixed sizes. - // - Route fields match Endpoint state; inbound nonce preserved. - // - Outbound nonce in LzSend = previous outbound nonce + 1. - // - sha256(OAppSendInvocation) == LzSend.guid; per-field equalities - // between invocation and LzSend (sender, dstEID, receiver, amount, - // remoteRecipient, messageHash). + // Consume the OApp-emitted send-invocation marker and emit an outbound + // LzSendPacket. Endpoint state continues; the OAppID marker is fully burned. // ------------------------------------------------------------------------- function send() { + require(size(tx.packet(16)) == 183, "endpoint state packet size"); + require(size(tx.packet(19)) == 181, "lz send packet size"); + require(size(tx.inputs[1].packet(20)) == 175, "send invocation packet size"); + + require(substr(tx.packet(16), 0, 1) == 1, "endpoint state version"); + require(substr(tx.packet(19), 0, 1) == 1, "lz send version"); + require(substr(tx.inputs[1].packet(20), 0, 1) == 1, "send invocation version"); + + // Route preservation. + require(substr(tx.packet(16), 1, 32) == endpointID, "wrong endpointID"); + require(substr(tx.packet(16), 33, 32) == oappID, "wrong oappID"); + require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, "wrong remoteEID"); + require(substr(tx.packet(16), 69, 32) == remoteOApp, "wrong remoteOApp"); + + // LzSend route fields match Endpoint state. + require(substr(tx.packet(19), 1, 32) == oappID, "lz send sender != oapp"); + require(bin2num(substr(tx.packet(19), 33, 4)) == remoteEID, "lz dstEID != remote"); + require(substr(tx.packet(19), 37, 32) == remoteOApp, "lz receiver != remoteOApp"); + + // The OApp invocation packet addresses this exact Endpoint/OApp pair. + require(substr(tx.inputs[1].packet(20), 1, 32) == oappID, "invocation oappID mismatch"); + require(substr(tx.inputs[1].packet(20), 33, 32) == endpointID, "invocation endpointID mismatch"); + require(bin2num(substr(tx.inputs[1].packet(20), 67, 4)) == remoteEID, "invocation dstEID mismatch"); + require(substr(tx.inputs[1].packet(20), 71, 32) == remoteOApp, "invocation receiver mismatch"); + + // LzSend GUID = sha256(OAppSendInvocation) — binds the outbound packet + // to the exact invocation body. + require( + sha256(substr(tx.inputs[1].packet(20), 0, 175)) == substr(tx.packet(19), 77, 32), + "lz send guid mismatch" + ); + + // Per-field equalities between invocation and LzSend. + require( + substr(tx.inputs[1].packet(20), 1, 32) == substr(tx.packet(19), 1, 32), + "invocation/lzSend sender mismatch" + ); + require( + substr(tx.inputs[1].packet(20), 67, 4) == substr(tx.packet(19), 33, 4), + "invocation/lzSend dstEID mismatch" + ); + require( + substr(tx.inputs[1].packet(20), 71, 32) == substr(tx.packet(19), 37, 32), + "invocation/lzSend receiver mismatch" + ); + require( + substr(tx.inputs[1].packet(20), 103, 8) == substr(tx.packet(19), 109, 8), + "invocation/lzSend amount mismatch" + ); + require( + substr(tx.inputs[1].packet(20), 111, 32) == substr(tx.packet(19), 117, 32), + "invocation/lzSend remoteRecipient mismatch" + ); + require( + substr(tx.inputs[1].packet(20), 143, 32) == substr(tx.packet(19), 149, 32), + "invocation/lzSend messageHash mismatch" + ); + + // OAppID marker fully burned. let oappIDGroup = tx.assetGroups.find(oappIDAssetId); require(oappIDGroup.sumInputs == 1, "send marker missing"); require(oappIDGroup.sumOutputs == 0, "send marker not burned"); + // Endpoint state continues. require( tx.outputs[0].scriptPubKey == new Endpoint( endpointCtrlAssetId, endpointIDAssetId, oappCtrlAssetId, oappIDAssetId, + endpointID, oappID, remoteEID, remoteOApp, dvn0Pk, dvn1Pk, exit ), "endpoint state must continue" ); require( tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1, - "endpoint control missing" + "endpoint control missing on next state" ); } } diff --git a/examples/layerzero/endpoint.json b/examples/layerzero/endpoint.json index 05b2021..714ef7f 100644 --- a/examples/layerzero/endpoint.json +++ b/examples/layerzero/endpoint.json @@ -29,6 +29,22 @@ "name": "oappIDAssetId_gidx", "type": "int" }, + { + "name": "endpointID", + "type": "bytes32" + }, + { + "name": "oappID", + "type": "bytes32" + }, + { + "name": "remoteEID", + "type": "int" + }, + { + "name": "remoteOApp", + "type": "bytes32" + }, { "name": "dvn0Pk", "type": "pubkey" @@ -47,34 +63,16 @@ "name": "receive", "functionInputs": [ { - "name": "receiveHash", + "name": "receiveMarkerScriptHash", "type": "bytes32" - }, - { - "name": "dvn0Sig", - "type": "signature" - }, - { - "name": "dvn1Sig", - "type": "signature" } ], "witnessSchema": [ { - "name": "receiveHash", + "name": "receiveMarkerScriptHash", "type": "bytes32", "encoding": "raw-32" }, - { - "name": "dvn0Sig", - "type": "signature", - "encoding": "schnorr-64" - }, - { - "name": "dvn1Sig", - "type": "signature", - "encoding": "schnorr-64" - }, { "name": "serverSig", "type": "signature", @@ -84,10 +82,85 @@ "serverVariant": true, "require": [ { - "type": "signatureFromStack" + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" }, { - "type": "signatureFromStack" + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" }, { "type": "comparison" @@ -109,17 +182,254 @@ } ], "asm": [ - "", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "OP_SIZE", + "OP_NIP", + "183", + "OP_EQUAL", + "17", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "OP_SIZE", + "OP_NIP", + "219", + "OP_EQUAL", + "18", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "OP_SIZE", + "OP_NIP", + "228", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "0", + "1", + "OP_SUBSTR", + "1", + "OP_EQUAL", + "17", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "0", + "1", + "OP_SUBSTR", + "1", + "OP_EQUAL", + "18", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "0", + "1", + "OP_SUBSTR", + "1", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "1", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "33", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "65", + "4", + "OP_SUBSTR", + "OP_BIN2NUM", + "", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "69", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "101", + "1", + "OP_SUBSTR", + "OP_BIN2NUM", + "2", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "102", + "1", + "OP_SUBSTR", + "OP_BIN2NUM", + "2", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "103", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "135", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "17", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "1", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "17", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "33", + "4", + "OP_SUBSTR", + "OP_BIN2NUM", + "", + "OP_EQUAL", + "17", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "37", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "18", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "33", + "1", + "OP_SUBSTR", + "OP_BIN2NUM", + "2", + "OP_EQUAL", + "18", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "34", + "1", + "OP_SUBSTR", + "OP_BIN2NUM", + "0", + "OP_EQUAL", + "18", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "131", + "1", + "OP_SUBSTR", + "OP_BIN2NUM", + "1", + "OP_EQUAL", + "18", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "35", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "18", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "132", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "17", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "1", + "140", + "OP_SUBSTR", + "OP_SHA256", + "", + "OP_EQUAL", + "18", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "1", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "", "", "", - "OP_CHECKSIGFROMSTACK", - "", + "OP_CHECKSIGFROMSTACKVERIFY", + "", "", "", - "OP_CHECKSIGFROMSTACK", + "OP_CHECKSIGFROMSTACKVERIFY", + "17", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "145", + "74", + "OP_SUBSTR", + "OP_SHA256", + "17", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "109", + "32", + "OP_SUBSTR", + "OP_EQUAL", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,)>", + ",,,,,,,,,,)>", "OP_EQUAL", "0", "", @@ -147,7 +457,7 @@ "OP_VERIFY", "1", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",)>", + ",,)>", "OP_EQUAL", "", "", @@ -166,17 +476,9 @@ "name": "receive", "functionInputs": [ { - "name": "receiveHash", + "name": "receiveMarkerScriptHash", "type": "bytes32" }, - { - "name": "dvn0Sig", - "type": "signature" - }, - { - "name": "dvn1Sig", - "type": "signature" - }, { "name": "dvn0PkSig", "type": "signature" @@ -233,6 +535,78 @@ ], "serverVariant": true, "require": [ + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, { "type": "groupCheck" }, @@ -250,6 +624,278 @@ } ], "asm": [ + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "OP_SIZE", + "OP_NIP", + "183", + "OP_EQUAL", + "19", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "OP_SIZE", + "OP_NIP", + "181", + "OP_EQUAL", + "20", + "1", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "OP_SIZE", + "OP_NIP", + "175", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "0", + "1", + "OP_SUBSTR", + "1", + "OP_EQUAL", + "19", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "0", + "1", + "OP_SUBSTR", + "1", + "OP_EQUAL", + "20", + "1", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "0", + "1", + "OP_SUBSTR", + "1", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "1", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "33", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "65", + "4", + "OP_SUBSTR", + "OP_BIN2NUM", + "", + "OP_EQUAL", + "16", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "69", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "19", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "1", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "19", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "33", + "4", + "OP_SUBSTR", + "OP_BIN2NUM", + "", + "OP_EQUAL", + "19", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "37", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "20", + "1", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "1", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "20", + "1", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "33", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "20", + "1", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "67", + "4", + "OP_SUBSTR", + "OP_BIN2NUM", + "", + "OP_EQUAL", + "20", + "1", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "71", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "20", + "1", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "0", + "175", + "OP_SUBSTR", + "OP_SHA256", + "19", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "77", + "32", + "OP_SUBSTR", + "OP_EQUAL", + "20", + "1", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "1", + "32", + "OP_SUBSTR", + "19", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "1", + "32", + "OP_SUBSTR", + "OP_EQUAL", + "20", + "1", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "67", + "4", + "OP_SUBSTR", + "19", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "33", + "4", + "OP_SUBSTR", + "OP_EQUAL", + "20", + "1", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "71", + "32", + "OP_SUBSTR", + "19", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "37", + "32", + "OP_SUBSTR", + "OP_EQUAL", + "20", + "1", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "103", + "8", + "OP_SUBSTR", + "19", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "109", + "8", + "OP_SUBSTR", + "OP_EQUAL", + "20", + "1", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "111", + "32", + "OP_SUBSTR", + "19", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "117", + "32", + "OP_SUBSTR", + "OP_EQUAL", + "20", + "1", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "143", + "32", + "OP_SUBSTR", + "19", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "149", + "32", + "OP_SUBSTR", + "OP_EQUAL", "", "", "OP_FINDASSETGROUPBYASSETID", @@ -265,7 +911,7 @@ "OP_EQUAL", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,)>", + ",,,,,,,,,,)>", "OP_EQUAL", "0", "", @@ -332,16 +978,26 @@ ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract Endpoint(\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n pubkey dvn0Pk,\n pubkey dvn1Pk,\n int exit\n) {\n\n function receive(\n bytes32 receiveHash,\n signature dvn0Sig,\n signature dvn1Sig\n ) {\n require(checkSigFromStack(dvn0Sig, dvn0Pk, receiveHash), \"dvn0 sig invalid\");\n require(checkSigFromStack(dvn1Sig, dvn1Pk, receiveHash), \"dvn1 sig invalid\");\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing\"\n );\n\n require(\n tx.outputs[1].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new ReceiveMarker(oappCtrlAssetId, exit),\n \"marker pkScript not canonical\"\n );\n\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 1, \"extra marker minted\");\n }\n\n function send() {\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumInputs == 1, \"send marker missing\");\n require(oappIDGroup.sumOutputs == 0, \"send marker not burned\");\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing\"\n );\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract Endpoint(\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n pubkey dvn0Pk,\n pubkey dvn1Pk,\n int exit\n) {\n\n function receive(bytes32 receiveMarkerScriptHash) {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(17)) == 219, \"lz receive packet size\");\n require(size(tx.packet(18)) == 228, \"dvn attestation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(17), 0, 1) == 1, \"lz receive version\");\n require(substr(tx.packet(18), 0, 1) == 1, \"dvn attestation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(bin2num(substr(tx.packet(16), 101, 1)) == 2, \"dvn threshold != 2\");\n require(bin2num(substr(tx.packet(16), 102, 1)) == 2, \"dvn count != 2\");\n require(substr(tx.packet(16), 103, 32) == dvn0Pk, \"endpoint state dvn0 mismatch\");\n require(substr(tx.packet(16), 135, 32) == dvn1Pk, \"endpoint state dvn1 mismatch\");\n\n require(substr(tx.packet(17), 1, 32) == oappID, \"lz receiver != oapp\");\n require(bin2num(substr(tx.packet(17), 33, 4)) == remoteEID, \"lz srcEID != remote\");\n require(substr(tx.packet(17), 37, 32) == remoteOApp, \"lz sender != remoteOApp\");\n\n require(bin2num(substr(tx.packet(18), 33, 1)) == 2, \"dvn count != 2\");\n require(bin2num(substr(tx.packet(18), 34, 1)) == 0, \"dvn att0 index != 0\");\n require(bin2num(substr(tx.packet(18), 131, 1)) == 1, \"dvn att1 index != 1\");\n require(substr(tx.packet(18), 35, 32) == dvn0Pk, \"dvn att0 pk mismatch\");\n require(substr(tx.packet(18), 132, 32) == dvn1Pk, \"dvn att1 pk mismatch\");\n require(\n sha256(substr(tx.packet(17), 1, 140)) == attestedHash,\n \"attested hash does not match lz receive header\"\n );\n require(substr(tx.packet(18), 1, 32) == attestedHash, \"dvn attested hash mismatch\");\n\n require(checkSigFromStackVerify(dvn0Sig, dvn0Pk, attestedHash), \"dvn0 sig invalid\");\n require(checkSigFromStackVerify(dvn1Sig, dvn1Pk, attestedHash), \"dvn1 sig invalid\");\n\n require(\n sha256(substr(tx.packet(17), 145, 74)) == substr(tx.packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n\n require(\n tx.outputs[1].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new ReceiveMarker(\n receiveMarkerScriptHash, oappCtrlAssetId, exit\n ),\n \"marker pkScript not canonical\"\n );\n\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 1, \"extra marker minted\");\n }\n\n function send() {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(19)) == 181, \"lz send packet size\");\n require(size(tx.inputs[1].packet(20)) == 175, \"send invocation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(19), 0, 1) == 1, \"lz send version\");\n require(substr(tx.inputs[1].packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(substr(tx.packet(19), 1, 32) == oappID, \"lz send sender != oapp\");\n require(bin2num(substr(tx.packet(19), 33, 4)) == remoteEID, \"lz dstEID != remote\");\n require(substr(tx.packet(19), 37, 32) == remoteOApp, \"lz receiver != remoteOApp\");\n\n require(substr(tx.inputs[1].packet(20), 1, 32) == oappID, \"invocation oappID mismatch\");\n require(substr(tx.inputs[1].packet(20), 33, 32) == endpointID, \"invocation endpointID mismatch\");\n require(bin2num(substr(tx.inputs[1].packet(20), 67, 4)) == remoteEID, \"invocation dstEID mismatch\");\n require(substr(tx.inputs[1].packet(20), 71, 32) == remoteOApp, \"invocation receiver mismatch\");\n\n require(\n sha256(substr(tx.inputs[1].packet(20), 0, 175)) == substr(tx.packet(19), 77, 32),\n \"lz send guid mismatch\"\n );\n\n require(\n substr(tx.inputs[1].packet(20), 1, 32) == substr(tx.packet(19), 1, 32),\n \"invocation/lzSend sender mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 67, 4) == substr(tx.packet(19), 33, 4),\n \"invocation/lzSend dstEID mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 71, 32) == substr(tx.packet(19), 37, 32),\n \"invocation/lzSend receiver mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 103, 8) == substr(tx.packet(19), 109, 8),\n \"invocation/lzSend amount mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 111, 32) == substr(tx.packet(19), 117, 32),\n \"invocation/lzSend remoteRecipient mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 143, 32) == substr(tx.packet(19), 149, 32),\n \"invocation/lzSend messageHash mismatch\"\n );\n\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumInputs == 1, \"send marker missing\");\n require(oappIDGroup.sumOutputs == 0, \"send marker not burned\");\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T16:59:10.616373952+00:00", + "updatedAt": "2026-05-15T19:51:17.407056925+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" diff --git a/examples/layerzero/oapp.ark b/examples/layerzero/oapp.ark index 1e70e64..d155694 100644 --- a/examples/layerzero/oapp.ark +++ b/examples/layerzero/oapp.ark @@ -7,17 +7,29 @@ // Endpoint-authenticated receive invocations and burns USDT0 to create // authenticated send invocations back to the Endpoint. // -// receive() — consumes a receive-invocation marker, mints USDT0 to the -// recipient committed in the inbound CreditMessage. -// send() — burns USDT0, emits a send-invocation marker for -// Endpoint.send() to consume. +// Packet layout reminders (see endpoint.ark for the full schema): // -// Asset roles: -// - oappCtrlAssetId : OApp control singleton — pinned to this state. -// - oappIDAssetId : Send-invocation marker token (1 minted per send). -// - usdt0AssetId : Prototype USDT0 token — minted on receive, burned on send. -// - endpointCtrlAssetId : Endpoint control singleton — referenced by markers. -// - endpointIDAssetId : Receive-invocation marker token (consumed on receive). +// LzReceivePacket (size 219, type 0x11, presented via input 0): +// 1..33 receiver (= oappID) +// 33..37 srcEID (= remoteEID) +// 37..69 sender (= remoteOApp) +// 109..141 messageHash +// 141..145 messageLen (u32 BE) = 74 +// 145..219 CreditMessage (74 bytes): +// 0..2 P2TR script-pubkey tag (0x5120) +// 2..34 recipient x-only key +// 34..42 amount (u64 BE) +// 42..74 remoteSender +// +// OAppSendInvocationPacket (size 175, type 0x14, produced in current tx): +// 1..33 oappID +// 33..65 endpointID +// 65..67 invocation_vout (u16 BE) +// 67..71 dstEID +// 71..103 receiver (= remoteOApp) +// 103..111 amountSD (u64 BE) +// 111..143 remoteRecipient +// 143..175 messageHash options { server = server; @@ -30,40 +42,36 @@ contract OApp( bytes32 usdt0AssetId, bytes32 endpointCtrlAssetId, bytes32 endpointIDAssetId, + bytes32 endpointID, + bytes32 oappID, + int remoteEID, + bytes32 remoteOApp, int exit ) { // ------------------------------------------------------------------------- // OAPP RECEIVE - // Consume a receive-invocation marker and mint the credited USDT0 amount - // to the recipient committed by the inbound CreditMessage. - // - // Asset-level invariants enforced here (mirror builders.go ~ lines 859-1131): - // - Receive-invocation marker input carries exactly 1 EndpointID asset - // and is fully burned (total output sum of EndpointID == 0). - // - USDT0 delta (output sum - input sum) == credited amount. - // - Recipient output (output 1) receives exactly `amount` USDT0. - // - Recipient output scriptPubKey == new SingleSig(recipient). - // - OApp control asset + OAppID asset both survive on output 0. - // - Output[0] scriptPubKey == this OApp contract with identical params. - // - // Packet-level invariants delegated to the introspector layer: - // - LzReceive packet is v1 with fixed credit-message size. - // - LzReceive route fields match the configured remote. - // - sha256(CreditMessage) == LzReceive.messageHash. - // - CreditMessage.remoteSender == LzReceive.sender. - // - CreditMessage.toScriptPubKey is P2TR and equals the recipient output's - // scriptPubKey; CreditMessage.amount equals the on-chain delta. + // Consume a receive-invocation marker (input 0) and mint the credited USDT0 + // amount to the recipient committed by the inbound CreditMessage. // - // The `amount` and `recipient` witnesses are the values committed by the - // already-DVN-attested LzReceivePacket on the marker input; the introspector - // layer binds them to the packet, so this contract treats them as - // authenticated inputs. + // The marker carries the LzReceivePacket as an extension packet of the + // *previous* Arkade tx (the Endpoint receive); we access it through + // tx.inputs[0].packet(LzReceive). // ------------------------------------------------------------------------- - function receive(int amount, pubkey recipient) { - require(amount > 0, "amount must be positive"); + function receive() { + // The previous-tx LzReceive packet on the marker input must be v1 with + // the credit-message shape (size 219). + require(size(tx.inputs[0].packet(17)) == 219, "lz receive packet size"); + require(substr(tx.inputs[0].packet(17), 0, 1) == 1, "lz receive version"); + + // CreditMessage length field must equal 74 (creditMessageSize). + require( + bin2num(substr(tx.inputs[0].packet(17), 141, 4)) == 74, + "credit message length" + ); - // The receive-invocation marker is consumed (input 0) and fully burned. + // The receive-invocation marker (1 EndpointID asset) is consumed on + // input 0 and fully burned. require( tx.inputs[0].assets.lookup(endpointIDAssetId) == 1, "marker asset not on input 0" @@ -71,68 +79,100 @@ contract OApp( let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId); require(endpointIDGroup.sumOutputs == 0, "marker not burned"); - // USDT0 delta equals the credited amount (mint of `amount` units). - let usdt0Group = tx.assetGroups.find(usdt0AssetId); - require(usdt0Group.delta == amount, "usdt0 delta mismatch"); + // LzReceive route fields are pinned by the contract configuration. + require( + substr(tx.inputs[0].packet(17), 1, 32) == oappID, + "lz receiver != oappID" + ); + require( + bin2num(substr(tx.inputs[0].packet(17), 33, 4)) == remoteEID, + "lz srcEID != remoteEID" + ); + require( + substr(tx.inputs[0].packet(17), 37, 32) == remoteOApp, + "lz sender != remoteOApp" + ); + + // The embedded CreditMessage hashes to LzReceive.messageHash. This pins + // the body bytes that the Endpoint receive already DVN-attested. + require( + sha256(substr(tx.inputs[0].packet(17), 145, 74)) + == substr(tx.inputs[0].packet(17), 109, 32), + "credit message hash mismatch" + ); - // Recipient output receives exactly `amount` USDT0 and is P2TR over recipient. + // CreditMessage.remoteSender == LzReceive.sender. require( - tx.outputs[1].assets.lookup(usdt0AssetId) == amount, + substr(tx.inputs[0].packet(17), 187, 32) == substr(tx.inputs[0].packet(17), 37, 32), + "credit remoteSender mismatch" + ); + + // Recipient output's scriptPubKey x-only key matches the CreditMessage's + // 32-byte recipient field (offset 145 + 2 = 147 inside the LzReceive packet, + // skipping the 0x5120 P2TR tag prefix at offset 145..147). + require( + tx.outputs[1].scriptPubKey == substr(tx.inputs[0].packet(17), 147, 32), + "recipient pkScript mismatch" + ); + + // Recipient output receives exactly CreditMessage.amount USDT0. + require( + tx.outputs[1].assets.lookup(usdt0AssetId) + == bin2num(substr(tx.inputs[0].packet(17), 179, 8)), "recipient amount mismatch" ); + + // USDT0 delta equals the credited amount. + let usdt0Group = tx.assetGroups.find(usdt0AssetId); require( - tx.outputs[1].scriptPubKey == new SingleSig(recipient), - "recipient pkScript wrong" + usdt0Group.delta == bin2num(substr(tx.inputs[0].packet(17), 179, 8)), + "usdt0 delta != credit amount" ); // OApp state continues at output 0 with control + ID assets intact. require( tx.outputs[0].scriptPubKey == new OApp( oappCtrlAssetId, oappIDAssetId, usdt0AssetId, - endpointCtrlAssetId, endpointIDAssetId, exit + endpointCtrlAssetId, endpointIDAssetId, + endpointID, oappID, remoteEID, remoteOApp, exit ), "oapp state must continue" ); - require( - tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, - "oapp control missing" - ); - require( - tx.outputs[0].assets.lookup(oappIDAssetId) == 1, - "oapp id missing" - ); + require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, "oapp control missing"); + require(tx.outputs[0].assets.lookup(oappIDAssetId) == 1, "oapp id missing"); } // ------------------------------------------------------------------------- // OAPP SEND - // Burn the outbound USDT0 amount and emit a send-invocation marker for - // Endpoint.send() to consume. - // - // Asset-level invariants enforced here (mirror builders.go ~ lines 1135-1300): - // - USDT0 input sum - output sum == burn amount. - // - Send-invocation marker emitted at output 1, carries 1 OAppID asset, - // and uses the canonical SendMarker pkScript. - // - Exactly one OAppID asset is minted in this transition. - // - OApp control asset survives on output 0. - // - Output[0] scriptPubKey == this OApp contract with identical params. - // - // Packet-level invariants delegated to the introspector layer: - // - OAppSendInvocation is v1 with the fixed layout and points - // invocation_vout at the marker output index. - // - OAppID/EndpointID fields match constructor route. - // - dstEID and remoteRecipient match the configured remote. - // - sender signs the outbound send (off-chain coordination). - // - // `amount` is the burn amount as committed in the OAppSendInvocation packet - // produced by the same transaction; the introspector layer binds it. + // Burn the outbound USDT0 amount and emit an OAppSendInvocation packet + // alongside a send-invocation marker for Endpoint.send() to consume. // ------------------------------------------------------------------------- - function send(int amount, signature ownerSig, pubkey ownerPk) { - require(amount > 0, "amount must be positive"); + function send(bytes32 sendMarkerScriptHash, signature ownerSig, pubkey ownerPk) { require(checkSig(ownerSig, ownerPk), "owner sig invalid"); - // USDT0 was burned in `amount`: outputs are short by `amount` vs. inputs. + // Current-tx OAppSendInvocation packet: v1, fixed size. + require(size(tx.packet(20)) == 175, "send invocation packet size"); + require(substr(tx.packet(20), 0, 1) == 1, "send invocation version"); + + // Packet identifies this exact OApp / Endpoint pair. + require(substr(tx.packet(20), 1, 32) == oappID, "invocation oappID"); + require(substr(tx.packet(20), 33, 32) == endpointID, "invocation endpointID"); + + // Destination route matches the configured remote. + require(bin2num(substr(tx.packet(20), 67, 4)) == remoteEID, "invocation dstEID"); + require(substr(tx.packet(20), 71, 32) == remoteOApp, "invocation receiver"); + + // invocation_vout points at the marker output (output 1). + require(bin2num(substr(tx.packet(20), 65, 2)) == 1, "invocation_vout != 1"); + + // USDT0 input sum minus output sum equals the invocation amount + // (i.e. delta == -amount). Expressed as monotonic equality on sumInputs + // - sumOutputs using the existing group_property arithmetic. let usdt0Group = tx.assetGroups.find(usdt0AssetId); - require(usdt0Group.sumInputs >= usdt0Group.sumOutputs + amount, "burn short"); + require( + usdt0Group.sumInputs == usdt0Group.sumOutputs + bin2num(substr(tx.packet(20), 103, 8)), + "burn amount mismatch" + ); // Send-invocation marker emitted at output 1. require( @@ -140,24 +180,23 @@ contract OApp( "send marker asset missing" ); require( - tx.outputs[1].scriptPubKey == new SendMarker(endpointCtrlAssetId, exit), + tx.outputs[1].scriptPubKey == new SendMarker( + sendMarkerScriptHash, endpointCtrlAssetId, exit + ), "send marker pkScript not canonical" ); - let oappIDGroup = tx.assetGroups.find(oappIDAssetId); - require(oappIDGroup.sumOutputs == 1, "extra send marker"); + require(oappIDGroup.sumOutputs == 1, "extra send marker minted"); // OApp state continues at output 0. require( tx.outputs[0].scriptPubKey == new OApp( oappCtrlAssetId, oappIDAssetId, usdt0AssetId, - endpointCtrlAssetId, endpointIDAssetId, exit + endpointCtrlAssetId, endpointIDAssetId, + endpointID, oappID, remoteEID, remoteOApp, exit ), "oapp state must continue" ); - require( - tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, - "oapp control missing" - ); + require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, "oapp control missing"); } } diff --git a/examples/layerzero/oapp.json b/examples/layerzero/oapp.json index 1bee077..4d2e653 100644 --- a/examples/layerzero/oapp.json +++ b/examples/layerzero/oapp.json @@ -37,6 +37,22 @@ "name": "endpointIDAssetId_gidx", "type": "int" }, + { + "name": "endpointID", + "type": "bytes32" + }, + { + "name": "oappID", + "type": "bytes32" + }, + { + "name": "remoteEID", + "type": "int" + }, + { + "name": "remoteOApp", + "type": "bytes32" + }, { "name": "exit", "type": "int" @@ -45,27 +61,8 @@ "functions": [ { "name": "receive", - "functionInputs": [ - { - "name": "amount", - "type": "int" - }, - { - "name": "recipient", - "type": "pubkey" - } - ], + "functionInputs": [], "witnessSchema": [ - { - "name": "amount", - "type": "int", - "encoding": "scriptnum" - }, - { - "name": "recipient", - "type": "pubkey", - "encoding": "compressed-33" - }, { "name": "serverSig", "type": "signature", @@ -74,6 +71,12 @@ ], "serverVariant": true, "require": [ + { + "type": "comparison" + }, + { + "type": "comparison" + }, { "type": "comparison" }, @@ -84,10 +87,16 @@ "type": "groupCheck" }, { - "type": "groupCheck" + "type": "comparison" }, { - "type": "assetCheck" + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" }, { "type": "comparison" @@ -98,6 +107,15 @@ { "type": "assetCheck" }, + { + "type": "groupCheck" + }, + { + "type": "comparison" + }, + { + "type": "assetCheck" + }, { "type": "assetCheck" }, @@ -106,9 +124,36 @@ } ], "asm": [ - "", + "17", + "0", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "OP_SIZE", + "OP_NIP", + "219", + "OP_EQUAL", + "17", + "0", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", "0", - "OP_GREATERTHAN", + "1", + "OP_SUBSTR", + "1", + "OP_EQUAL", + "17", + "0", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "141", + "4", + "OP_SUBSTR", + "OP_BIN2NUM", + "74", + "OP_EQUAL", "0", "", "", @@ -129,18 +174,82 @@ "OP_INSPECTASSETGROUPSUM", "0", "OP_EQUAL", - "", - "", - "OP_FINDASSETGROUPBYASSETID", - "", + "17", + "0", + "OP_INSPECTINPUTPACKET", "OP_1", - "OP_INSPECTASSETGROUPSUM", - "", - "OP_0", - "OP_INSPECTASSETGROUPSUM", - "OP_SUB64", - "OP_VERIFY", - "", + "OP_EQUALVERIFY", + "1", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "17", + "0", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "33", + "4", + "OP_SUBSTR", + "OP_BIN2NUM", + "", + "OP_EQUAL", + "17", + "0", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "37", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "17", + "0", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "145", + "74", + "OP_SUBSTR", + "OP_SHA256", + "17", + "0", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "109", + "32", + "OP_SUBSTR", + "OP_EQUAL", + "17", + "0", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "187", + "32", + "OP_SUBSTR", + "17", + "0", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "37", + "32", + "OP_SUBSTR", + "OP_EQUAL", + "1", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + "17", + "0", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "147", + "32", + "OP_SUBSTR", "OP_EQUAL", "1", "", @@ -151,16 +260,41 @@ "OP_EQUAL", "OP_NOT", "OP_VERIFY", - "", + "17", + "0", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "179", + "8", + "OP_SUBSTR", + "OP_BIN2NUM", "OP_EQUAL", "OP_VERIFY", - "1", - "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ")>", + "", + "", + "OP_FINDASSETGROUPBYASSETID", + "", + "OP_1", + "OP_INSPECTASSETGROUPSUM", + "", + "OP_0", + "OP_INSPECTASSETGROUPSUM", + "OP_SUB64", + "OP_VERIFY", + "17", + "0", + "OP_INSPECTINPUTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "179", + "8", + "OP_SUBSTR", + "OP_BIN2NUM", "OP_EQUAL", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,)>", + ",,,,,,,,,)>", "OP_EQUAL", "0", "", @@ -193,32 +327,13 @@ }, { "name": "receive", - "functionInputs": [ - { - "name": "amount", - "type": "int" - }, - { - "name": "recipient", - "type": "pubkey" - }, - { - "name": "recipientSig", - "type": "signature" - } - ], - "witnessSchema": [ - { - "name": "recipientSig", - "type": "signature", - "encoding": "schnorr-64" - } - ], + "functionInputs": [], + "witnessSchema": [], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "1-of-1 signatures required (introspection fallback)" + "message": "0-of-0 signatures required (introspection fallback)" }, { "type": "older", @@ -226,9 +341,6 @@ } ], "asm": [ - "", - "", - "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" @@ -238,8 +350,8 @@ "name": "send", "functionInputs": [ { - "name": "amount", - "type": "int" + "name": "sendMarkerScriptHash", + "type": "bytes32" }, { "name": "ownerSig", @@ -252,9 +364,9 @@ ], "witnessSchema": [ { - "name": "amount", - "type": "int", - "encoding": "scriptnum" + "name": "sendMarkerScriptHash", + "type": "bytes32", + "encoding": "raw-32" }, { "name": "ownerSig", @@ -274,11 +386,29 @@ ], "serverVariant": true, "require": [ + { + "type": "signature" + }, { "type": "comparison" }, { - "type": "signature" + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" }, { "type": "groupCheck" @@ -303,12 +433,73 @@ } ], "asm": [ - "", - "0", - "OP_GREATERTHAN", "", "", "OP_CHECKSIG", + "20", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "OP_SIZE", + "OP_NIP", + "175", + "OP_EQUAL", + "20", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "0", + "1", + "OP_SUBSTR", + "1", + "OP_EQUAL", + "20", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "1", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "20", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "33", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "20", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "67", + "4", + "OP_SUBSTR", + "OP_BIN2NUM", + "", + "OP_EQUAL", + "20", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "71", + "32", + "OP_SUBSTR", + "", + "OP_EQUAL", + "20", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "65", + "2", + "OP_SUBSTR", + "OP_BIN2NUM", + "1", + "OP_EQUAL", "", "", "OP_FINDASSETGROUPBYASSETID", @@ -318,11 +509,17 @@ "", "OP_1", "OP_INSPECTASSETGROUPSUM", - "", - "OP_SCRIPTNUMTOLE64", + "20", + "OP_INSPECTPACKET", + "OP_1", + "OP_EQUALVERIFY", + "103", + "8", + "OP_SUBSTR", + "OP_BIN2NUM", "OP_ADD64", "OP_VERIFY", - "OP_GREATERTHANOREQUAL", + "OP_EQUAL", "1", "", "", @@ -337,7 +534,7 @@ "OP_VERIFY", "1", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",)>", + ",,)>", "OP_EQUAL", "", "", @@ -349,7 +546,7 @@ "OP_EQUAL", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,)>", + ",,,,,,,,,)>", "OP_EQUAL", "0", "", @@ -372,8 +569,8 @@ "name": "send", "functionInputs": [ { - "name": "amount", - "type": "int" + "name": "sendMarkerScriptHash", + "type": "bytes32" }, { "name": "ownerSig", @@ -416,12 +613,12 @@ ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract OApp(\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 usdt0AssetId,\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n int exit\n) {\n\n function receive(int amount, pubkey recipient) {\n require(amount > 0, \"amount must be positive\");\n\n require(\n tx.inputs[0].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset not on input 0\"\n );\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 0, \"marker not burned\");\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(usdt0Group.delta == amount, \"usdt0 delta mismatch\");\n\n require(\n tx.outputs[1].assets.lookup(usdt0AssetId) == amount,\n \"recipient amount mismatch\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SingleSig(recipient),\n \"recipient pkScript wrong\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId, exit\n ),\n \"oapp state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1,\n \"oapp control missing\"\n );\n require(\n tx.outputs[0].assets.lookup(oappIDAssetId) == 1,\n \"oapp id missing\"\n );\n }\n\n function send(int amount, signature ownerSig, pubkey ownerPk) {\n require(amount > 0, \"amount must be positive\");\n require(checkSig(ownerSig, ownerPk), \"owner sig invalid\");\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(usdt0Group.sumInputs >= usdt0Group.sumOutputs + amount, \"burn short\");\n\n require(\n tx.outputs[1].assets.lookup(oappIDAssetId) == 1,\n \"send marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SendMarker(endpointCtrlAssetId, exit),\n \"send marker pkScript not canonical\"\n );\n\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumOutputs == 1, \"extra send marker\");\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId, exit\n ),\n \"oapp state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1,\n \"oapp control missing\"\n );\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract OApp(\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 usdt0AssetId,\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n int exit\n) {\n\n function receive() {\n require(size(tx.inputs[0].packet(17)) == 219, \"lz receive packet size\");\n require(substr(tx.inputs[0].packet(17), 0, 1) == 1, \"lz receive version\");\n\n require(\n bin2num(substr(tx.inputs[0].packet(17), 141, 4)) == 74,\n \"credit message length\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset not on input 0\"\n );\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 0, \"marker not burned\");\n\n require(\n substr(tx.inputs[0].packet(17), 1, 32) == oappID,\n \"lz receiver != oappID\"\n );\n require(\n bin2num(substr(tx.inputs[0].packet(17), 33, 4)) == remoteEID,\n \"lz srcEID != remoteEID\"\n );\n require(\n substr(tx.inputs[0].packet(17), 37, 32) == remoteOApp,\n \"lz sender != remoteOApp\"\n );\n\n require(\n sha256(substr(tx.inputs[0].packet(17), 145, 74))\n == substr(tx.inputs[0].packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n substr(tx.inputs[0].packet(17), 187, 32) == substr(tx.inputs[0].packet(17), 37, 32),\n \"credit remoteSender mismatch\"\n );\n\n require(\n tx.outputs[1].scriptPubKey == substr(tx.inputs[0].packet(17), 147, 32),\n \"recipient pkScript mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(usdt0AssetId)\n == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"recipient amount mismatch\"\n );\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.delta == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"usdt0 delta != credit amount\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n require(tx.outputs[0].assets.lookup(oappIDAssetId) == 1, \"oapp id missing\");\n }\n\n function send(bytes32 sendMarkerScriptHash, signature ownerSig, pubkey ownerPk) {\n require(checkSig(ownerSig, ownerPk), \"owner sig invalid\");\n\n require(size(tx.packet(20)) == 175, \"send invocation packet size\");\n require(substr(tx.packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(20), 1, 32) == oappID, \"invocation oappID\");\n require(substr(tx.packet(20), 33, 32) == endpointID, \"invocation endpointID\");\n\n require(bin2num(substr(tx.packet(20), 67, 4)) == remoteEID, \"invocation dstEID\");\n require(substr(tx.packet(20), 71, 32) == remoteOApp, \"invocation receiver\");\n\n require(bin2num(substr(tx.packet(20), 65, 2)) == 1, \"invocation_vout != 1\");\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.sumInputs == usdt0Group.sumOutputs + bin2num(substr(tx.packet(20), 103, 8)),\n \"burn amount mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(oappIDAssetId) == 1,\n \"send marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SendMarker(\n sendMarkerScriptHash, endpointCtrlAssetId, exit\n ),\n \"send marker pkScript not canonical\"\n );\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumOutputs == 1, \"extra send marker minted\");\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T16:59:10.690234971+00:00", + "updatedAt": "2026-05-15T19:51:17.482996482+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", @@ -431,6 +628,8 @@ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] } \ No newline at end of file diff --git a/examples/layerzero/receive_marker.ark b/examples/layerzero/receive_marker.ark index a88263d..e7dc77d 100644 --- a/examples/layerzero/receive_marker.ark +++ b/examples/layerzero/receive_marker.ark @@ -5,23 +5,13 @@ // // Locks a one-unit EndpointID asset alongside the LzReceivePacket extension // data emitted by Endpoint.receive(). Consumable only when the consuming -// transaction also spends the canonical OApp state UTXO (and therefore is -// running OApp.receive()). +// transaction also spends the canonical OApp state UTXO running OApp.receive(). // -// In the Go reference, BuildReceiveInvocationScript pins the marker to: -// (1) a fixed input position (OP_PUSHCURRENTINPUTINDEX == config.ReceiveInvocationInputIndex); -// (2) the OApp.receive() Arkade closure (OP_INSPECTINPUTARKADESCRIPTHASH -// on the OApp-state input == config.OAppReceiveScriptHash); -// (3) defense-in-depth: that same input carries 1 OApp control asset. -// -// The Arkade compiler currently exposes neither OP_PUSHCURRENTINPUTINDEX -// equality checks nor OP_INSPECTINPUTARKADESCRIPTHASH. Check (3) on its -// own is sufficient cryptographically: the OApp control asset is a one-shot -// singleton issued in the bootstrap transaction; only the real OApp state -// UTXO carries it. Any UTXO presenting the OApp control asset on its input -// has, by construction, gone through OApp issuance. Checks (1) and (2) are -// noted for reviewers; in production they'd be added as the compiler grows -// support for input-position and input-script-hash introspection. +// Mirrors the three Go-script checks: +// 1. Marker spent from input position config.ReceiveInvocationInputIndex. +// 2. The OApp state input was spent through the OApp.receive() Arkade +// closure (OP_INSPECTINPUTARKADESCRIPTHASH). +// 3. Defense-in-depth: OApp control singleton on the OApp state input. options { server = server; @@ -29,13 +19,27 @@ options { } contract ReceiveMarker( + bytes32 oappReceiveScriptHash, bytes32 oappCtrlAssetId, int exit ) { - // Single execution path: the marker is consumed inside OApp.receive(). - // OApp.receive() reads its marker input from input 0 (see oapp.ark); - // the OApp state input is at input 1, so we check there. + // Single execution path: consumed inside OApp.receive(). + // + // OApp.receive() reads its marker input from input 0 and its OApp state + // input from input 1. The marker must be at input 0 (PUSHCURRENTINPUTINDEX), + // and input 1 must have been spent through the OApp.receive() Arkade + // closure with the OApp control singleton attached. function consume() { + // (1) the marker is at the expected input position. + require(this.activeInputIndex == 0, "marker input position"); + + // (2) the OApp state input was spent through the OApp.receive() closure. + require( + tx.inputs[1].arkadeScriptHash == oappReceiveScriptHash, + "oapp state not via receive closure" + ); + + // (3) defense-in-depth: OApp control singleton on the consuming input. require( tx.inputs[1].assets.lookup(oappCtrlAssetId) == 1, "oapp state input missing control asset" diff --git a/examples/layerzero/receive_marker.json b/examples/layerzero/receive_marker.json index c73e0a2..4422841 100644 --- a/examples/layerzero/receive_marker.json +++ b/examples/layerzero/receive_marker.json @@ -1,6 +1,10 @@ { "contractName": "ReceiveMarker", "constructorInputs": [ + { + "name": "oappReceiveScriptHash", + "type": "bytes32" + }, { "name": "oappCtrlAssetId_txid", "type": "bytes32" @@ -27,6 +31,12 @@ ], "serverVariant": true, "require": [ + { + "type": "comparison" + }, + { + "type": "comparison" + }, { "type": "assetCheck" }, @@ -35,6 +45,13 @@ } ], "asm": [ + "OP_PUSHCURRENTINPUTINDEX", + "OP_EQUAL", + "0", + "1", + "OP_INSPECTINPUTARKADESCRIPTHASH", + "", + "OP_EQUAL", "1", "", "", @@ -74,12 +91,12 @@ ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract ReceiveMarker(\n bytes32 oappCtrlAssetId,\n int exit\n) {\n function consume() {\n require(\n tx.inputs[1].assets.lookup(oappCtrlAssetId) == 1,\n \"oapp state input missing control asset\"\n );\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract ReceiveMarker(\n bytes32 oappReceiveScriptHash,\n bytes32 oappCtrlAssetId,\n int exit\n) {\n function consume() {\n require(this.activeInputIndex == 0, \"marker input position\");\n\n require(\n tx.inputs[1].arkadeScriptHash == oappReceiveScriptHash,\n \"oapp state not via receive closure\"\n );\n\n require(\n tx.inputs[1].assets.lookup(oappCtrlAssetId) == 1,\n \"oapp state input missing control asset\"\n );\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T16:59:10.759796594+00:00", + "updatedAt": "2026-05-15T19:51:17.546947453+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/examples/layerzero/send_marker.ark b/examples/layerzero/send_marker.ark index 91d77ca..c024068 100644 --- a/examples/layerzero/send_marker.ark +++ b/examples/layerzero/send_marker.ark @@ -5,12 +5,14 @@ // // Locks a one-unit OAppID asset alongside the OAppSendInvocation extension // data emitted by OApp.send(). Consumable only when the consuming -// transaction also spends the canonical Endpoint state UTXO (and therefore -// is running Endpoint.send()). +// transaction also spends the canonical Endpoint state UTXO running +// Endpoint.send(). Symmetric to ReceiveMarker. // -// Symmetric to ReceiveMarker. See its comment for the rationale on which -// Go-script checks are expressed here vs. delegated to the introspector -// runtime. +// Mirrors the three Go-script checks: +// 1. Marker spent from input position config.SendInvocationInputIndex. +// 2. The Endpoint state input was spent through the Endpoint.send() +// Arkade closure (OP_INSPECTINPUTARKADESCRIPTHASH). +// 3. Defense-in-depth: Endpoint control singleton on the Endpoint state input. options { server = server; @@ -18,14 +20,27 @@ options { } contract SendMarker( + bytes32 endpointSendScriptHash, bytes32 endpointCtrlAssetId, int exit ) { - // Single execution path: the marker is consumed inside Endpoint.send(). - // Endpoint.send() reads the marker input from input 1; the Endpoint state - // input is at input 0, so we check there for the Endpoint control asset - // singleton. + // Single execution path: consumed inside Endpoint.send(). + // + // Endpoint.send() reads its Endpoint state input from input 0 and the + // OApp-emitted send marker from input 1. The marker must be at input 1 + // (PUSHCURRENTINPUTINDEX), and input 0 must have been spent through the + // Endpoint.send() Arkade closure with the Endpoint control singleton. function consume() { + // (1) the marker is at the expected input position. + require(this.activeInputIndex == 1, "marker input position"); + + // (2) the Endpoint state input was spent through the Endpoint.send() closure. + require( + tx.inputs[0].arkadeScriptHash == endpointSendScriptHash, + "endpoint state not via send closure" + ); + + // (3) defense-in-depth: Endpoint control singleton on the consuming input. require( tx.inputs[0].assets.lookup(endpointCtrlAssetId) == 1, "endpoint state input missing control asset" diff --git a/examples/layerzero/send_marker.json b/examples/layerzero/send_marker.json index dbb9a2d..68a1dbc 100644 --- a/examples/layerzero/send_marker.json +++ b/examples/layerzero/send_marker.json @@ -1,6 +1,10 @@ { "contractName": "SendMarker", "constructorInputs": [ + { + "name": "endpointSendScriptHash", + "type": "bytes32" + }, { "name": "endpointCtrlAssetId_txid", "type": "bytes32" @@ -27,6 +31,12 @@ ], "serverVariant": true, "require": [ + { + "type": "comparison" + }, + { + "type": "comparison" + }, { "type": "assetCheck" }, @@ -35,6 +45,13 @@ } ], "asm": [ + "OP_PUSHCURRENTINPUTINDEX", + "OP_EQUAL", + "1", + "0", + "OP_INSPECTINPUTARKADESCRIPTHASH", + "", + "OP_EQUAL", "0", "", "", @@ -74,12 +91,12 @@ ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract SendMarker(\n bytes32 endpointCtrlAssetId,\n int exit\n) {\n function consume() {\n require(\n tx.inputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint state input missing control asset\"\n );\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract SendMarker(\n bytes32 endpointSendScriptHash,\n bytes32 endpointCtrlAssetId,\n int exit\n) {\n function consume() {\n require(this.activeInputIndex == 1, \"marker input position\");\n\n require(\n tx.inputs[0].arkadeScriptHash == endpointSendScriptHash,\n \"endpoint state not via send closure\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint state input missing control asset\"\n );\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T16:59:10.827409801+00:00", + "updatedAt": "2026-05-15T19:51:17.612660169+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 160b4eb..793fe78 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -102,6 +102,7 @@ fn expression_uses_introspection(expr: &Expression) -> bool { Expression::ArrayIndex { array, index } => { expression_uses_introspection(array) || expression_uses_introspection(index) } + Expression::Sha256 { data } => expression_uses_introspection(data), Expression::Sha256Initialize { data } => expression_uses_introspection(data), Expression::Sha256Update { context, chunk } => { expression_uses_introspection(context) || expression_uses_introspection(chunk) @@ -897,7 +898,14 @@ fn generate_expression_asm(expr: &Expression, asm: &mut Vec) { asm.push(lit.clone()); } Expression::Property(prop) => { - asm.push(format!("<{}>", prop)); + // Map the introspector "this" properties to their dedicated opcodes + // (the parser stores them as Property strings; resolving them here + // keeps the placeholder pipeline untouched for everything else). + match prop.as_str() { + "this.activeInputIndex" => asm.push(OP_PUSHCURRENTINPUTINDEX.to_string()), + "this.activeBytecode" => asm.push(OP_INPUTBYTECODE.to_string()), + _ => asm.push(format!("<{}>", prop)), + } } Expression::BinaryOp { left, op, right } => { // Emit left operand @@ -995,6 +1003,10 @@ fn generate_expression_asm(expr: &Expression, asm: &mut Vec) { asm.push(OP_CHECKSIGFROMSTACK.to_string()); } // Streaming SHA256 + Expression::Sha256 { data } => { + generate_expression_asm(data, asm); + asm.push(OP_SHA256.to_string()); + } Expression::Sha256Initialize { data } => { generate_expression_asm(data, asm); asm.push(OP_SHA256INITIALIZE.to_string()); @@ -1262,7 +1274,12 @@ fn generate_comparison_asm(left: &Expression, op: &str, right: &Expression, asm: asm.push(format!("<{}>", var)); } (Expression::Property(prop), "==", Expression::Literal(value)) => { - asm.push(format!("<{}>", prop)); + // Mirror generate_expression_asm: map "this" properties to opcodes. + match prop.as_str() { + "this.activeInputIndex" => asm.push(OP_PUSHCURRENTINPUTINDEX.to_string()), + "this.activeBytecode" => asm.push(OP_INPUTBYTECODE.to_string()), + _ => asm.push(format!("<{}>", prop)), + } asm.push(OP_EQUAL.to_string()); asm.push(value.clone()); } @@ -1429,7 +1446,14 @@ fn emit_expression_asm(expr: &Expression, asm: &mut Vec) { asm.push(lit.clone()); } Expression::Property(prop) => { - asm.push(format!("<{}>", prop)); + // Map the introspector "this" properties to their dedicated opcodes + // (the parser stores them as Property strings; resolving them here + // keeps the placeholder pipeline untouched for everything else). + match prop.as_str() { + "this.activeInputIndex" => asm.push(OP_PUSHCURRENTINPUTINDEX.to_string()), + "this.activeBytecode" => asm.push(OP_INPUTBYTECODE.to_string()), + _ => asm.push(format!("<{}>", prop)), + } } Expression::CurrentInput(property) => { emit_current_input_asm(property.as_deref(), asm); @@ -1549,6 +1573,10 @@ fn emit_expression_asm(expr: &Expression, asm: &mut Vec) { asm.push(OP_CHECKSIGFROMSTACK.to_string()); } // Streaming SHA256 + Expression::Sha256 { data } => { + emit_expression_asm(data, asm); + asm.push(OP_SHA256.to_string()); + } Expression::Sha256Initialize { data } => { emit_expression_asm(data, asm); asm.push(OP_SHA256INITIALIZE.to_string()); diff --git a/src/models/mod.rs b/src/models/mod.rs index 4b10901..91c0189 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -336,6 +336,9 @@ pub enum Expression { message: String, }, // ─── Streaming SHA256 ────────────────────────────────────────────── + /// Plain SHA256: sha256(data) → emits ` OP_SHA256`. + /// Used for inline hashing of byte-string expressions like substr. + Sha256 { data: Box }, /// Streaming SHA256 initialize: sha256Initialize(data) Sha256Initialize { data: Box }, /// Streaming SHA256 update: sha256Update(ctx, chunk) diff --git a/src/parser/grammar.pest b/src/parser/grammar.pest index dae83af..952ddc4 100644 --- a/src/parser/grammar.pest +++ b/src/parser/grammar.pest @@ -181,6 +181,7 @@ complex_expression = _{ check_multisig | time_comparison | hash_comparison | + byte_expr_comparison | asset_lookup_comparison | asset_count_comparison | asset_at_comparison | @@ -250,8 +251,10 @@ asset_lookup_source = { "inputs" | "outputs" } // Handles: tx.inputs[0].assets.lookup(id) >= 0 // tx.outputs[0].assets.lookup(id) >= tx.inputs[0].assets.lookup(id) // tx.outputs[0].assets.lookup(id) >= tx.inputs[0].assets.lookup(id) + amount +// tx.outputs[1].assets.lookup(id) == bin2num(substr(packet, off, 8)) +// (used by LayerZero OApp.receive to credit USDT0 from the packet) asset_lookup_comparison = { - asset_lookup ~ binary_operator ~ (asset_lookup_arith_expr | asset_lookup | identifier | number_literal) + asset_lookup ~ binary_operator ~ (asset_lookup_arith_expr | asset_lookup | bin2num_func | identifier | number_literal) } // Asset count comparison: asset_count op expression @@ -312,14 +315,20 @@ output_introspection = { // Output introspection properties (excluding asset - use .assets.* API instead) output_introspection_property = { "value" | "scriptPubKey" | "nonce" } -// Input introspection comparison: input_introspection op expression +// Input introspection comparison: input_introspection op expression. +// substr_func is permitted on the RHS so contracts can match packet-derived +// byte fields against introspection results (e.g. arkadeScriptHash equality +// or scriptPubKey == substr(packet, ...)). input_introspection_comparison = { - input_introspection ~ binary_operator ~ (input_introspection | output_introspection | tx_property_access | this_property_access | constructor | identifier | number_literal) + input_introspection ~ binary_operator ~ (substr_func | input_introspection | output_introspection | tx_property_access | this_property_access | constructor | identifier | number_literal) } -// Output introspection comparison: output_introspection op expression +// Output introspection comparison: output_introspection op expression. +// See input_introspection_comparison for the substr_func extension rationale — +// LayerZero's OApp.receive() pins the recipient output's x-only key to the +// CreditMessage byte slice. output_introspection_comparison = { - output_introspection ~ binary_operator ~ (input_introspection | output_introspection | tx_property_access | this_property_access | constructor | identifier | number_literal) + output_introspection ~ binary_operator ~ (substr_func | input_introspection | output_introspection | tx_property_access | this_property_access | constructor | identifier | number_literal) } // ─── Asset Groups ────────────────────────────────────────────────────────────── @@ -344,13 +353,25 @@ group_property = @{ // Matches patterns like: tokenGroup.delta == amount, tokenGroup.sumOutputs >= 0 // Also supports: group.sumOutputs >= group.sumInputs (both sides can have group property) // Also supports: group.sumInputs >= group.sumOutputs + amount (arithmetic on right side) +// Also supports: usdt0Group.delta == bin2num(substr(packet, off, 8)) +// (used by LayerZero OApp.receive to credit USDT0 from a packet field) group_property_comparison = { - identifier ~ "." ~ group_property ~ binary_operator ~ (group_property_arith_expr | identifier_property_access | asset_lookup | asset_group_access | identifier | number_literal) + identifier ~ "." ~ group_property ~ binary_operator ~ ( + group_property_arith_expr | + identifier_property_access | + asset_lookup | + asset_group_access | + bin2num_func | + identifier | + number_literal + ) } // Arithmetic expression with group properties: group.property +/- value +// RHS may include bin2num(...) so packet-derived amounts can balance against +// group sums (used by LayerZero OApp.send burn-amount check). group_property_arith_expr = { - identifier_property_access ~ arith_op ~ (identifier | number_literal) + identifier_property_access ~ arith_op ~ (bin2num_func | identifier | number_literal) } // Arithmetic operator for group property expressions @@ -379,11 +400,45 @@ property_comparison = { (tx_property_access | this_property_access) ~ binary_operator ~ (asset_lookup | tx_property_access | this_property_access | constructor | number_literal | identifier) } -// Hash comparison (sha256(preimage) == hash) +// Hash comparison (sha256(preimage) == hash). The RHS may be a simple +// identifier (legacy fast path, e.g. htlc), or any byte-producing term so +// that constructions like `sha256(substr(packet, ...)) == substr(other, ...)` +// — used by the LayerZero scripts — parse natively. hash_comparison = { - sha256_func ~ "==" ~ identifier + sha256_func ~ "==" ~ (substr_func | cat_func | num2bin_func | identifier) +} + +// Byte-string / packet-field comparison. +// Lets contracts compare results of substr/cat/bin2num/size against +// constructor parameters, witnesses, integer literals, or each other — +// e.g. `require(substr(state, 1, 32) == endpointID)` or +// `require(bin2num(substr(state, 167, 8)) + 1 == bin2num(substr(recv, 69, 8)))`. +byte_expr_comparison = { + byte_expr_term ~ binary_operator ~ byte_expr_rhs } +// A single byte-string-producing or numeric-from-bytes term. +// sha256_func is included so `sha256(substr(...)) == substr(...)` works +// (the legacy hash_comparison shape, `sha256(x) == identifier`, keeps its +// fast path because PEG ordering puts hash_comparison earlier). +byte_expr_term = { + sha256_func | substr_func | cat_func | bin2num_func | num2bin_func | size_func +} + +// RHS of a byte comparison: another term, a small arithmetic expression +// over numeric byte-derived values, or a plain identifier / literal. +byte_expr_rhs = { + byte_expr_arith | byte_expr_term | identifier | number_literal +} + +// A two-operand arithmetic expression where either side may be a +// bin2num/size term, an identifier, or a number literal. +byte_expr_arith = { + byte_expr_atom ~ ("+" | "-") ~ byte_expr_atom +} + +byte_expr_atom = { bin2num_func | size_func | identifier | number_literal } + // Binary operations between literals or identifiers binary_operation = { (number_literal ~ binary_operator ~ (identifier | number_literal)) | @@ -575,19 +630,35 @@ check_sig_from_stack_verify = { // These map to the introspector's byte-string opcodes and are used to slice, // concat, and parse fixed-width fields out of extension packets. +// A byte-producing operand. Accepted as a child of substr/cat/bin2num/size +// so packet introspection results can flow directly into slicing: +// substr(tx.packet(EndpointState), 1, 32) +// sha256(substr(tx.inputs[1].packet(LzReceive), 1, 140)) +byte_value = { + substr_func | + cat_func | + num2bin_func | + packet_inspect | + input_packet_inspect | + input_introspection | + output_introspection | + asset_at | + identifier +} + // Substring extraction: substr(data, offset, size) → OP_SUBSTR substr_func = { - "substr" ~ "(" ~ (identifier | number_literal) ~ "," ~ (identifier | number_literal) ~ "," ~ (identifier | number_literal) ~ ")" + "substr" ~ "(" ~ byte_value ~ "," ~ (identifier | number_literal) ~ "," ~ (identifier | number_literal) ~ ")" } // Byte concatenation: cat(a, b) → OP_CAT cat_func = { - "cat" ~ "(" ~ (identifier | number_literal) ~ "," ~ (identifier | number_literal) ~ ")" + "cat" ~ "(" ~ byte_value ~ "," ~ byte_value ~ ")" } // Bytes → BigNum: bin2num(bytes) → OP_BIN2NUM bin2num_func = { - "bin2num" ~ "(" ~ (identifier | number_literal) ~ ")" + "bin2num" ~ "(" ~ byte_value ~ ")" } // BigNum → fixed-width bytes: num2bin(num, size) → OP_NUM2BIN @@ -597,7 +668,7 @@ num2bin_func = { // Byte-array length: size(bytes) → OP_SIZE OP_NIP (returns size only) size_func = { - "size" ~ "(" ~ (identifier | number_literal) ~ ")" + "size" ~ "(" ~ byte_value ~ ")" } // ─── Packet Introspection ────────────────────────────────────────────── diff --git a/src/parser/mod.rs b/src/parser/mod.rs index fef212e..15c0cb2 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -524,6 +524,7 @@ fn parse_complex_expression(pair: Pair) -> Result { Rule::identifier_comparison => parse_identifier_comparison(pair), Rule::property_comparison => parse_property_comparison(pair), Rule::hash_comparison => parse_hash_comparison(pair), + Rule::byte_expr_comparison => parse_byte_expr_comparison(pair), 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), @@ -861,14 +862,148 @@ fn parse_hash_comparison(pair: Pair) -> Result { let mut inner = pair.into_inner(); let sha256_func = inner.next().ok_or("Missing hash function")?; let mut sha256_inner = sha256_func.into_inner(); - let preimage = sha256_inner + let preimage_pair = sha256_inner.next().ok_or("Missing preimage")?; + let rhs_pair = inner.next().ok_or("Missing the hash")?; + + // Fast path: identifier preimage AND identifier RHS keep the legacy + // HashEqual emission (` OP_SHA256 OP_EQUAL`). + let preimage_is_atom = matches!( + preimage_pair.as_rule(), + Rule::identifier | Rule::number_literal + ); + let rhs_is_identifier = matches!(rhs_pair.as_rule(), Rule::identifier); + if preimage_is_atom && rhs_is_identifier { + return Ok(Requirement::HashEqual { + preimage: preimage_pair.as_str().to_string(), + hash: rhs_pair.as_str().to_string(), + }); + } + + // Complex preimage and/or complex RHS: parse both sides as Expressions + // and emit via Comparison so byte-producing primitives expand inline. + let preimage_expr = match preimage_pair.as_rule() { + Rule::substr_func => parse_substr(preimage_pair)?, + Rule::cat_func => parse_cat(preimage_pair)?, + Rule::bin2num_func => parse_bin2num(preimage_pair)?, + Rule::size_func => parse_size(preimage_pair)?, + Rule::packet_inspect => parse_packet_inspect(preimage_pair)?, + Rule::input_packet_inspect => parse_input_packet_inspect(preimage_pair)?, + Rule::input_introspection => parse_input_introspection_to_expression(preimage_pair)?, + Rule::output_introspection => parse_output_introspection_to_expression(preimage_pair)?, + Rule::identifier => Expression::Variable(preimage_pair.as_str().to_string()), + Rule::number_literal => Expression::Literal(preimage_pair.as_str().to_string()), + _ => Expression::Property(preimage_pair.as_str().to_string()), + }; + let rhs_expr = match rhs_pair.as_rule() { + Rule::substr_func => parse_substr(rhs_pair)?, + Rule::cat_func => parse_cat(rhs_pair)?, + Rule::num2bin_func => parse_num2bin(rhs_pair)?, + Rule::identifier => Expression::Variable(rhs_pair.as_str().to_string()), + Rule::number_literal => Expression::Literal(rhs_pair.as_str().to_string()), + _ => Expression::Property(rhs_pair.as_str().to_string()), + }; + + Ok(Requirement::Comparison { + left: Expression::Sha256 { + data: Box::new(preimage_expr), + }, + op: "==".to_string(), + right: rhs_expr, + }) +} + +/// Parse a `byte_expr_term` rule into an Expression +/// (sha256/substr/cat/bin2num/num2bin/size). +fn parse_byte_expr_term(pair: Pair) -> Result { + // byte_expr_term is a single-alternative wrapper — descend into the inner rule. + let inner = pair.into_inner().next().ok_or("Empty byte_expr_term")?; + match inner.as_rule() { + Rule::sha256_func => { + // Parse the inner argument and wrap with Expression::Sha256 so the + // compiler emits inline OP_SHA256. + let arg_pair = inner.into_inner().next().ok_or("Missing sha256 argument")?; + let data = match arg_pair.as_rule() { + Rule::substr_func => parse_substr(arg_pair)?, + Rule::cat_func => parse_cat(arg_pair)?, + Rule::bin2num_func => parse_bin2num(arg_pair)?, + Rule::size_func => parse_size(arg_pair)?, + Rule::packet_inspect => parse_packet_inspect(arg_pair)?, + Rule::input_packet_inspect => parse_input_packet_inspect(arg_pair)?, + Rule::input_introspection => parse_input_introspection_to_expression(arg_pair)?, + Rule::output_introspection => parse_output_introspection_to_expression(arg_pair)?, + Rule::identifier => Expression::Variable(arg_pair.as_str().to_string()), + Rule::number_literal => Expression::Literal(arg_pair.as_str().to_string()), + _ => Expression::Property(arg_pair.as_str().to_string()), + }; + Ok(Expression::Sha256 { + data: Box::new(data), + }) + } + Rule::substr_func => parse_substr(inner), + Rule::cat_func => parse_cat(inner), + Rule::bin2num_func => parse_bin2num(inner), + Rule::num2bin_func => parse_num2bin(inner), + Rule::size_func => parse_size(inner), + r => Err(format!("Unsupported byte_expr_term rule: {:?}", r)), + } +} + +/// Parse a `byte_expr_atom` rule (one operand of byte_expr_arith). +fn parse_byte_expr_atom(pair: Pair) -> Result { + let inner = pair.into_inner().next().ok_or("Empty byte_expr_atom")?; + match inner.as_rule() { + Rule::bin2num_func => parse_bin2num(inner), + Rule::size_func => parse_size(inner), + Rule::identifier => Ok(Expression::Variable(inner.as_str().to_string())), + Rule::number_literal => Ok(Expression::Literal(inner.as_str().to_string())), + r => Err(format!("Unsupported byte_expr_atom rule: {:?}", r)), + } +} + +/// Parse a `byte_expr_arith` rule into a BinaryOp. +fn parse_byte_expr_arith(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let left = parse_byte_expr_atom(inner.next().ok_or("Missing left of byte_expr_arith")?)?; + let op = inner .next() - .ok_or("Missing preimage")? + .ok_or("Missing op in byte_expr_arith")? .as_str() .to_string(); - let hash = inner.next().ok_or("Missing the hash")?.as_str().to_string(); + let right = parse_byte_expr_atom(inner.next().ok_or("Missing right of byte_expr_arith")?)?; + Ok(Expression::BinaryOp { + left: Box::new(left), + op, + right: Box::new(right), + }) +} + +/// Parse a `byte_expr_rhs` rule into an Expression. +fn parse_byte_expr_rhs(pair: Pair) -> Result { + let inner = pair.into_inner().next().ok_or("Empty byte_expr_rhs")?; + match inner.as_rule() { + Rule::byte_expr_arith => parse_byte_expr_arith(inner), + Rule::byte_expr_term => parse_byte_expr_term(inner), + Rule::identifier => Ok(Expression::Variable(inner.as_str().to_string())), + Rule::number_literal => Ok(Expression::Literal(inner.as_str().to_string())), + r => Err(format!("Unsupported byte_expr_rhs rule: {:?}", r)), + } +} - Ok(Requirement::HashEqual { preimage, hash }) +/// Parse `byte_expr_comparison` → Requirement::Comparison. +fn parse_byte_expr_comparison(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let left = parse_byte_expr_term(inner.next().ok_or("Missing left of byte_expr_comparison")?)?; + let op = inner + .next() + .ok_or("Missing op in byte_expr_comparison")? + .as_str() + .to_string(); + let right = parse_byte_expr_rhs( + inner + .next() + .ok_or("Missing right of byte_expr_comparison")?, + )?; + Ok(Requirement::Comparison { left, op, right }) } /// Parse binary operation: expr op expr → Comparison requirement @@ -916,6 +1051,7 @@ fn parse_asset_lookup_comparison(pair: Pair) -> Result parse_arith_expr_to_expression(right_pair)?, Rule::asset_lookup => parse_asset_lookup_to_expression(right_pair)?, + Rule::bin2num_func => parse_bin2num(right_pair)?, Rule::identifier => Expression::Variable(right_pair.as_str().to_string()), Rule::number_literal => Expression::Literal(right_pair.as_str().to_string()), _ => { @@ -1295,6 +1431,7 @@ fn parse_input_introspection_comparison(pair: Pair) -> Result parse_substr(right_pair)?, Rule::input_introspection => parse_input_introspection_to_expression(right_pair)?, Rule::output_introspection => parse_output_introspection_to_expression(right_pair)?, Rule::tx_property_access | Rule::this_property_access => { @@ -1329,6 +1466,7 @@ fn parse_output_introspection_comparison(pair: Pair) -> Result parse_substr(right_pair)?, Rule::input_introspection => parse_input_introspection_to_expression(right_pair)?, Rule::output_introspection => parse_output_introspection_to_expression(right_pair)?, Rule::tx_property_access | Rule::this_property_access => { @@ -1523,6 +1661,7 @@ fn parse_group_property_comparison(pair: Pair) -> Result parse_bin2num(right_operand)?, Rule::identifier => Expression::Variable(right_operand.as_str().to_string()), Rule::number_literal => Expression::Literal(right_operand.as_str().to_string()), _ => Expression::Property(right_operand.as_str().to_string()), @@ -1561,6 +1700,7 @@ fn parse_group_property_comparison(pair: Pair) -> Result parse_bin2num(right_pair)?, Rule::identifier => Expression::Variable(right_pair.as_str().to_string()), Rule::number_literal => Expression::Literal(right_pair.as_str().to_string()), _ => { @@ -1812,10 +1952,29 @@ fn parse_atom_pair(pair: Pair) -> Expression { } } +/// Parse a `byte_value` rule into an Expression. Used wherever the grammar +/// accepts an arbitrary byte-producing operand (substr/cat/bin2num/size args). +fn parse_byte_value(pair: Pair) -> Result { + // byte_value wraps exactly one inner rule. + let inner = pair.into_inner().next().ok_or("Empty byte_value")?; + match inner.as_rule() { + Rule::substr_func => parse_substr(inner), + Rule::cat_func => parse_cat(inner), + Rule::num2bin_func => parse_num2bin(inner), + Rule::packet_inspect => parse_packet_inspect(inner), + Rule::input_packet_inspect => parse_input_packet_inspect(inner), + Rule::input_introspection => parse_input_introspection_to_expression(inner), + Rule::output_introspection => parse_output_introspection_to_expression(inner), + Rule::asset_at => parse_asset_at_to_expression(inner), + Rule::identifier => Ok(Expression::Variable(inner.as_str().to_string())), + r => Err(format!("Unsupported byte_value rule: {:?}", r)), + } +} + /// Parse substr(data, offset, size) → Expression::Substr fn parse_substr(pair: Pair) -> Result { let mut inner = pair.into_inner(); - let data = parse_atom_pair(inner.next().ok_or("Missing data in substr")?); + let data = parse_byte_value(inner.next().ok_or("Missing data in substr")?)?; let offset = parse_atom_pair(inner.next().ok_or("Missing offset in substr")?); let size = parse_atom_pair(inner.next().ok_or("Missing size in substr")?); Ok(Expression::Substr { @@ -1828,8 +1987,8 @@ fn parse_substr(pair: Pair) -> Result { /// Parse cat(a, b) → Expression::Cat fn parse_cat(pair: Pair) -> Result { let mut inner = pair.into_inner(); - let left = parse_atom_pair(inner.next().ok_or("Missing first argument in cat")?); - let right = parse_atom_pair(inner.next().ok_or("Missing second argument in cat")?); + let left = parse_byte_value(inner.next().ok_or("Missing first argument in cat")?)?; + let right = parse_byte_value(inner.next().ok_or("Missing second argument in cat")?)?; Ok(Expression::Cat { left: Box::new(left), right: Box::new(right), @@ -1839,7 +1998,7 @@ fn parse_cat(pair: Pair) -> Result { /// Parse bin2num(data) → Expression::Bin2Num fn parse_bin2num(pair: Pair) -> Result { let mut inner = pair.into_inner(); - let data = parse_atom_pair(inner.next().ok_or("Missing data in bin2num")?); + let data = parse_byte_value(inner.next().ok_or("Missing data in bin2num")?)?; Ok(Expression::Bin2Num { data: Box::new(data), }) @@ -1859,7 +2018,7 @@ fn parse_num2bin(pair: Pair) -> Result { /// Parse size(data) → Expression::SizeOf fn parse_size(pair: Pair) -> Result { let mut inner = pair.into_inner(); - let data = parse_atom_pair(inner.next().ok_or("Missing data in size")?); + let data = parse_byte_value(inner.next().ok_or("Missing data in size")?)?; Ok(Expression::SizeOf { data: Box::new(data), }) diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 64723c8..109c0fb 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -473,8 +473,9 @@ pub fn infer_type(expr: &Expression, scope: &Scope) -> ArkType { _ => ArkType::Unknown, }, - // Streaming SHA256 — all produce a 32-byte digest or midstate - Expression::Sha256Initialize { .. } + // SHA256 — all produce a 32-byte digest or midstate + Expression::Sha256 { .. } + | Expression::Sha256Initialize { .. } | Expression::Sha256Update { .. } | Expression::Sha256Finalize { .. } => ArkType::Bytes32, diff --git a/tests/layerzero_test.rs b/tests/layerzero_test.rs index 22bff9e..42de900 100644 --- a/tests/layerzero_test.rs +++ b/tests/layerzero_test.rs @@ -1,7 +1,9 @@ use arkade_compiler::compile; use arkade_compiler::opcodes::{ - OP_CHECKSIG, OP_CHECKSIGFROMSTACK, OP_FINDASSETGROUPBYASSETID, OP_INSPECTASSETGROUPSUM, - OP_INSPECTINASSETLOOKUP, OP_INSPECTOUTASSETLOOKUP, OP_INSPECTOUTPUTSCRIPTPUBKEY, + OP_CHECKSIG, OP_CHECKSIGFROMSTACKVERIFY, OP_FINDASSETGROUPBYASSETID, OP_INSPECTASSETGROUPSUM, + OP_INSPECTINASSETLOOKUP, OP_INSPECTINPUTARKADESCRIPTHASH, OP_INSPECTINPUTPACKET, + OP_INSPECTOUTASSETLOOKUP, OP_INSPECTOUTPUTSCRIPTPUBKEY, OP_INSPECTPACKET, + OP_PUSHCURRENTINPUTINDEX, OP_SHA256, OP_SUBSTR, }; // --------------------------------------------------------------------------- @@ -76,20 +78,45 @@ fn test_endpoint_receive_verifies_both_dvn_signatures() { .find(|f| f.name == "receive" && f.server_variant) .unwrap(); + // Both DVNs are now verified via OP_CHECKSIGFROMSTACKVERIFY in the + // packet-native rewrite. The signed message is the on-chain-derived + // attestedHash; each DVN sig is the corresponding witness argument. let sig_count = receive .asm .iter() - .filter(|s| s.contains(OP_CHECKSIGFROMSTACK)) + .filter(|s| s.contains(OP_CHECKSIGFROMSTACKVERIFY)) .count(); assert!( sig_count >= 2, - "endpoint.receive() must verify exactly two DVN signatures via {}; found {} occurrences", - OP_CHECKSIGFROMSTACK, + "endpoint.receive() must verify both DVN signatures via {}; found {} occurrences", + OP_CHECKSIGFROMSTACKVERIFY, sig_count ); } +#[test] +fn test_endpoint_receive_uses_packet_introspection() { + // The packet-native rewrite must use OP_INSPECTPACKET, OP_SUBSTR, and + // OP_SHA256 to enforce route, version, size, and the DVN attested-hash + // binding to the LzReceive header. + let code = load_example("endpoint"); + let output = compile(&code).unwrap(); + let receive = output + .functions + .iter() + .find(|f| f.name == "receive" && f.server_variant) + .unwrap(); + + for op in [OP_INSPECTPACKET, OP_SUBSTR, OP_SHA256] { + assert!( + receive.asm.iter().any(|s| s == op), + "endpoint.receive() must use {} for native packet enforcement", + op + ); + } +} + #[test] fn test_endpoint_receive_emits_receive_marker_output() { let code = load_example("endpoint"); @@ -189,22 +216,30 @@ fn test_oapp_receive_consumes_endpoint_marker_and_mints_usdt0() { .unwrap(); // Marker consumed from input 0 (asset lookup on input side). - let in_lookup_count = receive - .asm - .iter() - .filter(|s| s.contains(OP_INSPECTINASSETLOOKUP)) - .count(); assert!( - in_lookup_count >= 1, + receive + .asm + .iter() + .any(|s| s.contains(OP_INSPECTINASSETLOOKUP)), "oapp.receive() must inspect input assets to consume the receive marker" ); - // Output recipient receives USDT0 — and OApp state continues. - let has_singlesig = receive.asm.iter().any(|s| s.contains("VTXO:SingleSig(")); + // Previous-tx packet introspection (OP_INSPECTINPUTPACKET) is now used + // to read the LzReceivePacket from the marker input. assert!( - has_singlesig, - "oapp.receive() must pin recipient output to SingleSig(recipient): {:?}", - receive.asm + receive.asm.iter().any(|s| s == OP_INSPECTINPUTPACKET), + "oapp.receive() must read the LzReceive packet via {}", + OP_INSPECTINPUTPACKET + ); + + // The recipient output's scriptPubKey is pinned to the credit message + // x-only key via OP_INSPECTOUTPUTSCRIPTPUBKEY + OP_SUBSTR. + assert!( + receive + .asm + .iter() + .any(|s| s.contains(OP_INSPECTOUTPUTSCRIPTPUBKEY)), + "oapp.receive() must pin recipient output scriptPubKey" ); let has_oapp_continuation = receive.asm.iter().any(|s| s.contains("VTXO:OApp(")); @@ -214,6 +249,37 @@ fn test_oapp_receive_consumes_endpoint_marker_and_mints_usdt0() { ); } +#[test] +fn test_marker_contracts_use_input_arkade_script_hash() { + // Both invocation marker contracts now bind themselves to a specific + // consumer closure via OP_INSPECTINPUTARKADESCRIPTHASH, mirroring the Go + // reference (BuildReceiveInvocationScript / BuildSendInvocationScript). + for name in &["receive_marker", "send_marker"] { + let code = load_example(name); + let output = compile(&code).unwrap(); + let consume = output + .functions + .iter() + .find(|f| f.name == "consume" && f.server_variant) + .unwrap(); + assert!( + consume + .asm + .iter() + .any(|s| s == OP_INSPECTINPUTARKADESCRIPTHASH), + "{}.consume() must check the consumer's Arkade-script hash via {}", + name, + OP_INSPECTINPUTARKADESCRIPTHASH + ); + assert!( + consume.asm.iter().any(|s| s == OP_PUSHCURRENTINPUTINDEX), + "{}.consume() must pin its own input position via {}", + name, + OP_PUSHCURRENTINPUTINDEX + ); + } +} + #[test] fn test_oapp_send_emits_send_marker() { let code = load_example("oapp"); From 00a562be70800a6f8e55c4c82d0d854f67b545ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 10:41:41 +0000 Subject: [PATCH 04/15] fix(oapp): drop redundant owner signature in OApp.send() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OApp.send() carried `checkSig(ownerSig, ownerPk)` where `ownerPk` was a function parameter — anyone calling send() could pass any keypair and a valid signature for it, so the check authenticated no fixed identity. The Go reference (BuildOAppSendScript) has no such check. Authority for OApp.send() comes from: - the OApp control singleton on the spent state input (only the real OApp state holds it), - per-UTXO USDT0 input scripts (each owner signs their own input), and - either the Arkade server cosign (cooperative, serverVariant=true) or the exit CSV (fallback, serverVariant=false) — both added by the compiler automatically. Also removes the now-orphaned `require(amount > 0)` line — the amount is read from the OAppSendInvocation packet via bin2num(substr(...)), not a witness. Test update: replaces "must contain OP_CHECKSIG" with the stronger invariant "the only OP_CHECKSIG in the server variant is the trailing server cosign — no contract-level owner sig precedes ". Suite: 138 passed, 0 failed. --- examples/layerzero/oapp.ark | 14 ++++++++-- examples/layerzero/oapp.json | 53 +++--------------------------------- tests/layerzero_test.rs | 25 +++++++++++++---- 3 files changed, 35 insertions(+), 57 deletions(-) diff --git a/examples/layerzero/oapp.ark b/examples/layerzero/oapp.ark index d155694..28320af 100644 --- a/examples/layerzero/oapp.ark +++ b/examples/layerzero/oapp.ark @@ -146,10 +146,18 @@ contract OApp( // OAPP SEND // Burn the outbound USDT0 amount and emit an OAppSendInvocation packet // alongside a send-invocation marker for Endpoint.send() to consume. + // + // Authorisation: this function takes no signatures. Mirroring the Go + // reference (BuildOAppSendScript), authority comes from + // (a) the OApp control singleton, which only sits on the real OApp state; + // (b) the USDT0 input UTXOs, each requiring their own owner sig under + // their per-UTXO scripts; + // (c) the Arkade server cosign on the cooperative path (added + // automatically by serverVariant=true) or the exit CSV on the + // fallback path. No contract-level owner signature exists or is + // needed. // ------------------------------------------------------------------------- - function send(bytes32 sendMarkerScriptHash, signature ownerSig, pubkey ownerPk) { - require(checkSig(ownerSig, ownerPk), "owner sig invalid"); - + function send(bytes32 sendMarkerScriptHash) { // Current-tx OAppSendInvocation packet: v1, fixed size. require(size(tx.packet(20)) == 175, "send invocation packet size"); require(substr(tx.packet(20), 0, 1) == 1, "send invocation version"); diff --git a/examples/layerzero/oapp.json b/examples/layerzero/oapp.json index 4d2e653..df2c363 100644 --- a/examples/layerzero/oapp.json +++ b/examples/layerzero/oapp.json @@ -352,14 +352,6 @@ { "name": "sendMarkerScriptHash", "type": "bytes32" - }, - { - "name": "ownerSig", - "type": "signature" - }, - { - "name": "ownerPk", - "type": "pubkey" } ], "witnessSchema": [ @@ -368,16 +360,6 @@ "type": "bytes32", "encoding": "raw-32" }, - { - "name": "ownerSig", - "type": "signature", - "encoding": "schnorr-64" - }, - { - "name": "ownerPk", - "type": "pubkey", - "encoding": "compressed-33" - }, { "name": "serverSig", "type": "signature", @@ -386,9 +368,6 @@ ], "serverVariant": true, "require": [ - { - "type": "signature" - }, { "type": "comparison" }, @@ -433,9 +412,6 @@ } ], "asm": [ - "", - "", - "OP_CHECKSIG", "20", "OP_INSPECTPACKET", "OP_1", @@ -571,32 +547,14 @@ { "name": "sendMarkerScriptHash", "type": "bytes32" - }, - { - "name": "ownerSig", - "type": "signature" - }, - { - "name": "ownerPk", - "type": "pubkey" - }, - { - "name": "ownerPkSig", - "type": "signature" - } - ], - "witnessSchema": [ - { - "name": "ownerPkSig", - "type": "signature", - "encoding": "schnorr-64" } ], + "witnessSchema": [], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "1-of-1 signatures required (introspection fallback)" + "message": "0-of-0 signatures required (introspection fallback)" }, { "type": "older", @@ -604,21 +562,18 @@ } ], "asm": [ - "", - "", - "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract OApp(\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 usdt0AssetId,\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n int exit\n) {\n\n function receive() {\n require(size(tx.inputs[0].packet(17)) == 219, \"lz receive packet size\");\n require(substr(tx.inputs[0].packet(17), 0, 1) == 1, \"lz receive version\");\n\n require(\n bin2num(substr(tx.inputs[0].packet(17), 141, 4)) == 74,\n \"credit message length\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset not on input 0\"\n );\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 0, \"marker not burned\");\n\n require(\n substr(tx.inputs[0].packet(17), 1, 32) == oappID,\n \"lz receiver != oappID\"\n );\n require(\n bin2num(substr(tx.inputs[0].packet(17), 33, 4)) == remoteEID,\n \"lz srcEID != remoteEID\"\n );\n require(\n substr(tx.inputs[0].packet(17), 37, 32) == remoteOApp,\n \"lz sender != remoteOApp\"\n );\n\n require(\n sha256(substr(tx.inputs[0].packet(17), 145, 74))\n == substr(tx.inputs[0].packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n substr(tx.inputs[0].packet(17), 187, 32) == substr(tx.inputs[0].packet(17), 37, 32),\n \"credit remoteSender mismatch\"\n );\n\n require(\n tx.outputs[1].scriptPubKey == substr(tx.inputs[0].packet(17), 147, 32),\n \"recipient pkScript mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(usdt0AssetId)\n == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"recipient amount mismatch\"\n );\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.delta == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"usdt0 delta != credit amount\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n require(tx.outputs[0].assets.lookup(oappIDAssetId) == 1, \"oapp id missing\");\n }\n\n function send(bytes32 sendMarkerScriptHash, signature ownerSig, pubkey ownerPk) {\n require(checkSig(ownerSig, ownerPk), \"owner sig invalid\");\n\n require(size(tx.packet(20)) == 175, \"send invocation packet size\");\n require(substr(tx.packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(20), 1, 32) == oappID, \"invocation oappID\");\n require(substr(tx.packet(20), 33, 32) == endpointID, \"invocation endpointID\");\n\n require(bin2num(substr(tx.packet(20), 67, 4)) == remoteEID, \"invocation dstEID\");\n require(substr(tx.packet(20), 71, 32) == remoteOApp, \"invocation receiver\");\n\n require(bin2num(substr(tx.packet(20), 65, 2)) == 1, \"invocation_vout != 1\");\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.sumInputs == usdt0Group.sumOutputs + bin2num(substr(tx.packet(20), 103, 8)),\n \"burn amount mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(oappIDAssetId) == 1,\n \"send marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SendMarker(\n sendMarkerScriptHash, endpointCtrlAssetId, exit\n ),\n \"send marker pkScript not canonical\"\n );\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumOutputs == 1, \"extra send marker minted\");\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract OApp(\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 usdt0AssetId,\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n int exit\n) {\n\n function receive() {\n require(size(tx.inputs[0].packet(17)) == 219, \"lz receive packet size\");\n require(substr(tx.inputs[0].packet(17), 0, 1) == 1, \"lz receive version\");\n\n require(\n bin2num(substr(tx.inputs[0].packet(17), 141, 4)) == 74,\n \"credit message length\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset not on input 0\"\n );\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 0, \"marker not burned\");\n\n require(\n substr(tx.inputs[0].packet(17), 1, 32) == oappID,\n \"lz receiver != oappID\"\n );\n require(\n bin2num(substr(tx.inputs[0].packet(17), 33, 4)) == remoteEID,\n \"lz srcEID != remoteEID\"\n );\n require(\n substr(tx.inputs[0].packet(17), 37, 32) == remoteOApp,\n \"lz sender != remoteOApp\"\n );\n\n require(\n sha256(substr(tx.inputs[0].packet(17), 145, 74))\n == substr(tx.inputs[0].packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n substr(tx.inputs[0].packet(17), 187, 32) == substr(tx.inputs[0].packet(17), 37, 32),\n \"credit remoteSender mismatch\"\n );\n\n require(\n tx.outputs[1].scriptPubKey == substr(tx.inputs[0].packet(17), 147, 32),\n \"recipient pkScript mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(usdt0AssetId)\n == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"recipient amount mismatch\"\n );\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.delta == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"usdt0 delta != credit amount\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n require(tx.outputs[0].assets.lookup(oappIDAssetId) == 1, \"oapp id missing\");\n }\n\n function send(bytes32 sendMarkerScriptHash) {\n require(size(tx.packet(20)) == 175, \"send invocation packet size\");\n require(substr(tx.packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(20), 1, 32) == oappID, \"invocation oappID\");\n require(substr(tx.packet(20), 33, 32) == endpointID, \"invocation endpointID\");\n\n require(bin2num(substr(tx.packet(20), 67, 4)) == remoteEID, \"invocation dstEID\");\n require(substr(tx.packet(20), 71, 32) == remoteOApp, \"invocation receiver\");\n\n require(bin2num(substr(tx.packet(20), 65, 2)) == 1, \"invocation_vout != 1\");\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.sumInputs == usdt0Group.sumOutputs + bin2num(substr(tx.packet(20), 103, 8)),\n \"burn amount mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(oappIDAssetId) == 1,\n \"send marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SendMarker(\n sendMarkerScriptHash, endpointCtrlAssetId, exit\n ),\n \"send marker pkScript not canonical\"\n );\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumOutputs == 1, \"extra send marker minted\");\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T19:51:17.482996482+00:00", + "updatedAt": "2026-05-16T10:41:23.223384951+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", diff --git a/tests/layerzero_test.rs b/tests/layerzero_test.rs index 42de900..863d823 100644 --- a/tests/layerzero_test.rs +++ b/tests/layerzero_test.rs @@ -297,11 +297,26 @@ fn test_oapp_send_emits_send_marker() { "oapp.send() must pin output[1] to the canonical SendMarker pkScript" ); - let has_sig = send.asm.iter().any(|s| s == OP_CHECKSIG); - assert!( - has_sig, - "oapp.send() must verify the OApp owner's signature via {}", - OP_CHECKSIG + // The only OP_CHECKSIG in the server variant must be the server cosign + // added by the compiler — there is NO contract-level owner sig (mirrors + // BuildOAppSendScript). Authority comes from the OApp control singleton + // and the per-UTXO USDT0 input scripts. + let server_key_pos = send + .asm + .iter() + .position(|s| s == "") + .expect("server variant must contain "); + let checksigs_before_server: usize = send + .asm + .iter() + .take(server_key_pos) + .filter(|s| *s == OP_CHECKSIG) + .count(); + assert_eq!( + checksigs_before_server, 0, + "oapp.send() must not perform a contract-level owner signature check; \ + found {} OP_CHECKSIG before ", + checksigs_before_server ); } From 79aa582c0e011c43d4b1566e1b7e1afede6234ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 12:21:50 +0000 Subject: [PATCH 05/15] fix(arkade-bindgen): clone cli.input before partial move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newer rustc (1.94.x on CI ubuntu-latest) rejects what older versions accepted: `cli.input.expect(...)` partially moves `cli.input`, then a later `&cli` borrow of the whole struct fails with E0382. Cloning the PathBuf (small, only a few bytes overhead) keeps `cli` intact for the subsequent borrows. Pre-existing bug surfaced by the toolchain upgrade — the arkade-bindgen subcrate was unchanged by the LayerZero work, but CI runs `cargo test --verbose` from the workspace root which compiles all members. Locally with `cargo test` (single-package), bindgen was never re-compiled so the issue stayed latent. Workspace test suite: 158 passed, 0 failed. --- arkade-bindgen/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arkade-bindgen/src/main.rs b/arkade-bindgen/src/main.rs index d070759..b8107fe 100644 --- a/arkade-bindgen/src/main.rs +++ b/arkade-bindgen/src/main.rs @@ -46,7 +46,7 @@ fn main() { return; } - let input = cli.input.expect("input path is required"); + let input = cli.input.clone().expect("input path is required"); if cli.lang.is_empty() { eprintln!("Error: --lang is required (e.g., --lang typescript,go)"); From d03468b303ad9c1080e0707912a5549b1668e0d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 12:29:59 +0000 Subject: [PATCH 06/15] ci: trigger rerun From aa49711942458d2cb5f70a43abda8229879ad06c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 12:38:06 +0000 Subject: [PATCH 07/15] fix(layerzero): add operatorPk so exit witness is non-empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #25 (now on master) added tests/compilation_roundtrip_test.rs which sweeps every examples/**/*.ark file and asserts each function variant (serverVariant=true and =false) has a non-empty witnessSchema. CI was failing on the merge with master because OApp / ReceiveMarker / SendMarker had no constructor pubkeys and no function signature parameters, producing an empty witnessSchema for the exit variant: just ` OP_CHECKSEQUENCEVERIFY OP_DROP`. Without a constructor pubkey, the unilateral exit path is effectively "anyone may force-spend after the CSV timelock" — which is broken: any party could force-recover stuck OApp / marker state and produce a continuation transaction by themselves. Fix: add `pubkey operatorPk` to OApp, ReceiveMarker, SendMarker, and Endpoint constructors. The operator is the off-chain LayerZero / USDT0 relay entity and is the only N-of-N exit-witness participant. The cooperative server-cosigned path is unchanged: DVN attestations + packet introspection + OApp control singleton do all the authorisation work, exactly as the Go reference (BuildOAppReceiveScript / BuildOAppSendScript) specifies — operatorPk does NOT appear in any function body. Endpoint already had dvn0Pk + dvn1Pk in its constructor (so its exit witness was non-empty), but operatorPk is added there too for symmetry — the same operator can recover stuck Endpoint state, and Endpoint now passes operatorPk through to `new ReceiveMarker(...)` so all four contracts share one operator identity. Locally: cargo test # 138/138 (was 138/138) git merge --no-commit --no-ff origin/master && cargo test --test compilation_roundtrip_test # now passes --- examples/layerzero/endpoint.ark | 11 ++++-- examples/layerzero/endpoint.json | 42 +++++++++++++++++---- examples/layerzero/oapp.ark | 15 ++++++-- examples/layerzero/oapp.json | 51 +++++++++++++++++++++----- examples/layerzero/receive_marker.ark | 4 ++ examples/layerzero/receive_marker.json | 28 +++++++++++--- examples/layerzero/send_marker.ark | 4 ++ examples/layerzero/send_marker.json | 28 +++++++++++--- 8 files changed, 150 insertions(+), 33 deletions(-) diff --git a/examples/layerzero/endpoint.ark b/examples/layerzero/endpoint.ark index d9219e6..587049c 100644 --- a/examples/layerzero/endpoint.ark +++ b/examples/layerzero/endpoint.ark @@ -90,6 +90,11 @@ contract Endpoint( bytes32 remoteOApp, pubkey dvn0Pk, pubkey dvn1Pk, + // Operator key — shared across the four LayerZero contracts so that all + // their unilateral exit paths are governed by the same off-chain entity. + // It is *not* a contract-level authoriser for the cooperative path; DVN + // attestations + packet introspection do that work. + pubkey operatorPk, int exit ) { @@ -168,7 +173,7 @@ contract Endpoint( endpointCtrlAssetId, endpointIDAssetId, oappCtrlAssetId, oappIDAssetId, endpointID, oappID, remoteEID, remoteOApp, - dvn0Pk, dvn1Pk, exit + dvn0Pk, dvn1Pk, operatorPk, exit ), "endpoint state must continue" ); @@ -188,7 +193,7 @@ contract Endpoint( ); require( tx.outputs[1].scriptPubKey == new ReceiveMarker( - receiveMarkerScriptHash, oappCtrlAssetId, exit + receiveMarkerScriptHash, oappCtrlAssetId, operatorPk, exit ), "marker pkScript not canonical" ); @@ -273,7 +278,7 @@ contract Endpoint( endpointCtrlAssetId, endpointIDAssetId, oappCtrlAssetId, oappIDAssetId, endpointID, oappID, remoteEID, remoteOApp, - dvn0Pk, dvn1Pk, exit + dvn0Pk, dvn1Pk, operatorPk, exit ), "endpoint state must continue" ); diff --git a/examples/layerzero/endpoint.json b/examples/layerzero/endpoint.json index 714ef7f..748edcf 100644 --- a/examples/layerzero/endpoint.json +++ b/examples/layerzero/endpoint.json @@ -53,6 +53,10 @@ "name": "dvn1Pk", "type": "pubkey" }, + { + "name": "operatorPk", + "type": "pubkey" + }, { "name": "exit", "type": "int" @@ -429,7 +433,7 @@ "OP_EQUAL", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,,,,,)>", + ",,,,,,,,,,,)>", "OP_EQUAL", "0", "", @@ -457,7 +461,7 @@ "OP_VERIFY", "1", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,)>", + ",,,)>", "OP_EQUAL", "", "", @@ -486,6 +490,10 @@ { "name": "dvn1PkSig", "type": "signature" + }, + { + "name": "operatorPkSig", + "type": "signature" } ], "witnessSchema": [ @@ -498,13 +506,18 @@ "name": "dvn1PkSig", "type": "signature", "encoding": "schnorr-64" + }, + { + "name": "operatorPkSig", + "type": "signature", + "encoding": "schnorr-64" } ], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "2-of-2 signatures required (introspection fallback)" + "message": "3-of-3 signatures required (introspection fallback)" }, { "type": "older", @@ -517,6 +530,9 @@ "OP_CHECKSIGVERIFY", "", "", + "OP_CHECKSIGVERIFY", + "", + "", "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", @@ -911,7 +927,7 @@ "OP_EQUAL", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,,,,,)>", + ",,,,,,,,,,,)>", "OP_EQUAL", "0", "", @@ -940,6 +956,10 @@ { "name": "dvn1PkSig", "type": "signature" + }, + { + "name": "operatorPkSig", + "type": "signature" } ], "witnessSchema": [ @@ -952,13 +972,18 @@ "name": "dvn1PkSig", "type": "signature", "encoding": "schnorr-64" + }, + { + "name": "operatorPkSig", + "type": "signature", + "encoding": "schnorr-64" } ], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "2-of-2 signatures required (introspection fallback)" + "message": "3-of-3 signatures required (introspection fallback)" }, { "type": "older", @@ -971,6 +996,9 @@ "OP_CHECKSIGVERIFY", "", "", + "OP_CHECKSIGVERIFY", + "", + "", "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", @@ -978,12 +1006,12 @@ ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract Endpoint(\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n pubkey dvn0Pk,\n pubkey dvn1Pk,\n int exit\n) {\n\n function receive(bytes32 receiveMarkerScriptHash) {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(17)) == 219, \"lz receive packet size\");\n require(size(tx.packet(18)) == 228, \"dvn attestation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(17), 0, 1) == 1, \"lz receive version\");\n require(substr(tx.packet(18), 0, 1) == 1, \"dvn attestation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(bin2num(substr(tx.packet(16), 101, 1)) == 2, \"dvn threshold != 2\");\n require(bin2num(substr(tx.packet(16), 102, 1)) == 2, \"dvn count != 2\");\n require(substr(tx.packet(16), 103, 32) == dvn0Pk, \"endpoint state dvn0 mismatch\");\n require(substr(tx.packet(16), 135, 32) == dvn1Pk, \"endpoint state dvn1 mismatch\");\n\n require(substr(tx.packet(17), 1, 32) == oappID, \"lz receiver != oapp\");\n require(bin2num(substr(tx.packet(17), 33, 4)) == remoteEID, \"lz srcEID != remote\");\n require(substr(tx.packet(17), 37, 32) == remoteOApp, \"lz sender != remoteOApp\");\n\n require(bin2num(substr(tx.packet(18), 33, 1)) == 2, \"dvn count != 2\");\n require(bin2num(substr(tx.packet(18), 34, 1)) == 0, \"dvn att0 index != 0\");\n require(bin2num(substr(tx.packet(18), 131, 1)) == 1, \"dvn att1 index != 1\");\n require(substr(tx.packet(18), 35, 32) == dvn0Pk, \"dvn att0 pk mismatch\");\n require(substr(tx.packet(18), 132, 32) == dvn1Pk, \"dvn att1 pk mismatch\");\n require(\n sha256(substr(tx.packet(17), 1, 140)) == attestedHash,\n \"attested hash does not match lz receive header\"\n );\n require(substr(tx.packet(18), 1, 32) == attestedHash, \"dvn attested hash mismatch\");\n\n require(checkSigFromStackVerify(dvn0Sig, dvn0Pk, attestedHash), \"dvn0 sig invalid\");\n require(checkSigFromStackVerify(dvn1Sig, dvn1Pk, attestedHash), \"dvn1 sig invalid\");\n\n require(\n sha256(substr(tx.packet(17), 145, 74)) == substr(tx.packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n\n require(\n tx.outputs[1].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new ReceiveMarker(\n receiveMarkerScriptHash, oappCtrlAssetId, exit\n ),\n \"marker pkScript not canonical\"\n );\n\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 1, \"extra marker minted\");\n }\n\n function send() {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(19)) == 181, \"lz send packet size\");\n require(size(tx.inputs[1].packet(20)) == 175, \"send invocation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(19), 0, 1) == 1, \"lz send version\");\n require(substr(tx.inputs[1].packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(substr(tx.packet(19), 1, 32) == oappID, \"lz send sender != oapp\");\n require(bin2num(substr(tx.packet(19), 33, 4)) == remoteEID, \"lz dstEID != remote\");\n require(substr(tx.packet(19), 37, 32) == remoteOApp, \"lz receiver != remoteOApp\");\n\n require(substr(tx.inputs[1].packet(20), 1, 32) == oappID, \"invocation oappID mismatch\");\n require(substr(tx.inputs[1].packet(20), 33, 32) == endpointID, \"invocation endpointID mismatch\");\n require(bin2num(substr(tx.inputs[1].packet(20), 67, 4)) == remoteEID, \"invocation dstEID mismatch\");\n require(substr(tx.inputs[1].packet(20), 71, 32) == remoteOApp, \"invocation receiver mismatch\");\n\n require(\n sha256(substr(tx.inputs[1].packet(20), 0, 175)) == substr(tx.packet(19), 77, 32),\n \"lz send guid mismatch\"\n );\n\n require(\n substr(tx.inputs[1].packet(20), 1, 32) == substr(tx.packet(19), 1, 32),\n \"invocation/lzSend sender mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 67, 4) == substr(tx.packet(19), 33, 4),\n \"invocation/lzSend dstEID mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 71, 32) == substr(tx.packet(19), 37, 32),\n \"invocation/lzSend receiver mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 103, 8) == substr(tx.packet(19), 109, 8),\n \"invocation/lzSend amount mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 111, 32) == substr(tx.packet(19), 117, 32),\n \"invocation/lzSend remoteRecipient mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 143, 32) == substr(tx.packet(19), 149, 32),\n \"invocation/lzSend messageHash mismatch\"\n );\n\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumInputs == 1, \"send marker missing\");\n require(oappIDGroup.sumOutputs == 0, \"send marker not burned\");\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract Endpoint(\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n pubkey dvn0Pk,\n pubkey dvn1Pk,\n pubkey operatorPk,\n int exit\n) {\n\n function receive(bytes32 receiveMarkerScriptHash) {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(17)) == 219, \"lz receive packet size\");\n require(size(tx.packet(18)) == 228, \"dvn attestation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(17), 0, 1) == 1, \"lz receive version\");\n require(substr(tx.packet(18), 0, 1) == 1, \"dvn attestation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(bin2num(substr(tx.packet(16), 101, 1)) == 2, \"dvn threshold != 2\");\n require(bin2num(substr(tx.packet(16), 102, 1)) == 2, \"dvn count != 2\");\n require(substr(tx.packet(16), 103, 32) == dvn0Pk, \"endpoint state dvn0 mismatch\");\n require(substr(tx.packet(16), 135, 32) == dvn1Pk, \"endpoint state dvn1 mismatch\");\n\n require(substr(tx.packet(17), 1, 32) == oappID, \"lz receiver != oapp\");\n require(bin2num(substr(tx.packet(17), 33, 4)) == remoteEID, \"lz srcEID != remote\");\n require(substr(tx.packet(17), 37, 32) == remoteOApp, \"lz sender != remoteOApp\");\n\n require(bin2num(substr(tx.packet(18), 33, 1)) == 2, \"dvn count != 2\");\n require(bin2num(substr(tx.packet(18), 34, 1)) == 0, \"dvn att0 index != 0\");\n require(bin2num(substr(tx.packet(18), 131, 1)) == 1, \"dvn att1 index != 1\");\n require(substr(tx.packet(18), 35, 32) == dvn0Pk, \"dvn att0 pk mismatch\");\n require(substr(tx.packet(18), 132, 32) == dvn1Pk, \"dvn att1 pk mismatch\");\n require(\n sha256(substr(tx.packet(17), 1, 140)) == attestedHash,\n \"attested hash does not match lz receive header\"\n );\n require(substr(tx.packet(18), 1, 32) == attestedHash, \"dvn attested hash mismatch\");\n\n require(checkSigFromStackVerify(dvn0Sig, dvn0Pk, attestedHash), \"dvn0 sig invalid\");\n require(checkSigFromStackVerify(dvn1Sig, dvn1Pk, attestedHash), \"dvn1 sig invalid\");\n\n require(\n sha256(substr(tx.packet(17), 145, 74)) == substr(tx.packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, operatorPk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n\n require(\n tx.outputs[1].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new ReceiveMarker(\n receiveMarkerScriptHash, oappCtrlAssetId, operatorPk, exit\n ),\n \"marker pkScript not canonical\"\n );\n\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 1, \"extra marker minted\");\n }\n\n function send() {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(19)) == 181, \"lz send packet size\");\n require(size(tx.inputs[1].packet(20)) == 175, \"send invocation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(19), 0, 1) == 1, \"lz send version\");\n require(substr(tx.inputs[1].packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(substr(tx.packet(19), 1, 32) == oappID, \"lz send sender != oapp\");\n require(bin2num(substr(tx.packet(19), 33, 4)) == remoteEID, \"lz dstEID != remote\");\n require(substr(tx.packet(19), 37, 32) == remoteOApp, \"lz receiver != remoteOApp\");\n\n require(substr(tx.inputs[1].packet(20), 1, 32) == oappID, \"invocation oappID mismatch\");\n require(substr(tx.inputs[1].packet(20), 33, 32) == endpointID, \"invocation endpointID mismatch\");\n require(bin2num(substr(tx.inputs[1].packet(20), 67, 4)) == remoteEID, \"invocation dstEID mismatch\");\n require(substr(tx.inputs[1].packet(20), 71, 32) == remoteOApp, \"invocation receiver mismatch\");\n\n require(\n sha256(substr(tx.inputs[1].packet(20), 0, 175)) == substr(tx.packet(19), 77, 32),\n \"lz send guid mismatch\"\n );\n\n require(\n substr(tx.inputs[1].packet(20), 1, 32) == substr(tx.packet(19), 1, 32),\n \"invocation/lzSend sender mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 67, 4) == substr(tx.packet(19), 33, 4),\n \"invocation/lzSend dstEID mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 71, 32) == substr(tx.packet(19), 37, 32),\n \"invocation/lzSend receiver mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 103, 8) == substr(tx.packet(19), 109, 8),\n \"invocation/lzSend amount mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 111, 32) == substr(tx.packet(19), 117, 32),\n \"invocation/lzSend remoteRecipient mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 143, 32) == substr(tx.packet(19), 149, 32),\n \"invocation/lzSend messageHash mismatch\"\n );\n\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumInputs == 1, \"send marker missing\");\n require(oappIDGroup.sumOutputs == 0, \"send marker not burned\");\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, operatorPk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T19:51:17.407056925+00:00", + "updatedAt": "2026-05-16T12:37:48.628149614+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", diff --git a/examples/layerzero/oapp.ark b/examples/layerzero/oapp.ark index 28320af..b5a0668 100644 --- a/examples/layerzero/oapp.ark +++ b/examples/layerzero/oapp.ark @@ -46,6 +46,13 @@ contract OApp( bytes32 oappID, int remoteEID, bytes32 remoteOApp, + // Operator key — authorises the unilateral exit path only. The cooperative + // server-cosigned path does not require this signature; permissioning of + // OApp.receive / OApp.send still comes entirely from packet introspection + // and the OApp control singleton (mirrors the Go reference). The operator + // exists solely so the N-of-N exit witness has a signer; without it, + // anyone could force-spend the OApp state after the CSV timelock. + pubkey operatorPk, int exit ) { @@ -134,7 +141,8 @@ contract OApp( tx.outputs[0].scriptPubKey == new OApp( oappCtrlAssetId, oappIDAssetId, usdt0AssetId, endpointCtrlAssetId, endpointIDAssetId, - endpointID, oappID, remoteEID, remoteOApp, exit + endpointID, oappID, remoteEID, remoteOApp, + operatorPk, exit ), "oapp state must continue" ); @@ -189,7 +197,7 @@ contract OApp( ); require( tx.outputs[1].scriptPubKey == new SendMarker( - sendMarkerScriptHash, endpointCtrlAssetId, exit + sendMarkerScriptHash, endpointCtrlAssetId, operatorPk, exit ), "send marker pkScript not canonical" ); @@ -201,7 +209,8 @@ contract OApp( tx.outputs[0].scriptPubKey == new OApp( oappCtrlAssetId, oappIDAssetId, usdt0AssetId, endpointCtrlAssetId, endpointIDAssetId, - endpointID, oappID, remoteEID, remoteOApp, exit + endpointID, oappID, remoteEID, remoteOApp, + operatorPk, exit ), "oapp state must continue" ); diff --git a/examples/layerzero/oapp.json b/examples/layerzero/oapp.json index df2c363..b415c32 100644 --- a/examples/layerzero/oapp.json +++ b/examples/layerzero/oapp.json @@ -53,6 +53,10 @@ "name": "remoteOApp", "type": "bytes32" }, + { + "name": "operatorPk", + "type": "pubkey" + }, { "name": "exit", "type": "int" @@ -294,7 +298,7 @@ "OP_EQUAL", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,,,,)>", + ",,,,,,,,,,)>", "OP_EQUAL", "0", "", @@ -327,13 +331,24 @@ }, { "name": "receive", - "functionInputs": [], - "witnessSchema": [], + "functionInputs": [ + { + "name": "operatorPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "operatorPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "0-of-0 signatures required (introspection fallback)" + "message": "1-of-1 signatures required (introspection fallback)" }, { "type": "older", @@ -341,6 +356,9 @@ } ], "asm": [ + "", + "", + "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" @@ -510,7 +528,7 @@ "OP_VERIFY", "1", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,)>", + ",,,)>", "OP_EQUAL", "", "", @@ -522,7 +540,7 @@ "OP_EQUAL", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,,,,)>", + ",,,,,,,,,,)>", "OP_EQUAL", "0", "", @@ -547,14 +565,24 @@ { "name": "sendMarkerScriptHash", "type": "bytes32" + }, + { + "name": "operatorPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "operatorPkSig", + "type": "signature", + "encoding": "schnorr-64" } ], - "witnessSchema": [], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "0-of-0 signatures required (introspection fallback)" + "message": "1-of-1 signatures required (introspection fallback)" }, { "type": "older", @@ -562,18 +590,21 @@ } ], "asm": [ + "", + "", + "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract OApp(\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 usdt0AssetId,\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n int exit\n) {\n\n function receive() {\n require(size(tx.inputs[0].packet(17)) == 219, \"lz receive packet size\");\n require(substr(tx.inputs[0].packet(17), 0, 1) == 1, \"lz receive version\");\n\n require(\n bin2num(substr(tx.inputs[0].packet(17), 141, 4)) == 74,\n \"credit message length\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset not on input 0\"\n );\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 0, \"marker not burned\");\n\n require(\n substr(tx.inputs[0].packet(17), 1, 32) == oappID,\n \"lz receiver != oappID\"\n );\n require(\n bin2num(substr(tx.inputs[0].packet(17), 33, 4)) == remoteEID,\n \"lz srcEID != remoteEID\"\n );\n require(\n substr(tx.inputs[0].packet(17), 37, 32) == remoteOApp,\n \"lz sender != remoteOApp\"\n );\n\n require(\n sha256(substr(tx.inputs[0].packet(17), 145, 74))\n == substr(tx.inputs[0].packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n substr(tx.inputs[0].packet(17), 187, 32) == substr(tx.inputs[0].packet(17), 37, 32),\n \"credit remoteSender mismatch\"\n );\n\n require(\n tx.outputs[1].scriptPubKey == substr(tx.inputs[0].packet(17), 147, 32),\n \"recipient pkScript mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(usdt0AssetId)\n == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"recipient amount mismatch\"\n );\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.delta == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"usdt0 delta != credit amount\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n require(tx.outputs[0].assets.lookup(oappIDAssetId) == 1, \"oapp id missing\");\n }\n\n function send(bytes32 sendMarkerScriptHash) {\n require(size(tx.packet(20)) == 175, \"send invocation packet size\");\n require(substr(tx.packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(20), 1, 32) == oappID, \"invocation oappID\");\n require(substr(tx.packet(20), 33, 32) == endpointID, \"invocation endpointID\");\n\n require(bin2num(substr(tx.packet(20), 67, 4)) == remoteEID, \"invocation dstEID\");\n require(substr(tx.packet(20), 71, 32) == remoteOApp, \"invocation receiver\");\n\n require(bin2num(substr(tx.packet(20), 65, 2)) == 1, \"invocation_vout != 1\");\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.sumInputs == usdt0Group.sumOutputs + bin2num(substr(tx.packet(20), 103, 8)),\n \"burn amount mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(oappIDAssetId) == 1,\n \"send marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SendMarker(\n sendMarkerScriptHash, endpointCtrlAssetId, exit\n ),\n \"send marker pkScript not canonical\"\n );\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumOutputs == 1, \"extra send marker minted\");\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract OApp(\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 usdt0AssetId,\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n pubkey operatorPk,\n int exit\n) {\n\n function receive() {\n require(size(tx.inputs[0].packet(17)) == 219, \"lz receive packet size\");\n require(substr(tx.inputs[0].packet(17), 0, 1) == 1, \"lz receive version\");\n\n require(\n bin2num(substr(tx.inputs[0].packet(17), 141, 4)) == 74,\n \"credit message length\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset not on input 0\"\n );\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 0, \"marker not burned\");\n\n require(\n substr(tx.inputs[0].packet(17), 1, 32) == oappID,\n \"lz receiver != oappID\"\n );\n require(\n bin2num(substr(tx.inputs[0].packet(17), 33, 4)) == remoteEID,\n \"lz srcEID != remoteEID\"\n );\n require(\n substr(tx.inputs[0].packet(17), 37, 32) == remoteOApp,\n \"lz sender != remoteOApp\"\n );\n\n require(\n sha256(substr(tx.inputs[0].packet(17), 145, 74))\n == substr(tx.inputs[0].packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n substr(tx.inputs[0].packet(17), 187, 32) == substr(tx.inputs[0].packet(17), 37, 32),\n \"credit remoteSender mismatch\"\n );\n\n require(\n tx.outputs[1].scriptPubKey == substr(tx.inputs[0].packet(17), 147, 32),\n \"recipient pkScript mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(usdt0AssetId)\n == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"recipient amount mismatch\"\n );\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.delta == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"usdt0 delta != credit amount\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n operatorPk, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n require(tx.outputs[0].assets.lookup(oappIDAssetId) == 1, \"oapp id missing\");\n }\n\n function send(bytes32 sendMarkerScriptHash) {\n require(size(tx.packet(20)) == 175, \"send invocation packet size\");\n require(substr(tx.packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(20), 1, 32) == oappID, \"invocation oappID\");\n require(substr(tx.packet(20), 33, 32) == endpointID, \"invocation endpointID\");\n\n require(bin2num(substr(tx.packet(20), 67, 4)) == remoteEID, \"invocation dstEID\");\n require(substr(tx.packet(20), 71, 32) == remoteOApp, \"invocation receiver\");\n\n require(bin2num(substr(tx.packet(20), 65, 2)) == 1, \"invocation_vout != 1\");\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.sumInputs == usdt0Group.sumOutputs + bin2num(substr(tx.packet(20), 103, 8)),\n \"burn amount mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(oappIDAssetId) == 1,\n \"send marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SendMarker(\n sendMarkerScriptHash, endpointCtrlAssetId, operatorPk, exit\n ),\n \"send marker pkScript not canonical\"\n );\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumOutputs == 1, \"extra send marker minted\");\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n operatorPk, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T10:41:23.223384951+00:00", + "updatedAt": "2026-05-16T12:37:48.720880894+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", diff --git a/examples/layerzero/receive_marker.ark b/examples/layerzero/receive_marker.ark index e7dc77d..ed69a7e 100644 --- a/examples/layerzero/receive_marker.ark +++ b/examples/layerzero/receive_marker.ark @@ -21,6 +21,10 @@ options { contract ReceiveMarker( bytes32 oappReceiveScriptHash, bytes32 oappCtrlAssetId, + // Operator key for the unilateral exit witness only — see oapp.ark for + // rationale. Cooperative consumption requires only the server cosign and + // the on-chain checks below. + pubkey operatorPk, int exit ) { // Single execution path: consumed inside OApp.receive(). diff --git a/examples/layerzero/receive_marker.json b/examples/layerzero/receive_marker.json index 4422841..f918627 100644 --- a/examples/layerzero/receive_marker.json +++ b/examples/layerzero/receive_marker.json @@ -13,6 +13,10 @@ "name": "oappCtrlAssetId_gidx", "type": "int" }, + { + "name": "operatorPk", + "type": "pubkey" + }, { "name": "exit", "type": "int" @@ -71,13 +75,24 @@ }, { "name": "consume", - "functionInputs": [], - "witnessSchema": [], + "functionInputs": [ + { + "name": "operatorPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "operatorPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "0-of-0 signatures required (introspection fallback)" + "message": "1-of-1 signatures required (introspection fallback)" }, { "type": "older", @@ -85,18 +100,21 @@ } ], "asm": [ + "", + "", + "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract ReceiveMarker(\n bytes32 oappReceiveScriptHash,\n bytes32 oappCtrlAssetId,\n int exit\n) {\n function consume() {\n require(this.activeInputIndex == 0, \"marker input position\");\n\n require(\n tx.inputs[1].arkadeScriptHash == oappReceiveScriptHash,\n \"oapp state not via receive closure\"\n );\n\n require(\n tx.inputs[1].assets.lookup(oappCtrlAssetId) == 1,\n \"oapp state input missing control asset\"\n );\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract ReceiveMarker(\n bytes32 oappReceiveScriptHash,\n bytes32 oappCtrlAssetId,\n pubkey operatorPk,\n int exit\n) {\n function consume() {\n require(this.activeInputIndex == 0, \"marker input position\");\n\n require(\n tx.inputs[1].arkadeScriptHash == oappReceiveScriptHash,\n \"oapp state not via receive closure\"\n );\n\n require(\n tx.inputs[1].assets.lookup(oappCtrlAssetId) == 1,\n \"oapp state input missing control asset\"\n );\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T19:51:17.546947453+00:00", + "updatedAt": "2026-05-16T12:37:48.805549741+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/examples/layerzero/send_marker.ark b/examples/layerzero/send_marker.ark index c024068..732723f 100644 --- a/examples/layerzero/send_marker.ark +++ b/examples/layerzero/send_marker.ark @@ -22,6 +22,10 @@ options { contract SendMarker( bytes32 endpointSendScriptHash, bytes32 endpointCtrlAssetId, + // Operator key for the unilateral exit witness only — see oapp.ark for + // rationale. Cooperative consumption requires only the server cosign and + // the on-chain checks below. + pubkey operatorPk, int exit ) { // Single execution path: consumed inside Endpoint.send(). diff --git a/examples/layerzero/send_marker.json b/examples/layerzero/send_marker.json index 68a1dbc..b8b77c3 100644 --- a/examples/layerzero/send_marker.json +++ b/examples/layerzero/send_marker.json @@ -13,6 +13,10 @@ "name": "endpointCtrlAssetId_gidx", "type": "int" }, + { + "name": "operatorPk", + "type": "pubkey" + }, { "name": "exit", "type": "int" @@ -71,13 +75,24 @@ }, { "name": "consume", - "functionInputs": [], - "witnessSchema": [], + "functionInputs": [ + { + "name": "operatorPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "operatorPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "0-of-0 signatures required (introspection fallback)" + "message": "1-of-1 signatures required (introspection fallback)" }, { "type": "older", @@ -85,18 +100,21 @@ } ], "asm": [ + "", + "", + "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract SendMarker(\n bytes32 endpointSendScriptHash,\n bytes32 endpointCtrlAssetId,\n int exit\n) {\n function consume() {\n require(this.activeInputIndex == 1, \"marker input position\");\n\n require(\n tx.inputs[0].arkadeScriptHash == endpointSendScriptHash,\n \"endpoint state not via send closure\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint state input missing control asset\"\n );\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract SendMarker(\n bytes32 endpointSendScriptHash,\n bytes32 endpointCtrlAssetId,\n pubkey operatorPk,\n int exit\n) {\n function consume() {\n require(this.activeInputIndex == 1, \"marker input position\");\n\n require(\n tx.inputs[0].arkadeScriptHash == endpointSendScriptHash,\n \"endpoint state not via send closure\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint state input missing control asset\"\n );\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T19:51:17.612660169+00:00", + "updatedAt": "2026-05-16T12:37:48.879082871+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] From 59d12ad5c44c2a79c835cc0281aa6785ba2d1c6b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 13:02:45 +0000 Subject: [PATCH 08/15] Revert "fix(layerzero): add operatorPk so exit witness is non-empty" This reverts commit aa49711942458d2cb5f70a43abda8229879ad06c. --- examples/layerzero/endpoint.ark | 11 ++---- examples/layerzero/endpoint.json | 42 ++++----------------- examples/layerzero/oapp.ark | 15 ++------ examples/layerzero/oapp.json | 51 +++++--------------------- examples/layerzero/receive_marker.ark | 4 -- examples/layerzero/receive_marker.json | 28 +++----------- examples/layerzero/send_marker.ark | 4 -- examples/layerzero/send_marker.json | 28 +++----------- 8 files changed, 33 insertions(+), 150 deletions(-) diff --git a/examples/layerzero/endpoint.ark b/examples/layerzero/endpoint.ark index 587049c..d9219e6 100644 --- a/examples/layerzero/endpoint.ark +++ b/examples/layerzero/endpoint.ark @@ -90,11 +90,6 @@ contract Endpoint( bytes32 remoteOApp, pubkey dvn0Pk, pubkey dvn1Pk, - // Operator key — shared across the four LayerZero contracts so that all - // their unilateral exit paths are governed by the same off-chain entity. - // It is *not* a contract-level authoriser for the cooperative path; DVN - // attestations + packet introspection do that work. - pubkey operatorPk, int exit ) { @@ -173,7 +168,7 @@ contract Endpoint( endpointCtrlAssetId, endpointIDAssetId, oappCtrlAssetId, oappIDAssetId, endpointID, oappID, remoteEID, remoteOApp, - dvn0Pk, dvn1Pk, operatorPk, exit + dvn0Pk, dvn1Pk, exit ), "endpoint state must continue" ); @@ -193,7 +188,7 @@ contract Endpoint( ); require( tx.outputs[1].scriptPubKey == new ReceiveMarker( - receiveMarkerScriptHash, oappCtrlAssetId, operatorPk, exit + receiveMarkerScriptHash, oappCtrlAssetId, exit ), "marker pkScript not canonical" ); @@ -278,7 +273,7 @@ contract Endpoint( endpointCtrlAssetId, endpointIDAssetId, oappCtrlAssetId, oappIDAssetId, endpointID, oappID, remoteEID, remoteOApp, - dvn0Pk, dvn1Pk, operatorPk, exit + dvn0Pk, dvn1Pk, exit ), "endpoint state must continue" ); diff --git a/examples/layerzero/endpoint.json b/examples/layerzero/endpoint.json index 748edcf..714ef7f 100644 --- a/examples/layerzero/endpoint.json +++ b/examples/layerzero/endpoint.json @@ -53,10 +53,6 @@ "name": "dvn1Pk", "type": "pubkey" }, - { - "name": "operatorPk", - "type": "pubkey" - }, { "name": "exit", "type": "int" @@ -433,7 +429,7 @@ "OP_EQUAL", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,,,,,,)>", + ",,,,,,,,,,)>", "OP_EQUAL", "0", "", @@ -461,7 +457,7 @@ "OP_VERIFY", "1", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,)>", + ",,)>", "OP_EQUAL", "", "", @@ -490,10 +486,6 @@ { "name": "dvn1PkSig", "type": "signature" - }, - { - "name": "operatorPkSig", - "type": "signature" } ], "witnessSchema": [ @@ -506,18 +498,13 @@ "name": "dvn1PkSig", "type": "signature", "encoding": "schnorr-64" - }, - { - "name": "operatorPkSig", - "type": "signature", - "encoding": "schnorr-64" } ], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "3-of-3 signatures required (introspection fallback)" + "message": "2-of-2 signatures required (introspection fallback)" }, { "type": "older", @@ -530,9 +517,6 @@ "OP_CHECKSIGVERIFY", "", "", - "OP_CHECKSIGVERIFY", - "", - "", "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", @@ -927,7 +911,7 @@ "OP_EQUAL", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,,,,,,)>", + ",,,,,,,,,,)>", "OP_EQUAL", "0", "", @@ -956,10 +940,6 @@ { "name": "dvn1PkSig", "type": "signature" - }, - { - "name": "operatorPkSig", - "type": "signature" } ], "witnessSchema": [ @@ -972,18 +952,13 @@ "name": "dvn1PkSig", "type": "signature", "encoding": "schnorr-64" - }, - { - "name": "operatorPkSig", - "type": "signature", - "encoding": "schnorr-64" } ], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "3-of-3 signatures required (introspection fallback)" + "message": "2-of-2 signatures required (introspection fallback)" }, { "type": "older", @@ -996,9 +971,6 @@ "OP_CHECKSIGVERIFY", "", "", - "OP_CHECKSIGVERIFY", - "", - "", "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", @@ -1006,12 +978,12 @@ ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract Endpoint(\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n pubkey dvn0Pk,\n pubkey dvn1Pk,\n pubkey operatorPk,\n int exit\n) {\n\n function receive(bytes32 receiveMarkerScriptHash) {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(17)) == 219, \"lz receive packet size\");\n require(size(tx.packet(18)) == 228, \"dvn attestation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(17), 0, 1) == 1, \"lz receive version\");\n require(substr(tx.packet(18), 0, 1) == 1, \"dvn attestation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(bin2num(substr(tx.packet(16), 101, 1)) == 2, \"dvn threshold != 2\");\n require(bin2num(substr(tx.packet(16), 102, 1)) == 2, \"dvn count != 2\");\n require(substr(tx.packet(16), 103, 32) == dvn0Pk, \"endpoint state dvn0 mismatch\");\n require(substr(tx.packet(16), 135, 32) == dvn1Pk, \"endpoint state dvn1 mismatch\");\n\n require(substr(tx.packet(17), 1, 32) == oappID, \"lz receiver != oapp\");\n require(bin2num(substr(tx.packet(17), 33, 4)) == remoteEID, \"lz srcEID != remote\");\n require(substr(tx.packet(17), 37, 32) == remoteOApp, \"lz sender != remoteOApp\");\n\n require(bin2num(substr(tx.packet(18), 33, 1)) == 2, \"dvn count != 2\");\n require(bin2num(substr(tx.packet(18), 34, 1)) == 0, \"dvn att0 index != 0\");\n require(bin2num(substr(tx.packet(18), 131, 1)) == 1, \"dvn att1 index != 1\");\n require(substr(tx.packet(18), 35, 32) == dvn0Pk, \"dvn att0 pk mismatch\");\n require(substr(tx.packet(18), 132, 32) == dvn1Pk, \"dvn att1 pk mismatch\");\n require(\n sha256(substr(tx.packet(17), 1, 140)) == attestedHash,\n \"attested hash does not match lz receive header\"\n );\n require(substr(tx.packet(18), 1, 32) == attestedHash, \"dvn attested hash mismatch\");\n\n require(checkSigFromStackVerify(dvn0Sig, dvn0Pk, attestedHash), \"dvn0 sig invalid\");\n require(checkSigFromStackVerify(dvn1Sig, dvn1Pk, attestedHash), \"dvn1 sig invalid\");\n\n require(\n sha256(substr(tx.packet(17), 145, 74)) == substr(tx.packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, operatorPk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n\n require(\n tx.outputs[1].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new ReceiveMarker(\n receiveMarkerScriptHash, oappCtrlAssetId, operatorPk, exit\n ),\n \"marker pkScript not canonical\"\n );\n\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 1, \"extra marker minted\");\n }\n\n function send() {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(19)) == 181, \"lz send packet size\");\n require(size(tx.inputs[1].packet(20)) == 175, \"send invocation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(19), 0, 1) == 1, \"lz send version\");\n require(substr(tx.inputs[1].packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(substr(tx.packet(19), 1, 32) == oappID, \"lz send sender != oapp\");\n require(bin2num(substr(tx.packet(19), 33, 4)) == remoteEID, \"lz dstEID != remote\");\n require(substr(tx.packet(19), 37, 32) == remoteOApp, \"lz receiver != remoteOApp\");\n\n require(substr(tx.inputs[1].packet(20), 1, 32) == oappID, \"invocation oappID mismatch\");\n require(substr(tx.inputs[1].packet(20), 33, 32) == endpointID, \"invocation endpointID mismatch\");\n require(bin2num(substr(tx.inputs[1].packet(20), 67, 4)) == remoteEID, \"invocation dstEID mismatch\");\n require(substr(tx.inputs[1].packet(20), 71, 32) == remoteOApp, \"invocation receiver mismatch\");\n\n require(\n sha256(substr(tx.inputs[1].packet(20), 0, 175)) == substr(tx.packet(19), 77, 32),\n \"lz send guid mismatch\"\n );\n\n require(\n substr(tx.inputs[1].packet(20), 1, 32) == substr(tx.packet(19), 1, 32),\n \"invocation/lzSend sender mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 67, 4) == substr(tx.packet(19), 33, 4),\n \"invocation/lzSend dstEID mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 71, 32) == substr(tx.packet(19), 37, 32),\n \"invocation/lzSend receiver mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 103, 8) == substr(tx.packet(19), 109, 8),\n \"invocation/lzSend amount mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 111, 32) == substr(tx.packet(19), 117, 32),\n \"invocation/lzSend remoteRecipient mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 143, 32) == substr(tx.packet(19), 149, 32),\n \"invocation/lzSend messageHash mismatch\"\n );\n\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumInputs == 1, \"send marker missing\");\n require(oappIDGroup.sumOutputs == 0, \"send marker not burned\");\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, operatorPk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract Endpoint(\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n pubkey dvn0Pk,\n pubkey dvn1Pk,\n int exit\n) {\n\n function receive(bytes32 receiveMarkerScriptHash) {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(17)) == 219, \"lz receive packet size\");\n require(size(tx.packet(18)) == 228, \"dvn attestation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(17), 0, 1) == 1, \"lz receive version\");\n require(substr(tx.packet(18), 0, 1) == 1, \"dvn attestation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(bin2num(substr(tx.packet(16), 101, 1)) == 2, \"dvn threshold != 2\");\n require(bin2num(substr(tx.packet(16), 102, 1)) == 2, \"dvn count != 2\");\n require(substr(tx.packet(16), 103, 32) == dvn0Pk, \"endpoint state dvn0 mismatch\");\n require(substr(tx.packet(16), 135, 32) == dvn1Pk, \"endpoint state dvn1 mismatch\");\n\n require(substr(tx.packet(17), 1, 32) == oappID, \"lz receiver != oapp\");\n require(bin2num(substr(tx.packet(17), 33, 4)) == remoteEID, \"lz srcEID != remote\");\n require(substr(tx.packet(17), 37, 32) == remoteOApp, \"lz sender != remoteOApp\");\n\n require(bin2num(substr(tx.packet(18), 33, 1)) == 2, \"dvn count != 2\");\n require(bin2num(substr(tx.packet(18), 34, 1)) == 0, \"dvn att0 index != 0\");\n require(bin2num(substr(tx.packet(18), 131, 1)) == 1, \"dvn att1 index != 1\");\n require(substr(tx.packet(18), 35, 32) == dvn0Pk, \"dvn att0 pk mismatch\");\n require(substr(tx.packet(18), 132, 32) == dvn1Pk, \"dvn att1 pk mismatch\");\n require(\n sha256(substr(tx.packet(17), 1, 140)) == attestedHash,\n \"attested hash does not match lz receive header\"\n );\n require(substr(tx.packet(18), 1, 32) == attestedHash, \"dvn attested hash mismatch\");\n\n require(checkSigFromStackVerify(dvn0Sig, dvn0Pk, attestedHash), \"dvn0 sig invalid\");\n require(checkSigFromStackVerify(dvn1Sig, dvn1Pk, attestedHash), \"dvn1 sig invalid\");\n\n require(\n sha256(substr(tx.packet(17), 145, 74)) == substr(tx.packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n\n require(\n tx.outputs[1].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new ReceiveMarker(\n receiveMarkerScriptHash, oappCtrlAssetId, exit\n ),\n \"marker pkScript not canonical\"\n );\n\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 1, \"extra marker minted\");\n }\n\n function send() {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(19)) == 181, \"lz send packet size\");\n require(size(tx.inputs[1].packet(20)) == 175, \"send invocation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(19), 0, 1) == 1, \"lz send version\");\n require(substr(tx.inputs[1].packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(substr(tx.packet(19), 1, 32) == oappID, \"lz send sender != oapp\");\n require(bin2num(substr(tx.packet(19), 33, 4)) == remoteEID, \"lz dstEID != remote\");\n require(substr(tx.packet(19), 37, 32) == remoteOApp, \"lz receiver != remoteOApp\");\n\n require(substr(tx.inputs[1].packet(20), 1, 32) == oappID, \"invocation oappID mismatch\");\n require(substr(tx.inputs[1].packet(20), 33, 32) == endpointID, \"invocation endpointID mismatch\");\n require(bin2num(substr(tx.inputs[1].packet(20), 67, 4)) == remoteEID, \"invocation dstEID mismatch\");\n require(substr(tx.inputs[1].packet(20), 71, 32) == remoteOApp, \"invocation receiver mismatch\");\n\n require(\n sha256(substr(tx.inputs[1].packet(20), 0, 175)) == substr(tx.packet(19), 77, 32),\n \"lz send guid mismatch\"\n );\n\n require(\n substr(tx.inputs[1].packet(20), 1, 32) == substr(tx.packet(19), 1, 32),\n \"invocation/lzSend sender mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 67, 4) == substr(tx.packet(19), 33, 4),\n \"invocation/lzSend dstEID mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 71, 32) == substr(tx.packet(19), 37, 32),\n \"invocation/lzSend receiver mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 103, 8) == substr(tx.packet(19), 109, 8),\n \"invocation/lzSend amount mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 111, 32) == substr(tx.packet(19), 117, 32),\n \"invocation/lzSend remoteRecipient mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 143, 32) == substr(tx.packet(19), 149, 32),\n \"invocation/lzSend messageHash mismatch\"\n );\n\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumInputs == 1, \"send marker missing\");\n require(oappIDGroup.sumOutputs == 0, \"send marker not burned\");\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T12:37:48.628149614+00:00", + "updatedAt": "2026-05-15T19:51:17.407056925+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", diff --git a/examples/layerzero/oapp.ark b/examples/layerzero/oapp.ark index b5a0668..28320af 100644 --- a/examples/layerzero/oapp.ark +++ b/examples/layerzero/oapp.ark @@ -46,13 +46,6 @@ contract OApp( bytes32 oappID, int remoteEID, bytes32 remoteOApp, - // Operator key — authorises the unilateral exit path only. The cooperative - // server-cosigned path does not require this signature; permissioning of - // OApp.receive / OApp.send still comes entirely from packet introspection - // and the OApp control singleton (mirrors the Go reference). The operator - // exists solely so the N-of-N exit witness has a signer; without it, - // anyone could force-spend the OApp state after the CSV timelock. - pubkey operatorPk, int exit ) { @@ -141,8 +134,7 @@ contract OApp( tx.outputs[0].scriptPubKey == new OApp( oappCtrlAssetId, oappIDAssetId, usdt0AssetId, endpointCtrlAssetId, endpointIDAssetId, - endpointID, oappID, remoteEID, remoteOApp, - operatorPk, exit + endpointID, oappID, remoteEID, remoteOApp, exit ), "oapp state must continue" ); @@ -197,7 +189,7 @@ contract OApp( ); require( tx.outputs[1].scriptPubKey == new SendMarker( - sendMarkerScriptHash, endpointCtrlAssetId, operatorPk, exit + sendMarkerScriptHash, endpointCtrlAssetId, exit ), "send marker pkScript not canonical" ); @@ -209,8 +201,7 @@ contract OApp( tx.outputs[0].scriptPubKey == new OApp( oappCtrlAssetId, oappIDAssetId, usdt0AssetId, endpointCtrlAssetId, endpointIDAssetId, - endpointID, oappID, remoteEID, remoteOApp, - operatorPk, exit + endpointID, oappID, remoteEID, remoteOApp, exit ), "oapp state must continue" ); diff --git a/examples/layerzero/oapp.json b/examples/layerzero/oapp.json index b415c32..df2c363 100644 --- a/examples/layerzero/oapp.json +++ b/examples/layerzero/oapp.json @@ -53,10 +53,6 @@ "name": "remoteOApp", "type": "bytes32" }, - { - "name": "operatorPk", - "type": "pubkey" - }, { "name": "exit", "type": "int" @@ -298,7 +294,7 @@ "OP_EQUAL", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,,,,,)>", + ",,,,,,,,,)>", "OP_EQUAL", "0", "", @@ -331,24 +327,13 @@ }, { "name": "receive", - "functionInputs": [ - { - "name": "operatorPkSig", - "type": "signature" - } - ], - "witnessSchema": [ - { - "name": "operatorPkSig", - "type": "signature", - "encoding": "schnorr-64" - } - ], + "functionInputs": [], + "witnessSchema": [], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "1-of-1 signatures required (introspection fallback)" + "message": "0-of-0 signatures required (introspection fallback)" }, { "type": "older", @@ -356,9 +341,6 @@ } ], "asm": [ - "", - "", - "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" @@ -528,7 +510,7 @@ "OP_VERIFY", "1", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,)>", + ",,)>", "OP_EQUAL", "", "", @@ -540,7 +522,7 @@ "OP_EQUAL", "0", "OP_INSPECTOUTPUTSCRIPTPUBKEY", - ",,,,,,,,,,)>", + ",,,,,,,,,)>", "OP_EQUAL", "0", "", @@ -565,24 +547,14 @@ { "name": "sendMarkerScriptHash", "type": "bytes32" - }, - { - "name": "operatorPkSig", - "type": "signature" - } - ], - "witnessSchema": [ - { - "name": "operatorPkSig", - "type": "signature", - "encoding": "schnorr-64" } ], + "witnessSchema": [], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "1-of-1 signatures required (introspection fallback)" + "message": "0-of-0 signatures required (introspection fallback)" }, { "type": "older", @@ -590,21 +562,18 @@ } ], "asm": [ - "", - "", - "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract OApp(\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 usdt0AssetId,\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n pubkey operatorPk,\n int exit\n) {\n\n function receive() {\n require(size(tx.inputs[0].packet(17)) == 219, \"lz receive packet size\");\n require(substr(tx.inputs[0].packet(17), 0, 1) == 1, \"lz receive version\");\n\n require(\n bin2num(substr(tx.inputs[0].packet(17), 141, 4)) == 74,\n \"credit message length\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset not on input 0\"\n );\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 0, \"marker not burned\");\n\n require(\n substr(tx.inputs[0].packet(17), 1, 32) == oappID,\n \"lz receiver != oappID\"\n );\n require(\n bin2num(substr(tx.inputs[0].packet(17), 33, 4)) == remoteEID,\n \"lz srcEID != remoteEID\"\n );\n require(\n substr(tx.inputs[0].packet(17), 37, 32) == remoteOApp,\n \"lz sender != remoteOApp\"\n );\n\n require(\n sha256(substr(tx.inputs[0].packet(17), 145, 74))\n == substr(tx.inputs[0].packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n substr(tx.inputs[0].packet(17), 187, 32) == substr(tx.inputs[0].packet(17), 37, 32),\n \"credit remoteSender mismatch\"\n );\n\n require(\n tx.outputs[1].scriptPubKey == substr(tx.inputs[0].packet(17), 147, 32),\n \"recipient pkScript mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(usdt0AssetId)\n == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"recipient amount mismatch\"\n );\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.delta == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"usdt0 delta != credit amount\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n operatorPk, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n require(tx.outputs[0].assets.lookup(oappIDAssetId) == 1, \"oapp id missing\");\n }\n\n function send(bytes32 sendMarkerScriptHash) {\n require(size(tx.packet(20)) == 175, \"send invocation packet size\");\n require(substr(tx.packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(20), 1, 32) == oappID, \"invocation oappID\");\n require(substr(tx.packet(20), 33, 32) == endpointID, \"invocation endpointID\");\n\n require(bin2num(substr(tx.packet(20), 67, 4)) == remoteEID, \"invocation dstEID\");\n require(substr(tx.packet(20), 71, 32) == remoteOApp, \"invocation receiver\");\n\n require(bin2num(substr(tx.packet(20), 65, 2)) == 1, \"invocation_vout != 1\");\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.sumInputs == usdt0Group.sumOutputs + bin2num(substr(tx.packet(20), 103, 8)),\n \"burn amount mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(oappIDAssetId) == 1,\n \"send marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SendMarker(\n sendMarkerScriptHash, endpointCtrlAssetId, operatorPk, exit\n ),\n \"send marker pkScript not canonical\"\n );\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumOutputs == 1, \"extra send marker minted\");\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n operatorPk, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract OApp(\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 usdt0AssetId,\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n int exit\n) {\n\n function receive() {\n require(size(tx.inputs[0].packet(17)) == 219, \"lz receive packet size\");\n require(substr(tx.inputs[0].packet(17), 0, 1) == 1, \"lz receive version\");\n\n require(\n bin2num(substr(tx.inputs[0].packet(17), 141, 4)) == 74,\n \"credit message length\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset not on input 0\"\n );\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 0, \"marker not burned\");\n\n require(\n substr(tx.inputs[0].packet(17), 1, 32) == oappID,\n \"lz receiver != oappID\"\n );\n require(\n bin2num(substr(tx.inputs[0].packet(17), 33, 4)) == remoteEID,\n \"lz srcEID != remoteEID\"\n );\n require(\n substr(tx.inputs[0].packet(17), 37, 32) == remoteOApp,\n \"lz sender != remoteOApp\"\n );\n\n require(\n sha256(substr(tx.inputs[0].packet(17), 145, 74))\n == substr(tx.inputs[0].packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n substr(tx.inputs[0].packet(17), 187, 32) == substr(tx.inputs[0].packet(17), 37, 32),\n \"credit remoteSender mismatch\"\n );\n\n require(\n tx.outputs[1].scriptPubKey == substr(tx.inputs[0].packet(17), 147, 32),\n \"recipient pkScript mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(usdt0AssetId)\n == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"recipient amount mismatch\"\n );\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.delta == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"usdt0 delta != credit amount\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n require(tx.outputs[0].assets.lookup(oappIDAssetId) == 1, \"oapp id missing\");\n }\n\n function send(bytes32 sendMarkerScriptHash) {\n require(size(tx.packet(20)) == 175, \"send invocation packet size\");\n require(substr(tx.packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(20), 1, 32) == oappID, \"invocation oappID\");\n require(substr(tx.packet(20), 33, 32) == endpointID, \"invocation endpointID\");\n\n require(bin2num(substr(tx.packet(20), 67, 4)) == remoteEID, \"invocation dstEID\");\n require(substr(tx.packet(20), 71, 32) == remoteOApp, \"invocation receiver\");\n\n require(bin2num(substr(tx.packet(20), 65, 2)) == 1, \"invocation_vout != 1\");\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.sumInputs == usdt0Group.sumOutputs + bin2num(substr(tx.packet(20), 103, 8)),\n \"burn amount mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(oappIDAssetId) == 1,\n \"send marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SendMarker(\n sendMarkerScriptHash, endpointCtrlAssetId, exit\n ),\n \"send marker pkScript not canonical\"\n );\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumOutputs == 1, \"extra send marker minted\");\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T12:37:48.720880894+00:00", + "updatedAt": "2026-05-16T10:41:23.223384951+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", diff --git a/examples/layerzero/receive_marker.ark b/examples/layerzero/receive_marker.ark index ed69a7e..e7dc77d 100644 --- a/examples/layerzero/receive_marker.ark +++ b/examples/layerzero/receive_marker.ark @@ -21,10 +21,6 @@ options { contract ReceiveMarker( bytes32 oappReceiveScriptHash, bytes32 oappCtrlAssetId, - // Operator key for the unilateral exit witness only — see oapp.ark for - // rationale. Cooperative consumption requires only the server cosign and - // the on-chain checks below. - pubkey operatorPk, int exit ) { // Single execution path: consumed inside OApp.receive(). diff --git a/examples/layerzero/receive_marker.json b/examples/layerzero/receive_marker.json index f918627..4422841 100644 --- a/examples/layerzero/receive_marker.json +++ b/examples/layerzero/receive_marker.json @@ -13,10 +13,6 @@ "name": "oappCtrlAssetId_gidx", "type": "int" }, - { - "name": "operatorPk", - "type": "pubkey" - }, { "name": "exit", "type": "int" @@ -75,24 +71,13 @@ }, { "name": "consume", - "functionInputs": [ - { - "name": "operatorPkSig", - "type": "signature" - } - ], - "witnessSchema": [ - { - "name": "operatorPkSig", - "type": "signature", - "encoding": "schnorr-64" - } - ], + "functionInputs": [], + "witnessSchema": [], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "1-of-1 signatures required (introspection fallback)" + "message": "0-of-0 signatures required (introspection fallback)" }, { "type": "older", @@ -100,21 +85,18 @@ } ], "asm": [ - "", - "", - "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract ReceiveMarker(\n bytes32 oappReceiveScriptHash,\n bytes32 oappCtrlAssetId,\n pubkey operatorPk,\n int exit\n) {\n function consume() {\n require(this.activeInputIndex == 0, \"marker input position\");\n\n require(\n tx.inputs[1].arkadeScriptHash == oappReceiveScriptHash,\n \"oapp state not via receive closure\"\n );\n\n require(\n tx.inputs[1].assets.lookup(oappCtrlAssetId) == 1,\n \"oapp state input missing control asset\"\n );\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract ReceiveMarker(\n bytes32 oappReceiveScriptHash,\n bytes32 oappCtrlAssetId,\n int exit\n) {\n function consume() {\n require(this.activeInputIndex == 0, \"marker input position\");\n\n require(\n tx.inputs[1].arkadeScriptHash == oappReceiveScriptHash,\n \"oapp state not via receive closure\"\n );\n\n require(\n tx.inputs[1].assets.lookup(oappCtrlAssetId) == 1,\n \"oapp state input missing control asset\"\n );\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T12:37:48.805549741+00:00", + "updatedAt": "2026-05-15T19:51:17.546947453+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/examples/layerzero/send_marker.ark b/examples/layerzero/send_marker.ark index 732723f..c024068 100644 --- a/examples/layerzero/send_marker.ark +++ b/examples/layerzero/send_marker.ark @@ -22,10 +22,6 @@ options { contract SendMarker( bytes32 endpointSendScriptHash, bytes32 endpointCtrlAssetId, - // Operator key for the unilateral exit witness only — see oapp.ark for - // rationale. Cooperative consumption requires only the server cosign and - // the on-chain checks below. - pubkey operatorPk, int exit ) { // Single execution path: consumed inside Endpoint.send(). diff --git a/examples/layerzero/send_marker.json b/examples/layerzero/send_marker.json index b8b77c3..68a1dbc 100644 --- a/examples/layerzero/send_marker.json +++ b/examples/layerzero/send_marker.json @@ -13,10 +13,6 @@ "name": "endpointCtrlAssetId_gidx", "type": "int" }, - { - "name": "operatorPk", - "type": "pubkey" - }, { "name": "exit", "type": "int" @@ -75,24 +71,13 @@ }, { "name": "consume", - "functionInputs": [ - { - "name": "operatorPkSig", - "type": "signature" - } - ], - "witnessSchema": [ - { - "name": "operatorPkSig", - "type": "signature", - "encoding": "schnorr-64" - } - ], + "functionInputs": [], + "witnessSchema": [], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "1-of-1 signatures required (introspection fallback)" + "message": "0-of-0 signatures required (introspection fallback)" }, { "type": "older", @@ -100,21 +85,18 @@ } ], "asm": [ - "", - "", - "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract SendMarker(\n bytes32 endpointSendScriptHash,\n bytes32 endpointCtrlAssetId,\n pubkey operatorPk,\n int exit\n) {\n function consume() {\n require(this.activeInputIndex == 1, \"marker input position\");\n\n require(\n tx.inputs[0].arkadeScriptHash == endpointSendScriptHash,\n \"endpoint state not via send closure\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint state input missing control asset\"\n );\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract SendMarker(\n bytes32 endpointSendScriptHash,\n bytes32 endpointCtrlAssetId,\n int exit\n) {\n function consume() {\n require(this.activeInputIndex == 1, \"marker input position\");\n\n require(\n tx.inputs[0].arkadeScriptHash == endpointSendScriptHash,\n \"endpoint state not via send closure\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint state input missing control asset\"\n );\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T12:37:48.879082871+00:00", + "updatedAt": "2026-05-15T19:51:17.612660169+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] From 369c4aa5efb707314834d6fb440a62115dd6cfe1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 13:06:44 +0000 Subject: [PATCH 09/15] feat(compiler): auto-inject operator key into empty-pubkey exit witness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a contract uses introspection and has no constructor- or function- supplied pubkeys, the exit-path N-of-N CHECKSIG chain is empty, so the emitted exit script is just ` OP_CHECKSEQUENCEVERIFY OP_DROP` and the witnessSchema is empty. That means "anyone may force-spend after the CSV timelock" — a broken unilateral exit shape. Fix the issue at the compiler level rather than asking every contract author to declare a placeholder pubkey: asm when all_pubkeys.is_empty() and the exit variant is being generated, emit OP_CHECKSIG in front of the CSV. Same auto-injection pattern as the placeholder used for the cooperative path. witnessSchema add `operatorSig` (signature, schnorr-64). function ABI push an `operatorSig` FunctionInput so SDK bindgen surfaces it as a required witness. require emit `nOfNMultisig` with the message "operator signature required (auto-injected exit fallback)". The placeholder `` is resolved by the runtime / wallet exactly like `` is — `.ark` source never mentions it. Contracts that already have constructor pubkeys (htlc, fuji_safe, nft_mint, price_beacon, …) are unaffected: the empty-pubkey branch is not taken, so their exit asm and witnessSchema are byte-identical to before. Reverts the previous LayerZero workaround commit ("add operatorPk to constructor"). The four LayerZero contracts are back to their clean shape and now compile through the master `compilation_roundtrip_test` because each variant has a non-empty witnessSchema (cooperative: serverSig; exit: operatorSig). Local: 138 passed, 0 failed. Merge-with-master: 227 passed, 0 failed. --- examples/layerzero/endpoint.json | 2 +- examples/layerzero/oapp.json | 39 +++++++++++-- examples/layerzero/receive_marker.json | 22 ++++++-- examples/layerzero/send_marker.json | 22 ++++++-- src/compiler/mod.rs | 76 ++++++++++++++++++++------ 5 files changed, 128 insertions(+), 33 deletions(-) diff --git a/examples/layerzero/endpoint.json b/examples/layerzero/endpoint.json index 714ef7f..4cd60e3 100644 --- a/examples/layerzero/endpoint.json +++ b/examples/layerzero/endpoint.json @@ -983,7 +983,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T19:51:17.407056925+00:00", + "updatedAt": "2026-05-16T13:05:48.398836759+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", diff --git a/examples/layerzero/oapp.json b/examples/layerzero/oapp.json index df2c363..f2d8335 100644 --- a/examples/layerzero/oapp.json +++ b/examples/layerzero/oapp.json @@ -327,13 +327,24 @@ }, { "name": "receive", - "functionInputs": [], - "witnessSchema": [], + "functionInputs": [ + { + "name": "operatorSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "operatorSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "0-of-0 signatures required (introspection fallback)" + "message": "operator signature required (auto-injected exit fallback)" }, { "type": "older", @@ -341,6 +352,9 @@ } ], "asm": [ + "", + "", + "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" @@ -547,14 +561,24 @@ { "name": "sendMarkerScriptHash", "type": "bytes32" + }, + { + "name": "operatorSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "operatorSig", + "type": "signature", + "encoding": "schnorr-64" } ], - "witnessSchema": [], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "0-of-0 signatures required (introspection fallback)" + "message": "operator signature required (auto-injected exit fallback)" }, { "type": "older", @@ -562,6 +586,9 @@ } ], "asm": [ + "", + "", + "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" @@ -573,7 +600,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T10:41:23.223384951+00:00", + "updatedAt": "2026-05-16T13:05:48.328378498+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", diff --git a/examples/layerzero/receive_marker.json b/examples/layerzero/receive_marker.json index 4422841..29e88fb 100644 --- a/examples/layerzero/receive_marker.json +++ b/examples/layerzero/receive_marker.json @@ -71,13 +71,24 @@ }, { "name": "consume", - "functionInputs": [], - "witnessSchema": [], + "functionInputs": [ + { + "name": "operatorSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "operatorSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "0-of-0 signatures required (introspection fallback)" + "message": "operator signature required (auto-injected exit fallback)" }, { "type": "older", @@ -85,6 +96,9 @@ } ], "asm": [ + "", + "", + "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" @@ -96,7 +110,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T19:51:17.546947453+00:00", + "updatedAt": "2026-05-16T13:05:48.184528975+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/examples/layerzero/send_marker.json b/examples/layerzero/send_marker.json index 68a1dbc..ec7f076 100644 --- a/examples/layerzero/send_marker.json +++ b/examples/layerzero/send_marker.json @@ -71,13 +71,24 @@ }, { "name": "consume", - "functionInputs": [], - "witnessSchema": [], + "functionInputs": [ + { + "name": "operatorSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "operatorSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "0-of-0 signatures required (introspection fallback)" + "message": "operator signature required (auto-injected exit fallback)" }, { "type": "older", @@ -85,6 +96,9 @@ } ], "asm": [ + "", + "", + "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" @@ -96,7 +110,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T19:51:17.612660169+00:00", + "updatedAt": "2026-05-16T13:05:48.258210439+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 793fe78..6b7ba58 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -390,12 +390,24 @@ fn generate_witness_schema( if !server_variant && uses_introspection { // N-of-N exit path: one signature per constructor pubkey. - for pk in all_pubkeys { + // Empty-pubkey case is fronted by the auto-injected operator key (see + // generate_nofn_checksig_asm). Surface the matching `operatorSig` + // witness entry so the schema is non-empty and tooling can prompt for + // a signature. + if all_pubkeys.is_empty() { schema.push(WitnessElement { - name: format!("{}Sig", pk), + name: "operatorSig".to_string(), elem_type: "signature".to_string(), encoding: ArkType::Signature.encoding().to_string(), }); + } else { + for pk in all_pubkeys { + schema.push(WitnessElement { + name: format!("{}Sig", pk), + elem_type: "signature".to_string(), + encoding: ArkType::Signature.encoding().to_string(), + }); + } } } else { // Normal path: function parameters form the witness elements. @@ -482,30 +494,45 @@ fn generate_function( .map(|i| i.name.clone()) .collect(); - for pk in &all_pubkeys { - let sig_name = format!("{}Sig", pk); - let has_sig = existing_sig_names - .iter() - .any(|s| s.contains(pk) || s == &sig_name); - if !has_sig { - function_inputs.push(FunctionInput { - name: sig_name, - param_type: "signature".to_string(), - }); + if all_pubkeys.is_empty() { + // Auto-injected operator-key fallback (mirrors the asm emission in + // generate_nofn_checksig_asm). + function_inputs.push(FunctionInput { + name: "operatorSig".to_string(), + param_type: "signature".to_string(), + }); + } else { + for pk in &all_pubkeys { + let sig_name = format!("{}Sig", pk); + let has_sig = existing_sig_names + .iter() + .any(|s| s.contains(pk) || s == &sig_name); + if !has_sig { + function_inputs.push(FunctionInput { + name: sig_name, + param_type: "signature".to_string(), + }); + } } } } let mut require = if !server_variant && uses_introspection { - // Exit path with any introspection: N-of-N multisig fallback. - // No non-Bitcoin-Script opcodes are allowed on the exit path. - vec![RequireStatement { - req_type: "nOfNMultisig".to_string(), - message: Some(format!( + // Exit path with any introspection: N-of-N multisig fallback (or the + // auto-injected operator-key singleton when there are no constructor + // pubkeys). + let req_message = if all_pubkeys.is_empty() { + "operator signature required (auto-injected exit fallback)".to_string() + } else { + format!( "{}-of-{} signatures required (introspection fallback)", all_pubkeys.len(), all_pubkeys.len() - )), + ) + }; + vec![RequireStatement { + req_type: "nOfNMultisig".to_string(), + message: Some(req_message), }] } else { generate_requirements(function) @@ -587,6 +614,19 @@ fn generate_function( fn generate_nofn_checksig_asm(pubkeys: &[String], _function: &Function) -> Vec { let mut asm = Vec::new(); + // Empty case: the contract has no constructor- or function-supplied pubkeys + // for the exit N-of-N, which would leave the exit witness empty (and the + // unilateral exit path would be "anyone after the CSV timelock"). Inject + // the operator key — the same auto-injection pattern as for + // the cooperative path. The placeholder is resolved by the runtime; it + // never surfaces in `.ark` source. + if pubkeys.is_empty() { + asm.push("".to_string()); + asm.push("".to_string()); + asm.push(OP_CHECKSIG.to_string()); + return asm; + } + // Generate ONLY N-of-N CHECKSIG chain - no original requirements // This is pure Bitcoin script with no Arkade-specific opcodes for (i, pk) in pubkeys.iter().enumerate() { From 18a407eaf54350c9642dd395a764d9dd8c5c9907 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 13:09:35 +0000 Subject: [PATCH 10/15] Revert "feat(compiler): auto-inject operator key into empty-pubkey exit witness" This reverts commit 369c4aa5efb707314834d6fb440a62115dd6cfe1. --- examples/layerzero/endpoint.json | 2 +- examples/layerzero/oapp.json | 39 ++----------- examples/layerzero/receive_marker.json | 22 ++------ examples/layerzero/send_marker.json | 22 ++------ src/compiler/mod.rs | 76 ++++++-------------------- 5 files changed, 33 insertions(+), 128 deletions(-) diff --git a/examples/layerzero/endpoint.json b/examples/layerzero/endpoint.json index 4cd60e3..714ef7f 100644 --- a/examples/layerzero/endpoint.json +++ b/examples/layerzero/endpoint.json @@ -983,7 +983,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T13:05:48.398836759+00:00", + "updatedAt": "2026-05-15T19:51:17.407056925+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", diff --git a/examples/layerzero/oapp.json b/examples/layerzero/oapp.json index f2d8335..df2c363 100644 --- a/examples/layerzero/oapp.json +++ b/examples/layerzero/oapp.json @@ -327,24 +327,13 @@ }, { "name": "receive", - "functionInputs": [ - { - "name": "operatorSig", - "type": "signature" - } - ], - "witnessSchema": [ - { - "name": "operatorSig", - "type": "signature", - "encoding": "schnorr-64" - } - ], + "functionInputs": [], + "witnessSchema": [], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "operator signature required (auto-injected exit fallback)" + "message": "0-of-0 signatures required (introspection fallback)" }, { "type": "older", @@ -352,9 +341,6 @@ } ], "asm": [ - "", - "", - "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" @@ -561,24 +547,14 @@ { "name": "sendMarkerScriptHash", "type": "bytes32" - }, - { - "name": "operatorSig", - "type": "signature" - } - ], - "witnessSchema": [ - { - "name": "operatorSig", - "type": "signature", - "encoding": "schnorr-64" } ], + "witnessSchema": [], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "operator signature required (auto-injected exit fallback)" + "message": "0-of-0 signatures required (introspection fallback)" }, { "type": "older", @@ -586,9 +562,6 @@ } ], "asm": [ - "", - "", - "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" @@ -600,7 +573,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T13:05:48.328378498+00:00", + "updatedAt": "2026-05-16T10:41:23.223384951+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", diff --git a/examples/layerzero/receive_marker.json b/examples/layerzero/receive_marker.json index 29e88fb..4422841 100644 --- a/examples/layerzero/receive_marker.json +++ b/examples/layerzero/receive_marker.json @@ -71,24 +71,13 @@ }, { "name": "consume", - "functionInputs": [ - { - "name": "operatorSig", - "type": "signature" - } - ], - "witnessSchema": [ - { - "name": "operatorSig", - "type": "signature", - "encoding": "schnorr-64" - } - ], + "functionInputs": [], + "witnessSchema": [], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "operator signature required (auto-injected exit fallback)" + "message": "0-of-0 signatures required (introspection fallback)" }, { "type": "older", @@ -96,9 +85,6 @@ } ], "asm": [ - "", - "", - "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" @@ -110,7 +96,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T13:05:48.184528975+00:00", + "updatedAt": "2026-05-15T19:51:17.546947453+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/examples/layerzero/send_marker.json b/examples/layerzero/send_marker.json index ec7f076..68a1dbc 100644 --- a/examples/layerzero/send_marker.json +++ b/examples/layerzero/send_marker.json @@ -71,24 +71,13 @@ }, { "name": "consume", - "functionInputs": [ - { - "name": "operatorSig", - "type": "signature" - } - ], - "witnessSchema": [ - { - "name": "operatorSig", - "type": "signature", - "encoding": "schnorr-64" - } - ], + "functionInputs": [], + "witnessSchema": [], "serverVariant": false, "require": [ { "type": "nOfNMultisig", - "message": "operator signature required (auto-injected exit fallback)" + "message": "0-of-0 signatures required (introspection fallback)" }, { "type": "older", @@ -96,9 +85,6 @@ } ], "asm": [ - "", - "", - "OP_CHECKSIG", "", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" @@ -110,7 +96,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T13:05:48.258210439+00:00", + "updatedAt": "2026-05-15T19:51:17.612660169+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 6b7ba58..793fe78 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -390,24 +390,12 @@ fn generate_witness_schema( if !server_variant && uses_introspection { // N-of-N exit path: one signature per constructor pubkey. - // Empty-pubkey case is fronted by the auto-injected operator key (see - // generate_nofn_checksig_asm). Surface the matching `operatorSig` - // witness entry so the schema is non-empty and tooling can prompt for - // a signature. - if all_pubkeys.is_empty() { + for pk in all_pubkeys { schema.push(WitnessElement { - name: "operatorSig".to_string(), + name: format!("{}Sig", pk), elem_type: "signature".to_string(), encoding: ArkType::Signature.encoding().to_string(), }); - } else { - for pk in all_pubkeys { - schema.push(WitnessElement { - name: format!("{}Sig", pk), - elem_type: "signature".to_string(), - encoding: ArkType::Signature.encoding().to_string(), - }); - } } } else { // Normal path: function parameters form the witness elements. @@ -494,45 +482,30 @@ fn generate_function( .map(|i| i.name.clone()) .collect(); - if all_pubkeys.is_empty() { - // Auto-injected operator-key fallback (mirrors the asm emission in - // generate_nofn_checksig_asm). - function_inputs.push(FunctionInput { - name: "operatorSig".to_string(), - param_type: "signature".to_string(), - }); - } else { - for pk in &all_pubkeys { - let sig_name = format!("{}Sig", pk); - let has_sig = existing_sig_names - .iter() - .any(|s| s.contains(pk) || s == &sig_name); - if !has_sig { - function_inputs.push(FunctionInput { - name: sig_name, - param_type: "signature".to_string(), - }); - } + for pk in &all_pubkeys { + let sig_name = format!("{}Sig", pk); + let has_sig = existing_sig_names + .iter() + .any(|s| s.contains(pk) || s == &sig_name); + if !has_sig { + function_inputs.push(FunctionInput { + name: sig_name, + param_type: "signature".to_string(), + }); } } } let mut require = if !server_variant && uses_introspection { - // Exit path with any introspection: N-of-N multisig fallback (or the - // auto-injected operator-key singleton when there are no constructor - // pubkeys). - let req_message = if all_pubkeys.is_empty() { - "operator signature required (auto-injected exit fallback)".to_string() - } else { - format!( + // Exit path with any introspection: N-of-N multisig fallback. + // No non-Bitcoin-Script opcodes are allowed on the exit path. + vec![RequireStatement { + req_type: "nOfNMultisig".to_string(), + message: Some(format!( "{}-of-{} signatures required (introspection fallback)", all_pubkeys.len(), all_pubkeys.len() - ) - }; - vec![RequireStatement { - req_type: "nOfNMultisig".to_string(), - message: Some(req_message), + )), }] } else { generate_requirements(function) @@ -614,19 +587,6 @@ fn generate_function( fn generate_nofn_checksig_asm(pubkeys: &[String], _function: &Function) -> Vec { let mut asm = Vec::new(); - // Empty case: the contract has no constructor- or function-supplied pubkeys - // for the exit N-of-N, which would leave the exit witness empty (and the - // unilateral exit path would be "anyone after the CSV timelock"). Inject - // the operator key — the same auto-injection pattern as for - // the cooperative path. The placeholder is resolved by the runtime; it - // never surfaces in `.ark` source. - if pubkeys.is_empty() { - asm.push("".to_string()); - asm.push("".to_string()); - asm.push(OP_CHECKSIG.to_string()); - return asm; - } - // Generate ONLY N-of-N CHECKSIG chain - no original requirements // This is pure Bitcoin script with no Arkade-specific opcodes for (i, pk) in pubkeys.iter().enumerate() { From 72f6d61e30b641d0de505378281a4b3b02971224 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 13:12:24 +0000 Subject: [PATCH 11/15] test/validator: allow empty exit witness on permissionless contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CLAUDE.md and the compiler spec: - is auto-injected on the COOPERATIVE path; that's the only auto-injected key. - The UNILATERAL exit path is "N-of-N CHECKSIG over the sum of all pubkeys in the constructor". When that sum is zero (no constructor pubkeys), the exit path collapses to pure CSV — an empty witness is the intended, correct shape for a fully-permissionless contract. PR #25's compilation_roundtrip_test asserted `!witness_schema.is_empty()` unconditionally on every variant, which baked in an implicit assumption that every contract has at least one constructor pubkey. That broke for the new permissionless LayerZero markers + OApp.send / OApp.receive, which intentionally have no signer. Changes: tests/compilation_roundtrip_test.rs Tighten the assertion so only the cooperative variant must have a non-empty witnessSchema (at minimum the auto-injected serverSig). Exit variants may be empty when there are no constructor pubkeys. Docstring updated with the rationale and a pointer to CLAUDE.md. src/validator/mod.rs Stop emitting the "empty witnessSchema" warning on exit variants for the same reason. The validator now warns only when the cooperative variant is missing serverSig (a real compiler bug). LayerZero contracts revert to their natural shape: Endpoint (has dvn0Pk + dvn1Pk in ctor) exit = 2-of-2 over DVN keys + CSV OApp exit = pure CSV ReceiveMarker exit = pure CSV SendMarker exit = pure CSV This commit also reverts ce369c4aa (auto-injecting on the exit path) — that was a misread of the spec; the operator/server key is only on the cooperative path, never the exit one. See the preceding revert commit (18a407e). Local: 227 passed, 0 failed (full merged-with-master suite). --- examples/layerzero/endpoint.json | 13 +++++++++++-- examples/layerzero/oapp.json | 10 ++++++++-- examples/layerzero/receive_marker.json | 2 +- examples/layerzero/send_marker.json | 2 +- src/validator/mod.rs | 15 +++++++++++---- tests/compilation_roundtrip_test.rs | 26 ++++++++++++++++++-------- 6 files changed, 50 insertions(+), 18 deletions(-) diff --git a/examples/layerzero/endpoint.json b/examples/layerzero/endpoint.json index 714ef7f..4188894 100644 --- a/examples/layerzero/endpoint.json +++ b/examples/layerzero/endpoint.json @@ -983,7 +983,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T19:51:17.407056925+00:00", + "updatedAt": "2026-05-16T13:12:04.840225430+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", @@ -1000,6 +1000,15 @@ "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" + "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'attestedHash'", + "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'attestedHash'", + "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'attestedHash'", + "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'dvn0Sig'", + "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'attestedHash'", + "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'dvn1Sig'", + "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'endpointIDGroup'", + "warning[output-invariant]: fn 'send' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oappIDGroup'", + "warning[output-invariant]: fn 'send' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oappIDGroup'" ] } \ No newline at end of file diff --git a/examples/layerzero/oapp.json b/examples/layerzero/oapp.json index df2c363..83f033b 100644 --- a/examples/layerzero/oapp.json +++ b/examples/layerzero/oapp.json @@ -573,7 +573,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T10:41:23.223384951+00:00", + "updatedAt": "2026-05-16T13:12:04.911511643+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", @@ -585,6 +585,12 @@ "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" + "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'endpointIDGroup'", + "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'usdt0Group'", + "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'usdt0Group'", + "warning[output-invariant]: fn 'send' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'usdt0Group'", + "warning[output-invariant]: fn 'send' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'usdt0Group'", + "warning[output-invariant]: fn 'send' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oappIDGroup'" ] } \ No newline at end of file diff --git a/examples/layerzero/receive_marker.json b/examples/layerzero/receive_marker.json index 4422841..69c45f1 100644 --- a/examples/layerzero/receive_marker.json +++ b/examples/layerzero/receive_marker.json @@ -96,7 +96,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T19:51:17.546947453+00:00", + "updatedAt": "2026-05-16T13:12:04.979065584+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/examples/layerzero/send_marker.json b/examples/layerzero/send_marker.json index 68a1dbc..b00142c 100644 --- a/examples/layerzero/send_marker.json +++ b/examples/layerzero/send_marker.json @@ -96,7 +96,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-15T19:51:17.612660169+00:00", + "updatedAt": "2026-05-16T13:12:05.043779373+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/src/validator/mod.rs b/src/validator/mod.rs index f07779f..f21d617 100644 --- a/src/validator/mod.rs +++ b/src/validator/mod.rs @@ -220,7 +220,10 @@ fn statement_has_require(stmt: &Statement) -> bool { /// - `contractName` is non-empty. /// - `functions` array is non-empty. /// - Every function variant has non-empty `asm`. -/// - Every function variant has non-empty `witnessSchema`. +/// - The cooperative variant (`serverVariant=true`) has non-empty +/// `witnessSchema`. The exit variant may legitimately be empty when the +/// contract has no constructor pubkeys (N-of-N CHECKSIG fallback +/// collapsed to a pure-CSV unilateral path). /// - Every unique function name has both a `serverVariant=true` and /// `serverVariant=false` entry. /// - **BSST-style ASM structure**: OP_IF/OP_ELSE/OP_ENDIF are balanced, no empty @@ -254,10 +257,14 @@ pub fn validate_output(output: &ContractJson) -> Vec { func.name, func.server_variant ))); } - if func.witness_schema.is_empty() { + // Empty witnessSchema is only suspicious on the cooperative variant. + // The exit variant is the N-of-N CHECKSIG over constructor pubkeys — + // a contract with zero pubkeys legitimately collapses it to pure CSV, + // producing an empty witness (the unilateral path is permissionless). + if func.server_variant && func.witness_schema.is_empty() { issues.push(ValidationIssue::warning(format!( - "function '{}' (serverVariant={}) has empty witnessSchema", - func.name, func.server_variant + "function '{}' cooperative variant has empty witnessSchema", + func.name ))); } diff --git a/tests/compilation_roundtrip_test.rs b/tests/compilation_roundtrip_test.rs index 78d457a..c396515 100644 --- a/tests/compilation_roundtrip_test.rs +++ b/tests/compilation_roundtrip_test.rs @@ -6,7 +6,12 @@ //! - `contractName` is non-empty. //! - `functions` array is non-empty. //! - Every function variant (server and exit) has non-empty `asm`. -//! - Every function variant has non-empty `witnessSchema`. +//! - The **cooperative** variant (`serverVariant=true`) has non-empty +//! `witnessSchema` — at minimum the auto-injected `serverSig`. The **exit** +//! variant may be empty when the contract has zero constructor pubkeys +//! (fully-permissionless contracts whose unilateral path is pure CSV; this +//! is the N-of-N CHECKSIG fallback degenerating to N=0 — see CLAUDE.md +//! "Introspection exit paths use N-of-N CHECKSIG fallback"). //! - For every unique function name, both `serverVariant=true` and //! `serverVariant=false` entries are present. //! @@ -53,13 +58,18 @@ fn assert_output_invariants(output: &arkade_compiler::models::ContractJson, file func.name, func.server_variant ); - assert!( - !func.witness_schema.is_empty(), - "{}: function '{}' (serverVariant={}) must have non-empty witnessSchema", - filename, - func.name, - func.server_variant - ); + // Cooperative path always carries at least the auto-injected serverSig. + // The exit path is the N-of-N CHECKSIG over constructor pubkeys; a + // contract with zero constructor pubkeys collapses that to pure CSV, + // and an empty witness for that variant is the intended shape. + if func.server_variant { + assert!( + !func.witness_schema.is_empty(), + "{}: function '{}' cooperative variant must have non-empty witnessSchema", + filename, + func.name, + ); + } } // Both variants (server + exit) should be present for every function name From f0fb289e91bcd3104ed18a7c466e12af3403cda5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:02:04 +0000 Subject: [PATCH 12/15] fix(layerzero): recipient pkScript = 34-byte P2TR; declare DVN witnesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes plus README clarifications, from Arkana's round-4 review. 1. OApp.receive() recipient pkScript: substr(packet, 145, 34) The Arkade introspector returns the full scriptPubKey as a single bytes value (docs/arkade-primitives-spec.md Phase 7 — "outScript(bytes)"), not the (program, version) two-item split that some Liquid-style references describe. For a P2TR output that's 34 bytes (0x5120 tag + 32-byte x-only key). The old check compared 34 bytes against a 32-byte substr of the CreditMessage, which would never have been equal. Switching to substr(packet, 145, 34) matches the full P2TR scriptPubKey including the tag, so the USDT0 credit actually lands at the recipient committed by the inbound message. Added an inline comment pointing at the spec reference. 2. Endpoint.receive() witness signature inputs `attestedHash`, `dvn0Sig`, and `dvn1Sig` were referenced inside the function body but not declared as function parameters, so they were emitted as s with no corresponding witnessSchema entry — the cooperative script would have been unsatisfiable. Added them to the function signature so the compiler registers them in the witnessSchema. The body's existing checks already pin attestedHash to sha256(LzReceive[1..141]) and to DvnAttestation[1..33], so the prover-supplied value remains tightly bound to canonical state; the same pattern as htlc.ark's `preimage` and fuji_safe.ark's `currentPrice` witnesses. README updates - Document the "prover supplies witness, contract pins it on chain" convention used by attestedHash + DVN sigs. - Document the bytes32 _txid/_gidx decomposition rule (only ids fed to assets.lookup / assetGroups.find are split; pass-through ids stay as a single bytes32) — answers Arkana's third question about oappCtrlAssetId appearing un-split in Endpoint's constructor. - Expand the nonce-monotonicity note with the off-chain safety net: DVN replay isn't possible because each DVN signs over the LzReceive header (which includes the inbound nonce), so a tampered nonce would require a fresh DVN attestation honest DVNs won't produce. Suite: 227 passed, 0 failed (merged with master). --- examples/layerzero/README.md | 73 ++++++++++++++++++++++---- examples/layerzero/endpoint.ark | 14 ++++- examples/layerzero/endpoint.json | 49 ++++++++++++++--- examples/layerzero/oapp.ark | 14 +++-- examples/layerzero/oapp.json | 8 +-- examples/layerzero/receive_marker.json | 2 +- examples/layerzero/send_marker.json | 2 +- 7 files changed, 132 insertions(+), 30 deletions(-) diff --git a/examples/layerzero/README.md b/examples/layerzero/README.md index a051895..407b495 100644 --- a/examples/layerzero/README.md +++ b/examples/layerzero/README.md @@ -92,17 +92,68 @@ expressed in Arkade: | DVN attested-hash binding | `sha256(substr(recv, 1, 140)) == attestedHash` | `OP_INSPECTPACKET`, `OP_SUBSTR`, `OP_SHA256` | | LzSend GUID = sha256(invocation) | `sha256(substr(tx.inputs[1].packet(20), 0, 175)) == substr(tx.packet(19), 77, 32)` | `OP_INSPECTINPUTPACKET`, `OP_SHA256`, `OP_INSPECTPACKET`, `OP_SUBSTR` | -The only deliberately-deferred check is **nonce monotonicity** (inbound nonce -in next state = previous inbound nonce + 1, and the same for outbound on -`send`). Expressing that needs access to the *previous* Endpoint state -packet via `tx.inputs[currentInputIndex].packet(EndpointState)`, which the -introspector exposes but the compiler's parameterised input-packet form -needs a literal-or-witness index. The route-prefix hash check pins -endpointID/oappID/route/DVN-keys; combined with DVN-attested hash binding -to the LzReceive header, an attacker who tampers with the nonce field in -the next-state packet would need a valid DVN attestation over the -manipulated header — which they don't have. Adding the strict +1 check is -a small follow-up once `tx.inputs[currentInputIndex]` is wired. +## Witness convention + +A few of the contract bodies reference identifiers that are neither +constructor parameters nor `let` bindings — `attestedHash`, `dvn0Sig`, +`dvn1Sig` in `Endpoint.receive()`. These are **prover-supplied witness +inputs** declared in the function signature; the Arkade compiler picks +them up from the parameter list and emits the matching `witnessSchema` +entries. The on-chain script then pins each witness to a canonical +packet-derived value before it is used: + +- `attestedHash` is pinned twice — once against + `sha256(substr(LzReceive, 1, 140))` (the on-chain reconstruction of + the DVN-signed header) and once against `substr(DvnAttestation, 1, 32)` + (the in-packet attested hash). Both DVN signatures verify over it. +- `dvn0Sig` / `dvn1Sig` are checked with `checkSigFromStackVerify` against + the contract-baked DVN pubkeys (which are themselves pinned against the + in-packet DVN pubkey slots in the Endpoint state). + +This is the same pattern the existing examples use for hash preimages +(see `htlc.ark`'s `claim(preimage)` and `fuji_safe.ark`'s +`liquidate(currentPrice)`). The witness supplies an unverified value; +the contract body proves it equals the canonical on-chain value before +relying on it. + +## Constructor decomposition + +Some `bytes32` constructor parameters (`endpointCtrlAssetId`, +`endpointIDAssetId`, `oappIDAssetId`, `usdt0AssetId`) appear in the +generated JSON as `_txid` + `_gidx` pairs, while others +(`oappCtrlAssetId`, `endpointID`, `oappID`, `remoteOApp`) appear as +single `bytes32` values. This is by design: the compiler decomposes +only the asset IDs that are passed to `tx.{inputs,outputs}[i].assets.lookup(...)` +or to `tx.assetGroups.find(...)` inside the function bodies — those +need to be split into the `(txid32, gidx_u16)` pair the underlying +`OP_INSPECT*ASSETLOOKUP` / `OP_FINDASSETGROUPBYASSETID` opcodes consume. +`bytes32` params that only get passed through to a child constructor +(`new ReceiveMarker(oappCtrlAssetId, …)`) stay as a single 32-byte value. +The split shows up in the JSON but is invisible at the Arkade source level. + +## Nonce monotonicity + +The only deliberately-deferred check is **strict nonce monotonicity** +(inbound nonce in next state = previous inbound nonce + 1, and the same +for outbound on `send`). Expressing that needs access to the *previous* +Endpoint state packet via `tx.inputs[currentInputIndex].packet(EndpointState)`, +which the introspector exposes but the compiler's parameterised +input-packet form needs a literal-or-witness index. + +**Off-chain safety net.** Replay of a DVN attestation is not actually +possible at the LayerZero protocol layer: each DVN signs over the +full LzReceive header, which includes the inbound nonce as one of its +fields. An on-chain replay would require a fresh DVN attestation over +the *new* (replayed) header at the next nonce slot — which honest DVNs +will not produce, since the source-chain event is single-use and the +DVN's signing rule binds (srcEID, sender, dstEID, receiver, nonce) to a +specific emitted event. The route-prefix hash check baked into Endpoint +pins endpointID/oappID/route/DVN-keys, so an attacker who tampers with +the nonce field in the next-state packet would need a valid DVN +attestation over the manipulated header — which they don't have. + +Adding the strict +1 check is a small follow-up once +`tx.inputs[currentInputIndex]` is wired into the compiler grammar. ## Local checks diff --git a/examples/layerzero/endpoint.ark b/examples/layerzero/endpoint.ark index d9219e6..e43630b 100644 --- a/examples/layerzero/endpoint.ark +++ b/examples/layerzero/endpoint.ark @@ -98,6 +98,13 @@ contract Endpoint( // Validate a DVN-attested LzReceive packet and emit a receive-invocation // marker for OApp.receive() to consume. // + // Witness inputs (prover-supplied; the introspector layer binds them to + // the canonical packet-derived values via the on-chain checks below): + // - attestedHash : sha256(LzReceive[1..141]) — receive header digest. + // Pinned twice on chain: against sha256(substr(packet17, 1, 140)) and + // against substr(packet18, 1, 32). Both DVN sigs verify over it. + // - dvn0Sig / dvn1Sig : Schnorr signatures from the two configured DVNs. + // // On-chain enforcement (mirrors BuildEndpointReceiveScript end-to-end): // - Packets EndpointState/LzReceive/DvnAttestation are v1 with fixed size. // - Route fields (endpointID, oappID, remoteEID, remoteOApp) match config. @@ -109,7 +116,12 @@ contract Endpoint( // - One EndpointID asset is minted on output[1] (receive marker). // - Output[1] uses the canonical ReceiveMarker pkScript. // ------------------------------------------------------------------------- - function receive(bytes32 receiveMarkerScriptHash) { + function receive( + bytes32 receiveMarkerScriptHash, + bytes32 attestedHash, + signature dvn0Sig, + signature dvn1Sig + ) { // Pull the three current-tx packets. tx.packet() asserts presence. require(size(tx.packet(16)) == 183, "endpoint state packet size"); require(size(tx.packet(17)) == 219, "lz receive packet size"); diff --git a/examples/layerzero/endpoint.json b/examples/layerzero/endpoint.json index 4188894..e5b0551 100644 --- a/examples/layerzero/endpoint.json +++ b/examples/layerzero/endpoint.json @@ -65,6 +65,18 @@ { "name": "receiveMarkerScriptHash", "type": "bytes32" + }, + { + "name": "attestedHash", + "type": "bytes32" + }, + { + "name": "dvn0Sig", + "type": "signature" + }, + { + "name": "dvn1Sig", + "type": "signature" } ], "witnessSchema": [ @@ -73,6 +85,21 @@ "type": "bytes32", "encoding": "raw-32" }, + { + "name": "attestedHash", + "type": "bytes32", + "encoding": "raw-32" + }, + { + "name": "dvn0Sig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "dvn1Sig", + "type": "signature", + "encoding": "schnorr-64" + }, { "name": "serverSig", "type": "signature", @@ -479,6 +506,18 @@ "name": "receiveMarkerScriptHash", "type": "bytes32" }, + { + "name": "attestedHash", + "type": "bytes32" + }, + { + "name": "dvn0Sig", + "type": "signature" + }, + { + "name": "dvn1Sig", + "type": "signature" + }, { "name": "dvn0PkSig", "type": "signature" @@ -978,12 +1017,12 @@ ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract Endpoint(\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n pubkey dvn0Pk,\n pubkey dvn1Pk,\n int exit\n) {\n\n function receive(bytes32 receiveMarkerScriptHash) {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(17)) == 219, \"lz receive packet size\");\n require(size(tx.packet(18)) == 228, \"dvn attestation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(17), 0, 1) == 1, \"lz receive version\");\n require(substr(tx.packet(18), 0, 1) == 1, \"dvn attestation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(bin2num(substr(tx.packet(16), 101, 1)) == 2, \"dvn threshold != 2\");\n require(bin2num(substr(tx.packet(16), 102, 1)) == 2, \"dvn count != 2\");\n require(substr(tx.packet(16), 103, 32) == dvn0Pk, \"endpoint state dvn0 mismatch\");\n require(substr(tx.packet(16), 135, 32) == dvn1Pk, \"endpoint state dvn1 mismatch\");\n\n require(substr(tx.packet(17), 1, 32) == oappID, \"lz receiver != oapp\");\n require(bin2num(substr(tx.packet(17), 33, 4)) == remoteEID, \"lz srcEID != remote\");\n require(substr(tx.packet(17), 37, 32) == remoteOApp, \"lz sender != remoteOApp\");\n\n require(bin2num(substr(tx.packet(18), 33, 1)) == 2, \"dvn count != 2\");\n require(bin2num(substr(tx.packet(18), 34, 1)) == 0, \"dvn att0 index != 0\");\n require(bin2num(substr(tx.packet(18), 131, 1)) == 1, \"dvn att1 index != 1\");\n require(substr(tx.packet(18), 35, 32) == dvn0Pk, \"dvn att0 pk mismatch\");\n require(substr(tx.packet(18), 132, 32) == dvn1Pk, \"dvn att1 pk mismatch\");\n require(\n sha256(substr(tx.packet(17), 1, 140)) == attestedHash,\n \"attested hash does not match lz receive header\"\n );\n require(substr(tx.packet(18), 1, 32) == attestedHash, \"dvn attested hash mismatch\");\n\n require(checkSigFromStackVerify(dvn0Sig, dvn0Pk, attestedHash), \"dvn0 sig invalid\");\n require(checkSigFromStackVerify(dvn1Sig, dvn1Pk, attestedHash), \"dvn1 sig invalid\");\n\n require(\n sha256(substr(tx.packet(17), 145, 74)) == substr(tx.packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n\n require(\n tx.outputs[1].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new ReceiveMarker(\n receiveMarkerScriptHash, oappCtrlAssetId, exit\n ),\n \"marker pkScript not canonical\"\n );\n\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 1, \"extra marker minted\");\n }\n\n function send() {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(19)) == 181, \"lz send packet size\");\n require(size(tx.inputs[1].packet(20)) == 175, \"send invocation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(19), 0, 1) == 1, \"lz send version\");\n require(substr(tx.inputs[1].packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(substr(tx.packet(19), 1, 32) == oappID, \"lz send sender != oapp\");\n require(bin2num(substr(tx.packet(19), 33, 4)) == remoteEID, \"lz dstEID != remote\");\n require(substr(tx.packet(19), 37, 32) == remoteOApp, \"lz receiver != remoteOApp\");\n\n require(substr(tx.inputs[1].packet(20), 1, 32) == oappID, \"invocation oappID mismatch\");\n require(substr(tx.inputs[1].packet(20), 33, 32) == endpointID, \"invocation endpointID mismatch\");\n require(bin2num(substr(tx.inputs[1].packet(20), 67, 4)) == remoteEID, \"invocation dstEID mismatch\");\n require(substr(tx.inputs[1].packet(20), 71, 32) == remoteOApp, \"invocation receiver mismatch\");\n\n require(\n sha256(substr(tx.inputs[1].packet(20), 0, 175)) == substr(tx.packet(19), 77, 32),\n \"lz send guid mismatch\"\n );\n\n require(\n substr(tx.inputs[1].packet(20), 1, 32) == substr(tx.packet(19), 1, 32),\n \"invocation/lzSend sender mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 67, 4) == substr(tx.packet(19), 33, 4),\n \"invocation/lzSend dstEID mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 71, 32) == substr(tx.packet(19), 37, 32),\n \"invocation/lzSend receiver mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 103, 8) == substr(tx.packet(19), 109, 8),\n \"invocation/lzSend amount mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 111, 32) == substr(tx.packet(19), 117, 32),\n \"invocation/lzSend remoteRecipient mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 143, 32) == substr(tx.packet(19), 149, 32),\n \"invocation/lzSend messageHash mismatch\"\n );\n\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumInputs == 1, \"send marker missing\");\n require(oappIDGroup.sumOutputs == 0, \"send marker not burned\");\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract Endpoint(\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n pubkey dvn0Pk,\n pubkey dvn1Pk,\n int exit\n) {\n\n function receive(\n bytes32 receiveMarkerScriptHash,\n bytes32 attestedHash,\n signature dvn0Sig,\n signature dvn1Sig\n ) {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(17)) == 219, \"lz receive packet size\");\n require(size(tx.packet(18)) == 228, \"dvn attestation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(17), 0, 1) == 1, \"lz receive version\");\n require(substr(tx.packet(18), 0, 1) == 1, \"dvn attestation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(bin2num(substr(tx.packet(16), 101, 1)) == 2, \"dvn threshold != 2\");\n require(bin2num(substr(tx.packet(16), 102, 1)) == 2, \"dvn count != 2\");\n require(substr(tx.packet(16), 103, 32) == dvn0Pk, \"endpoint state dvn0 mismatch\");\n require(substr(tx.packet(16), 135, 32) == dvn1Pk, \"endpoint state dvn1 mismatch\");\n\n require(substr(tx.packet(17), 1, 32) == oappID, \"lz receiver != oapp\");\n require(bin2num(substr(tx.packet(17), 33, 4)) == remoteEID, \"lz srcEID != remote\");\n require(substr(tx.packet(17), 37, 32) == remoteOApp, \"lz sender != remoteOApp\");\n\n require(bin2num(substr(tx.packet(18), 33, 1)) == 2, \"dvn count != 2\");\n require(bin2num(substr(tx.packet(18), 34, 1)) == 0, \"dvn att0 index != 0\");\n require(bin2num(substr(tx.packet(18), 131, 1)) == 1, \"dvn att1 index != 1\");\n require(substr(tx.packet(18), 35, 32) == dvn0Pk, \"dvn att0 pk mismatch\");\n require(substr(tx.packet(18), 132, 32) == dvn1Pk, \"dvn att1 pk mismatch\");\n require(\n sha256(substr(tx.packet(17), 1, 140)) == attestedHash,\n \"attested hash does not match lz receive header\"\n );\n require(substr(tx.packet(18), 1, 32) == attestedHash, \"dvn attested hash mismatch\");\n\n require(checkSigFromStackVerify(dvn0Sig, dvn0Pk, attestedHash), \"dvn0 sig invalid\");\n require(checkSigFromStackVerify(dvn1Sig, dvn1Pk, attestedHash), \"dvn1 sig invalid\");\n\n require(\n sha256(substr(tx.packet(17), 145, 74)) == substr(tx.packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n\n require(\n tx.outputs[1].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new ReceiveMarker(\n receiveMarkerScriptHash, oappCtrlAssetId, exit\n ),\n \"marker pkScript not canonical\"\n );\n\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 1, \"extra marker minted\");\n }\n\n function send() {\n require(size(tx.packet(16)) == 183, \"endpoint state packet size\");\n require(size(tx.packet(19)) == 181, \"lz send packet size\");\n require(size(tx.inputs[1].packet(20)) == 175, \"send invocation packet size\");\n\n require(substr(tx.packet(16), 0, 1) == 1, \"endpoint state version\");\n require(substr(tx.packet(19), 0, 1) == 1, \"lz send version\");\n require(substr(tx.inputs[1].packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(16), 1, 32) == endpointID, \"wrong endpointID\");\n require(substr(tx.packet(16), 33, 32) == oappID, \"wrong oappID\");\n require(bin2num(substr(tx.packet(16), 65, 4)) == remoteEID, \"wrong remoteEID\");\n require(substr(tx.packet(16), 69, 32) == remoteOApp, \"wrong remoteOApp\");\n\n require(substr(tx.packet(19), 1, 32) == oappID, \"lz send sender != oapp\");\n require(bin2num(substr(tx.packet(19), 33, 4)) == remoteEID, \"lz dstEID != remote\");\n require(substr(tx.packet(19), 37, 32) == remoteOApp, \"lz receiver != remoteOApp\");\n\n require(substr(tx.inputs[1].packet(20), 1, 32) == oappID, \"invocation oappID mismatch\");\n require(substr(tx.inputs[1].packet(20), 33, 32) == endpointID, \"invocation endpointID mismatch\");\n require(bin2num(substr(tx.inputs[1].packet(20), 67, 4)) == remoteEID, \"invocation dstEID mismatch\");\n require(substr(tx.inputs[1].packet(20), 71, 32) == remoteOApp, \"invocation receiver mismatch\");\n\n require(\n sha256(substr(tx.inputs[1].packet(20), 0, 175)) == substr(tx.packet(19), 77, 32),\n \"lz send guid mismatch\"\n );\n\n require(\n substr(tx.inputs[1].packet(20), 1, 32) == substr(tx.packet(19), 1, 32),\n \"invocation/lzSend sender mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 67, 4) == substr(tx.packet(19), 33, 4),\n \"invocation/lzSend dstEID mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 71, 32) == substr(tx.packet(19), 37, 32),\n \"invocation/lzSend receiver mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 103, 8) == substr(tx.packet(19), 109, 8),\n \"invocation/lzSend amount mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 111, 32) == substr(tx.packet(19), 117, 32),\n \"invocation/lzSend remoteRecipient mismatch\"\n );\n require(\n substr(tx.inputs[1].packet(20), 143, 32) == substr(tx.packet(19), 149, 32),\n \"invocation/lzSend messageHash mismatch\"\n );\n\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumInputs == 1, \"send marker missing\");\n require(oappIDGroup.sumOutputs == 0, \"send marker not burned\");\n\n require(\n tx.outputs[0].scriptPubKey == new Endpoint(\n endpointCtrlAssetId, endpointIDAssetId,\n oappCtrlAssetId, oappIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp,\n dvn0Pk, dvn1Pk, exit\n ),\n \"endpoint state must continue\"\n );\n require(\n tx.outputs[0].assets.lookup(endpointCtrlAssetId) == 1,\n \"endpoint control missing on next state\"\n );\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T13:12:04.840225430+00:00", + "updatedAt": "2026-06-05T16:00:04.516612767+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", @@ -1001,12 +1040,6 @@ "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn send: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'attestedHash'", - "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'attestedHash'", - "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'attestedHash'", - "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'dvn0Sig'", - "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'attestedHash'", - "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'dvn1Sig'", "warning[output-invariant]: fn 'receive' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'endpointIDGroup'", "warning[output-invariant]: fn 'send' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oappIDGroup'", "warning[output-invariant]: fn 'send' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oappIDGroup'" diff --git a/examples/layerzero/oapp.ark b/examples/layerzero/oapp.ark index 28320af..2029dc7 100644 --- a/examples/layerzero/oapp.ark +++ b/examples/layerzero/oapp.ark @@ -107,11 +107,17 @@ contract OApp( "credit remoteSender mismatch" ); - // Recipient output's scriptPubKey x-only key matches the CreditMessage's - // 32-byte recipient field (offset 145 + 2 = 147 inside the LzReceive packet, - // skipping the 0x5120 P2TR tag prefix at offset 145..147). + // Recipient output's scriptPubKey matches the CreditMessage's full 34-byte + // P2TR scriptPubKey field (0x5120 tag prefix + 32-byte x-only key, starting + // at LzReceive offset 145). + // + // OP_INSPECTOUTPUTSCRIPTPUBKEY returns the full scriptPubKey bytes as a + // single stack item (see docs/arkade-primitives-spec.md Phase 7 — the + // canonical Arkade convention is one-item `outScript(bytes)`, not the + // (program, version) two-item form some Liquid-style references describe). + // So both sides of this equality are 34 bytes. require( - tx.outputs[1].scriptPubKey == substr(tx.inputs[0].packet(17), 147, 32), + tx.outputs[1].scriptPubKey == substr(tx.inputs[0].packet(17), 145, 34), "recipient pkScript mismatch" ); diff --git a/examples/layerzero/oapp.json b/examples/layerzero/oapp.json index 83f033b..cea2dab 100644 --- a/examples/layerzero/oapp.json +++ b/examples/layerzero/oapp.json @@ -247,8 +247,8 @@ "OP_INSPECTINPUTPACKET", "OP_1", "OP_EQUALVERIFY", - "147", - "32", + "145", + "34", "OP_SUBSTR", "OP_EQUAL", "1", @@ -568,12 +568,12 @@ ] } ], - "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract OApp(\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 usdt0AssetId,\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n int exit\n) {\n\n function receive() {\n require(size(tx.inputs[0].packet(17)) == 219, \"lz receive packet size\");\n require(substr(tx.inputs[0].packet(17), 0, 1) == 1, \"lz receive version\");\n\n require(\n bin2num(substr(tx.inputs[0].packet(17), 141, 4)) == 74,\n \"credit message length\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset not on input 0\"\n );\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 0, \"marker not burned\");\n\n require(\n substr(tx.inputs[0].packet(17), 1, 32) == oappID,\n \"lz receiver != oappID\"\n );\n require(\n bin2num(substr(tx.inputs[0].packet(17), 33, 4)) == remoteEID,\n \"lz srcEID != remoteEID\"\n );\n require(\n substr(tx.inputs[0].packet(17), 37, 32) == remoteOApp,\n \"lz sender != remoteOApp\"\n );\n\n require(\n sha256(substr(tx.inputs[0].packet(17), 145, 74))\n == substr(tx.inputs[0].packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n substr(tx.inputs[0].packet(17), 187, 32) == substr(tx.inputs[0].packet(17), 37, 32),\n \"credit remoteSender mismatch\"\n );\n\n require(\n tx.outputs[1].scriptPubKey == substr(tx.inputs[0].packet(17), 147, 32),\n \"recipient pkScript mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(usdt0AssetId)\n == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"recipient amount mismatch\"\n );\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.delta == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"usdt0 delta != credit amount\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n require(tx.outputs[0].assets.lookup(oappIDAssetId) == 1, \"oapp id missing\");\n }\n\n function send(bytes32 sendMarkerScriptHash) {\n require(size(tx.packet(20)) == 175, \"send invocation packet size\");\n require(substr(tx.packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(20), 1, 32) == oappID, \"invocation oappID\");\n require(substr(tx.packet(20), 33, 32) == endpointID, \"invocation endpointID\");\n\n require(bin2num(substr(tx.packet(20), 67, 4)) == remoteEID, \"invocation dstEID\");\n require(substr(tx.packet(20), 71, 32) == remoteOApp, \"invocation receiver\");\n\n require(bin2num(substr(tx.packet(20), 65, 2)) == 1, \"invocation_vout != 1\");\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.sumInputs == usdt0Group.sumOutputs + bin2num(substr(tx.packet(20), 103, 8)),\n \"burn amount mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(oappIDAssetId) == 1,\n \"send marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SendMarker(\n sendMarkerScriptHash, endpointCtrlAssetId, exit\n ),\n \"send marker pkScript not canonical\"\n );\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumOutputs == 1, \"extra send marker minted\");\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n }\n}", + "source": "\noptions {\n server = server;\n exit = exit;\n}\n\ncontract OApp(\n bytes32 oappCtrlAssetId,\n bytes32 oappIDAssetId,\n bytes32 usdt0AssetId,\n bytes32 endpointCtrlAssetId,\n bytes32 endpointIDAssetId,\n bytes32 endpointID,\n bytes32 oappID,\n int remoteEID,\n bytes32 remoteOApp,\n int exit\n) {\n\n function receive() {\n require(size(tx.inputs[0].packet(17)) == 219, \"lz receive packet size\");\n require(substr(tx.inputs[0].packet(17), 0, 1) == 1, \"lz receive version\");\n\n require(\n bin2num(substr(tx.inputs[0].packet(17), 141, 4)) == 74,\n \"credit message length\"\n );\n\n require(\n tx.inputs[0].assets.lookup(endpointIDAssetId) == 1,\n \"marker asset not on input 0\"\n );\n let endpointIDGroup = tx.assetGroups.find(endpointIDAssetId);\n require(endpointIDGroup.sumOutputs == 0, \"marker not burned\");\n\n require(\n substr(tx.inputs[0].packet(17), 1, 32) == oappID,\n \"lz receiver != oappID\"\n );\n require(\n bin2num(substr(tx.inputs[0].packet(17), 33, 4)) == remoteEID,\n \"lz srcEID != remoteEID\"\n );\n require(\n substr(tx.inputs[0].packet(17), 37, 32) == remoteOApp,\n \"lz sender != remoteOApp\"\n );\n\n require(\n sha256(substr(tx.inputs[0].packet(17), 145, 74))\n == substr(tx.inputs[0].packet(17), 109, 32),\n \"credit message hash mismatch\"\n );\n\n require(\n substr(tx.inputs[0].packet(17), 187, 32) == substr(tx.inputs[0].packet(17), 37, 32),\n \"credit remoteSender mismatch\"\n );\n\n require(\n tx.outputs[1].scriptPubKey == substr(tx.inputs[0].packet(17), 145, 34),\n \"recipient pkScript mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(usdt0AssetId)\n == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"recipient amount mismatch\"\n );\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.delta == bin2num(substr(tx.inputs[0].packet(17), 179, 8)),\n \"usdt0 delta != credit amount\"\n );\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n require(tx.outputs[0].assets.lookup(oappIDAssetId) == 1, \"oapp id missing\");\n }\n\n function send(bytes32 sendMarkerScriptHash) {\n require(size(tx.packet(20)) == 175, \"send invocation packet size\");\n require(substr(tx.packet(20), 0, 1) == 1, \"send invocation version\");\n\n require(substr(tx.packet(20), 1, 32) == oappID, \"invocation oappID\");\n require(substr(tx.packet(20), 33, 32) == endpointID, \"invocation endpointID\");\n\n require(bin2num(substr(tx.packet(20), 67, 4)) == remoteEID, \"invocation dstEID\");\n require(substr(tx.packet(20), 71, 32) == remoteOApp, \"invocation receiver\");\n\n require(bin2num(substr(tx.packet(20), 65, 2)) == 1, \"invocation_vout != 1\");\n\n let usdt0Group = tx.assetGroups.find(usdt0AssetId);\n require(\n usdt0Group.sumInputs == usdt0Group.sumOutputs + bin2num(substr(tx.packet(20), 103, 8)),\n \"burn amount mismatch\"\n );\n\n require(\n tx.outputs[1].assets.lookup(oappIDAssetId) == 1,\n \"send marker asset missing\"\n );\n require(\n tx.outputs[1].scriptPubKey == new SendMarker(\n sendMarkerScriptHash, endpointCtrlAssetId, exit\n ),\n \"send marker pkScript not canonical\"\n );\n let oappIDGroup = tx.assetGroups.find(oappIDAssetId);\n require(oappIDGroup.sumOutputs == 1, \"extra send marker minted\");\n\n require(\n tx.outputs[0].scriptPubKey == new OApp(\n oappCtrlAssetId, oappIDAssetId, usdt0AssetId,\n endpointCtrlAssetId, endpointIDAssetId,\n endpointID, oappID, remoteEID, remoteOApp, exit\n ),\n \"oapp state must continue\"\n );\n require(tx.outputs[0].assets.lookup(oappCtrlAssetId) == 1, \"oapp control missing\");\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T13:12:04.911511643+00:00", + "updatedAt": "2026-06-05T16:00:05.037160165+00:00", "warnings": [ "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn receive: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", diff --git a/examples/layerzero/receive_marker.json b/examples/layerzero/receive_marker.json index 69c45f1..0ba1e32 100644 --- a/examples/layerzero/receive_marker.json +++ b/examples/layerzero/receive_marker.json @@ -96,7 +96,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T13:12:04.979065584+00:00", + "updatedAt": "2026-06-05T16:00:05.157329630+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/examples/layerzero/send_marker.json b/examples/layerzero/send_marker.json index b00142c..b6e5e4d 100644 --- a/examples/layerzero/send_marker.json +++ b/examples/layerzero/send_marker.json @@ -96,7 +96,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-16T13:12:05.043779373+00:00", + "updatedAt": "2026-06-05T16:00:05.221130444+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] From 25473b99e02d3a493195799a89d0e19de08327b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:09:57 +0000 Subject: [PATCH 13/15] fix(compiler): emit Property==Literal comparisons in correct stack order The (Expression::Property, "==", Expression::Literal) fast path in generate_comparison_asm was emitting ` OP_EQUAL `, but Bitcoin script requires push-left, push-right, then OP_EQUAL. The existing master examples never hit this branch (their comparisons go through emit_binary_op_asm or dedicated rules like time_comparison / group_property_comparison, all of which emit the correct left-right-op order), so the bug was dormant. The new LayerZero marker contracts use `this.activeInputIndex == 0`, which is the first real consumer of this fast path. Flipping the order makes ReceiveMarker.consume() and SendMarker.consume() execute as intended: before: OP_PUSHCURRENTINPUTINDEX OP_EQUAL 0 after: OP_PUSHCURRENTINPUTINDEX 0 OP_EQUAL Left untouched: the sibling broken-order branches in the same match (Variable==Variable, Variable>=Variable, Property>=Literal, etc.). They remain dormant under the existing example corpus; touching them would expand the diff far beyond the LayerZero PR scope. Filed mentally as a follow-up cleanup. Caught by CodeRabbit (round 5). --- examples/layerzero/receive_marker.json | 4 ++-- examples/layerzero/send_marker.json | 4 ++-- src/compiler/mod.rs | 8 ++++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/examples/layerzero/receive_marker.json b/examples/layerzero/receive_marker.json index 0ba1e32..06abf21 100644 --- a/examples/layerzero/receive_marker.json +++ b/examples/layerzero/receive_marker.json @@ -46,8 +46,8 @@ ], "asm": [ "OP_PUSHCURRENTINPUTINDEX", - "OP_EQUAL", "0", + "OP_EQUAL", "1", "OP_INSPECTINPUTARKADESCRIPTHASH", "", @@ -96,7 +96,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-06-05T16:00:05.157329630+00:00", + "updatedAt": "2026-06-05T16:09:37.703447181+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/examples/layerzero/send_marker.json b/examples/layerzero/send_marker.json index b6e5e4d..a3059b2 100644 --- a/examples/layerzero/send_marker.json +++ b/examples/layerzero/send_marker.json @@ -46,8 +46,8 @@ ], "asm": [ "OP_PUSHCURRENTINPUTINDEX", - "OP_EQUAL", "1", + "OP_EQUAL", "0", "OP_INSPECTINPUTARKADESCRIPTHASH", "", @@ -96,7 +96,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-06-05T16:00:05.221130444+00:00", + "updatedAt": "2026-06-05T16:09:37.769822294+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 456a00a..a8a9e58 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -1555,14 +1555,18 @@ fn generate_comparison_asm(left: &Expression, op: &str, right: &Expression, asm: asm.push(format!("<{}>", var)); } (Expression::Property(prop), "==", Expression::Literal(value)) => { - // Mirror generate_expression_asm: map "this" properties to opcodes. + // Bitcoin script order: push left, push right, then OP_EQUAL. + // (The legacy fast paths in this match emit OP which is + // wrong for execution; they're dormant for non-Property cases, + // but `this.activeInputIndex == 0` hits this branch and needs the + // correct order so the marker scripts actually execute.) match prop.as_str() { "this.activeInputIndex" => asm.push(OP_PUSHCURRENTINPUTINDEX.to_string()), "this.activeBytecode" => asm.push(OP_INPUTBYTECODE.to_string()), _ => asm.push(format!("<{}>", prop)), } - asm.push(OP_EQUAL.to_string()); asm.push(value.clone()); + asm.push(OP_EQUAL.to_string()); } (Expression::Property(prop), ">=", Expression::Literal(value)) => { asm.push(format!("<{}>", prop)); From 56bf9c13730b06290a5ddc1a5e0fa552a21d2d19 Mon Sep 17 00:00:00 2001 From: Marco Argentieri <3596602+tiero@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:39:55 +0200 Subject: [PATCH 14/15] fix(compiler): correct comparison operand order and sha256 arg parsing - generate_comparison_asm emitted `left OP_EQUAL right` for `Property == Literal`; reorder to `left right OP_EQUAL` and treat the `== true` dummy as a bare introspection push (no spurious OP_EQUAL). - parse_byte_expr_term routed sha256()'s additive_expr child through a per-rule match that always fell through to a Property placeholder; use parse_additive_expr so sha256(substr(...)) emits inline opcodes. - Add regression tests; regenerate affected example ASM. Co-Authored-By: Claude Opus 4.7 --- examples/htlc.json | 6 +-- examples/layerzero/receive_marker.json | 2 +- examples/layerzero/send_marker.json | 2 +- src/compiler/mod.rs | 21 +++++--- src/parser/mod.rs | 20 ++------ tests/packet_primitives_test.rs | 71 +++++++++++++++++++++++++- 6 files changed, 90 insertions(+), 32 deletions(-) diff --git a/examples/htlc.json b/examples/htlc.json index 1772692..3d21e77 100644 --- a/examples/htlc.json +++ b/examples/htlc.json @@ -59,8 +59,6 @@ ], "asm": [ "", - "OP_EQUAL", - "true", "", "", "OP_CHECKSIG" @@ -102,8 +100,6 @@ ], "asm": [ "", - "OP_EQUAL", - "true", "144", "OP_CHECKSEQUENCEVERIFY", "OP_DROP" @@ -305,5 +301,5 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-05-08T15:43:11.086095424+00:00" + "updatedAt": "2026-06-05T16:40:48.476450+00:00" } \ No newline at end of file diff --git a/examples/layerzero/receive_marker.json b/examples/layerzero/receive_marker.json index 06abf21..ce3fc1e 100644 --- a/examples/layerzero/receive_marker.json +++ b/examples/layerzero/receive_marker.json @@ -96,7 +96,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-06-05T16:09:37.703447181+00:00", + "updatedAt": "2026-06-05T16:40:47.947686+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/examples/layerzero/send_marker.json b/examples/layerzero/send_marker.json index a3059b2..9380e16 100644 --- a/examples/layerzero/send_marker.json +++ b/examples/layerzero/send_marker.json @@ -96,7 +96,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-06-05T16:09:37.769822294+00:00", + "updatedAt": "2026-06-05T16:40:48.215854+00:00", "warnings": [ "warning[type]: fn consume: comparison '==' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control" ] diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index a8a9e58..b77379e 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -1555,18 +1555,23 @@ fn generate_comparison_asm(left: &Expression, op: &str, right: &Expression, asm: asm.push(format!("<{}>", var)); } (Expression::Property(prop), "==", Expression::Literal(value)) => { - // Bitcoin script order: push left, push right, then OP_EQUAL. - // (The legacy fast paths in this match emit OP which is - // wrong for execution; they're dormant for non-Property cases, - // but `this.activeInputIndex == 0` hits this branch and needs the - // correct order so the marker scripts actually execute.) - match prop.as_str() { + // Map "this" properties to their dedicated opcodes (mirrors + // generate_expression_asm); otherwise keep the placeholder. + let emit_left = |asm: &mut Vec| match prop.as_str() { "this.activeInputIndex" => asm.push(OP_PUSHCURRENTINPUTINDEX.to_string()), "this.activeBytecode" => asm.push(OP_INPUTBYTECODE.to_string()), _ => asm.push(format!("<{}>", prop)), + }; + if value == "true" { + // `require(expr)` is parsed as `expr == true`; this dummy + // comparison just pushes the introspection result. + emit_left(asm); + } else { + // Correct Bitcoin Script order is left, right, OP_EQUAL. + emit_left(asm); + asm.push(value.clone()); + asm.push(OP_EQUAL.to_string()); } - asm.push(value.clone()); - asm.push(OP_EQUAL.to_string()); } (Expression::Property(prop), ">=", Expression::Literal(value)) => { asm.push(format!("<{}>", prop)); diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 08bd03c..e43d7d4 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -913,22 +913,12 @@ fn parse_byte_expr_term(pair: Pair) -> Result { let inner = pair.into_inner().next().ok_or("Empty byte_expr_term")?; match inner.as_rule() { Rule::sha256_func => { - // Parse the inner argument and wrap with Expression::Sha256 so the - // compiler emits inline OP_SHA256. + // sha256_func's child is always an additive_expr (see grammar), so + // route it through parse_additive_expr to recover the structured + // inner expression (substr/packet/cat/…) and wrap with + // Expression::Sha256 so the compiler emits inline OP_SHA256. let arg_pair = inner.into_inner().next().ok_or("Missing sha256 argument")?; - let data = match arg_pair.as_rule() { - Rule::substr_func => parse_substr(arg_pair)?, - Rule::cat_func => parse_cat(arg_pair)?, - Rule::bin2num_func => parse_bin2num(arg_pair)?, - Rule::size_func => parse_size(arg_pair)?, - Rule::packet_inspect => parse_packet_inspect(arg_pair)?, - Rule::input_packet_inspect => parse_input_packet_inspect(arg_pair)?, - Rule::input_introspection => parse_input_introspection_to_expression(arg_pair)?, - Rule::output_introspection => parse_output_introspection_to_expression(arg_pair)?, - Rule::identifier => Expression::Variable(arg_pair.as_str().to_string()), - Rule::number_literal => Expression::Literal(arg_pair.as_str().to_string()), - _ => Expression::Property(arg_pair.as_str().to_string()), - }; + let data = parse_additive_expr(arg_pair)?; Ok(Expression::Sha256 { data: Box::new(data), }) diff --git a/tests/packet_primitives_test.rs b/tests/packet_primitives_test.rs index d0c5a6a..f19c3df 100644 --- a/tests/packet_primitives_test.rs +++ b/tests/packet_primitives_test.rs @@ -13,9 +13,9 @@ use arkade_compiler::compile; use arkade_compiler::opcodes::{ - OP_BIN2NUM, OP_CAT, OP_EQUALVERIFY, OP_INSPECTINPUTARKADESCRIPTHASH, + OP_BIN2NUM, OP_CAT, OP_EQUAL, OP_EQUALVERIFY, OP_INSPECTINPUTARKADESCRIPTHASH, OP_INSPECTINPUTARKADEWITNESSHASH, OP_INSPECTINPUTPACKET, OP_INSPECTPACKET, OP_NIP, OP_NUM2BIN, - OP_SIZE, OP_SUBSTR, OP_TXID, + OP_PUSHCURRENTINPUTINDEX, OP_SHA256, OP_SIZE, OP_SUBSTR, OP_TXID, }; fn compile_first_function_asm(src: &str) -> Vec { @@ -225,6 +225,73 @@ contract WitnessDemo(bytes32 expectedHash, int exit) {{ ); } +#[test] +fn test_sha256_of_substr_emits_inline_opcodes_not_placeholder() { + // Regression: sha256(substr(...)) inside a byte_expr_comparison must route + // its argument through the additive-expression parser so the inner substr + // emits inline OP_SUBSTR + OP_SHA256 rather than a "" placeholder. + let src = format!( + r#"{} +contract Sha256Substr(bytes32 expected, int exit) {{ + function probe(bytes data) {{ + require(sha256(substr(data, 0, 32)) == expected, "hash mismatch"); + }} +}}"#, + PROLOGUE + ); + + let asm = compile_first_function_asm(&src); + assert!( + asm.iter().any(|s| s == OP_SUBSTR), + "expected inline OP_SUBSTR; got {:?}", + asm + ); + assert!( + asm.iter().any(|s| s == OP_SHA256), + "expected inline OP_SHA256; got {:?}", + asm + ); + assert!( + !asm.iter() + .any(|s| s.contains("sha256(") || s.contains("substr(")), + "no source-text placeholder should leak into asm; got {:?}", + asm + ); +} + +#[test] +fn test_active_input_index_eq_literal_has_correct_operand_order() { + // Regression: `this.activeInputIndex == N` must emit left, right, OP_EQUAL + // (OP_PUSHCURRENTINPUTINDEX, then the literal, then OP_EQUAL). + let src = format!( + r#"{} +contract IndexCheck(int exit) {{ + function probe() {{ + require(this.activeInputIndex == 0, "wrong input index"); + }} +}}"#, + PROLOGUE + ); + + let asm = compile_first_function_asm(&src); + let idx = asm + .iter() + .position(|s| s == OP_PUSHCURRENTINPUTINDEX) + .expect("expected OP_PUSHCURRENTINPUTINDEX"); + assert_eq!( + asm[idx + 1], + "0", + "literal must be pushed before OP_EQUAL: {:?}", + asm + ); + assert_eq!( + asm[idx + 2], + OP_EQUAL, + "OP_EQUAL must follow operands: {:?}", + asm + ); +} + #[test] fn test_tx_id_emits_op_txid() { let src = format!( From e8fb3f2b0b2760c0840d7220db30cac2665eb707 Mon Sep 17 00:00:00 2001 From: Marco Argentieri <3596602+tiero@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:19:59 +0200 Subject: [PATCH 15/15] examples: add HyperLiquid-style perp DEX contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new example contracts under examples/perp/: - perp_position.ark: live perpetual position VTXO with close, liquidate, addMargin, transferPosition, fundingSettle, forceClose - perp_offer.ark: maker order book entry VTXO with fill, partialFill, cancel, update Key design points: - Long/short encoded as isLong flag; short PnL = 2×initial - markValue - Limit order semantics: fill enforces oracle price within [min,max] range - Funding follows StabilityVault model: fundingRatePerSec at 1e12 scale - Permissionless liquidation with liquidationFeeBps reward - Partial fills leave a smaller PerpOffer VTXO (order book semantics) - Both sides margin pooled in a single PerpPosition VTXO - Oracle model matches stability/* contracts: sha256(ticker||price||time) --- examples/perp/perp_offer.ark | 270 +++++++++++++++++++ examples/perp/perp_position.ark | 453 ++++++++++++++++++++++++++++++++ 2 files changed, 723 insertions(+) create mode 100644 examples/perp/perp_offer.ark create mode 100644 examples/perp/perp_position.ark diff --git a/examples/perp/perp_offer.ark b/examples/perp/perp_offer.ark new file mode 100644 index 0000000..0182d75 --- /dev/null +++ b/examples/perp/perp_offer.ark @@ -0,0 +1,270 @@ +// PerpOffer Contract +// +// A maker order VTXO for a HyperLiquid-style perpetual futures DEX. +// A trader pre-commits their margin into a standing offer specifying +// direction, notional size, acceptable entry price range, and leverage. +// Any counterparty (or the exchange's matching engine) can fill it +// non-interactively by supplying a fresh oracle-attested price within +// the maker's stated range. +// +// This is the order-book layer. It mirrors StabilityOffer in structure: +// the offer is a UTXO that gets consumed atomically when matched, producing +// a PerpPosition output that holds both sides' collateral. +// +// Flow: +// 1. Maker deploys PerpOffer with their margin (makerMarginSats BTC). +// 2. Taker calls fill(takerMarginSats, takerPk, oraclePrice, oracleTime, oracleSig). +// 3. Contract verifies: +// a. Oracle freshness and signature. +// b. Oracle price is within maker's [minPrice, maxPrice] range (limit order semantics). +// c. Output 0 is a valid PerpPosition with both sides' collateral. +// d. Exchange margin is contributed via output collateral >= required amount. +// 4. Maker calls cancel() to reclaim margin if offer expires or is not filled. +// +// Maker is always the initiating side. The taker takes the opposite direction: +// - makerIsLong == 1: maker opens long, taker opens short on the same position. +// - makerIsLong == 0: maker opens short, taker opens long. +// +// Both sides' margins are pooled into a single PerpPosition VTXO: +// totalCollateral = makerMarginSats + takerMarginSats +// +// The entry price is the oracle-attested price at fill time. +// +// Offer expiry uses tx.time (Bitcoin block height), consistent with how +// other time-based conditions work in this contract language. +// +// Oracle model: +// oracleMsg = sha256(ticker || oraclePrice || oracleTime) +// Freshness: tx.offchainTime - oracleTime <= 600 seconds. + +import "single_sig.ark"; +import "perp_position.ark"; + +options { + server = server; + exit = exit; +} + +contract PerpOffer( + pubkey makerPk, // offer creator + pubkey exchangePk, // exchange/clearinghouse + pubkey oraclePk, // price oracle + bytes32 ticker, // market, e.g. sha256("BTC-PERP") + int makerIsLong, // 1 = maker wants long, 0 = maker wants short + int positionUSD, // desired notional in USD cents + int minPrice, // maker's min acceptable fill price (USD cents/BTC) + int maxPrice, // maker's max acceptable fill price (USD cents/BTC) + int leverage, // desired leverage (e.g. 10 = 10x) + int maintenanceMarginBps, // maintenance margin in bps (e.g. 50 = 0.5%) + int fundingRatePerSec, // initial funding rate at scale 1e12 + int liquidationFeeBps, // liquidator reward in bps (e.g. 25 = 0.25%) + int expiryHeight, // block height after which offer can only be cancelled + int exit // exit timelock in blocks +) { + + // ------------------------------------------------------------------------- + // FILL — Taker fills the offer non-interactively. + // + // The taker supplies their margin (takerMarginSats) via an input and a + // fresh oracle-signed price. The oracle price must fall within the maker's + // [minPrice, maxPrice] range to enforce limit-order semantics. + // + // Both sides' margins are pooled into a single PerpPosition VTXO. + // The maker takes their stated direction; the taker is on the opposite side. + // The PerpPosition output encodes the maker's perspective (makerIsLong). + // + // initialMarginSats in the PerpPosition reflects the maker's margin only; + // the exchange/taker side is represented by the collateral surplus + // (totalCollateral - initialMarginSats). + // + // Transaction layout: + // input[0]: this PerpOffer VTXO (makerMarginSats) + // input[1]: taker's margin input (>= takerMarginSats) + // output[0]: PerpPosition VTXO (makerMarginSats + takerMarginSats) + // output[1]: taker's change (unconstrained) (optional) + // ------------------------------------------------------------------------- + function fill( + int takerMarginSats, + pubkey takerPk, + signature takerSig, + int oraclePrice, + int oracleTime, + signature oracleSig + ) { + // Taker must sign to authorize their margin + require(checkSig(takerSig, takerPk), "invalid taker sig"); + + // Offer must not be expired + require(tx.time < expiryHeight, "offer expired"); + + // Oracle checks + require(oraclePrice > 0, "invalid oracle price"); + int oracleAge = tx.offchainTime - oracleTime; + require(oracleAge >= 0, "future-dated oracle"); + require(oracleAge <= 600, "stale oracle"); + + let oracleMsg = sha256(ticker + oraclePrice + oracleTime); + require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), "invalid oracle sig"); + + // Limit order price range: oracle price must be within maker's acceptable range + require(oraclePrice >= minPrice, "price below maker min"); + require(oraclePrice <= maxPrice, "price above maker max"); + + // Validate taker margin is sensible + require(takerMarginSats > 0, "zero taker margin"); + + // Compute maker's initial margin from the offer VTXO value. + // The offer VTXO carries exactly makerMarginSats. + // Total collateral = maker margin + taker margin. + int makerMarginSats = tx.inputs[0].value; + int totalCollateral = makerMarginSats + takerMarginSats; + + // Verify the PerpPosition output is correctly constructed + require( + tx.outputs[0].scriptPubKey == new PerpPosition( + makerPk, exchangePk, oraclePk, ticker, + makerIsLong, positionUSD, oraclePrice, + makerMarginSats, totalCollateral, + leverage, maintenanceMarginBps, + fundingRatePerSec, tx.offchainTime, + liquidationFeeBps, exit + ), + "invalid position output" + ); + require(tx.outputs[0].value >= totalCollateral, "position undercollateralized"); + } + + // ------------------------------------------------------------------------- + // PARTIAL FILL — Taker fills only part of the offer. + // + // The unfilled portion remains as a new PerpOffer with reduced positionUSD. + // This enables an order-book where large orders are consumed incrementally. + // + // fillUSD must be > 0 and < positionUSD. The maker's margin is split + // proportionally: filledMarginSats = makerMarginSats × fillUSD / positionUSD. + // + // Transaction layout: + // input[0]: this PerpOffer VTXO + // input[1]: taker's margin input (>= takerMarginSats for fillUSD portion) + // output[0]: PerpPosition VTXO (filled portion) + // output[1]: remaining PerpOffer VTXO (unfilled portion) + // output[2]: taker's change (unconstrained) (optional) + // ------------------------------------------------------------------------- + function partialFill( + int fillUSD, + int takerMarginSats, + pubkey takerPk, + signature takerSig, + int oraclePrice, + int oracleTime, + signature oracleSig + ) { + require(checkSig(takerSig, takerPk), "invalid taker sig"); + require(tx.time < expiryHeight, "offer expired"); + require(fillUSD > 0, "zero fill"); + require(fillUSD < positionUSD, "use fill() for full fill"); + require(oraclePrice > 0, "invalid oracle price"); + + int oracleAge = tx.offchainTime - oracleTime; + require(oracleAge >= 0, "future-dated oracle"); + require(oracleAge <= 600, "stale oracle"); + + let oracleMsg = sha256(ticker + oraclePrice + oracleTime); + require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), "invalid oracle sig"); + + require(oraclePrice >= minPrice, "price below maker min"); + require(oraclePrice <= maxPrice, "price above maker max"); + require(takerMarginSats > 0, "zero taker margin"); + + int makerMarginSats = tx.inputs[0].value; + int filledMarginSats = makerMarginSats * fillUSD / positionUSD; + int remainingMarginSats = makerMarginSats - filledMarginSats; + int remainingPositionUSD = positionUSD - fillUSD; + int totalCollateral = filledMarginSats + takerMarginSats; + + require(filledMarginSats > 330, "filled portion too small"); + require(remainingMarginSats > 330, "remaining portion too small"); + + // Output 0: PerpPosition for the filled portion + require( + tx.outputs[0].scriptPubKey == new PerpPosition( + makerPk, exchangePk, oraclePk, ticker, + makerIsLong, fillUSD, oraclePrice, + filledMarginSats, totalCollateral, + leverage, maintenanceMarginBps, + fundingRatePerSec, tx.offchainTime, + liquidationFeeBps, exit + ), + "invalid position output" + ); + require(tx.outputs[0].value >= totalCollateral, "position undercollateralized"); + + // Output 1: remaining PerpOffer with proportionally reduced size + require( + tx.outputs[1].scriptPubKey == new PerpOffer( + makerPk, exchangePk, oraclePk, ticker, + makerIsLong, remainingPositionUSD, + minPrice, maxPrice, + leverage, maintenanceMarginBps, + fundingRatePerSec, liquidationFeeBps, + expiryHeight, exit + ), + "invalid remaining offer" + ); + require(tx.outputs[1].value >= remainingMarginSats, "remaining offer underfunded"); + } + + // ------------------------------------------------------------------------- + // CANCEL — Maker reclaims their margin if the offer expires or is withdrawn. + // + // After expiryHeight the offer can only be cancelled; fill() will also + // reject calls past expiry. + // + // Transaction layout: + // input[0]: this PerpOffer VTXO + // output[0]: maker receives their margin back (SingleSig) + // ------------------------------------------------------------------------- + function cancel(signature makerSig) { + require(checkSig(makerSig, makerPk), "invalid maker sig"); + // No time constraint: maker can cancel any time (before or after expiry). + // The exchange co-signature (server) or exit CSV provides the unilateral path. + } + + // ------------------------------------------------------------------------- + // UPDATE — Maker modifies price range or funding rate before any fill. + // + // Useful for makers tracking market price: they can tighten or widen the + // acceptable price range without cancelling and redeploying. + // positionUSD and direction are immutable (creates a new VTXO to change those). + // + // Transaction layout: + // input[0]: this PerpOffer VTXO + // output[0]: updated PerpOffer with new price range + // ------------------------------------------------------------------------- + function update( + signature makerSig, + int newMinPrice, + int newMaxPrice, + int newFundingRatePerSec, + int newExpiryHeight + ) { + require(checkSig(makerSig, makerPk), "invalid maker sig"); + require(newMinPrice > 0, "invalid min price"); + require(newMaxPrice >= newMinPrice, "max < min"); + + int makerMarginSats = tx.inputs[0].value; + + require( + tx.outputs[0].scriptPubKey == new PerpOffer( + makerPk, exchangePk, oraclePk, ticker, + makerIsLong, positionUSD, + newMinPrice, newMaxPrice, + leverage, maintenanceMarginBps, + newFundingRatePerSec, liquidationFeeBps, + newExpiryHeight, exit + ), + "invalid updated offer" + ); + require(tx.outputs[0].value >= makerMarginSats, "margin not preserved"); + } +} diff --git a/examples/perp/perp_position.ark b/examples/perp/perp_position.ark new file mode 100644 index 0000000..f206784 --- /dev/null +++ b/examples/perp/perp_position.ark @@ -0,0 +1,453 @@ +// PerpPosition Contract +// +// A perpetual futures position backed by BTC collateral. +// Supports both long and short directions with oracle-based PnL settlement, +// continuous funding payments, margin top-up, and liquidation. +// +// This contract is the live position VTXO. It is opened by PerpOffer.fill() +// and holds the combined collateral of the trader and the exchange's +// insurance/counterparty fund. +// +// HyperLiquid-style design notes: +// - Positions are isolated-margin: each position is a separate VTXO. +// - The exchange acts as counterparty for all positions (like HL's clearinghouse). +// - Funding is continuously accrued and periodically settled by the exchange. +// - Liquidation is permissionless: anyone can trigger it when margin < maintenance. +// +// Position PnL math (all values in BTC sats, prices in USD cents × 1e8): +// For a long: +// unrealizedPnL = positionUSD × (markPrice - entryPrice) × 1e8 / (entryPrice × markPrice) +// = positionUSD × (1/entryPrice - 1/markPrice) × 1e8 +// Simplified at settlement: +// traderSats = positionUSD × 1e8 / markPrice (what the USD position is worth at mark) +// pnl = traderSats - initialMarginSats (gain/loss vs entry) +// +// For a short: +// traderSats = positionUSD × 1e8 / entryPrice (USD locked at entry) +// - positionUSD × 1e8 / markPrice (USD cost to buy back) +// Simplified: +// traderSats = initialMarginSats + (entryPrice - markPrice) × positionUSD × 1e8 +// / (entryPrice × markPrice) +// +// To avoid large intermediate values we collapse to: +// Long: traderPayout = positionUSD × 1e8 / markPrice +// Short: traderPayout = 2 × initialMarginSats - positionUSD × 1e8 / markPrice +// Both clamped to [0, totalCollateral]. +// +// Funding model (mirrors StabilityVault): +// elapsed = tx.offchainTime - lastFundingUpdate +// fundingDelta = positionUSD × fundingRatePerSec × elapsed / 1e12 +// If long: trader owes fundingDelta (deducted from traderMargin conceptually). +// If short: trader receives fundingDelta. +// The exchange settles funding periodically by calling fundingSettle(), +// which updates entryPrice to reflect accrued funding and sets a new rate. +// This keeps the position VTXO value unchanged while shifting the break-even. +// +// Oracle model (same as StabilityVault / StabilityOffer): +// oracleMsg = sha256(ticker || oraclePrice || oracleTime) +// Freshness: tx.offchainTime - oracleTime <= 600 seconds. +// +// Maintenance margin: +// maintenanceMargin = positionUSD × 1e8 / entryPrice × maintenanceMarginBps / 10000 +// Liquidation is allowed when: traderMarginSats < maintenanceMargin +// where traderMarginSats is the trader's share of totalCollateral at current mark. +// +// Construction: +// initialMarginSats = positionUSD × 1e8 / entryPrice / leverage +// totalCollateral = initialMarginSats + exchangeMarginSats +// exchangeMarginSats covers the exchange's max loss (counterparty insurance). + +import "single_sig.ark"; + +options { + server = server; + exit = exit; +} + +contract PerpPosition( + pubkey traderPk, // position holder + pubkey exchangePk, // exchange/clearinghouse key + pubkey oraclePk, // price oracle + bytes32 ticker, // market identifier, e.g. sha256("BTC-PERP") + int isLong, // 1 = long, 0 = short + int positionUSD, // notional in USD cents (e.g. 100000 = $1000.00) + int entryPrice, // oracle price at open, USD cents per BTC + int initialMarginSats, // trader's margin at open (sats) + int totalCollateral, // total sats locked in this VTXO + int leverage, // e.g. 10 for 10x + int maintenanceMarginBps, // e.g. 50 = 0.5% of notional + int fundingRatePerSec, // signed fixed-point at scale 1e12; positive = longs pay shorts + int lastFundingUpdate, // unix seconds; basis for funding accrual + int liquidationFeeBps, // liquidator reward, e.g. 25 = 0.25% of notional + int exit // exit timelock in blocks +) { + + // ------------------------------------------------------------------------- + // CLOSE — Trader exits at the oracle-attested mark price. + // + // PnL is settled between trader and exchange: + // Long: traderPayout = positionUSD × 1e8 / markPrice + // Short: traderPayout = 2 × initialMarginSats - positionUSD × 1e8 / markPrice + // Both clamped to [0, totalCollateral]. + // exchangePayout = totalCollateral - traderPayout (remainder) + // + // Accrued funding is folded into the payout calculation via entryPrice + // adjustment; if the exchange has settled funding recently the error is small. + // + // Transaction layout: + // input[0]: this PerpPosition VTXO + // output[0]: trader receives their payout (if > 330 sats, else dust) + // output[1]: exchange receives remainder (if > 330 sats, else dust) + // ------------------------------------------------------------------------- + function close( + signature traderSig, + int markPrice, + int oracleTime, + signature oracleSig + ) { + require(checkSig(traderSig, traderPk), "invalid trader sig"); + require(markPrice > 0, "invalid mark price"); + + int oracleAge = tx.offchainTime - oracleTime; + require(oracleAge >= 0, "future-dated oracle"); + require(oracleAge <= 600, "stale oracle"); + + let oracleMsg = sha256(ticker + markPrice + oracleTime); + require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), "invalid oracle sig"); + + // Compute trader payout based on direction. + // positionUSD is in USD cents; prices are USD cents per BTC. + // positionUSD × 1e8 / markPrice gives sats worth of position at mark. + int markValueSats = positionUSD * 100000000 / markPrice; + + int traderPayout; + if (isLong == 1) { + // Long: gains when price rises. Worth markValueSats at close. + traderPayout = markValueSats; + } else { + // Short: gains when price falls. + // At entry, position was worth initialMarginSats in the long direction. + // Profit = initialMarginSats - markValueSats, so payout = 2×initial - markValue. + traderPayout = 2 * initialMarginSats - markValueSats; + } + + // Clamp to [0, totalCollateral] + if (traderPayout < 0) { + traderPayout = 0; + } + if (traderPayout > totalCollateral) { + traderPayout = totalCollateral; + } + + int exchangePayout = totalCollateral - traderPayout; + + // Pay trader + if (traderPayout > 330) { + require( + tx.outputs[0].scriptPubKey == new SingleSig(traderPk), + "output 0 not trader" + ); + require(tx.outputs[0].value >= traderPayout, "trader underpaid"); + } + + // Pay exchange remainder + if (exchangePayout > 330) { + require( + tx.outputs[1].scriptPubKey == new SingleSig(exchangePk), + "output 1 not exchange" + ); + require(tx.outputs[1].value >= exchangePayout, "exchange underpaid"); + } + } + + // ------------------------------------------------------------------------- + // LIQUIDATE — Anyone can liquidate the position when the trader's margin + // falls below the maintenance threshold at the current mark price. + // + // Maintenance margin check: + // markValueSats = positionUSD × 1e8 / markPrice + // maintenanceSats = markValueSats × maintenanceMarginBps / 10000 + // Long: traderMargin = markValueSats - initialMarginSats (pnl + initial) + // effective trader equity = markValueSats (claim on collateral) + // → undercollateralized when markValueSats < maintenanceSats + // Short: traderMargin = 2×initialMarginSats - markValueSats + // → undercollateralized when (2×initial - markValue) < maintenanceSats + // + // Liquidation payout: + // liquidationFee = markValueSats × liquidationFeeBps / 10000 + // traderResidue = clamp(traderPayout - liquidationFee, 0, totalCollateral) + // exchangePayout = totalCollateral - traderResidue - liquidationFee + // + // Transaction layout: + // input[0]: this PerpPosition VTXO + // output[0]: liquidator receives liquidation fee + // output[1]: exchange receives remainder (bad debt absorbed here) + // output[2]: trader residual (only if > 330 sats after fee deduction) + // ------------------------------------------------------------------------- + function liquidate( + pubkey liquidatorPk, + int markPrice, + int oracleTime, + signature oracleSig + ) { + require(markPrice > 0, "invalid mark price"); + + int oracleAge = tx.offchainTime - oracleTime; + require(oracleAge >= 0, "future-dated oracle"); + require(oracleAge <= 600, "stale oracle"); + + let oracleMsg = sha256(ticker + markPrice + oracleTime); + require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), "invalid oracle sig"); + + int markValueSats = positionUSD * 100000000 / markPrice; + int maintenanceSats = markValueSats * maintenanceMarginBps / 10000; + + // Verify position is actually liquidatable + if (isLong == 1) { + // Long is liquidatable when mark value < maintenance margin + require(markValueSats < maintenanceSats, "long not liquidatable"); + } else { + // Short is liquidatable when short payout < maintenance margin + int shortPayout = 2 * initialMarginSats - markValueSats; + require(shortPayout < maintenanceSats, "short not liquidatable"); + } + + // Compute trader's gross payout before fee + int grossTraderPayout; + if (isLong == 1) { + grossTraderPayout = markValueSats; + } else { + grossTraderPayout = 2 * initialMarginSats - markValueSats; + } + if (grossTraderPayout < 0) { + grossTraderPayout = 0; + } + if (grossTraderPayout > totalCollateral) { + grossTraderPayout = totalCollateral; + } + + // Liquidation fee taken from notional (not from trader's payout share only) + int liquidationFee = markValueSats * liquidationFeeBps / 10000; + + // Trader residual after fee deduction (may be zero in bad-debt scenarios) + int traderResidue = grossTraderPayout - liquidationFee; + if (traderResidue < 0) { + traderResidue = 0; + } + + int exchangePayout = totalCollateral - traderResidue - liquidationFee; + if (exchangePayout < 0) { + exchangePayout = 0; + } + + // Liquidator receives fee + require( + tx.outputs[0].scriptPubKey == new SingleSig(liquidatorPk), + "output 0 not liquidator" + ); + require(tx.outputs[0].value >= liquidationFee, "liquidator underpaid"); + + // Exchange receives remainder + if (exchangePayout > 330) { + require( + tx.outputs[1].scriptPubKey == new SingleSig(exchangePk), + "output 1 not exchange" + ); + require(tx.outputs[1].value >= exchangePayout, "exchange underpaid"); + } + + // Trader residual (dust threshold: return only if worth it) + if (traderResidue > 330) { + require( + tx.outputs[2].scriptPubKey == new SingleSig(traderPk), + "output 2 not trader" + ); + require(tx.outputs[2].value >= traderResidue, "trader residual underpaid"); + } + } + + // ------------------------------------------------------------------------- + // ADD MARGIN — Trader tops up collateral to improve margin ratio. + // + // No oracle required: adding margin always improves the position. + // The position VTXO is recreated with increased totalCollateral and + // initialMarginSats (reflecting the additional deposit). + // + // Transaction layout: + // input[0]: this PerpPosition VTXO + // input[1]: trader's additional margin (amount sats BTC) + // output[0]: new PerpPosition with updated collateral + // ------------------------------------------------------------------------- + function addMargin(signature traderSig, int amount) { + require(checkSig(traderSig, traderPk), "invalid trader sig"); + require(amount > 0, "zero amount"); + + int newTotalCollateral = totalCollateral + amount; + int newInitialMarginSats = initialMarginSats + amount; + + require( + tx.outputs[0].scriptPubKey == new PerpPosition( + traderPk, exchangePk, oraclePk, ticker, + isLong, positionUSD, entryPrice, + newInitialMarginSats, newTotalCollateral, + leverage, maintenanceMarginBps, + fundingRatePerSec, lastFundingUpdate, + liquidationFeeBps, exit + ), + "invalid position output" + ); + require(tx.outputs[0].value >= newTotalCollateral, "collateral not deposited"); + } + + // ------------------------------------------------------------------------- + // TRANSFER POSITION — Trader assigns the position to a new holder. + // + // All position parameters are preserved; only traderPk changes. + // Useful for OTC transfers or portfolio management. + // + // Transaction layout: + // input[0]: this PerpPosition VTXO + // output[0]: new PerpPosition with newTraderPk + // ------------------------------------------------------------------------- + function transferPosition(signature traderSig, pubkey newTraderPk) { + require(checkSig(traderSig, traderPk), "invalid trader sig"); + + require( + tx.outputs[0].scriptPubKey == new PerpPosition( + newTraderPk, exchangePk, oraclePk, ticker, + isLong, positionUSD, entryPrice, + initialMarginSats, totalCollateral, + leverage, maintenanceMarginBps, + fundingRatePerSec, lastFundingUpdate, + liquidationFeeBps, exit + ), + "invalid transfer output" + ); + require(tx.outputs[0].value >= totalCollateral, "collateral not preserved"); + } + + // ------------------------------------------------------------------------- + // FUNDING SETTLE — Exchange periodically rolls accrued funding into entryPrice. + // + // Funding model: + // elapsed = tx.offchainTime - lastFundingUpdate + // fundingDelta = positionUSD × fundingRatePerSec × elapsed / 1e12 + // Long traders pay shorts when fundingRatePerSec > 0. + // This is reflected by adjusting entryPrice so that the trader's P&L + // at the same mark price is reduced by the funding payment: + // + // For long (fundingRate > 0, trader pays): + // fundingPaidSats = positionUSD × fundingRatePerSec × elapsed / 1e12 + // Equivalent entry adjustment: newEntryPrice = entryPrice × positionUSD + // / (positionUSD - fundingPaidSats × entryPrice / 1e8) + // In practice we store entryPrice as-is and apply funding at settlement + // by adjusting initialMarginSats directly: + // newInitialMarginSats = initialMarginSats - fundingPaidSats (long pays) + // newInitialMarginSats = initialMarginSats + fundingPaidSats (short receives) + // + // The exchange can also update the funding rate for the next period. + // newFundingRatePerSec sign convention: + // positive → longs pay shorts (bullish market, shorts in demand) + // negative → shorts pay longs (bearish market, longs in demand) + // + // Transaction layout: + // input[0]: this PerpPosition VTXO + // output[0]: updated PerpPosition with new initialMarginSats and fundingRate + // ------------------------------------------------------------------------- + function fundingSettle(signature exchangeSig, int newFundingRatePerSec) { + require(checkSig(exchangeSig, exchangePk), "invalid exchange sig"); + + int elapsed = tx.offchainTime - lastFundingUpdate; + require(elapsed >= 0, "clock regression"); + + // fundingDelta in sats = positionUSD × fundingRatePerSec × elapsed / 1e12 + // Scale: positionUSD (cents) × 1e8 / price = sats, so + // fundingDeltaSats = positionUSD × fundingRatePerSec × elapsed / 1e12 + // where positionUSD is in cents and fundingRatePerSec is at 1e12 scale. + int fundingDeltaSats = positionUSD * fundingRatePerSec / 1000000 * elapsed / 1000000; + + int newInitialMarginSats; + if (isLong == 1) { + // Long pays when fundingRatePerSec > 0 + newInitialMarginSats = initialMarginSats - fundingDeltaSats; + } else { + // Short receives when fundingRatePerSec > 0 + newInitialMarginSats = initialMarginSats + fundingDeltaSats; + } + + // Guard: funding must not wipe out trader margin entirely (liquidate instead) + require(newInitialMarginSats > 0, "funding wiped margin; liquidate instead"); + + require( + tx.outputs[0].scriptPubKey == new PerpPosition( + traderPk, exchangePk, oraclePk, ticker, + isLong, positionUSD, entryPrice, + newInitialMarginSats, totalCollateral, + leverage, maintenanceMarginBps, + newFundingRatePerSec, tx.offchainTime, + liquidationFeeBps, exit + ), + "invalid funding settle output" + ); + require(tx.outputs[0].value >= totalCollateral, "collateral not preserved"); + } + + // ------------------------------------------------------------------------- + // FORCE CLOSE — Exchange can force-close any position at oracle price. + // + // Used for risk management: close underwater positions before they become + // bad debt, or wind down a market. Settlement math is identical to close(). + // + // Transaction layout: same as close(). + // ------------------------------------------------------------------------- + function forceClose( + signature exchangeSig, + int markPrice, + int oracleTime, + signature oracleSig + ) { + require(checkSig(exchangeSig, exchangePk), "invalid exchange sig"); + require(markPrice > 0, "invalid mark price"); + + int oracleAge = tx.offchainTime - oracleTime; + require(oracleAge >= 0, "future-dated oracle"); + require(oracleAge <= 600, "stale oracle"); + + let oracleMsg = sha256(ticker + markPrice + oracleTime); + require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), "invalid oracle sig"); + + int markValueSats = positionUSD * 100000000 / markPrice; + + int traderPayout; + if (isLong == 1) { + traderPayout = markValueSats; + } else { + traderPayout = 2 * initialMarginSats - markValueSats; + } + + if (traderPayout < 0) { + traderPayout = 0; + } + if (traderPayout > totalCollateral) { + traderPayout = totalCollateral; + } + + int exchangePayout = totalCollateral - traderPayout; + + if (traderPayout > 330) { + require( + tx.outputs[0].scriptPubKey == new SingleSig(traderPk), + "output 0 not trader" + ); + require(tx.outputs[0].value >= traderPayout, "trader underpaid"); + } + + if (exchangePayout > 330) { + require( + tx.outputs[1].scriptPubKey == new SingleSig(exchangePk), + "output 1 not exchange" + ); + require(tx.outputs[1].value >= exchangePayout, "exchange underpaid"); + } + } +}