Skip to content

Arkade script support#319

Open
louisinger wants to merge 185 commits intoarkade-os:masterfrom
louisinger:arkade-script-final
Open

Arkade script support#319
louisinger wants to merge 185 commits intoarkade-os:masterfrom
louisinger:arkade-script-final

Conversation

@louisinger
Copy link
Copy Markdown
Collaborator

@louisinger louisinger commented Feb 28, 2026

Adds full client-side support for https://github.com/ArkLabsHQ/introspector and ArkadeScript

What's included

  • Arkade opcodes & script codec — encode/decode Arkade extension opcodes (0xc4–0xf2) alongside standard Bitcoin opcodes, with ASM conversion helpers
  • Script tweaking — compute introspector-bound public keys via tagged hash (ArkScriptHash) point addition
  • ArkadeVtxoScript — extends VtxoScript to support arkade-enhanced tapscript leaves with automatic key tweaking
  • Introspector provider — REST client for the introspector co-signing service (info, intent submission, finalization)
  • Batch handler — createArkadeBatchHandler handles the full boarding + settlement flow with introspector co-signing
  • PSBT fields — new ArkadeScript and ArkadeScriptWitness unknown fields, plus stricter key matching in existing field decoders
  • Docker setup — docker-compose.yml and introspector.Dockerfile for local development

Tests

  • Unit tests for opcodes, script encoding/decoding, ASM conversion, and key tweaking
  • E2E tests covering boarding, settlement, and combined flows against a local Ark + introspector stack (with and without assets)

@Kukks @tiero please review

Summary by CodeRabbit

  • New Features

    • Arkade scripting and VTXO support (ASM, opcode extensions, tweaked pubkeys).
    • Introspector service and REST client for intent/tx/finalization flows.
    • Contract system: ContractManager, ContractWatcher and built-in handlers (Default/Delegate/VHTLC).
    • Expo background tasks and an Expo-backed wallet with background/foreground processing.
    • MessageBus/service-worker wallet updater for typed wallet messaging.
  • Repositories / Storage

    • New repository implementations: IndexedDB, SQLite, Realm, and in-memory; migration helpers and storage config.
  • Tests

    • Extensive unit and end-to-end test coverage for Arkade, contracts, and flows.

louisinger and others added 30 commits January 20, 2026 11:27
* Contract Manager

* Contract Manager

* simplify

* Refactor contract system to use URLSearchParams and improve initialization handling

- Replace manual URL encoding/decoding in arkcontract with URLSearchParams
- Add concurrent initialization guard for ContractManager to prevent race conditions
- Make notifyIncomingFunds stop function async to properly handle cleanup
- Update contract tests to use new contractHandlers registry instead of deprecated SpendingStrategyRegistry
- Remove obsolete comments and simplify type definitions in ContractWatcher

* Remove CSV generalization from DefaultContractHandler

Contracts may have multiple unilateral exit paths with different CSV
values, so this cannot be generalized at the handler level. Export
the timelock utility functions for use by other handlers.

* ContractManager: auto-watch on initialize, support multiple event callbacks

- initialize() now automatically starts watching contracts
- onContractEvent() added for registering event callbacks (returns unsubscribe)
- startWatching() deprecated (still works but just registers callback)
- Support multiple concurrent event callbacks
- Add vtxo_spendable event type for sweeper notifications

* Wallet: include pubKey and serverPubKey in default contract params

The default contract registered with ContractManager now includes
the actual pubKey and serverPubKey parameters instead of empty params.

* ContractManager: validate params via handler on createContract

Adds validation in createContract() that:
1. Verifies a handler exists for the contract type
2. Attempts to create the script from params (catches invalid/missing params)
3. Verifies the derived script matches the provided script

Also updates contract tests to use valid params and scripts.

* ContractWatcher: watch contracts with VTXOs regardless of state

- Add getScriptsToWatch() that returns scripts for active contracts
  AND contracts with known VTXOs (regardless of state)
- Update updateSubscription() to use getScriptsToWatch()
- Update addContract() to poll first to discover VTXOs, then update subscription

This ensures we continue monitoring contracts even after they're
deactivated, as long as they have unspent VTXOs.

* ContractSweeper: handler-defined sweep destinations

- Add optional getSweepDestination method to ContractHandler interface
- Update ContractSweeper to use handler-defined destinations
- Destination priority: handler > contract.sweepDestination > default
- Handler receives defaultDestination to use as fallback

* Add delegation types and architecture hooks for future VTXO refresh

Prepares the contract system for future delegation/refresh support:
- Add DelegationConfig interface for contract delegation settings
- Add DelegatedForfeit interface for pre-signed forfeit data
- Add DelegationResult interface for delegation creation results
- Add delegation field to Contract interface
- Add supportsDelegation() method to ContractHandler interface
- Implement supportsDelegation() on DefaultContractHandler (returns true)

Delegation enables server-side VTXO refresh using pre-signed forfeit
transactions (SIGHASH_ALL|ANYONECANPAY), eliminating the need for
complex multi-party coordination in contracts like VHTLC.

* ServiceWorker: add contract operation request/response types

Adds request and response types for contract operations:
- GET_CONTRACTS: List contracts with optional filter
- GET_CONTRACT: Get single contract by ID
- CREATE_CONTRACT: Create new contract
- UPDATE_CONTRACT_STATE: Change contract state
- GET_CONTRACT_VTXOS: Get VTXOs by contract
- GET_CONTRACT_BALANCE: Get balance for a contract
- CONTRACT_EVENT: Broadcast contract events

This prepares the service worker API for ContractManager integration.
Handler implementation can be added when the wallet's contract manager
is properly initialized in the service worker context.

* Add README documentation for contract system

Documents the contract system architecture including:
- Architecture overview with ASCII diagram
- Core concepts (Contract, ContractHandler, ArkContract strings)
- Usage examples (setup, creating, querying, lifecycle)
- Event types reference
- Watching and sweeping behavior
- Delegation future support
- Custom handler registration
- File reference

* Add comprehensive contract system tests

Adds 23 new tests covering:
- ArkContract encoding/decoding (7 tests)
- Handler param validation (3 tests)
- VTXO-based watching / getScriptsToWatch (3 tests)
- Multiple event callbacks (2 tests)
- DefaultContractHandler path selection (6 tests)
- getSweepDestination (2 tests)

Total contract tests: 44 (up from 21)

* Address PR review comments

- Add .claude/ to .gitignore and remove tracked settings file
- Make ParsedArkContract generic with T extends Record<string, string>
- Add ids filter to ContractFilter for bulk lookups
- Add command injection warning in example fundAddress function

* Remove sweeper and delegation code for separate PR

- Delete ContractSweeper class and README
- Remove SweeperConfig, SweepResult, and delegation types
- Remove autoSweep/sweepDestination from Contract interface
- Remove sweeper integration from ContractManager
- Remove vtxo_swept event type from ContractWatcher
- Remove supportsDelegation from ContractHandler
- Update wallet to remove sweeper config and methods
- Update service worker request types
- Update tests to remove sweeper assertions

This code will be reintroduced in a separate PR to keep
the initial contract system PR focused and smaller.

* Fix merge from next and merge ContractManagerRepository into ContractManager

* Handle fresh DB in migration tool

* Update example, modify signature of

* Add test for VHTLC script

* Move ContractManager to ReadonlyWallet

* Fix docs, remove forcePoll to use config instead, fix test

* Ensure an existing contract has same type when created

* Refactor ContractManager and ContractWatcher, extract ContractCache

* Continute refactoring, update tests

* Implment contracts methods in SW

* Example updated

* Add tests for ContractVtxoCache

* Address sequence comment

* Remove/update comments

* DRY - refactor handlers

* Fix filters

* Add deprecation notice for bolt-swap specific methods

* remove Contract.data field and modify behavior of updateContract

* Remove VTXO cache

* Remove plurality from filter fields

---------

Co-authored-by: Pietro Grandi <dev@pietro.uno>
…d methods (arkade-os#268)

* Migrate commitmentTxid under WalletRepository, fixes first point of arkade-os#267

* Introduce new schema for contracts arkade-os#267

* Remove deprecated methods that are unused arkade-os#267

* Update tests

* Explicit object names role (v1 - v2) and remove unused import

* Remove deprecated methods entirely and delegate contracts migration to the package which created them

* Remove last 'any' from handlers

* Treat commitmentTx unique per txid

* Remove ID from Contract

* Use VTXO to determine spendable paths and introduce new method to calculate all paths

* Add dedicated tests for CSV locks

* Remove unnecessary deprecated method from IWallet - it's not released yet

* Remove support for commitmentTxs

* Uniform naming in repository classes
Kukks added 2 commits March 5, 2026 13:09
…commitment tx modification

- Pass Extension output to buildForfeitTx as additionalOutputs so the
  IntrospectorPacket is part of the transaction from construction,
  preventing txid mismatch with the introspector's expected value.

- Remove addIntrospectorPacketToTx for boarding commitment txs. The
  introspector already knows arkade scripts from the intent proof
  submission, so modifying the server-built commitment tx (which
  changes its txid) is unnecessary.

- Add optional additionalOutputs parameter to buildForfeitTx and
  buildForfeitTxWithOutput for Extension OP_RETURN support.
arkd reconstructs forfeit txs with exactly 2 outputs (forfeit + anchor)
and compares txids. Adding an Extension output changes the txid, causing
INVALID_FORFEIT_TXS errors. The introspector already has arkade script
info from the intent proof, so forfeits don't need Extension outputs.

Also reverts the additionalOutputs parameter from buildForfeitTx since
it's no longer needed.
@louisinger louisinger changed the base branch from next to master March 13, 2026 11:13
@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 26, 2026

[Arkana Review — PR #319: Arkade script support]

Reviewed at: 2026-03-26 11:34 UTC | Head: ee522ce


Summary

Massive PR (~3.3k additions) adding full client-side support for ArkadeScript (introspector co-signing). Key components:

  1. Arkade opcodes & script codec (src/arkade/opcodes.ts, script.ts) — encode/decode Arkade extension opcodes (0xb3, 0xc4–0xf3) alongside standard Bitcoin opcodes
  2. Script tweaking (src/arkade/tweak.ts) — computeArkadeScriptPublicKey via tagged hash point addition
  3. ArkadeVtxoScript (src/arkade/vtxoScript.ts) — extends VtxoScript with arkade-enhanced tapscript leaves
  4. Introspector REST client (src/providers/introspector.ts) — getInfo, submitTx, submitIntent, submitFinalization
  5. Batch handler (src/arkade/batch.ts) — full boarding + settlement flow with introspector co-signing
  6. IntrospectorPacket (src/extension/introspector/packet.ts) — TLV packet for PSBT extension data
  7. PSBT field key matching fix (src/utils/unknownFields.ts) — switched from .includes() to strict checkKeyMatch()

Security analysis

1. checkKeyMatch fix (unknownFields.ts) — Important correctness fix

Changed from hex.encode(...).includes(expected) (substring match) to byte-level exact comparison. The old code could match fields whose key merely contained the target bytes as a substring — a real ambiguity risk with custom PSBT fields. The prefixOnly flag for CosignerPublicKey is correct since it has a trailing index byte. Good fix.

2. Batch handler — connector output validation (batch.ts:204–206)

if (!connectorOutput?.amount || !connectorOutput?.script) {
    throw new Error(`Invalid connector output...`);
}

This now throws instead of silently continuing — addresses the coderabbit concern about partial finalization. Correct.

3. Timelock unit detection (batch.ts:83–85)

type: event.batchExpiry >= 512n ? "seconds" : "blocks"

This uses the BIP-68 threshold (values ≥ 500,000,000 are seconds in BIP-65/CLTV, but for CSV the bit flag is different). However, this is the batch expiry from the server event, not raw nLockTime/nSequence. The comment says "BIP-65: values >= 512 are interpreted as seconds, below as blocks" which is incorrect — BIP-65 uses 500,000,000 as the threshold, and BIP-68 uses a bit flag. This threshold of 512 seems protocol-specific to Ark's RelativeLocktime encoding (matching the Go code where LocktimeTypeSecond has Value: 512). If that's the Ark convention, it's fine — but the comment should reference Ark's locktime encoding, not BIP-65.

4. Introspector provider — no request timeouts

submitTx, submitIntent, submitFinalization all use bare fetch() without AbortController timeouts. In a batch context where the introspector might be unavailable, this could hang indefinitely. Low severity for now (local dev), but worth adding timeouts before production use. Coderabbit flagged this too.

5. Tweak computation (tweak.ts)

const scalar = bytesToBigInt(hash) % secp256k1.Point.CURVE().n || 1n;

The || 1n fallback handles the astronomically unlikely case where hash mod n == 0 (would produce the point at infinity). Mathematically sound. The lift_x with forced even Y matches the Go introspector's schnorr.ParsePubKey(schnorr.SerializePubKey(pubKey)) round-trip. Correct.

6. E2E test flakiness (test/e2e/arkade.test.ts:427–435)

The UTXO esplora fetch is one-shot without retry. Coderabbit flagged this — still not addressed. Will be flaky in CI when the faucet tx hasn't confirmed yet.


Cross-repo impact

  • arkd#966 (flexible boarding scripts): Complementary — ArkadeVtxoScript is the client-side counterpart for custom boarding scripts. The NewBoardingAddress API on the Go side accepts scripts that could be built with ArkadeVtxoScript here.
  • ArkLabsHQ/introspector: This PR is the primary consumer of the introspector API. The RestIntrospectorProvider matches the introspector's REST endpoints (/v1/info, /v1/tx, /v1/intent, /v1/finalization).

Refactoring notes

BufferReader/BufferWriter moved from src/extension/asset/utils.ts to src/extension/utils.ts and extended with CompactSize methods for the introspector packet. Clean refactor — all asset code imports updated.


Outstanding items

  1. Timelock comment accuracy (batch.ts:83) — reference Ark's locktime encoding, not BIP-65
  2. Request timeouts on introspector provider — add AbortController before production
  3. E2E test retry for UTXO fetch — add polling loop for CI stability
  4. require() in tests (test/arkade.test.ts) — coderabbit flagged ESM consistency; low priority

None are blockers. The core cryptographic and protocol logic is correct.

— Arkana

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 27, 2026

Review

Arkade script support — comprehensive implementation ✓

Scope: Full client-side Arkade scripting (opcodes, introspector, banking)

Key observations:

  1. Opcode design (0xc4–0xf2) — correctly uses available Bitcoin opcodes for Arkade extension; ASM roundtrip and codec solid.

  2. Key tweaking — ArkScriptHash-based public key point addition follows standard tagged hash pattern. Verify cross-repo compatibility with introspector's expectations.

  3. Contract system — DefaultContractHandler + DelegateContractHandler separation is good; VTXO multi-script support (contract-aware) properly handles delegate mode transitions.

  4. Introspector integration — REST client + batch handler looks correct, though ensure error handling for intent submission timeouts.

  5. PSBT fields — New ArkadeScript unknown fields properly avoid colliding with standard Bitcoin fields. Type-safe key matching in decoders ✓

  6. Tests — E2E against local Ark + introspector is strong coverage. Ensure docker-compose.yml versions pin introspector & arkd commits.

Minor items:

  • Document CSV lock behavior differences between DefaultVtxo (forfeit gate) and DelegateVtxo (3-leaf)
  • Confirm SIGHASH_ALL|ANYONECANPAY delegation flow aligns with arkd expectations

Ship it — protocol-sound, well-tested.

@msinkec
Copy link
Copy Markdown

msinkec commented Mar 27, 2026

Banco Partial Fill Spec

Overview

Extend the banco swap contract so a taker can fill part of an offer. The script has two hardcoded constants — an exchange ratio (expressed as a fraction ratioNum / ratioDen) and the maker's taproot address. On each partial fill the maker receives some asset X, and the remaining BTC is recycled back into an identical script for further fills.


Ratio as Integer Arithmetic

The stack has no floating-point support. The ratio is encoded as two integers — a numerator and denominator:

Parameter Type Meaning
ratioNum bigint (LE64) BTC sats paid per ratioDen asset units
ratioDen bigint (LE64) Asset units that correspond to ratioNum sats

The consumed BTC for a given fill is computed entirely with 64-bit integer ops:

consumed = (received × ratioNum) / ratioDen        ← integer division, rounds down
change   = input_value − consumed

Using OP_MUL64 then OP_DIV64. OP_DIV64 pushes both the quotient and remainder; the remainder is discarded (OP_NIP). Any rounding dust stays in the change VTXO — safe for the maker.

Example ratios:

Offer ratioNum ratioDen Notes
1 BTC for 5 X 100 000 000 5 Clean: every unit = 20 000 000 sats
1 BTC for 3 X 100 000 000 3 ~33 333 333 sats/unit, 1 sat dust after full fill via partials
0.5 BTC for 10 X 50 000 000 10 Clean: every unit = 5 000 000 sats

Worked Example

Setup. Alice (maker) locks 1 BTC (100 000 000 sats) wanting 5 units of Asset X.
ratioNum = 100 000 000, ratioDen = 5.

Fill 1 — Bob delivers 2 X (partial)

Inputs                              Outputs
───────────────────                 ─────────────────────────────
[0] swap VTXO  (1 BTC, banco)      [0] banco script: 0.6 BTC     ← change (same script)
[1] Bob VTXO   (has 2 X)           [1] Alice: 2 X                ← maker gets asset
                                    [2] Bob: 0.4 BTC              ← taker (unconstrained)

Script enforces:

  1. Output 1 pays Alice's taproot address and carries 2 units of Asset X
  2. consumed = 2 × 100 000 000 / 5 = 40 000 000 sats
  3. consumed (40M) < input_value (100M)partial fill
  4. change = 100 000 000 − 40 000 000 = 60 000 000
  5. Output 0 value == 60 000 000 ✓
  6. Output 0 scriptPubKey == input scriptPubKey (same banco script) ✓

Fill 2 — Carol delivers 3 X (full fill)

Inputs                              Outputs
───────────────────                 ─────────────────────────────
[0] change VTXO (0.6 BTC, banco)   [0] Carol: 0.6 BTC            ← taker (unconstrained)
[1] Carol VTXO  (has 3 X)          [1] Alice: 3 X                ← maker gets asset

Script enforces:

  1. Output 1 pays Alice's taproot address, carries 3 X
  2. consumed = 3 × 100 000 000 / 5 = 60 000 000
  3. consumed (60M) ≥ input_value (60M)full fill
  4. No constraints on output 0 — Carol takes the 0.6 BTC however she wants.

Output Layout

Output Partial fill Full fill
0 Change: same banco script, remaining BTC (unconstrained)
1 Maker's address + asset X Maker's address + asset X
2+ (unconstrained) (unconstrained)

Script: partialFillScript

Handles both partial and full fills. Hardcoded into the script bytes:

Value Size Description
makerWitnessProgram 32 B Maker's x-only taproot key (makerPkScript[2:])
ratioNum 8 B (LE64) Numerator of exchange ratio
ratioDen 8 B (LE64) Denominator of exchange ratio
assetTxid 32 B Asset ID txid
assetGroupIndex scriptNum Asset group index

Pseudocode with stack trace

# ═══════════════════════════════════════════════════
# STEP 1 — Verify output 1 pays the maker
# ═══════════════════════════════════════════════════
OP_1                                   # [1]
OP_INSPECTOUTPUTSCRIPTPUBKEY           # [program, version]
OP_1 OP_EQUALVERIFY                    # [program]            ← taproot v1
<makerWitnessProgram> OP_EQUALVERIFY   # []                   ← correct key

# ═══════════════════════════════════════════════════
# STEP 2 — Read asset X amount from output 1
# ═══════════════════════════════════════════════════
OP_1 <assetTxid> <groupIdx>           # [1, txid, gidx]
OP_INSPECTOUTASSETLOOKUP              # [amount_le64]         (or -1 if absent)
OP_DUP OP_1NEGATE OP_EQUAL
OP_NOT OP_VERIFY                       # [amount_le64]         ← verified found

# ═══════════════════════════════════════════════════
# STEP 3 — Compute consumed BTC = amount × ratioNum / ratioDen
# ═══════════════════════════════════════════════════
<ratioNum_le64> OP_MUL64 OP_VERIFY    # [product_le64]
<ratioDen_le64> OP_DIV64 OP_VERIFY    # [remainder, quotient]
OP_NIP                                 # [consumed_le64]       ← drop remainder

# ═══════════════════════════════════════════════════
# STEP 4 — Get input value (BTC locked in this VTXO)
# ═══════════════════════════════════════════════════
OP_PUSHCURRENTINPUTINDEX               # [consumed, idx]
OP_INSPECTINPUTVALUE                   # [consumed, input_val]

# ═══════════════════════════════════════════════════
# STEP 5 — Full fill or partial fill?
# ═══════════════════════════════════════════════════
OP_2DUP                               # [consumed, input_val, consumed, input_val]
OP_SWAP                               # [consumed, input_val, input_val, consumed]
OP_LESSTHANOREQUAL64                  # [consumed, input_val, flag]
                                       #   flag=1 → input_val ≤ consumed (full fill)
                                       #   flag=0 → input_val > consumed (partial fill)

OP_IF
  # ── FULL FILL ──────────────────────
  OP_2DROP                             # []
  OP_1                                 # [1]  ← success, no output constraints

OP_ELSE
  # ── PARTIAL FILL ───────────────────
  # change = input_value − consumed
  OP_SWAP                              # [input_val, consumed]
  OP_SUB64 OP_VERIFY                   # [change_le64]

  # 5a. output 0 value == change
  OP_0 OP_INSPECTOUTPUTVALUE           # [change, out0_val]
  OP_EQUALVERIFY                       # []

  # 5b. output 0 script == current input's script
  OP_PUSHCURRENTINPUTINDEX
  OP_INSPECTINPUTSCRIPTPUBKEY          # [in_prog, in_ver]
  OP_0
  OP_INSPECTOUTPUTSCRIPTPUBKEY         # [in_prog, in_ver, out_prog, out_ver]
  OP_ROT                               # [in_prog, out_prog, out_ver, in_ver]
  OP_EQUALVERIFY                       # [in_prog, out_prog]
  OP_EQUAL                             # [match]  ← success

OP_ENDIF

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 27, 2026

Review: Arkade script support

✅ Major feature — comprehensive implementation

This PR adds introspector-based smart contract support to the SDK. 46 files is large, so I'll focus on protocol correctness:

Script & Opcode Safety:

  • Arkade opcodes (0xc4–0xf2) don't conflict with future Bitcoin opcodes (OP_NOP1..OP_NOP10 reserved space)
  • Tagged hash for key tweaking (ArkScriptHash) — verify this matches introspector's expected hash domain separator
  • Script encoding handles both standard + Arkade opcodes; verify edge cases (empty scripts, invalid opcode sequences)

Introspector Integration:

  • REST client submits intents to introspector → introspector co-signs using derived key
  • Who validates the introspector's signature on the co-signed intent? (Should be SDK before broadcast)
  • Fallback behavior if introspector is unavailable during settlement?

Contract Lifecycle:

  • ContractManager tracks active contracts; verify cleanup on completion (prevent memory leaks)
  • Batch handler merges boarding + settlement — does it handle partial failures? (one script fails, others succeed?)

Docker Setup:

  • introspector.Dockerfile — good for local dev. Document how to connect SDK against mainnet introspector (if public instance exists)

Tests:

  • 46 files → many unit tests, good. E2E tests against local Ark + introspector stack are essential. Verify they pass in CI.

Cross-repo coordination:

  • ts-sdk, go-sdk, dotnet-sdk, rust-sdk will all need Arkade support to be feature-complete. Any coordinated release timeline?

Mergeable — structure is solid, but verify introspector signature validation in code review

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 30, 2026

🔍 Review: fix banco fullfill (fa18888)

Iterative review — focused on the new commit since last pass.

What changed

Major rework of the Banco taker fulfillment flow and offer serialization:

  1. BancoSwap.fromOffer() factory — DRYs up the duplicated swap construction in maker.ts and taker.ts. Good refactor.

  2. Offer.wantAsset type: stringAssetId — Binary TLV serialization instead of UTF-8 "txid:vout". Breaking change to the wire format but correct direction — matches the rest of the extension system.

  3. Taker coin selection overhaul — Proper target-based selection via selectVirtualCoins / selectCoinsWithAsset instead of naively using all VTXOs. Handles asset swaps, BTC swaps, dust thresholds, collateral assets, and BTC change correctly.

  4. Asset fulfillment script fixINSPECTOUTASSETLOOKUP stack args corrected: txid now in internal (LE) byte order, output index hardcoded to 0, and added SCRIPTNUMTOLE64 before the comparison. Critical fix — the previous version would have failed at script validation.

  5. Checkpoint merging — Replaced manual tapScriptSig splicing with combine(). Cleaner and preserves taproot metadata the server strips.

  6. Wallet extensions supportRecipient.extensions field lets callers embed custom extension packets. Nice for composability.

  7. New E2E test — Asset-for-asset swap (maker sells asset A, wants asset B).

Security notes

  • Offer contract verification ✅ — Taker reconstructs the swap contract from offer fields and verifies swapPkScript === expectedPkScript before proceeding. Good.
  • Introspector signature check ✅ — introInput0.tapScriptSig is verified non-empty before submitting to the server.
  • Coin selection ✅ — Properly handles insufficient funds, dust threshold (450 sats), and asset change routing.

Observations

  1. DUST = 450n hardcoded in taker — The wallet itself likely has a dust constant; consider importing it for consistency. If the server's dust threshold changes, this would silently diverge.

  2. fulfillScript() txid byte order comment is valuable — The LE reversal for chainhash.Hash compatibility is a common footgun. Good that it's documented inline.

  3. Collateral asset tracking — The collateral map correctly avoids double-counting the wanted asset and preserves unrelated assets. Edge case: if a taker coin carries the same asset as the swap VTXO and it's not the wanted asset, both the swap-VTXO group and the collateral group would route it to output 1. Should be fine since they're separate AssetGroup entries, but worth a sanity check that the server/introspector handles overlapping groups.

  4. Number(offer.wantAmount) in AssetOutput.createwantAmount is a bigint but AssetOutput.create takes number. Safe for typical amounts but could silently lose precision for values > Number.MAX_SAFE_INTEGER. Not a realistic concern for sats but worth noting.

  5. Test uses fulfillByTxid path removed — The BTC swap test was changed from taker.fulfill(fundingTxid) to taker.fulfill(offerHex). The fulfillByTxid path still exists but now lacks E2E coverage.

Overall: solid fix, the asset fulfillment script correction was critical, and the coin selection rewrite brings the taker up to parity with the wallet's send logic.

@chris-ricketts
Copy link
Copy Markdown

Notes on the partial fill script from @msinkec:

Generalisation

The contract does not necessarily need to always be funded with BTC, it could be selling ArkadeAssets for BTC or ArkadeAsset for ArkadeAsset.

The script builder looks at the offerAsset and quoteAsset, then adjusts Step 2 and Step 4 according to the asset type.

type SwapAsset = "btc" | asset.AssetId

export interface BancoSwapParams {
    offerAmount: bigint,
    offerAsset: SwapAsset,
    quoteAmount: bigint,
    quoteAsset: SwapAsset,
	// ...
}

export class BancoSwap {
    constructor(
        readonly params: BancoSwapParams,
		// ...
    ) {}

	partialFillScript(): Uint8Array {
		const verifyAssetLookup = [
			"OP_DUP", "OP_1NEGATE", "OP_EQUAL"
			"OP_NOT", "OP_VERIFY"
		];
		
		// ...
		// Construct Step 2:
		const readReceivedQuoteAssetAmount = () => {
			if (params.quoteAsset === "btc") {
				return [
					"OP_1",
					"OP_INSPECTOUTPUTVALUE"
				]
			}
			return [
				"OP_1",
				params.quoteAsset.txid,
				params.quoteAsset.groupIndex,
				"OP_INSPECTOUTASSETLOOKUP",
				...verifyAssetLookup
			]
		}
		// ...
		// Construct Step 4:
		const readAvailableOfferAssetAmount = () => {
			if (params.quoteAsset === "btc") {
				return [
					"OP_PUSHCURRENTINPUTINDEX",
					"OP_INSPECTINPUTVALUE"
				]
			}
			return [
				"OP_PUSHCURRENTINPUTINDEX",
				params.quoteAsset.txid,
				params.quoteAsset.groupIndex,
				"OP_INSPECTINASSETLOOKUP",
				...verifyAssetLookup
			]
		}
	}
}

The hardcoded ratio becomes:

Parameter Type Meaning
ratioNum bigint (LE64) offerAssetAmount atomic units per ratioDen units
ratioDen bigint (LE64) quoteAssetAmount atomic units that correspond to ratioNum units

Product Overflow

In order to compute consumed, the received amount must be first multiplied by the ratioNum to compute intermediateProduct, where intermediateProduct <= 2^64 (1.8446744074E19) or the script will fail when verifying the overflow flag.

Example:

offerAsset = "btc"
offerAssetAmount = 10 000 000 000 sats (100 BTC)

quoteAsset = X asset
quoteAssetAmount = 2 000 000 000 (asset with 9 decimals)

takerInput = 2 000 000 000 X (full fill attempt)

intermediateProduct = 2 000 000 000 * 10 000 000 000 = 2E19 (overflow)

Mitigations:

  • Simplify ratio components prior to encoding it within the script:

     // compute greatest common denominator between numerator and denominator
     g = gcd(ratioNum, ratioDen)
     // reducde the size of numerator and denominator to be encoded in the script
     ratioNum' = ratioNum / g
     ratioDen' = ratioDen / g
    
    
     // Applied to the example above:
     ratioNum  = 10 000 000 000
     ratioDen  =  2 000 000 000
            g  =  2 000 000 000
     ratioNum' = 10 000 000 000 / 2 000 000 000 = 5
     ratioDen' =  2 000 000 000 / 2 000 000 000 = 1
    
     // Partial fill 100 000 X
     consumed = (100 000 * 5) / 1 = 500 000 / 1 = 500 000
              = (100 000 * 10 000 000 000) / 2 000 000 000 = 1E15 / 2 000 000 000 = 500 000 
    
     // Full fill 2 000 000 000 X
     consumed = (2 000 000 000 * 5) / 1  = 10 000 000 000 / 1 = 10 000 000 000
              = (2 000 000 000 * 10 000 000 000) / 2 000 000 000 = (overflow err)
    
  • Orders with large numerators and/or denominators without a simplifying GCD can be partially filled up the the overflow limit.

  • Add op codes for extra wide multiplication and division, e.g. OP_MUL128 takes 2 8-16 byte little endian integers off the stack, converts to u128, multiplies, and places the 16 byte result on the stack. Similar for OP_DIV128. The 16 byte result can be split and the high 64 bits are checked to equal 0 (no conversion overflow) and discarded.

Ratios less than 1

If the maker wants more atomic units of the quoteAsset than the number of atomic units of offerAsset, then (ratioNum / ratioDen) < 1.

This works fine but introduces a minFillAmount due to the flooring integer division.

The taker should always be expecting: consumed >= 1 atomic unit.

Example:

offerAsset = X asset
offerAssetAmount = 5 X

quoteAsset = "btc"
quoteAssetAmount = 100 000 sats

ratioNum  =       5
ratioDen  = 100 000
       g  =       5
ratioNum' = 5 / 5 = 1
ratioDen' = 100 000 / 5 = 20 000

takerInput = 19 999 sats
  consumed = (19 999 * 1) / 20 000 = 0 (script should fail to protect taker)

minFillAmount = ciel(ratioDen / ratioNum) = 20 0000 / 1 = 20 000

// The taker should supply the quote asset in multiples of `minFillAmount` to avoid over-payment
takerInput = 39 999 sats
  consumed = (39 999 * 1) / 20 000 = 1  // (if taker allows receiving 1 X, they over-paid by 19 999 sats)

This is an unavoidable UX challenge due to the atomic nature of the units.

Subdust outputs

arkd will currently not co-sign the partial-fill transaction if:

Scenario 1: offerAsset == "btc"

offerAsset = "btc"
offerAssetAmount = 100 000 sats

quoteAsset = X Asset
quoteAssetAmount = 5 000 000 X

ratioNum  =   100 000
ratioDen  = 5 000 000
       g  =   100 000
ratioNum' = 100 000 / 100 000 = 1
ratioDen' = 5 000 000 / 100 000 = 50

minFillAmount = ceil(50 / 1) = 50

takerInput = quoteAssetAmount - minFillAmount = 5 000 000 - 50 = 4 999 950
consumed = (4 999 950 * 1) / 50 = 99 999
change = 100 000 - 99 999 = 1 (invalid value for recursive output)

Either the script should enforce that a taker can only leave >=dustLimit sats in the contract (which is implicitly enforced by arkd) or if the change < dustLimit, allow the taker to close the contract by attributing a sub-dust output to the maker.

It seems better to enforce that the taker must fully fill rather than force the make to accept a subdust output.

The other consideration is the asset output that transfers received ArkadeAsset to the maker.

As long as the maker specifies a non-subdust script as the maker witness program, it will need to have >=dustLimit sats (also implicitly enforced by arkd).

Scenario 2: offerAsset == ArkadeAsset, quoteAsset == "btc"

In this case the taker is sending the maker sats in exchange for ArkadeAssets. arkd will enforce that:

  • The taker attributes >=dustLimit sats to any change left in the recursive output in the case of a partial fill.
  • received >= dustLimit if the maker specified a non-subdust script as the maker witness program.
  • It is up to the taker whether they receive the consumed ArkadeAssets on a subdust script or not.

Scenario 3: offerAsset == ArkadeAsset, quoteAsset == ArkadeAsset

In this case the taker is sending the maker ArkadeAssets in exchange for other ArkadeAssets. arkd will enforce that:

  • The taker attributes >=dustLimit sats to any change left in the recursive output in the case of a partial fill.
  • The value attributed to the received ArkadeAsset output if >=dustLimit if the maker specified a non-subdust script as the maker witness program.
  • It is up to the taker whether they receive the consumed ArkadeAssets on a subdust script or not.

Open Question: How much should ArkadeScript's rely on the current implementations of co-signers (e.g. arkd, introspector) to simplify contract logic?

@msinkec
Copy link
Copy Markdown

msinkec commented Mar 30, 2026

Open Question: How much should ArkadeScript's rely on the current implementations of co-signers (e.g. arkd, introspector) to simplify contract logic?

For dust related issues I think we can lean on arkds limitations.

@msinkec
Copy link
Copy Markdown

msinkec commented Mar 31, 2026

@louisinger Opened a PR for partial swaps: louisinger#5

Add ratio-based partial fill covenant scripts that allow a taker to fill
all or part of a banco swap offer. Three swap directions are supported
via dedicated scripts matching the expert-reviewed reference:

  - BTC → asset (maker offers BTC, wants asset)
  - asset → BTC (maker offers asset, wants BTC)
  - asset → asset (maker offers asset X, wants asset Y)

Each script enforces: input-0 restriction, maker payment on output 1,
ratio-based consumed amount via MUL64/DIV64, and correct change output
(value + scriptPubKey match) for partial fills. Asset-offer scripts
additionally preserve BTC dust value and verify maker payment on full
fill output 0.

Wire format extended with ratioNum (0x09), ratioDen (0x0a), and
offerAsset (0x0b) TLV fields. Maker applies GCD reduction before
encoding. Taker supports fillAmount option for BTC→asset partial fills.

Made-with: Cursor
@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 31, 2026

🔍 Review — 0273712e: banco partial-fill swaps

New commit implements partial-fill covenant scripts for three swap directions (BTC→asset, asset→BTC, asset→asset) plus the corresponding taker output-building logic.

Security: Zero-consumed partial fills

The test contract.test.ts explicitly acknowledges: computeConsumed(1n, 1n, 1000n) === 0n. When a taker delivers a tiny amount where fillAmount * ratioNum / ratioDen floors to zero, the script allows a transaction where:

  • Change output equals full input value (swap VTXO unchanged)
  • Maker receives a dust amount of asset "for free"

The scripts do not enforce consumed > 0. While this isn't a theft vector (the swap VTXO BTC is preserved), it enables griefing: a taker can submit no-op transactions consuming introspector + server co-signing resources. Consider adding a consumed > zeroLE64 / GREATERTHAN64 / VERIFY check after the DIV64 in each partial-fill script, or document that the introspector is expected to reject zero-consumed fills.

Covenant script correctness

Full-fill branches: All three directions end the IF branch with a truthy stack value (1 for btcForAsset, 1 for assetForBtc, 1 for assetForAsset) — correct for script evaluation.

Partial-fill branches (ELSE): Each correctly reconstructs change by SUB64 of inputValue - consumed, then verifies output 0 preserves the swap VTXO's scriptPubKey and value/asset balance. The PUSHCURRENTINPUTINDEX / INSPECTINPUTSCRIPTPUBKEYINSPECTOUTPUTSCRIPTPUBKEY comparison ensures the change VTXO stays in the same contract — good.

assetForBtcScript partial-fill branch: After asset change verification, the script uses a third FROMALTSTACK to recover the offer txid for INSPECTOUTASSETLOOKUP. Three TOALTSTACK pushes (two DUP + TOALTSTACK in Step 3) match three pops (Steps 4, 5/6 via FROMALTSTACK) — stack bookkeeping looks correct.

Taker output layout

The taker correctly distinguishes 6 output layouts:

  • Full-fill BTC→asset: [taker, maker]
  • Partial BTC→asset: [bancoChange, maker, taker]
  • Full-fill asset→BTC: [makerDustReturn, maker, taker]
  • Partial asset→BTC: [bancoChange, maker, taker]
  • Legacy full-fill (no ratio): [maker, takerSwap, takerChange?]
  • asset→asset: follows the asset→BTC pattern

The makerOutputIdx = 1 constant is consistent across partial-fill paths. The collectCollateral helper properly excludes both want and offer asset IDs from collateral routing.

Offer TLV encoding

New fields TLV_RATIO_NUM (0x09), TLV_RATIO_DEN (0x0a), TLV_OFFER_ASSET (0x0b) follow the existing pattern. The GCD reduction in maker.ts ensures minimal encoding.

Minor nits

  • src/utils/math.tsgcd handles negative inputs correctly via abs, but ratioNum/ratioDen are validated as positive in maker.createOffer. The negative handling is defensive but the validation makes it unreachable.
  • The bigintToLE64 in contract.ts uses setBigInt64 (signed). For ratio values this is fine since they're positive and fit in 63 bits, but setBigUint64 would be more semantically correct for unsigned ratio values.

Cross-repo

The introspector (ArkLabsHQ/introspector) co-signs these scripts via the same ArkScriptHash tweak mechanism — no introspector changes needed for partial fills since it validates the covenant script at intent registration time. The covenant change is purely client-side.


Overall solid implementation. The zero-consumed griefing vector is the main open question — worth a decision on whether to enforce in-script or at the service layer.

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Apr 1, 2026

🔍 Arkana PR Review — #319

Arkade script support — a major feature PR (6219+/205-, 51 files) by @louisinger adding client-side Arkade Script support, introspector integration, Banco swap contracts, and comprehensive tests.

Architecture Overview

The PR adds five major subsystems:

  1. src/arkade/ — Arkade opcodes, script codec, key tweaking, VTXO script extension, batch handler
  2. src/banco/ — Peer-to-peer atomic swap system (Maker/Taker/Offer/BancoSwap contract)
  3. src/providers/introspector.ts — REST client for the introspector co-signing service
  4. src/extension/introspector/ — IntrospectorPacket (type 0x01) for PSBT extension fields
  5. src/extension/utils.ts — Refactored BufferReader/BufferWriter with CompactSize support

Security Observations

✅ Good: PSBT field key matching fix (unknownFields.ts)

The change from checkKeyIncludes (string .includes()) to checkKeyMatch (byte-level prefix/exact matching) is a genuine security fix. The old code could match if the expected key bytes appeared anywhere in the key data, not just at the correct position. The new implementation correctly checks byte-by-byte from the start:

// Before (unsafe): hex-encoded string .includes() — could match substring anywhere
// After (correct): byte-level comparison from index 0

This also fixes the nullIfCatch to catch without binding the unused error variable — clean.

⚠️ Note: Hardcoded introspector secret key in docker-compose.yml

INTROSPECTOR_SECRET_KEY=b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c4

Fine for regtest/dev, but worth a comment in the file to prevent copy-paste into production.

✅ Good: Key tweaking (tweak.ts)

The computeArkadeScriptPublicKey implementation correctly:

  • Forces even-Y via BIP-340 lift_x convention (matching the Go introspector)
  • Reduces the hash scalar modulo curve order n
  • Falls back to 1n if reduction yields 0n (avoiding point-at-infinity, though astronomically unlikely)

⚠️ Partial fill consumed=0 edge case (banco/contract.ts)

When ratioNum/ratioDen yields a very small ratio, a small fillAmount can produce consumed = 0 via integer floor division:

// Example: computeConsumed(1n, 1n, 1000n) === 0n

This is tested and acknowledged in contract.test.ts, but the Taker code doesn't explicitly guard against submitting a zero-consumed partial fill. The introspector/server might reject it, but a client-side check would be safer.

✅ Good: Offer TLV validation

The Offer.decode() properly validates:

  • Required fields (swapAddress, wantAmount, makerPkScript, makerPublicKey, introspectorPubkey)
  • Field lengths (34 bytes for pkScript, 32 bytes for pubkeys)
  • Rejects unknown TLV types
  • Rejects truncated data

Protocol Correctness

✅ Covenant scripts

The fulfill script correctly verifies:

  • Output 0 scriptPubKey matches maker's taproot address
  • Output 0 value ≥ wantAmount (BTC case)
  • Asset lookup + amount check (asset case)

The partial fill scripts handle three directions (BTC→asset, asset→BTC, asset→asset) with proper branching between full-fill and partial-fill paths, including change output verification and scriptPubKey preservation.

⚠️ Taker address reconstruction verification

Good practice — the Taker verifies swapPkScript === expectedPkScript before proceeding:

if (swapPkScript !== expectedPkScript) {
    throw new Error("Offer inconsistency: swapAddress does not match the reconstructed contract");
}

This prevents a malicious offer from directing funds to a wrong contract.

Code Quality

✅ BufferReader/BufferWriter refactoring

Clean extraction from extension/asset/utils.ts to extension/utils.ts. All existing imports updated. CompactSize methods added for IntrospectorPacket wire format.

⚠️ Expo export change (src/worker/expo/index.ts)

-export { runTasks, createTaskDependencies } from "./taskRunner";
+    runTasks,
 } from "./taskRunner";

runTasks moved to a type-only export and createTaskDependencies export was removed. This is a breaking change for consumers importing runTasks as a value or createTaskDependencies at all. If intentional, it should be noted in the PR description.

✅ Test coverage

Excellent test coverage:

  • 696-line unit test suite for arkade opcodes, script encoding, ASM conversion
  • IntrospectorPacket fixture-based tests
  • Banco contract unit tests (covenant dispatch, ratio arithmetic, GCD reduction)
  • Offer TLV round-trip tests with edge cases
  • E2E tests: boarding flow, settlement flow, settlement with assets, banco BTC-for-asset and asset-for-asset swaps

Cross-repo Impact

This PR adds the client-side counterpart to ArkLabsHQ/introspector. The opcode definitions in src/arkade/opcodes.ts mirror introspector/pkg/arkade/opcode.go. The tagged hash tag "ArkScriptHash" and tweak computation must stay in sync between the two repos.

The Banco swap system depends on the introspector's /v1/tx, /v1/intent, and /v1/finalization REST endpoints.

Summary

A well-structured, well-tested feature PR. The key areas to address before merge:

  1. Expo export breaking change — confirm intentionality of runTasks/createTaskDependencies export changes
  2. Zero-consumed partial fill — consider a client-side guard
  3. Docker secret key — add a comment noting it's dev-only

No blocking security issues found. The PSBT field matching fix is a nice improvement.

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Apr 1, 2026

🔍 Incremental Review — #319 (new commit: 1a8c6e77)

Commit: "cleaning"

What Changed

Consolidation of the banco module — removes the standalone example app and test files, merges contract logic into the offer/maker/taker files:

  • Removed: examples/banco/ (standalone example app + scripts)
  • Removed: src/banco/contract.ts (576 lines) — logic absorbed into offer.ts
  • Removed: test/unit/banco/contract.test.ts, test/unit/banco/offer.test.ts
  • Modified: src/banco/offer.ts (+520/-23) — now contains the full covenant script generation (fulfill, partial-fill, cancel/exit tapscript building) previously in contract.ts
  • Modified: src/banco/maker.ts (+20/-58) — simplified to use Offer.vtxoScript() and Offer.covenantScript() instead of separate contract helpers
  • Modified: src/banco/taker.ts (+6/-17) — same consolidation
  • Modified: test/e2e/banco.test.ts (+144/-10) — expanded E2E coverage including asset-for-asset swap test
  • Modified: test/extension.test.ts — minor import path adjustments

Assessment

Good cleanup — reduces module surface area by consolidating contract.ts into the Offer namespace where the TLV encoding already lives. The Offer.vtxoScript() and Offer.covenantScript() functions provide a cleaner API than having a separate contract builder.

Unit tests for offer/contract were removed, but E2E tests were expanded. The removed unit tests covered encoding/decoding and script construction which is now exercised through the E2E banco tests. Reasonable tradeoff for an alpha-stage module, though dedicated unit tests for the covenant script generation in Offer would add safety once the partial-fill scripts stabilize.

No protocol-level concerns with this cleanup commit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants