diff --git a/CHANGELOG.md b/CHANGELOG.md index 83858d1..8f108b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added - Release binaries for macOS (Apple Silicon). -- Supports manifest format [`0.3.1`](docs/format-changelog.md), which adds program pinning. `0.3.0` manifests still load. +- Supports manifest format [`0.3.1`](docs/format-changelog.md), which adds program pinning and an action-level `lock_time`. `0.3.0` manifests still load. - `pin` command: writes every program's current hash into a manifest without reformatting it; `pin --check` fails if any is missing or stale. - `validate --strict`: unpinned programs are errors rather than warnings. - `run --debug`: development mode. For now the same as `--allow-unpinned`; it may enable more development-only behaviour later. @@ -23,6 +23,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and - `validate` errors when a manifest uses a Simplicity program in a `tapleaf` or `simf_fn` compute, or a `programs` entry, without declaring `"requires": ["simplicity"]`. Previously only covenant `utxo_types` triggered this. Add `"simplicity"` to `requires` if `validate` now reports it. - `validate` also checks each program file: that it exists, matches its hash, meets its compiler requirement, and declares the witnesses the manifest supplies. +- On Liquid, `run` refuses to finalize a Simplicity input whose program may cost more than its witness's budget, and says how many bytes short it is. Previously the transaction was built (or exported with `--export-pset`) and the node rejected it with `Program's execution cost could exceed budget`. ## [0.3.0] - 2026-10-06 diff --git a/docs/format-changelog.md b/docs/format-changelog.md index d079cb0..59cee62 100644 --- a/docs/format-changelog.md +++ b/docs/format-changelog.md @@ -40,6 +40,12 @@ unless it uses a Simplicity program without declaring `simplicity`. compiler-version requirement; a manifest is pinned when all its programs are. Wallets must refuse unpinned manifests and programs that don't match their hash. Tools may accept them for development. +- **`lock_time` on actions**: `{"height": }` or `{"time": }` sets the + transaction's `nLockTime`, which the `check_lock_height` and `check_lock_time` jets read. + A height must be below 500,000,000 and a time (unix seconds) at least that, since + consensus tells them apart by size. A lock time is ignored when every input is final, + so each input that gives no `sequence` gets `0xfffffffe`. Without `lock_time`, + `nLockTime` is zero. ### Rules diff --git a/examples/README.md b/examples/README.md index b14edfa..5ed60ca 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,8 +14,9 @@ Roughly in order of complexity: | [`last_will`](last_will) | Liquid | A recursive covenant with a timelocked inheritance path, a cold-key break-out and a hot-key refresh. | | [`dex`](dex) | Liquid | Tessera: a keyless atomic-swap offer anyone can fill, or refund after a timeout. | | [`deadcat_v3`](deadcat_v3) | Liquid | A binary prediction market with on-chain oracle resolution and confidential reissuance tokens. Derived from [Deadcat](https://github.com/Resolvr-io/deadcat); not interoperable with it. | +| [`oracle_demo`](oracle_demo) | Liquid | Burn L-BTC for a stablecoin at the current price, checked by a challenge window: ECDSA-signed oracle statements in [Mycelia Signal](https://github.com/jonathanbulkeley/Mycelia-Signal-Sovereign-Oracle)'s format, verified and parsed on chain. Issues its asset with a second manifest in `issue_usdt/`. | | [`lending_v2`](lending_v2) | Liquid | Peer-to-peer collateralised lending, wire-compatible with [simplicity-lending](https://github.com/BlockstreamResearch/simplicity-lending). | | [`lending_v3`](lending_v3) | Liquid | Work in progress: the redesigned "issuance factory" version of simplicity-lending — create a factory, then offer, accept, cancel, claim and repay loans. | -The examples with their own README (`bitcoin_pay`, `bitcoin_covenant`, `lending_v2`) +The examples with their own README (`bitcoin_pay`, `bitcoin_covenant`, `lending_v2`, `oracle_demo`) include full walkthroughs. diff --git a/examples/oracle_demo/README.md b/examples/oracle_demo/README.md new file mode 100644 index 0000000..9f58484 --- /dev/null +++ b/examples/oracle_demo/README.md @@ -0,0 +1,188 @@ +# oracle_demo + +Burn L-BTC for a stablecoin at the **current** BTC/USD price, on Liquid. + +A Simplicity program can check that an oracle signed a price, but not that the price is +current: nothing on chain says what time it is now. Without that check, a user could +present an old statement from when the price was higher, and mint more than their L-BTC is +worth. This example closes the gap with a challenge window instead of an oracle-side +expiry mechanism: + +1. **RequestMint**: the user presents a signed price statement. The covenant checks the + signature, locks the L-BTC and reserves the tUSDT it buys at that price. +2. **Challenge**: until the request is finalized, anyone holding a *newer* statement whose + price differs by more than a tolerance can cancel it. The L-BTC is refunded and the + tUSDT goes back to the treasury. +3. **Finalize**: once the window has passed (5 blocks, about 5 minutes, by default), + anyone can complete the request. The tUSDT goes to the user and the L-BTC is burned. + +A stale request is safe to submit only if nobody is watching. A watcher needs nothing but +the latest statement and a fee, so one honest watcher is enough. + +## Oracle statements + +Statements use [Mycelia Signal](https://github.com/jonathanbulkeley/Mycelia-Signal-Sovereign-Oracle/blob/main/docs/DEMO.md)'s +format, signed with secp256k1 ECDSA over the sha256 of a canonical string: + +```json +{ + "domain": "BTCUSD", + "canonical": "v1|PRICE|BTCUSD|84231.50|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514400|482910", + "signature": "MEQCICzgChRNZZFqQ4VF1/TluMyqneOcwBfPnSviptH2X9QgAiA/GR4IyZIIALG76QjUwiqcdUKfVm69Y0+bqvzhiWyNqw==", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "signing_scheme": "secp256k1_ecdsa" +} +``` + +The statements here are fake. [`fake_oracle.py`](fake_oracle.py) signs them with a local +test key, [`data/example_oracle_key.json`](data/example_oracle_key.json), which is public: anyone can sign +"oracle" statements with it. Use it on testnet only. + +The covenant verifies the string itself. It cannot split a string, so the witness carries +the variable fields already separated (price digits, sources, method, timestamp, nonce), +and the program hashes them back together between the fixed parts (`v1|PRICE|BTCUSD|`, +`.`, `|USD|2|` and the separators). Number fields must be all digits and text fields may +not contain `|`. A signed string can therefore only be read one way, and a witness can't +move a field boundary to make some other number read as the timestamp. The ECDSA check is +`lib/secp256k1/ecdsa.simf`, a proposed addition to +[simplicityhl-std](https://github.com/BlockstreamResearch/simplicityhl-std) that is not +yet upstream. It is copied into the program because the wallet compiles each program from +a single file. + +## How it works + +One program, [`oracle_mint.simf`](oracle_mint.simf), holds two kinds of address, told apart +by a tapdata leaf beside the program leaf: + +| State | Tapdata leaf | Holds | +|-------|--------------|-------| +| treasury | `0x00` | the tUSDT supply | +| pending | `0x01 ‖ price ‖ timestamp ‖ L-BTC amount ‖ recipient` | one request: its L-BTC and its reserved tUSDT, two UTXOs at one address | + +| Action | Spends | Creates | The covenant checks | +|--------|--------|---------|---------------------| +| `RequestMint` | treasury (input 0), wallet L-BTC | pending L-BTC, pending tUSDT, treasury change | the statement's signature; that the tUSDT is `L-BTC × price` and both outputs pay the request's pending address | +| `Finalize` | pending L-BTC (input 0), pending tUSDT (input 1) | tUSDT to the recipient, L-BTC burned | that input 0 is at least `WINDOW_BLOCKS` old, through its own `nSequence` | +| `Challenge` | pending L-BTC (input 0), pending tUSDT (input 1) | L-BTC refund, tUSDT to the treasury | a newer statement, whose price differs from the request's by more than `TOLERANCE_BPS` | + +The tUSDT input of a pending pair only checks that it sits beside its L-BTC at input 0, +which checks the whole transaction. That shortcut is refused at the treasury address, +because otherwise a second treasury UTXO could be spent alongside a request with nothing +checking where its tUSDT went. + +Both assets have 8 decimal places, so a request for `A` satoshis at `P` cents buys +`A × P / 100` tUSDT base units, rounded down. + +tUSDT is issued by a separate manifest, [`issue_usdt/`](issue_usdt), because the service's +addresses depend on the asset id. The id only exists once the issuing transaction has +chosen its input. + +## Files + +| File | What it is | +|------|------------| +| [`txmanifest.json`](txmanifest.json) | The mint service: `CreateTreasury`, `RequestMint`, `Finalize`, `Challenge`. | +| [`oracle_mint.simf`](oracle_mint.simf) | The covenant. | +| [`issue_usdt/txmanifest.json`](issue_usdt/txmanifest.json) | Issues the tUSDT supply: `IssueUSDT`. | +| [`fake_oracle.py`](fake_oracle.py) | Signs statements, verifies them, and turns them into `--params` files. Needs `pip install ecdsa`. | +| [`data/`](data) | The test oracle key and the signed statements the tests use. | + +## Running it on Liquid testnet + +You need a funded Liquid testnet wallet (see the [quick start](../../README.md#quick-start-liquid-testnet)). +Each request below burns 5,000 sat. `run` asks for a fee rate and confirms before each +broadcast; after each one, wait for it to confirm and `sync` before the next. + +```sh +W=wallet.json +TXM=tx-manifest-wallet +cd examples/oracle_demo +``` + +**1. Issue tUSDT and start the service.** `IssueUSDT` writes +`issue_usdt/txmanifest.instance.1.json`; its `USD_ASSET` is the new asset id. +`CreateTreasury` asks for it, for the amount to lock (`SUPPLY`; lock all of it, by default +100000000000000), and for the oracle key, which `fake_oracle.py keygen` printed when the key +was made. For the committed key, the prefix is `2` and the x-coordinate is +`917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0`. The window and +tolerance have defaults. + +```sh +$TXM run issue_usdt/txmanifest.json IssueUSDT --wallet $W +$TXM sync --wallet $W +$TXM run txmanifest.json CreateTreasury --wallet $W +``` + +`CreateTreasury` writes `txmanifest.instance.1.json` and `txmanifest.state.1.json`. Every +later action takes the instance and the newest state file, and writes the next state file. + +**2. An honest mint.** Sign a statement at the current price, request, wait out the window, +finalize. `--recipient` is the address that receives the tUSDT, or the refund. Use one +from `$TXM info --wallet $W`. + +```sh +python3 fake_oracle.py sign --price 84231.50 --out now.json +python3 fake_oracle.py request-params now.json --lbtc-amount 5000 --recipient
--out request.json + +$TXM run txmanifest.json RequestMint --wallet $W --params request.json \ + --instance txmanifest.instance.1.json --state txmanifest.state.1.json + +# Finalize before 5 blocks have passed and the node refuses it: non-BIP68-final. +$TXM run txmanifest.json Finalize --wallet $W --params request.json \ + --instance txmanifest.instance.1.json --state txmanifest.state.2.json +``` + +**3. A stale price, challenged.** Request with a statement from an hour ago, when the price +was higher, then challenge it with a current one. The challenge needs no wait, and any +wallet can send it. + +```sh +python3 fake_oracle.py sign --price 95000.00 --timestamp $(( $(date +%s) - 3600 )) --out stale.json +python3 fake_oracle.py request-params stale.json --lbtc-amount 5000 --recipient
--out stale-request.json +$TXM run txmanifest.json RequestMint --wallet $W --params stale-request.json \ + --instance txmanifest.instance.1.json --state txmanifest.state.3.json + +python3 fake_oracle.py sign --price 84231.50 --out now.json +python3 fake_oracle.py challenge-params stale-request.json now.json --out challenge.json +$TXM run txmanifest.json Challenge --wallet $W --params challenge.json \ + --instance txmanifest.instance.1.json --state txmanifest.state.4.json +``` + +A challenge with a statement within the tolerance (0.5% by default), or one no newer than +the request's, fails the covenant's dry run and is never broadcast. + +### A run on Liquid testnet + +| Step | Transaction | +|------|-------------| +| `IssueUSDT` | [`67ed381d…a72c`](https://liquid.network/testnet/tx/67ed381d6ff6566b73d209141a68c586f56e2b8942106903a8736c9ec251a72c) | +| `CreateTreasury` | [`6043ee78…c1d1`](https://liquid.network/testnet/tx/6043ee78ab30fe90957f5a5d8112211a606f8d06e74c6b08477605e5b29ac1d1) | +| `RequestMint` at 84231.50 | [`4b3d45d9…24b7`](https://liquid.network/testnet/tx/4b3d45d955b3dd3174a305f6e178b9bd10342ba1a030720fff0c3248111824b7) | +| `Finalize`: 4.211575 tUSDT paid, 5,000 sat burned | [`69c2c404…0786`](https://liquid.network/testnet/tx/69c2c404d0c1a0e11d752d01154d1c6fdc6900cdc77ad59e0d76542316cc0786) | +| `RequestMint` at a stale 95000.00 | [`513edff5…f74b`](https://liquid.network/testnet/tx/513edff5ac5d07a42dc9e84015e5b7a33a8dbab0f602ad72072f4a2d5cfef74b) | +| `Challenge` at 84231.50: 5,000 sat refunded, 4.75 tUSDT back to the treasury | [`f6eaad84…bcfa`](https://liquid.network/testnet/tx/f6eaad84c2a4e71f871ec50fc07245bcb9a18ef5352e45aa445cba51cfb7bcfa) | + +## Limitations + +- **A request can be challenged until it is finalized, not only during the window.** A + program can't require that a height has *not* been reached. If the price moves past the + tolerance after the window but before `Finalize`, an honest request can still be + cancelled. The user loses only fees, and finalizing promptly avoids it. +- **One oracle, fixed at deployment.** The key is a compile param. There is no quorum of + statements, no committee-attested key, and no reward for a challenger. +- **Requests run one at a time per state file.** When a state file holds several pending + requests, the wallet takes the first one it finds; pass `--input` to choose another. +- **Only `BTCUSD` with two decimals.** The program fixes `v1|PRICE|BTCUSD|…|USD|2|`, and + the price must be printed with exactly two fraction digits. +- **Budget padding.** Checking a statement costs more execution budget than the + statement's bytes buy, so `RequestMint` and `Challenge` pad the witness with zero words + (`BUDGET_PAD`). Liquid doesn't relay the annex that would otherwise pad it. If `run` + reports that the program's cost exceeds its budget, add more zeros. + +## Tests + +`cargo test -p tx-manifest-lib --test oracle_demo` runs every spending path on the +Simplicity interpreter against transactions it builds, with the statements in `data/`. +It covers forged prices, a `|` hidden in a text field, a finalize before the window, a +challenge within the tolerance or with an older statement, a second treasury UTXO spent +alongside a request, and the execution budget of each path. diff --git a/examples/oracle_demo/data/example_oracle_key.json b/examples/oracle_demo/data/example_oracle_key.json new file mode 100644 index 0000000..ca854d7 --- /dev/null +++ b/examples/oracle_demo/data/example_oracle_key.json @@ -0,0 +1,5 @@ +{ + "note": "PUBLIC TEST KEY for the oracle_demo example's fake oracle. Anyone can sign statements with it. Testnet only: never use it for anything of value.", + "secret": "6b9370fd63ad6037c3a85ddb22bcd0d871eff4a4a27496e453f606c48cffe089", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0" +} diff --git a/examples/oracle_demo/data/statement_close.json b/examples/oracle_demo/data/statement_close.json new file mode 100644 index 0000000..6242cb9 --- /dev/null +++ b/examples/oracle_demo/data/statement_close.json @@ -0,0 +1,7 @@ +{ + "domain": "BTCUSD", + "canonical": "v1|PRICE|BTCUSD|84400.00|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514700|482913", + "signature": "MEUCIQChHZN0Sfnv/XrRX/yErXbh7LQclZhx4wXfXtvg6/WcZwIgcQZ/bVYZ1qGa/Bm2UwALaEi9hCVTcTcofAk1+mse30U=", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "signing_scheme": "secp256k1_ecdsa" +} diff --git a/examples/oracle_demo/data/statement_fell.json b/examples/oracle_demo/data/statement_fell.json new file mode 100644 index 0000000..a9d0da4 --- /dev/null +++ b/examples/oracle_demo/data/statement_fell.json @@ -0,0 +1,7 @@ +{ + "domain": "BTCUSD", + "canonical": "v1|PRICE|BTCUSD|83000.00|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514700|482912", + "signature": "MEQCIEWtechNGhh28F7Ho6vFNKO7BrJLvCbSCl4LJK8PsPO7AiBhn3OBV/fcs4Q4xWG2evXGwp9ysIpzeY/eP4+CuioYiQ==", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "signing_scheme": "secp256k1_ecdsa" +} diff --git a/examples/oracle_demo/data/statement_older.json b/examples/oracle_demo/data/statement_older.json new file mode 100644 index 0000000..eb88261 --- /dev/null +++ b/examples/oracle_demo/data/statement_older.json @@ -0,0 +1,7 @@ +{ + "domain": "BTCUSD", + "canonical": "v1|PRICE|BTCUSD|85231.50|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514100|482909", + "signature": "MEUCIQDTrgQfPwAPn33sZkQom4ql2f8twwg+RPZL2FSP8RqFPwIgYCkdTMNajEcD9wERCgC+8cL5i471YpLT5cR84iOafnQ=", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "signing_scheme": "secp256k1_ecdsa" +} diff --git a/examples/oracle_demo/data/statement_request.json b/examples/oracle_demo/data/statement_request.json new file mode 100644 index 0000000..88b4d0c --- /dev/null +++ b/examples/oracle_demo/data/statement_request.json @@ -0,0 +1,7 @@ +{ + "domain": "BTCUSD", + "canonical": "v1|PRICE|BTCUSD|84231.50|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514400|482910", + "signature": "MEQCICzgChRNZZFqQ4VF1/TluMyqneOcwBfPnSviptH2X9QgAiA/GR4IyZIIALG76QjUwiqcdUKfVm69Y0+bqvzhiWyNqw==", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "signing_scheme": "secp256k1_ecdsa" +} diff --git a/examples/oracle_demo/data/statement_rose.json b/examples/oracle_demo/data/statement_rose.json new file mode 100644 index 0000000..63ebe2d --- /dev/null +++ b/examples/oracle_demo/data/statement_rose.json @@ -0,0 +1,7 @@ +{ + "domain": "BTCUSD", + "canonical": "v1|PRICE|BTCUSD|85231.50|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514700|482911", + "signature": "MEQCIE5S2ynJq7uSksklsV581IJslQDeEGWKrxQfUKuSH3IvAiBQ/vhiMMjWJy2JG6o61Iki+4WKu+ccge2iFGxevM9lVQ==", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "signing_scheme": "secp256k1_ecdsa" +} diff --git a/examples/oracle_demo/fake_oracle.py b/examples/oracle_demo/fake_oracle.py new file mode 100644 index 0000000..b77d9bd --- /dev/null +++ b/examples/oracle_demo/fake_oracle.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""A stand-in for the Mycelia Signal price oracle, for testnet demos only. + +It signs BTC/USD price statements in Mycelia Signal's exact format (its docs/DEMO.md): +a pipe-separated canonical string, signed with secp256k1 ECDSA over sha256(canonical), +returned as the same JSON the paid API endpoint returns. The key is a local test key, +so nothing here is a real price. + +It also turns a statement into the --params file for an oracle_demo action. The +covenant cannot split a string, so the canonical string is handed to it already cut into +its fields, each as a SimplicityHL literal. + +Needs the `ecdsa` package (pip install ecdsa). +""" + +import argparse +import base64 +import hashlib +import json +import sys +import time +from decimal import Decimal, InvalidOperation +from pathlib import Path + +from ecdsa import SECP256k1, SigningKey, VerifyingKey +from ecdsa.util import sigdecode_der, sigencode_der + +DOMAIN = "BTCUSD" +QUOTE = "USD" +DECIMALS = 2 +DEFAULT_SOURCES = "binance,bitstamp,coinbase,kraken,okx" +DEFAULT_METHOD = "median" + +# Bounds of the program's witness lists. A `List` holds at most N - 1 elements. +MAX_PRICE_DIGITS = 15 +MAX_SOURCES_LEN = 127 +MAX_METHOD_LEN = 15 +MAX_TIMESTAMP_DIGITS = 15 +MAX_NONCE_LEN = 31 + +N = SECP256k1.order + +# Written into every key file, so nobody mistakes one for a real key. +TEST_KEY_NOTE = "Test key made by fake_oracle.py. Testnet only: never use it for anything of value." + + +def load_key(path): + data = json.loads(Path(path).read_text()) + return SigningKey.from_string(bytes.fromhex(data["secret"]), curve=SECP256k1) + + +def compressed_pubkey(vk): + return vk.to_string("compressed").hex() + + +def canonical_string(price, timestamp, nonce, sources, method): + return "|".join( + ["v1", "PRICE", DOMAIN, price, QUOTE, str(DECIMALS), sources, method, str(timestamp), str(nonce)] + ) + + +def format_price(raw): + try: + value = Decimal(raw) + except InvalidOperation: + sys.exit(f"not a price: {raw!r}") + if value <= 0: + sys.exit("price must be positive") + return f"{value.quantize(Decimal(1).scaleb(-DECIMALS))}" + + +def sign(sk, canonical): + digest = hashlib.sha256(canonical.encode()).digest() + sig = sk.sign_digest_deterministic(digest, hashfunc=hashlib.sha256, sigencode=sigencode_der) + r, s = sigdecode_der(sig, N) + # libsecp256k1, which the DEMO.md check uses through coincurve, rejects high-S. + if s > N // 2: + sig = sigencode_der(r, N - s, N) + return base64.b64encode(sig).decode() + + +def verify(statement): + """The check from Mycelia Signal's DEMO.md, step 4.""" + canonical = statement["canonical"] + msg_hash = hashlib.sha256(canonical.encode()).digest() + vk = VerifyingKey.from_string(bytes.fromhex(statement["pubkey"]), curve=SECP256k1) + sig = base64.b64decode(statement["signature"]) + _, s = sigdecode_der(sig, N) + if s > N // 2: + return False + try: + return vk.verify_digest(sig, msg_hash, sigdecode=sigdecode_der) + except Exception: + return False + + +def parse_canonical(canonical): + fields = canonical.split("|") + if len(fields) != 10: + sys.exit(f"expected 10 fields in the canonical string, found {len(fields)}") + version, kind, domain, price, quote, decimals, sources, method, timestamp, nonce = fields + expected = ("v1", "PRICE", DOMAIN, QUOTE, str(DECIMALS)) + if (version, kind, domain, quote, decimals) != expected: + sys.exit(f"the covenant only accepts v1|PRICE|{DOMAIN}||{QUOTE}|{DECIMALS}|…") + integer, dot, fraction = price.partition(".") + if not dot or len(fraction) != DECIMALS or not (integer + fraction).isdigit(): + sys.exit(f"price {price!r} is not .<{DECIMALS} digits>") + if not timestamp.isdigit(): + sys.exit(f"timestamp {timestamp!r} is not decimal digits") + for name, value, limit in [ + ("price", integer, MAX_PRICE_DIGITS), + ("sources", sources, MAX_SOURCES_LEN), + ("method", method, MAX_METHOD_LEN), + ("timestamp", timestamp, MAX_TIMESTAMP_DIGITS), + ("nonce", nonce, MAX_NONCE_LEN), + ]: + if len(value) > limit: + sys.exit(f"{name} is {len(value)} bytes; the covenant takes at most {limit}") + return { + "price_int": integer, + "price_frac": fraction, + "price_cents": int(integer + fraction), + "sources": sources, + "method": method, + "timestamp": int(timestamp), + "timestamp_digits": timestamp, + "nonce": nonce, + } + + +def simf_list(text): + return "list![" + ", ".join(str(b) for b in text.encode()) + "]" + + +def statement_params(statement): + """The STATEMENT_* witness params for one signed statement.""" + if not verify(statement): + sys.exit("the statement's signature does not verify") + fields = parse_canonical(statement["canonical"]) + r, s = sigdecode_der(base64.b64decode(statement["signature"]), N) + frac = fields["price_frac"].encode() + return fields, { + "STATEMENT_PRICE_INT": simf_list(fields["price_int"]), + "STATEMENT_PRICE_FRAC": f"({frac[0]}, {frac[1]})", + "STATEMENT_SOURCES": simf_list(fields["sources"]), + "STATEMENT_METHOD": simf_list(fields["method"]), + "STATEMENT_TIMESTAMP": simf_list(fields["timestamp_digits"]), + "STATEMENT_NONCE": simf_list(fields["nonce"]), + "STATEMENT_SIG": f"(0x{r:064x}, 0x{s:064x})", + } + + +def write_json(obj, out): + text = json.dumps(obj, indent=2) + "\n" + if out: + Path(out).write_text(text) + print(f"wrote {out}", file=sys.stderr) + else: + sys.stdout.write(text) + + +def cmd_keygen(args): + if Path(args.out).exists() and not args.force: + sys.exit(f"{args.out} exists; pass --force to replace it") + sk = SigningKey.generate(curve=SECP256k1) + pubkey = compressed_pubkey(sk.get_verifying_key()) + write_json({"note": TEST_KEY_NOTE, "secret": sk.to_string().hex(), "pubkey": pubkey}, args.out) + print(f"ORACLE_PUBKEY_PREFIX={int(pubkey[:2], 16)} ORACLE_PUBKEY_X={pubkey[2:]}", file=sys.stderr) + + +def cmd_sign(args): + sk = load_key(args.key) + timestamp = args.timestamp if args.timestamp is not None else int(time.time()) + nonce = args.nonce if args.nonce is not None else timestamp % 1000000 + canonical = canonical_string(format_price(args.price), timestamp, nonce, args.sources, args.method) + statement = { + "domain": DOMAIN, + "canonical": canonical, + "signature": sign(sk, canonical), + "pubkey": compressed_pubkey(sk.get_verifying_key()), + "signing_scheme": "secp256k1_ecdsa", + } + assert verify(statement) + write_json(statement, args.out) + + +def cmd_verify(args): + statement = json.loads(Path(args.statement).read_text()) + if not verify(statement): + sys.exit("INVALID: the signature does not verify") + fields = parse_canonical(statement["canonical"]) + print(f"valid: {DOMAIN} {fields['price_cents'] / 100:.2f} at {fields['timestamp']}") + + +def cmd_request_params(args): + statement = json.loads(Path(args.statement).read_text()) + fields, params = statement_params(statement) + params.update( + { + "REQUEST_PRICE": str(fields["price_cents"]), + "REQUEST_TIMESTAMP": str(fields["timestamp"]), + "REQUEST_LBTC_AMOUNT": str(args.lbtc_amount), + "REQUEST_RECIPIENT": args.recipient, + } + ) + write_json(params, args.out) + + +def cmd_challenge_params(args): + request = json.loads(Path(args.request).read_text()) + statement = json.loads(Path(args.statement).read_text()) + fields, params = statement_params(statement) + if fields["timestamp"] <= int(request["REQUEST_TIMESTAMP"]): + print("warning: this statement is not newer than the request, so the covenant will refuse it", file=sys.stderr) + params.update({k: v for k, v in request.items() if k.startswith("REQUEST_")}) + write_json(params, args.out) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("keygen", help="create the test oracle key") + p.add_argument("--out", default="data/example_oracle_key.json") + p.add_argument("--force", action="store_true") + p.set_defaults(func=cmd_keygen) + + p = sub.add_parser("sign", help="sign a BTC/USD price statement") + p.add_argument("--key", default="data/example_oracle_key.json") + p.add_argument("--price", required=True, help="BTC/USD price, e.g. 84231.50") + p.add_argument("--timestamp", type=int, help="unix seconds (default: now)") + p.add_argument("--nonce", type=int) + p.add_argument("--sources", default=DEFAULT_SOURCES) + p.add_argument("--method", default=DEFAULT_METHOD) + p.add_argument("--out") + p.set_defaults(func=cmd_sign) + + p = sub.add_parser("verify", help="check a statement the way DEMO.md does") + p.add_argument("statement") + p.set_defaults(func=cmd_verify) + + p = sub.add_parser("request-params", help="params for RequestMint (and later Finalize)") + p.add_argument("statement") + p.add_argument("--lbtc-amount", type=int, required=True, help="L-BTC to burn, in satoshis") + p.add_argument("--recipient", required=True, help="address that receives the tUSDT (or the refund)") + p.add_argument("--out") + p.set_defaults(func=cmd_request_params) + + p = sub.add_parser("challenge-params", help="params for Challenge") + p.add_argument("request", help="the request's params file") + p.add_argument("statement", help="a newer statement whose price differs") + p.add_argument("--out") + p.set_defaults(func=cmd_challenge_params) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/examples/oracle_demo/issue_usdt/txmanifest.json b/examples/oracle_demo/issue_usdt/txmanifest.json new file mode 100644 index 0000000..5c9f01a --- /dev/null +++ b/examples/oracle_demo/issue_usdt/txmanifest.json @@ -0,0 +1,68 @@ +{ + "$schema": "../../../schema/txmanifest.schema.json", + "$comment": "Separate from the mint service because the service's addresses depend on the asset id, and the id only exists once this transaction has picked the outpoint it is derived from: the engine fixes compile params before it resolves inputs, so one action cannot issue an asset and pay it to an address derived from it.", + "manifest_version": "0.3.1", + "protocol": "oracle-demo-issue-tusdt", + "description": "Issue tUSDT, the testnet stablecoin the oracle_demo mint service sells: a fixed supply with no reissuance token, paid to your wallet. Lock it into a treasury with ../txmanifest.json CreateTreasury.", + "chain": "liquid", + "contract_templates": { + "tusdt": { + "description": "One tUSDT asset. The instance file records its id, which CreateTreasury takes as USD_ASSET.", + "fields": { + "USD_ASSET": { "type": "liquid.asset_id" }, + "SUPPLY": { "type": "u64" } + }, + "actions": { + "IssueUSDT": { + "description": "Issue the whole tUSDT supply to your wallet and write the instance file recording its asset id.", + "intent": "issue {params.SUPPLY} base units of a new tUSDT asset", + "params": { + "SUPPLY": { + "type": "u64", + "default": "100000000000000", + "description": "tUSDT to issue, in base units. 8 decimal places, like L-BTC, so the default is 1,000,000 tUSDT." + } + }, + "inputs": [ + { + "id": "issuer_in", + "description": "Wallet L-BTC UTXO whose outpoint defines the asset id. It also pays the fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": "params.SUPPLY", + "inflation_amount_sat": 0 + }, + "ui": { "label": "input that defines the tUSDT asset", "role": "issuance" } + } + ], + "outputs": [ + { + "id": "usd_out", + "description": "The tUSDT supply, to your wallet.", + "destination": "wallet", + "asset": "inputs.issuer_in.issued_asset", + "amount_sat": "params.SUPPLY", + "ui": { "label": "the new tUSDT", "role": "issuance" } + }, + { + "id": "lbtc_change", + "description": "L-BTC change from the issuing input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { "label": "change returned to you", "role": "change" } + } + ], + "create_instance": { + "fields": { + "USD_ASSET": "$inputs.issuer_in.issued_asset", + "SUPPLY": "$params.SUPPLY" + } + } + } + } + } + } +} diff --git a/examples/oracle_demo/oracle_mint.simf b/examples/oracle_demo/oracle_mint.simf new file mode 100644 index 0000000..f77b72b --- /dev/null +++ b/examples/oracle_demo/oracle_mint.simf @@ -0,0 +1,451 @@ +/* + * ORACLE MINT + * + * Burn L-BTC for tUSDT at the current BTC/USD price, where "current" is enforced by a + * challenge window rather than by the chain. + * + * A Simplicity program cannot tell whether an oracle statement is recent. So a mint takes + * two steps. A request presents a signed statement and reserves the tUSDT it is worth. + * Then, for WINDOW_BLOCKS, anyone holding a newer statement whose price differs by more + * than TOLERANCE_BPS can cancel it: the L-BTC is refunded and the tUSDT goes back to the + * treasury. Once the window has passed, anyone can finalize it: the tUSDT goes to the + * recipient and the L-BTC is burned. + * + * One program, two states, told apart by a tapdata leaf beside the program leaf: + * + * treasury tapdata(0x00) holds the tUSDT supply + * pending tapdata(0x01 || price || timestamp || lbtc_amount || recipient) + * one address per request, holding two UTXOs: the L-BTC (always input 0 + * when spent) and the reserved tUSDT (always input 1) + * + * Oracle statements are Mycelia Signal's canonical strings, ECDSA-signed over their + * sha256: + * + * v1|PRICE|BTCUSD|84231.50|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514400|482910 + * + * The witness carries the string already split into its variable fields, and the program + * hashes it back together. Number fields must be all digits and text fields must contain + * no '|', so a signed string can only be read one way. + */ + +// ============================================================================ +// Boolean helpers (from simplicityhl-std, lib/binary.simf) +// ============================================================================ + +fn not(bit: bool) -> bool { + ::into(jet::complement_1(::into(bit))) +} + +fn or(a: bool, b: bool) -> bool { + ::into(jet::or_1(::into(a), ::into(b))) +} + +fn and(a: bool, b: bool) -> bool { + ::into(jet::and_1(::into(a), ::into(b))) +} + +// ============================================================================ +// ECDSA (verbatim from lib/secp256k1/ecdsa.simf, a proposed addition to simplicityhl-std) +// +// Copied because this wallet compiles each program from a single file. +// ============================================================================ + +/// Check that a signature component is a canonical scalar in `[1, n - 1]`. +/// +/// The scalar jets reduce their inputs modulo `n`, so without this check `s + n` would +/// verify wherever `s` does. +fn is_signature_scalar(a: Scalar) -> bool { + and(jet::eq_256(jet::scalar_normalize(a), a), not(jet::scalar_is_zero(a))) +} + +/// Decompress a public key, rejecting an x-coordinate that is not reduced modulo `p`. +/// +/// `jet::decompress` reduces the x-coordinate itself, so without this check `x + p` would +/// decode to the same key as `x`. +fn decompress_public_key(pk: Point) -> Option { + let (_, x): (u1, Fe) = pk; + + match jet::eq_256(jet::fe_normalize(x), x) { + true => jet::decompress(pk), + false => None, + } +} + +/// Check an ECDSA signature `(r, s)` over the message digest `msg` against the compressed +/// public key `pk`, as in SEC 1 v2.0, section 4.1.4. +/// +/// Like SEC 1, and unlike Bitcoin's low-S rule, this accepts both `(r, s)` and `(r, n - s)`. +pub fn ecdsa_is_valid(pk: Point, msg: Message, sig: (Scalar, Scalar)) -> bool { + let (r, s): (Scalar, Scalar) = sig; + + match decompress_public_key(pk) { + Some(q: Ge) => { + // The scalar jets reduce `msg` modulo `n`. If `s = 0` then `w = 0`, and the range + // check below rejects the signature. + let w: Scalar = jet::scalar_invert(s); + let u1: Scalar = jet::scalar_multiply(msg, w); + let u2: Scalar = jet::scalar_multiply(r, w); + + // R = u1 * G + u2 * Q + let q: Gej = (q, 1); + let big_r: Gej = jet::linear_combination_1((u2, q), u1); + + let r_matches: bool = match jet::gej_normalize(big_r) { + Some(big_r: Ge) => { + let (x, _): (Fe, Fe) = big_r; + // x < p < 2n, so reducing once gives x mod n. + jet::eq_256(jet::scalar_normalize(x), r) + }, + None => false, + }; + + and(and(is_signature_scalar(r), is_signature_scalar(s)), r_matches) + }, + None => false, + } +} + +/// Assert that `(r, s)` is a valid ECDSA signature over the message digest `msg` for the +/// compressed public key `pk`. +pub fn ecdsa_verify(pk: Point, msg: Message, sig: (Scalar, Scalar)) { + assert!(ecdsa_is_valid(pk, msg, sig)); +} + +// ============================================================================ +// Arithmetic helpers +// ============================================================================ + +fn safe_multiply(a: u64, b: u64) -> u64 { + let (high, low): (u64, u64) = ::into(jet::multiply_64(a, b)); + assert!(jet::is_zero_64(high)); + low +} + +fn safe_add(a: u64, b: u64) -> u64 { + let (carry, sum): (bool, u64) = jet::add_64(a, b); + assert!(not(carry)); + sum +} + +fn safe_subtract(a: u64, b: u64) -> u64 { + let (borrow, diff): (bool, u64) = jet::subtract_64(a, b); + assert!(not(borrow)); + diff +} + +/// tUSDT base units for `lbtc_amount` satoshis at `price_cents` US cents per BTC. +/// +/// Both assets have 8 decimal places, so this is `lbtc_amount * price_cents / 100`, +/// rounded down. +fn usd_for(lbtc_amount: u64, price_cents: u64) -> u64 { + let (usd, _): (u64, u64) = jet::div_mod_64(safe_multiply(lbtc_amount, price_cents), 100); + usd +} + +/// Whether `other` differs from `price` by more than TOLERANCE_BPS basis points of `price`. +fn differs_beyond_tolerance(price: u64, other: u64) -> bool { + let diff: u64 = match jet::lt_64(other, price) { + true => safe_subtract(price, other), + false => safe_subtract(other, price), + }; + jet::lt_64(safe_multiply(price, param::TOLERANCE_BPS), safe_multiply(diff, 10000)) +} + +// ============================================================================ +// Transaction helpers +// ============================================================================ + +fn get_input_script_hash(index: u32) -> u256 { + unwrap(jet::input_script_hash(index)) +} + +fn get_input_explicit_asset_amount(index: u32) -> (u256, u64) { + let (asset, amount): (Asset1, Amount1) = unwrap(jet::input_amount(index)); + let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); + let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_val, amount_val) +} + +fn get_output_explicit_asset_amount(index: u32) -> (u256, u64) { + let (asset, amount): (Asset1, Amount1) = unwrap(jet::output_amount(index)); + let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); + let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_val, amount_val) +} + +fn ensure_input_asset_with_amount_eq(index: u32, expected_asset: u256, expected_amount: u64) { + let (asset, amount): (u256, u64) = get_input_explicit_asset_amount(index); + assert!(jet::eq_256(asset, expected_asset)); + assert!(jet::eq_64(amount, expected_amount)); +} + +fn ensure_output_script_hash_eq(index: u32, expected: u256) { + let actual: u256 = unwrap(jet::output_script_hash(index)); + assert!(jet::eq_256(actual, expected)); +} + +fn ensure_output_asset_with_amount_eq(index: u32, expected_asset: u256, expected_amount: u64) { + let (asset, amount): (u256, u64) = get_output_explicit_asset_amount(index); + assert!(jet::eq_256(asset, expected_asset)); + assert!(jet::eq_64(amount, expected_amount)); +} + +/// sha256 of a bare `OP_RETURN` script, the burn output this wallet builds. +fn burn_script_hash() -> u256 { + 0x189f40034be7a199f1fa9891668ee3ab6049f82d38c68be70f596eab2e1857b7 +} + +/// Assert that WINDOW_BLOCKS have passed since this input was confirmed. +/// +/// This reads the input's own nSequence, which BIP68 enforces input by input. It avoids +/// `check_lock_distance`, which reads the largest lock across all inputs, so another +/// input's lock would satisfy it. +fn ensure_window_elapsed() { + assert!(jet::le_32(2, jet::version())); + let (flags, blocks): (u16, u16) = ::into(jet::current_sequence()); + // Bit 31 disables the lock; bit 22 would count it in 512-second units, not blocks. + assert!(jet::is_zero_16(jet::and_16(flags, 0x8040))); + assert!(jet::le_16(param::WINDOW_BLOCKS, blocks)); +} + +// ============================================================================ +// Addresses +// ============================================================================ + +fn covenant_nums_key() -> u256 { + 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0 +} + +fn p2tr_script_hash(output_key: u256) -> u256 { + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_2(ctx, 0x5120); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, output_key); + jet::sha_256_ctx_8_finalize(ctx) +} + +/// The script hash of this program's address with `state_leaf` as its tapdata leaf. +fn script_hash_for_state(state_leaf: u256) -> u256 { + let tap_node: u256 = jet::build_tapbranch(jet::tapleaf_hash(), state_leaf); + p2tr_script_hash(jet::build_taptweak(covenant_nums_key(), tap_node)) +} + +fn treasury_script_hash() -> u256 { + let ctx: Ctx8 = jet::tapdata_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, 0); + script_hash_for_state(jet::sha_256_ctx_8_finalize(ctx)) +} + +/// A mint request: price in US cents, oracle timestamp, L-BTC satoshis, and the sha256 of +/// the scriptPubKey that receives the tUSDT, or the refund. +type Request = (u64, u64, u64, u256); + +fn pending_script_hash(request: Request) -> u256 { + let (price, timestamp, lbtc_amount, recipient): Request = request; + let ctx: Ctx8 = jet::tapdata_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, 1); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_8(ctx, price); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_8(ctx, timestamp); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_8(ctx, lbtc_amount); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, recipient); + script_hash_for_state(jet::sha_256_ctx_8_finalize(ctx)) +} + +// ============================================================================ +// Oracle statements +// ============================================================================ + +/// A canonical string's variable fields: the price's integer digits, its two fraction +/// digits, sources, method, timestamp digits and nonce. Then the signature `(r, s)`. +type Statement = ( + List, + (u8, u8), + List, + List, + List, + List, + (Scalar, Scalar) +); + +fn oracle_pubkey() -> Point { + let prefix: u8 = param::ORACLE_PUBKEY_PREFIX; + assert!(or(jet::eq_8(prefix, 2), jet::eq_8(prefix, 3))); + (::into(jet::eq_8(prefix, 3)), param::ORACLE_PUBKEY_X) +} + +/// Hash one decimal digit and fold it into the number read so far. +fn digit_step(byte: u8, acc: (Ctx8, u64)) -> (Ctx8, u64) { + let (ctx, value): (Ctx8, u64) = acc; + assert!(jet::le_8(48, byte)); + assert!(jet::le_8(byte, 57)); + let (_, digit): (bool, u8) = jet::subtract_8(byte, 48); + let value: u64 = safe_add(safe_multiply(value, 10), jet::left_pad_low_8_64(digit)); + (jet::sha_256_ctx_8_add_1(ctx, byte), value) +} + +/// Hash one byte of a text field, which may be anything but the '|' separator. +fn text_step(byte: u8, ctx: Ctx8) -> Ctx8 { + assert!(not(jet::eq_8(byte, 124))); + jet::sha_256_ctx_8_add_1(ctx, byte) +} + +fn add_separator(ctx: Ctx8) -> Ctx8 { + jet::sha_256_ctx_8_add_1(ctx, 124) +} + +/// Rebuild a statement's canonical string, check the oracle signed it, and return its +/// price in US cents and its timestamp. +fn verify_statement(statement: Statement) -> (u64, u64) { + let (price_int, price_frac, sources, method, timestamp, nonce, sig): Statement = statement; + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + // "v1|PRICE|BTCUSD|" + let ctx: Ctx8 = jet::sha_256_ctx_8_add_16(ctx, 0x76317c50524943457c4254435553447c); + let (ctx, price): (Ctx8, u64) = fold::(price_int, (ctx, 0)); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, 46); // "." + let (frac_tens, frac_units): (u8, u8) = price_frac; + let (ctx, price): (Ctx8, u64) = digit_step(frac_units, digit_step(frac_tens, (ctx, price))); + // "|USD|2|" + let ctx: Ctx8 = jet::sha_256_ctx_8_add_4(ctx, 0x7c555344); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_2(ctx, 0x7c32); + let ctx: Ctx8 = add_separator(ctx); + let ctx: Ctx8 = fold::(sources, ctx); + let ctx: Ctx8 = add_separator(ctx); + let ctx: Ctx8 = fold::(method, ctx); + let ctx: Ctx8 = add_separator(ctx); + let (ctx, timestamp): (Ctx8, u64) = fold::(timestamp, (ctx, 0)); + let ctx: Ctx8 = add_separator(ctx); + let ctx: Ctx8 = fold::(nonce, ctx); + ecdsa_verify(oracle_pubkey(), jet::sha_256_ctx_8_finalize(ctx), sig); + (price, timestamp) +} + +// ============================================================================ +// Spending paths +// ============================================================================ + +/// Request: spend the treasury (input 0) to open a pending request. +/// +/// Outputs: 0 the L-BTC to burn and 1 the tUSDT it buys, both at the request's pending +/// address; 2 the rest of the treasury, back at the treasury address. +fn request_path(statement: Statement, recipient: u256) { + assert!(jet::eq_32(jet::current_index(), 0)); + let treasury: u256 = treasury_script_hash(); + assert!(jet::eq_256(get_input_script_hash(0), treasury)); + + let (price, timestamp): (u64, u64) = verify_statement(statement); + let (lbtc, lbtc_amount): (u256, u64) = get_output_explicit_asset_amount(0); + assert!(jet::eq_256(lbtc, param::LBTC_ASSET)); + let pending: u256 = pending_script_hash((price, timestamp, lbtc_amount, recipient)); + let usd_amount: u64 = usd_for(lbtc_amount, price); + + ensure_output_script_hash_eq(0, pending); + ensure_output_script_hash_eq(1, pending); + ensure_output_asset_with_amount_eq(1, param::USD_ASSET, usd_amount); + + let (usd, held): (u256, u64) = get_input_explicit_asset_amount(0); + assert!(jet::eq_256(usd, param::USD_ASSET)); + ensure_output_script_hash_eq(2, treasury); + ensure_output_asset_with_amount_eq(2, param::USD_ASSET, safe_subtract(held, usd_amount)); +} + +/// Check that inputs 0 and 1 are `request`'s L-BTC and tUSDT, run from input 0, and return +/// the tUSDT amount. +fn ensure_pending_inputs(request: Request) -> u64 { + assert!(jet::eq_32(jet::current_index(), 0)); + let (price, _, lbtc_amount, _): Request = request; + let pending: u256 = pending_script_hash(request); + assert!(jet::eq_256(get_input_script_hash(0), pending)); + assert!(jet::eq_256(get_input_script_hash(1), pending)); + + let usd_amount: u64 = usd_for(lbtc_amount, price); + ensure_input_asset_with_amount_eq(0, param::LBTC_ASSET, lbtc_amount); + ensure_input_asset_with_amount_eq(1, param::USD_ASSET, usd_amount); + usd_amount +} + +/// Finalize: after the window, pay the tUSDT to the recipient (output 0) and burn the +/// L-BTC (output 1). +fn finalize_path(request: Request) { + let usd_amount: u64 = ensure_pending_inputs(request); + ensure_window_elapsed(); + let (_, _, lbtc_amount, recipient): Request = request; + + ensure_output_script_hash_eq(0, recipient); + ensure_output_asset_with_amount_eq(0, param::USD_ASSET, usd_amount); + ensure_output_script_hash_eq(1, burn_script_hash()); + ensure_output_asset_with_amount_eq(1, param::LBTC_ASSET, lbtc_amount); +} + +/// Challenge: with a newer statement whose price differs by more than the tolerance, +/// refund the L-BTC to the recipient (output 0) and return the tUSDT to the treasury +/// (output 1). +/// +/// Nothing stops a challenge after the window: a program can't require that a block +/// height has *not* been reached. A request stays open to challenge until it is finalized. +fn challenge_path(request: Request, statement: Statement) { + let usd_amount: u64 = ensure_pending_inputs(request); + let (price, timestamp, lbtc_amount, recipient): Request = request; + let (new_price, new_timestamp): (u64, u64) = verify_statement(statement); + assert!(jet::lt_64(timestamp, new_timestamp)); + assert!(differs_beyond_tolerance(price, new_price)); + + ensure_output_script_hash_eq(0, recipient); + ensure_output_asset_with_amount_eq(0, param::LBTC_ASSET, lbtc_amount); + ensure_output_script_hash_eq(1, treasury_script_hash()); + ensure_output_asset_with_amount_eq(1, param::USD_ASSET, usd_amount); +} + +/// Sibling: the pending tUSDT, spent at input 1 beside its L-BTC at input 0, whose finalize +/// or challenge path checks the whole transaction. +/// +/// Refused at the treasury address. Otherwise a second treasury UTXO could ride along with +/// a request as input 1, with nothing checking where its tUSDT goes. +fn sibling_path() { + assert!(jet::eq_32(jet::current_index(), 1)); + let mine: u256 = get_input_script_hash(1); + assert!(jet::eq_256(mine, get_input_script_hash(0))); + assert!(not(jet::eq_256(mine, treasury_script_hash()))); +} + +/// One word of budget padding, which must be zero. +fn padding_word(word: u256, acc: ()) -> () { + assert!(jet::eq_256(word, 0)); + acc +} + +fn main() { + // A program may not cost more than its witness's size buys, and checking a statement + // costs more than the statement's own bytes. Liquid does not relay the annex that + // would otherwise pad it, so the request and challenge paths are padded here with + // zero words. Other paths leave the list empty. + fold::(witness::BUDGET_PAD, ()); + + let request: Request = ( + witness::REQUEST_PRICE, + witness::REQUEST_TIMESTAMP, + witness::REQUEST_LBTC_AMOUNT, + witness::REQUEST_RECIPIENT + ); + let statement: Statement = ( + witness::STATEMENT_PRICE_INT, + witness::STATEMENT_PRICE_FRAC, + witness::STATEMENT_SOURCES, + witness::STATEMENT_METHOD, + witness::STATEMENT_TIMESTAMP, + witness::STATEMENT_NONCE, + witness::STATEMENT_SIG + ); + + match witness::PATH { + Left(open_or_close: Either<(), ()>) => match open_or_close { + Left(u: ()) => { + let (_, _, _, recipient): Request = request; + request_path(statement, recipient) + }, + Right(u: ()) => finalize_path(request), + }, + Right(cancel_or_sibling: Either<(), ()>) => match cancel_or_sibling { + Left(u: ()) => challenge_path(request, statement), + Right(u: ()) => sibling_path(), + }, + } +} diff --git a/examples/oracle_demo/txmanifest.json b/examples/oracle_demo/txmanifest.json new file mode 100644 index 0000000..ed5e27f --- /dev/null +++ b/examples/oracle_demo/txmanifest.json @@ -0,0 +1,898 @@ +{ + "$schema": "../../schema/txmanifest.schema.json", + "$comment": "A demonstration of an oracle challenge window: using an oracle's price as the current price, which a Simplicity program can't check for itself. Statements use Mycelia Signal's canonical format and ECDSA signing scheme, signed here by a local test key (fake_oracle.py). The tUSDT asset is issued by the separate manifest in issue_usdt/.", + "manifest_version": "0.3.1", + "protocol": "oracle-demo-mint", + "description": "Burn L-BTC for tUSDT at the current BTC/USD price. Simplicity cannot tell whether an oracle statement is current, so a mint takes two steps. A request presents a signed price statement and reserves the tUSDT it is worth. Anyone with a newer statement whose price differs by more than a tolerance can then challenge it, refunding the L-BTC. If no one does within the window, anyone can finalize it: the tUSDT goes to the requester and the L-BTC is burned. One instance per service; requests are tracked in its state file.", + "chain": "liquid", + "requires": [ + "simplicity" + ], + "simplicity_hl": { + "version": "0.7.1" + }, + "utxo_types": { + "treasury": { + "description": "The tUSDT supply, waiting to be sold. Its tapdata leaf is the single byte 0x00.", + "script": { + "type": "simplicity", + "source": "./oracle_mint.simf", + "source_hash": "sha256:60835c51a0818685319ad98e58969f04140e1926ba8624e1e02fcf2159c8d3ef", + "compile_params": { + "ORACLE_PUBKEY_PREFIX": "ORACLE_PUBKEY_PREFIX", + "ORACLE_PUBKEY_X": "ORACLE_PUBKEY_X", + "LBTC_ASSET": "LBTC_ASSET", + "USD_ASSET": "USD_ASSET", + "WINDOW_BLOCKS": "WINDOW_BLOCKS", + "TOLERANCE_BPS": "TOLERANCE_BPS" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + "0x00" + ] + } + ] + }, + "asset": "USD_ASSET" + }, + "pending_mint": { + "description": "One open mint request: the L-BTC to burn and the tUSDT reserved for it, two UTXOs at one address. The tapdata leaf commits to the request, 0x01 || price || timestamp || L-BTC amount || recipient, so every request has its own address.", + "params": { + "ORACLE_PUBKEY_PREFIX": { + "type": "u8", + "default": "instance.ORACLE_PUBKEY_PREFIX", + "description": "The service's oracle key prefix." + }, + "ORACLE_PUBKEY_X": { + "type": "bytes32", + "default": "instance.ORACLE_PUBKEY_X", + "description": "The service's oracle key x-coordinate." + }, + "LBTC_ASSET": { + "type": "liquid.asset_id", + "default": "instance.LBTC_ASSET", + "description": "The asset requests burn." + }, + "USD_ASSET": { + "type": "liquid.asset_id", + "default": "instance.USD_ASSET", + "description": "The tUSDT asset id." + }, + "WINDOW_BLOCKS": { + "type": "u16", + "default": "instance.WINDOW_BLOCKS", + "description": "The challenge window, in blocks." + }, + "TOLERANCE_BPS": { + "type": "u64", + "default": "instance.TOLERANCE_BPS", + "description": "The challenge tolerance, in basis points." + }, + "REQUEST_PRICE": { + "type": "u64", + "description": "The request's price, in US cents per BTC." + }, + "REQUEST_TIMESTAMP": { + "type": "u64", + "description": "The request statement's unix timestamp." + }, + "REQUEST_LBTC_AMOUNT": { + "type": "u64", + "description": "The L-BTC to burn, in satoshis." + }, + "RECIPIENT_SPK_HASH": { + "type": "bytes32", + "description": "sha256 of the scriptPubKey that receives the tUSDT, or the refund." + } + }, + "script": { + "type": "simplicity", + "source": "./oracle_mint.simf", + "source_hash": "sha256:60835c51a0818685319ad98e58969f04140e1926ba8624e1e02fcf2159c8d3ef", + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + "0x01", + { + "value": "params.REQUEST_PRICE", + "type": "u64", + "endian": "be" + }, + { + "value": "params.REQUEST_TIMESTAMP", + "type": "u64", + "endian": "be" + }, + { + "value": "params.REQUEST_LBTC_AMOUNT", + "type": "u64", + "endian": "be" + }, + { + "value": "params.RECIPIENT_SPK_HASH", + "type": "bytes32" + } + ] + } + ] + } + } + }, + "contract_templates": { + "oracle_mint": { + "description": "One mint service: a treasury of tUSDT, the oracle key it trusts, and the challenge window's length and tolerance. All six fields are compile params of oracle_mint.simf, so they fix every address the service uses.", + "fields": { + "ORACLE_PUBKEY_PREFIX": { + "type": "u8" + }, + "ORACLE_PUBKEY_X": { + "type": "bytes32" + }, + "LBTC_ASSET": { + "type": "liquid.asset_id" + }, + "USD_ASSET": { + "type": "liquid.asset_id" + }, + "WINDOW_BLOCKS": { + "type": "u16" + }, + "TOLERANCE_BPS": { + "type": "u64" + } + }, + "actions": { + "CreateTreasury": { + "description": "Start the service: lock tUSDT into the treasury and write the instance file. Issue the tUSDT first with issue_usdt/txmanifest.json.", + "intent": "lock {params.SUPPLY} of {params.USD_ASSET:symbol} into a mint service treasury", + "params": { + "USD_ASSET": { + "type": "liquid.asset_id", + "description": "The tUSDT asset id, from the instance file issue_usdt wrote." + }, + "SUPPLY": { + "type": "u64", + "description": "tUSDT base units to lock in the treasury (8 decimal places)." + }, + "ORACLE_PUBKEY_PREFIX": { + "type": "u8", + "description": "First byte of the oracle's compressed public key: 2 or 3. fake_oracle.py keygen prints it." + }, + "ORACLE_PUBKEY_X": { + "type": "bytes32", + "description": "The rest of the oracle's compressed public key: its 32-byte x-coordinate." + }, + "LBTC_ASSET": { + "type": "liquid.asset_id", + "default": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", + "description": "The asset requests burn. The default is Liquid testnet L-BTC." + }, + "WINDOW_BLOCKS": { + "type": "u16", + "default": "5", + "description": "Blocks a request must wait before it can be finalized. Liquid makes a block a minute, so 5 is about 5 minutes." + }, + "TOLERANCE_BPS": { + "type": "u64", + "default": "50", + "description": "How far a newer statement's price may differ from a request's, in basis points of the request's price, before it can cancel the request. 50 is 0.5%." + } + }, + "inputs": [ + { + "id": "usd_in", + "description": "Wallet UTXO holding the tUSDT.", + "utxo_source": "wallet", + "asset": "params.USD_ASSET", + "amount_sat": { + "min_amount": "params.SUPPLY" + }, + "ui": { + "label": "the tUSDT to sell", + "role": "payment" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "treasury_out", + "description": "The treasury.", + "destination": { + "utxo_type": "treasury" + }, + "asset": "params.USD_ASSET", + "amount_sat": "params.SUPPLY", + "ui": { + "label": "tUSDT locked in the treasury", + "role": "covenant" + } + }, + { + "id": "usd_change", + "description": "tUSDT change back to your wallet.", + "destination": "change", + "asset": "params.USD_ASSET", + "optional": true, + "ui": { + "label": "tUSDT returned to you", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ], + "create_instance": { + "fields": { + "ORACLE_PUBKEY_PREFIX": "$params.ORACLE_PUBKEY_PREFIX", + "ORACLE_PUBKEY_X": "$params.ORACLE_PUBKEY_X", + "LBTC_ASSET": "$params.LBTC_ASSET", + "USD_ASSET": "$params.USD_ASSET", + "WINDOW_BLOCKS": "$params.WINDOW_BLOCKS", + "TOLERANCE_BPS": "$params.TOLERANCE_BPS" + } + } + }, + "RequestMint": { + "$comment": "Outputs 0-2 are the ones the treasury checks, in this order. Generate the params with fake_oracle.py request-params.", + "description": "Step 1 of a mint: present a signed price statement, lock the L-BTC to burn and reserve the tUSDT it buys at that price. Opens the challenge window.", + "intent": "request {params.USD_AMOUNT} tUSDT for {params.REQUEST_LBTC_AMOUNT} L-BTC at {params.REQUEST_PRICE} cents per BTC", + "params": { + "STATEMENT_PRICE_INT": { + "type": "string", + "description": "The statement's price, integer digits, as a SimplicityHL list of bytes." + }, + "STATEMENT_PRICE_FRAC": { + "type": "string", + "description": "The statement's price, the two fraction digits, as a pair of bytes." + }, + "STATEMENT_SOURCES": { + "type": "string", + "description": "The statement's sources field, as a list of bytes." + }, + "STATEMENT_METHOD": { + "type": "string", + "description": "The statement's method field, as a list of bytes." + }, + "STATEMENT_TIMESTAMP": { + "type": "string", + "description": "The statement's timestamp digits, as a list of bytes." + }, + "STATEMENT_NONCE": { + "type": "string", + "description": "The statement's nonce field, as a list of bytes." + }, + "STATEMENT_SIG": { + "type": "string", + "description": "The oracle's signature, as (r, s)." + }, + "REQUEST_PRICE": { + "type": "u64", + "description": "The statement's price in US cents. Must match the statement." + }, + "REQUEST_TIMESTAMP": { + "type": "u64", + "description": "The statement's timestamp. Must match the statement." + }, + "REQUEST_LBTC_AMOUNT": { + "type": "u64", + "description": "L-BTC to burn, in satoshis." + }, + "REQUEST_RECIPIENT": { + "type": "string", + "description": "Address that receives the tUSDT, or the refund if the request is challenged." + }, + "RECIPIENT_SPK_HASH": { + "type": "bytes32", + "compute": { + "type": "script_hash", + "address": "params.REQUEST_RECIPIENT" + } + }, + "USD_AMOUNT": { + "type": "u64", + "compute": "params.REQUEST_LBTC_AMOUNT * params.REQUEST_PRICE / 100" + } + }, + "inputs": [ + { + "id": "treasury_in", + "description": "The treasury, input 0. It checks the statement and the three outputs after it.", + "utxo_source": { + "utxo_type": "treasury" + }, + "asset": "instance.USD_ASSET", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>", + "value": "Left(Left(()))", + "description": "The request path." + }, + "REQUEST_PRICE": "unused", + "REQUEST_TIMESTAMP": "unused", + "REQUEST_LBTC_AMOUNT": "unused", + "REQUEST_RECIPIENT": { + "type": "simplicityhl", + "simplicity_type": "u256", + "value": "params.RECIPIENT_SPK_HASH" + }, + "STATEMENT_PRICE_INT": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_PRICE_INT" + }, + "STATEMENT_PRICE_FRAC": { + "type": "simplicityhl", + "simplicity_type": "(u8, u8)", + "value": "params.STATEMENT_PRICE_FRAC" + }, + "STATEMENT_SOURCES": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_SOURCES" + }, + "STATEMENT_METHOD": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_METHOD" + }, + "STATEMENT_TIMESTAMP": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_TIMESTAMP" + }, + "STATEMENT_NONCE": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_NONCE" + }, + "STATEMENT_SIG": { + "type": "simplicityhl", + "simplicity_type": "(u256, u256)", + "value": "params.STATEMENT_SIG" + }, + "BUDGET_PAD": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "list![0, 0, 0, 0, 0, 0, 0, 0]", + "description": "Zero words that buy execution budget for checking the statement. If a broadcast fails with \"Program's execution cost could exceed budget\", add more." + } + }, + "ui": { + "label": "the tUSDT treasury", + "role": "covenant" + } + }, + { + "id": "lbtc_in", + "description": "Wallet L-BTC to burn.", + "utxo_source": "wallet", + "asset": "lbtc", + "amount_sat": { + "min_amount": "params.REQUEST_LBTC_AMOUNT" + }, + "ui": { + "label": "the L-BTC you are burning", + "role": "payment" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "pending_lbtc_out", + "description": "Output 0: the L-BTC, held at the request's pending address until the request is finalized or challenged.", + "destination": { + "utxo_type": "pending_mint", + "args": { + "REQUEST_PRICE": "params.REQUEST_PRICE", + "REQUEST_TIMESTAMP": "params.REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT": "params.REQUEST_LBTC_AMOUNT", + "RECIPIENT_SPK_HASH": "params.RECIPIENT_SPK_HASH" + } + }, + "asset": "lbtc", + "amount_sat": "params.REQUEST_LBTC_AMOUNT", + "ui": { + "label": "L-BTC held until the request settles", + "role": "covenant" + } + }, + { + "id": "pending_usd_out", + "description": "Output 1: the tUSDT reserved for this request, at the same address.", + "destination": { + "utxo_type": "pending_mint", + "args": { + "REQUEST_PRICE": "params.REQUEST_PRICE", + "REQUEST_TIMESTAMP": "params.REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT": "params.REQUEST_LBTC_AMOUNT", + "RECIPIENT_SPK_HASH": "params.RECIPIENT_SPK_HASH" + } + }, + "asset": "instance.USD_ASSET", + "amount_sat": "params.USD_AMOUNT", + "ui": { + "label": "tUSDT reserved for the request", + "role": "covenant" + } + }, + { + "id": "treasury_change_out", + "description": "Output 2: the rest of the treasury.", + "destination": { + "utxo_type": "treasury" + }, + "asset": "instance.USD_ASSET", + "amount_sat": "treasury_in.amount_sat - params.USD_AMOUNT", + "ui": { + "label": "tUSDT left in the treasury", + "role": "covenant" + } + }, + { + "id": "lbtc_change", + "description": "L-BTC change from the burn and fee inputs.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "L-BTC change returned to you", + "role": "change" + } + } + ] + }, + "Finalize": { + "$comment": "Use the request's params file. Anyone can run it: the covenant fixes where both outputs go.", + "description": "Step 2 of a mint, once the window has passed: pay the reserved tUSDT to the recipient and burn the L-BTC.", + "intent": "finalize a request: pay {params.USD_AMOUNT} tUSDT to {params.REQUEST_RECIPIENT} and burn {params.REQUEST_LBTC_AMOUNT} L-BTC", + "params": { + "REQUEST_PRICE": { + "type": "u64", + "description": "The request's price in US cents." + }, + "REQUEST_TIMESTAMP": { + "type": "u64", + "description": "The request statement's timestamp." + }, + "REQUEST_LBTC_AMOUNT": { + "type": "u64", + "description": "The request's L-BTC, in satoshis." + }, + "REQUEST_RECIPIENT": { + "type": "string", + "description": "The request's recipient address." + }, + "RECIPIENT_SPK_HASH": { + "type": "bytes32", + "compute": { + "type": "script_hash", + "address": "params.REQUEST_RECIPIENT" + } + }, + "USD_AMOUNT": { + "type": "u64", + "compute": "params.REQUEST_LBTC_AMOUNT * params.REQUEST_PRICE / 100" + } + }, + "inputs": [ + { + "id": "pending_lbtc_in", + "description": "The request's L-BTC, input 0. Its relative timelock is the challenge window, and it checks the whole transaction.", + "utxo_source": { + "utxo_type": "pending_mint", + "args": { + "REQUEST_PRICE": "params.REQUEST_PRICE", + "REQUEST_TIMESTAMP": "params.REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT": "params.REQUEST_LBTC_AMOUNT", + "RECIPIENT_SPK_HASH": "params.RECIPIENT_SPK_HASH" + } + }, + "asset": "lbtc", + "sequence": { + "relative_blocks": "instance.WINDOW_BLOCKS" + }, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>", + "value": "Left(Right(()))", + "description": "The finalize path." + }, + "REQUEST_PRICE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.REQUEST_PRICE" + }, + "REQUEST_TIMESTAMP": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.REQUEST_TIMESTAMP" + }, + "REQUEST_LBTC_AMOUNT": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.REQUEST_LBTC_AMOUNT" + }, + "REQUEST_RECIPIENT": { + "type": "simplicityhl", + "simplicity_type": "u256", + "value": "params.RECIPIENT_SPK_HASH" + }, + "STATEMENT_PRICE_INT": "unused", + "STATEMENT_PRICE_FRAC": "unused", + "STATEMENT_SOURCES": "unused", + "STATEMENT_METHOD": "unused", + "STATEMENT_TIMESTAMP": "unused", + "STATEMENT_NONCE": "unused", + "STATEMENT_SIG": "unused", + "BUDGET_PAD": "unused" + }, + "ui": { + "label": "the request's L-BTC", + "role": "covenant" + } + }, + { + "id": "pending_usd_in", + "description": "The request's reserved tUSDT, input 1.", + "utxo_source": { + "utxo_type": "pending_mint", + "args": { + "REQUEST_PRICE": "params.REQUEST_PRICE", + "REQUEST_TIMESTAMP": "params.REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT": "params.REQUEST_LBTC_AMOUNT", + "RECIPIENT_SPK_HASH": "params.RECIPIENT_SPK_HASH" + } + }, + "asset": "instance.USD_ASSET", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>", + "value": "Right(Right(()))", + "description": "The sibling path." + }, + "REQUEST_PRICE": "unused", + "REQUEST_TIMESTAMP": "unused", + "REQUEST_LBTC_AMOUNT": "unused", + "REQUEST_RECIPIENT": "unused", + "STATEMENT_PRICE_INT": "unused", + "STATEMENT_PRICE_FRAC": "unused", + "STATEMENT_SOURCES": "unused", + "STATEMENT_METHOD": "unused", + "STATEMENT_TIMESTAMP": "unused", + "STATEMENT_NONCE": "unused", + "STATEMENT_SIG": "unused", + "BUDGET_PAD": "unused" + }, + "ui": { + "label": "the request's reserved tUSDT", + "role": "covenant" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "usd_out", + "description": "Output 0: the tUSDT, to the recipient. Explicit, because the covenant reads its amount.", + "destination": "params.REQUEST_RECIPIENT", + "asset": "instance.USD_ASSET", + "amount_sat": "params.USD_AMOUNT", + "confidential": false, + "ui": { + "label": "tUSDT paid to the recipient", + "role": "settlement" + } + }, + { + "id": "lbtc_burn", + "description": "Output 1: the L-BTC, burned.", + "destination": { + "type": "burn" + }, + "asset": "lbtc", + "amount_sat": "params.REQUEST_LBTC_AMOUNT", + "ui": { + "label": "L-BTC burned", + "role": "burn" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ] + }, + "Challenge": { + "$comment": "Generate the params with fake_oracle.py challenge-params. Anyone can run it.", + "description": "Cancel a request with a newer statement whose price differs from the request's by more than the tolerance. Refunds the L-BTC to the recipient and returns the tUSDT to the treasury.", + "intent": "challenge a request at {params.REQUEST_PRICE} cents per BTC: refund {params.REQUEST_LBTC_AMOUNT} L-BTC to {params.REQUEST_RECIPIENT}", + "params": { + "STATEMENT_PRICE_INT": { + "type": "string", + "description": "The newer statement's price, integer digits, as a SimplicityHL list of bytes." + }, + "STATEMENT_PRICE_FRAC": { + "type": "string", + "description": "The newer statement's price, the two fraction digits, as a pair of bytes." + }, + "STATEMENT_SOURCES": { + "type": "string", + "description": "The newer statement's sources field, as a list of bytes." + }, + "STATEMENT_METHOD": { + "type": "string", + "description": "The newer statement's method field, as a list of bytes." + }, + "STATEMENT_TIMESTAMP": { + "type": "string", + "description": "The newer statement's timestamp digits, as a list of bytes." + }, + "STATEMENT_NONCE": { + "type": "string", + "description": "The newer statement's nonce field, as a list of bytes." + }, + "STATEMENT_SIG": { + "type": "string", + "description": "The oracle's signature on the newer statement, as (r, s)." + }, + "REQUEST_PRICE": { + "type": "u64", + "description": "The request's price in US cents." + }, + "REQUEST_TIMESTAMP": { + "type": "u64", + "description": "The request statement's timestamp." + }, + "REQUEST_LBTC_AMOUNT": { + "type": "u64", + "description": "The request's L-BTC, in satoshis." + }, + "REQUEST_RECIPIENT": { + "type": "string", + "description": "The request's recipient address." + }, + "RECIPIENT_SPK_HASH": { + "type": "bytes32", + "compute": { + "type": "script_hash", + "address": "params.REQUEST_RECIPIENT" + } + }, + "USD_AMOUNT": { + "type": "u64", + "compute": "params.REQUEST_LBTC_AMOUNT * params.REQUEST_PRICE / 100" + } + }, + "inputs": [ + { + "id": "pending_lbtc_in", + "description": "The request's L-BTC, input 0. It checks the newer statement and the whole transaction.", + "utxo_source": { + "utxo_type": "pending_mint", + "args": { + "REQUEST_PRICE": "params.REQUEST_PRICE", + "REQUEST_TIMESTAMP": "params.REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT": "params.REQUEST_LBTC_AMOUNT", + "RECIPIENT_SPK_HASH": "params.RECIPIENT_SPK_HASH" + } + }, + "asset": "lbtc", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>", + "value": "Right(Left(()))", + "description": "The challenge path." + }, + "REQUEST_PRICE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.REQUEST_PRICE" + }, + "REQUEST_TIMESTAMP": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.REQUEST_TIMESTAMP" + }, + "REQUEST_LBTC_AMOUNT": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.REQUEST_LBTC_AMOUNT" + }, + "REQUEST_RECIPIENT": { + "type": "simplicityhl", + "simplicity_type": "u256", + "value": "params.RECIPIENT_SPK_HASH" + }, + "STATEMENT_PRICE_INT": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_PRICE_INT" + }, + "STATEMENT_PRICE_FRAC": { + "type": "simplicityhl", + "simplicity_type": "(u8, u8)", + "value": "params.STATEMENT_PRICE_FRAC" + }, + "STATEMENT_SOURCES": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_SOURCES" + }, + "STATEMENT_METHOD": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_METHOD" + }, + "STATEMENT_TIMESTAMP": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_TIMESTAMP" + }, + "STATEMENT_NONCE": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_NONCE" + }, + "STATEMENT_SIG": { + "type": "simplicityhl", + "simplicity_type": "(u256, u256)", + "value": "params.STATEMENT_SIG" + }, + "BUDGET_PAD": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "list![0, 0, 0, 0, 0, 0, 0, 0]", + "description": "Zero words that buy execution budget for checking the statement. If a broadcast fails with \"Program's execution cost could exceed budget\", add more." + } + }, + "ui": { + "label": "the request's L-BTC", + "role": "covenant" + } + }, + { + "id": "pending_usd_in", + "description": "The request's reserved tUSDT, input 1.", + "utxo_source": { + "utxo_type": "pending_mint", + "args": { + "REQUEST_PRICE": "params.REQUEST_PRICE", + "REQUEST_TIMESTAMP": "params.REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT": "params.REQUEST_LBTC_AMOUNT", + "RECIPIENT_SPK_HASH": "params.RECIPIENT_SPK_HASH" + } + }, + "asset": "instance.USD_ASSET", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>", + "value": "Right(Right(()))", + "description": "The sibling path." + }, + "REQUEST_PRICE": "unused", + "REQUEST_TIMESTAMP": "unused", + "REQUEST_LBTC_AMOUNT": "unused", + "REQUEST_RECIPIENT": "unused", + "STATEMENT_PRICE_INT": "unused", + "STATEMENT_PRICE_FRAC": "unused", + "STATEMENT_SOURCES": "unused", + "STATEMENT_METHOD": "unused", + "STATEMENT_TIMESTAMP": "unused", + "STATEMENT_NONCE": "unused", + "STATEMENT_SIG": "unused", + "BUDGET_PAD": "unused" + }, + "ui": { + "label": "the request's reserved tUSDT", + "role": "covenant" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "lbtc_refund", + "description": "Output 0: the L-BTC, refunded to the recipient. Explicit, because the covenant reads its amount.", + "destination": "params.REQUEST_RECIPIENT", + "asset": "lbtc", + "amount_sat": "params.REQUEST_LBTC_AMOUNT", + "confidential": false, + "ui": { + "label": "L-BTC refunded to the requester", + "role": "refund" + } + }, + { + "id": "usd_to_treasury", + "description": "Output 1: the reserved tUSDT, back to the treasury.", + "destination": { + "utxo_type": "treasury" + }, + "asset": "instance.USD_ASSET", + "amount_sat": "params.USD_AMOUNT", + "ui": { + "label": "tUSDT returned to the treasury", + "role": "covenant" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ] + } + } + } + } +} diff --git a/schema/txmanifest.schema.json b/schema/txmanifest.schema.json index ddba907..90b7be8 100644 --- a/schema/txmanifest.schema.json +++ b/schema/txmanifest.schema.json @@ -51,6 +51,17 @@ "null" ] }, + "lock_time": { + "anyOf": [ + { + "$ref": "#/definitions/LockTime" + }, + { + "type": "null" + } + ], + "description": "The transaction's `nLockTime`: the earliest block height, or time, at which it can be mined. This is what the `check_lock_height` / `check_lock_time` Simplicity jets read. See [`LockTime`].\n\nOmitted → zero, no absolute timelock." + }, "on_post_broadcast": { "anyOf": [ { @@ -375,6 +386,41 @@ ], "type": "object" }, + "LockTime": { + "description": "An action's absolute timelock, `{\"height\": }` or `{\"time\": }`.\n\nBoth set the same `nLockTime` field, which consensus reads as a height below 500,000,000 and as unix seconds from there up, so the two are separate keys rather than one number whose meaning depends on its size. A lock time is ignored when every input is final, so the engine gives each input that sets no `sequence` of its own `0xfffffffe`.", + "oneOf": [ + { + "additionalProperties": false, + "description": "The transaction can be mined in a block at least this high. Below 500,000,000.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "height": true + }, + "required": [ + "height" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "The transaction can be mined once the chain's median time past is beyond this unix time, in seconds. At least 500,000,000. On a chain with one-minute blocks, median time past runs about six minutes behind the clock.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "time": true + }, + "required": [ + "time" + ], + "type": "object" + } + ] + }, "Output": { "additionalProperties": false, "properties": { diff --git a/txmanifest_lib/src/assembly.rs b/txmanifest_lib/src/assembly.rs index 36cdb7b..151d64a 100644 --- a/txmanifest_lib/src/assembly.rs +++ b/txmanifest_lib/src/assembly.rs @@ -632,6 +632,7 @@ mod tests { fee_rate: 1.0, policy_asset: bitcoin_policy_asset(), change_assets: Default::default(), + lock_time: None, }; let narrowed = crate::psbt_builder::from_pset_request(&req, None).expect("narrows"); let crate::psbt_builder::PsbtInput::Wallet { @@ -668,6 +669,7 @@ mod tests { fee_rate: 1.0, policy_asset: c.policy_asset(), change_assets: Default::default(), + lock_time: None, }; assert!(crate::psbt_builder::from_pset_request(&req, None).is_ok()); diff --git a/txmanifest_lib/src/covenant.rs b/txmanifest_lib/src/covenant.rs index 862f884..eeafd9b 100644 --- a/txmanifest_lib/src/covenant.rs +++ b/txmanifest_lib/src/covenant.rs @@ -815,7 +815,21 @@ pub fn finalize_covenant_input( let cb_bytes = control_block.serialize(); // Simplicity tapscript witness: [witness_bits, program_bytes, cmr_script, control_block] - pset_input.final_script_witness = Some(vec![witness, prog, cmr_bytes, cb_bytes]); + let stack = vec![witness, prog, cmr_bytes, cb_bytes]; + + // The node refuses a program that may cost more than the budget its witness buys (the + // serialized stack plus 50 weight units). Elements can pad with an annex, but Liquid + // does not relay one (`bad-witness-nonstandard`), so the padding has to be a witness + // the program itself reads. Say so here rather than as a broadcast error. + let cost = satisfied.redeem().bounds().cost; + if let Some(short) = cost.get_padding_size(&stack) { + anyhow::bail!( + "Program's execution cost exceeds its budget: the witness is {short} bytes short. \ + Pad it with a witness the program reads." + ); + } + + pset_input.final_script_witness = Some(stack); Ok(()) } diff --git a/txmanifest_lib/src/lifecycle.rs b/txmanifest_lib/src/lifecycle.rs index a2cba50..975dd7b 100644 --- a/txmanifest_lib/src/lifecycle.rs +++ b/txmanifest_lib/src/lifecycle.rs @@ -68,6 +68,39 @@ fn encode_sequence(spec: &serde_json::Value, ctx: &ExecutionContext) -> Result Result { + use crate::manifest::LockTime; + match spec { + LockTime::Height(v) => { + let height = eval::eval_amount(v, ctx).context("evaluating lock_time.height")?; + if height >= LOCKTIME_THRESHOLD { + anyhow::bail!( + "lock_time.height {height} is not below {LOCKTIME_THRESHOLD}, so consensus \ + would read it as a time" + ); + } + Ok(height as u32) + } + LockTime::Time(v) => { + let time = eval::eval_amount(v, ctx).context("evaluating lock_time.time")?; + if time < LOCKTIME_THRESHOLD { + anyhow::bail!( + "lock_time.time {time} is below {LOCKTIME_THRESHOLD}, so consensus would \ + read it as a block height" + ); + } + u32::try_from(time) + .map_err(|_| anyhow::anyhow!("lock_time.time {time} does not fit in 32 bits")) + } + } +} + /// Resolve `inp.sequence` (if present) to a raw nSequence, warning loudly if it would /// silently disable the relative timelock the user asked for. fn resolve_input_sequence(inp: &Input, ctx: &ExecutionContext) -> Result> { @@ -2092,8 +2125,24 @@ pub fn run( } } + let lock_time = match action.lock_time.as_ref() { + None => Ok(None), + Some(spec) => encode_lock_time(spec, &ctx).map(Some), + }; + let lock_time_ok = match &lock_time { + Ok(Some(lt)) => { + println!(" · nLockTime = {lt}"); + true + } + Ok(None) => true, + Err(e) => { + println!(" {} {e:#}", style("[error]").red()); + false + } + }; + // ---- Build PSET ---- - if collect_inputs_ok && collect_outputs_ok { + if collect_inputs_ok && collect_outputs_ok && lock_time_ok { // Change is permitted per ASSET: by an explicit `destination: "change"` // output (collected while resolving outputs, above), or by the action's // `allow_change` setting, which covers surpluses that cannot be predicted — @@ -2120,6 +2169,7 @@ pub fn run( fee_rate: fee_rate as f32, policy_asset: actx.policy_asset(), change_assets: change_assets.clone(), + lock_time: lock_time.ok().flatten(), }; // Resolve the `fee` keyword: estimate the fee from the current (fee=0) @@ -4997,6 +5047,25 @@ mod tests { use crate::manifest::{ComputeSpec, InstanceCreate}; use std::collections::BTreeMap; + /// `nLockTime` means a height below 500,000,000 and a time from there up, so each key + /// refuses a value consensus would read as the other. + #[test] + fn a_lock_time_must_be_the_kind_its_key_says() { + use crate::manifest::LockTime; + use serde_json::json; + + let mut ctx = ExecutionContext::new(); + ctx.set_param("T", "1800000000"); + let time = |v| encode_lock_time(&LockTime::Time(v), &ctx); + let height = |v| encode_lock_time(&LockTime::Height(v), &ctx); + + assert_eq!(time(json!("params.T - 300")).unwrap(), 1_799_999_700); + assert_eq!(height(json!(2_670_000)).unwrap(), 2_670_000); + assert!(height(json!("params.T")).is_err()); + assert!(time(json!(2_670_000)).is_err()); + assert!(time(json!(5_000_000_000u64)).is_err()); + } + /// `confidential: false` must beat a confidential address's own blinding key. /// /// The covenant case that forces it: a payout check does `unwrap_right` on diff --git a/txmanifest_lib/src/manifest.rs b/txmanifest_lib/src/manifest.rs index fd85472..0016810 100644 --- a/txmanifest_lib/src/manifest.rs +++ b/txmanifest_lib/src/manifest.rs @@ -610,6 +610,24 @@ impl AllowChange { } } +/// An action's absolute timelock, `{"height": }` or `{"time": }`. +/// +/// Both set the same `nLockTime` field, which consensus reads as a height below +/// 500,000,000 and as unix seconds from there up, so the two are separate keys rather than +/// one number whose meaning depends on its size. A lock time is ignored when every input +/// is final, so the engine gives each input that sets no `sequence` of its own +/// `0xfffffffe`. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum LockTime { + /// The transaction can be mined in a block at least this high. Below 500,000,000. + Height(serde_json::Value), + /// The transaction can be mined once the chain's median time past is beyond this + /// unix time, in seconds. At least 500,000,000. On a chain with one-minute blocks, + /// median time past runs about six minutes behind the clock. + Time(serde_json::Value), +} + #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Action { @@ -639,6 +657,12 @@ pub struct Action { pub allow_change: AllowChange, /// Runtime action parameters (Spec §5). Prompted, or set by hooks. pub params: Option>, + /// The transaction's `nLockTime`: the earliest block height, or time, at which it can + /// be mined. This is what the `check_lock_height` / `check_lock_time` Simplicity jets + /// read. See [`LockTime`]. + /// + /// Omitted → zero, no absolute timelock. + pub lock_time: Option, pub inputs: Option>, pub outputs: Option>, /// Method-level hook: runs after inputs are resolved, before PSET is built. diff --git a/txmanifest_lib/src/psbt_builder.rs b/txmanifest_lib/src/psbt_builder.rs index cc5983b..1a4f984 100644 --- a/txmanifest_lib/src/psbt_builder.rs +++ b/txmanifest_lib/src/psbt_builder.rs @@ -729,6 +729,7 @@ mod tests { fee_rate: 2.0, policy_asset: policy(), change_assets: std::collections::HashSet::from([policy()]), + lock_time: None, } } @@ -762,6 +763,24 @@ mod tests { assert_eq!(psbt.change_script, Some(p2tr(7))); } + /// A lock time crosses to Bitcoin, and makes an input that asked for no sequence + /// non-final there too. + #[test] + fn narrowing_carries_the_lock_time() { + let mut pset = covenant_pset_request(policy()); + pset.lock_time = Some(900_000); + let psbt = from_pset_request(&pset, Some(p2tr(7))).expect("narrows"); + assert_eq!(psbt.lock_time, Some(900_000)); + assert_eq!(psbt.inputs[0].sequence().to_consensus_u32(), 144); + + let crate::pset_builder::PsetInput::Covenant { sequence, .. } = &mut pset.inputs[0] else { + panic!("the request has one covenant input"); + }; + *sequence = None; + let psbt = from_pset_request(&pset, Some(p2tr(7))).expect("narrows"); + assert_eq!(psbt.inputs[0].sequence().to_consensus_u32(), 0xffff_fffe); + } + /// Change is emitted only where the action declared it, matching the Elements rule /// that an undeclared surplus is an error rather than an invented output. #[test] @@ -1159,7 +1178,7 @@ pub fn from_pset_request( value: Amount::from_sat(utxo.unblinded.value), script_pubkey: convert_script(&utxo.script_pubkey), }, - sequence: *sequence, + sequence: pset.input_sequence(*sequence), }); } EIn::Covenant { @@ -1186,7 +1205,7 @@ pub fn from_pset_request( outpoint: convert_outpoint(*outpoint), script_pubkey: convert_script(script_pubkey), amount: *amount, - sequence: *sequence, + sequence: pset.input_sequence(*sequence), }); } } @@ -1225,9 +1244,7 @@ pub fn from_pset_request( // A change output is emitted only where the action declared one, which on Bitcoin // means the policy asset appeared in `change_assets`. change_script: change_script.filter(|_| pset.change_assets.contains(&policy)), - // The Elements request has no transaction-level locktime; absolute timelocks reach - // a covenant through its own `check_lock_height` rather than through the builder. - lock_time: None, + lock_time: pset.lock_time, }) } diff --git a/txmanifest_lib/src/pset_builder.rs b/txmanifest_lib/src/pset_builder.rs index adb7eee..8508871 100644 --- a/txmanifest_lib/src/pset_builder.rs +++ b/txmanifest_lib/src/pset_builder.rs @@ -8,8 +8,9 @@ use lwk_wollet::{ hashes::{sha256, Hash as _}, pset::{Input, Output, PartiallySignedTransaction}, secp256k1_zkp::{RangeProof, SecretKey, SurjectionProof, Tweak}, - AssetId, BlindAssetProofs, BlindValueProofs, ContractHash, OutPoint, RangeProofMessage, - Script, Sequence, SurjectionInput, TxOut, TxOutSecrets, TxOutWitness, Txid, + AssetId, BlindAssetProofs, BlindValueProofs, ContractHash, LockTime, OutPoint, + RangeProofMessage, Script, Sequence, SurjectionInput, TxOut, TxOutSecrets, TxOutWitness, + Txid, }, ElementsNetwork, WalletTxOut, Wollet, EC, }; @@ -106,6 +107,23 @@ pub struct BuildPsetRequest { /// declared change output is an error rather than a silently-invented output: the /// alternative is a transaction that moves value the manifest never mentioned. pub change_assets: std::collections::HashSet, + /// Transaction-level `nLockTime`. `None` leaves it at zero (no absolute timelock). + pub lock_time: Option, +} + +/// `nSequence` for an input that enables a lock time without a relative one. +const SEQUENCE_ENABLE_LOCKTIME: u32 = 0xffff_fffe; + +impl BuildPsetRequest { + /// The `nSequence` an input gets, given the one it asked for. + /// + /// A lock time is ignored when every input is at the maximum sequence, so an input + /// that asks for none gets [`SEQUENCE_ENABLE_LOCKTIME`] whenever the transaction + /// has a lock time. Shared with the Bitcoin builder through `from_pset_request`, so + /// both chains apply the same rule. + pub fn input_sequence(&self, requested: Option) -> Option { + requested.or(self.lock_time.map(|_| SEQUENCE_ENABLE_LOCKTIME)) + } } pub struct IssuanceResult { @@ -534,11 +552,14 @@ fn build_inner( Vec, )> { let mut pset = PartiallySignedTransaction::new_v2(); + pset.global.tx_data.fallback_locktime = req.lock_time.map(LockTime::from_consensus); let mut inp_txout_sec: HashMap = HashMap::new(); let mut issuances: Vec = Vec::new(); let mut total_lbtc_in: u64 = 0; - // Track non-LBTC wallet input amounts for automatic change output generation. - let mut wallet_asset_in: HashMap = HashMap::new(); + // Non-LBTC amounts in, from every input, for change and for the balance check. Covenant + // inputs count too: an explicit wallet UTXO arrives as one, and leaving it out once + // dropped a tUSDT change output, so the node refused the transaction. + let mut asset_in: HashMap = HashMap::new(); // Add inputs for pset_input in &req.inputs { @@ -550,7 +571,7 @@ fn build_inner( sequence, } => { let idx = add_wallet_input(&mut pset, &mut inp_txout_sec, wollet, secp, rng, utxo)?; - apply_sequence(&mut pset, idx, *sequence); + apply_sequence(&mut pset, idx, req.input_sequence(*sequence)); if let Some(iso) = issuance { apply_new_issuance(&mut pset, idx, iso)?; let (asset_id, token_id) = pset.inputs()[idx].issuance_ids(); @@ -575,8 +596,7 @@ fn build_inner( if utxo.unblinded.asset == req.policy_asset { total_lbtc_in += utxo.unblinded.value; } else { - *wallet_asset_in.entry(utxo.unblinded.asset).or_default() += - utxo.unblinded.value; + *asset_in.entry(utxo.unblinded.asset).or_default() += utxo.unblinded.value; } } PsetInput::Covenant { @@ -599,7 +619,7 @@ fn build_inner( *amount, *blinding, )?; - apply_sequence(&mut pset, idx, *sequence); + apply_sequence(&mut pset, idx, req.input_sequence(*sequence)); if let Some(iso) = issuance { // A covenant input may carry either a NEW issuance (e.g. an issuance-factory // covenant minting a fresh NFT from its own outpoint) or a reissuance. @@ -627,6 +647,8 @@ fn build_inner( } if *asset == req.policy_asset { total_lbtc_in += amount; + } else { + *asset_in.entry(*asset).or_default() += amount; } } } @@ -685,7 +707,7 @@ fn build_inner( )); } - // Non-LBTC change outputs: for any wallet-input asset where the input exceeds the outputs. + // Non-LBTC change outputs: for any asset where the inputs exceed the outputs. let total_non_lbtc_out: HashMap = req .outputs .iter() @@ -694,7 +716,7 @@ fn build_inner( *m.entry(o.asset).or_default() += o.amount; m }); - for (asset, in_amt) in &wallet_asset_in { + for (asset, in_amt) in &asset_in { let out_amt = total_non_lbtc_out.get(asset).copied().unwrap_or(0); if *in_amt > out_amt && !req.change_assets.contains(asset) { // Only the fee may be an output the manifest did not declare. Inventing a @@ -1696,6 +1718,92 @@ mod abf_reuse_tests { } } +#[cfg(test)] +mod change_tests { + use super::*; + + const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about"; + + fn wollet() -> Wollet { + let w = crate::wallet::WalletFile { + network: "testnet".to_string(), + mnemonic: MNEMONIC.to_string(), + }; + Wollet::new( + ElementsNetwork::LiquidTestnet, + lwk_wollet::NoPersist::new(), + crate::wallet::descriptor(&w).unwrap(), + ) + .unwrap() + } + + /// Spend 100,000 L-BTC and 1,000 of another asset, all explicit, the way an explicit + /// wallet UTXO reaches the builder, and pay 400 of the other asset out. + fn request(change_assets: &[AssetId]) -> BuildPsetRequest { + let policy = ElementsNetwork::LiquidTestnet.policy_asset(); + let other = AssetId::from_slice(&[0xaa; 32]).unwrap(); + let p2tr = Script::from([&[0x51, 0x20][..], &[7; 32]].concat()); + let input = |n: u8, asset, amount| PsetInput::Covenant { + input_id: format!("in{n}"), + outpoint: OutPoint::new(Txid::from_byte_array([n; 32]), 0), + script_pubkey: p2tr.clone(), + asset, + amount, + issuance: None, + sequence: None, + blinding: None, + }; + BuildPsetRequest { + inputs: vec![input(1, policy, 100_000), input(2, other, 1_000)], + outputs: vec![PsetOutputSpec { + script_pubkey: p2tr.clone(), + amount: 400, + asset: other, + blinding_key: None, + blinding: None, + }], + fee_rate: 0.1, + policy_asset: policy, + change_assets: change_assets.iter().copied().collect(), + lock_time: None, + } + } + + #[test] + fn an_explicit_input_in_another_asset_gets_its_change() { + let policy = ElementsNetwork::LiquidTestnet.policy_asset(); + let other = AssetId::from_slice(&[0xaa; 32]).unwrap(); + let built = build_pset( + &wollet(), + ElementsNetwork::LiquidTestnet, + &request(&[policy, other]), + ) + .expect("builds"); + let change: u64 = built + .pset + .outputs() + .iter() + .filter(|o| o.asset == Some(other)) + .map(|o| o.amount.unwrap()) + .sum(); + assert_eq!(change, 1_000, "400 paid out and 600 back as change"); + } + + #[test] + fn an_explicit_input_with_nowhere_for_its_surplus_is_an_error() { + let policy = ElementsNetwork::LiquidTestnet.policy_asset(); + let err = build_pset( + &wollet(), + ElementsNetwork::LiquidTestnet, + &request(&[policy]), + ) + .err() + .expect("600 of the other asset has nowhere to go"); + assert!(err.to_string().contains("does not balance"), "{err}"); + } +} + #[cfg(test)] mod lbtc_balance_tests { use super::resolve_lbtc_balance; @@ -1799,3 +1907,85 @@ mod lbtc_balance_tests { assert_eq!(fee, 0); } } + +#[cfg(test)] +mod lock_time_tests { + use super::*; + + const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about"; + + fn wollet() -> Wollet { + let w = crate::wallet::WalletFile { + network: "testnet".to_string(), + mnemonic: MNEMONIC.to_string(), + }; + Wollet::new( + ElementsNetwork::LiquidTestnet, + lwk_wollet::NoPersist::new(), + crate::wallet::descriptor(&w).unwrap(), + ) + .unwrap() + } + + /// Spend one explicit covenant UTXO to one explicit output, change to the wallet. + fn request(lock_time: Option, sequence: Option) -> BuildPsetRequest { + let policy = ElementsNetwork::LiquidTestnet.policy_asset(); + let p2tr = Script::from([&[0x51, 0x20][..], &[7; 32]].concat()); + BuildPsetRequest { + inputs: vec![PsetInput::Covenant { + input_id: "cov".to_string(), + outpoint: OutPoint::new(Txid::from_byte_array([3; 32]), 0), + script_pubkey: p2tr.clone(), + asset: policy, + amount: 100_000, + issuance: None, + sequence, + blinding: None, + }], + outputs: vec![PsetOutputSpec { + script_pubkey: p2tr, + amount: 90_000, + asset: policy, + blinding_key: None, + blinding: None, + }], + fee_rate: 0.1, + policy_asset: policy, + change_assets: std::collections::HashSet::from([policy]), + lock_time, + } + } + + fn build(req: &BuildPsetRequest) -> lwk_wollet::elements::Transaction { + build_pset(&wollet(), ElementsNetwork::LiquidTestnet, req) + .expect("builds") + .pset + .extract_tx() + .expect("extracts") + } + + #[test] + fn a_lock_time_reaches_the_transaction_and_makes_its_inputs_non_final() { + let tx = build(&request(Some(1_800_000_000), None)); + assert_eq!(tx.lock_time.to_consensus_u32(), 1_800_000_000); + // At the maximum sequence the lock time would be ignored. + assert_eq!( + tx.input[0].sequence.to_consensus_u32(), + SEQUENCE_ENABLE_LOCKTIME + ); + } + + #[test] + fn an_input_keeps_the_sequence_it_asked_for() { + let tx = build(&request(Some(1_800_000_000), Some(5))); + assert_eq!(tx.input[0].sequence.to_consensus_u32(), 5); + } + + #[test] + fn without_a_lock_time_nothing_changes() { + let tx = build(&request(None, None)); + assert_eq!(tx.lock_time.to_consensus_u32(), 0); + assert_eq!(tx.input[0].sequence, Sequence::MAX); + } +} diff --git a/txmanifest_lib/tests/bitcoin_path.rs b/txmanifest_lib/tests/bitcoin_path.rs index d44364b..86b66e5 100644 --- a/txmanifest_lib/tests/bitcoin_path.rs +++ b/txmanifest_lib/tests/bitcoin_path.rs @@ -86,6 +86,7 @@ fn a_scanned_utxo_becomes_a_signed_transaction() { fee_rate: 2.0, policy_asset: bitcoin_policy_asset(), change_assets: std::collections::HashSet::from([bitcoin_policy_asset()]), + lock_time: None, }; // 3. Narrow to Bitcoin. @@ -244,6 +245,7 @@ fn a_covenant_input_is_refused_rather_than_left_unsigned() { fee_rate: 1.0, policy_asset: bitcoin_policy_asset(), change_assets: std::collections::HashSet::from([bitcoin_policy_asset()]), + lock_time: None, }; // It narrows and builds — a covenant output is a perfectly ordinary P2TR input at this @@ -339,6 +341,7 @@ fn the_fee_keyword_is_estimable_on_bitcoin() { fee_rate: 2.0, policy_asset: bitcoin_policy_asset(), change_assets: Default::default(), + lock_time: None, }; let psbt_req = psbt_builder::from_pset_request(&req, None).expect("narrows"); diff --git a/txmanifest_lib/tests/examples_parse.rs b/txmanifest_lib/tests/examples_parse.rs index 51590a8..ac108c2 100644 --- a/txmanifest_lib/tests/examples_parse.rs +++ b/txmanifest_lib/tests/examples_parse.rs @@ -15,19 +15,35 @@ fn examples_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples") } -/// Every `examples/*/txmanifest.json`, sorted for stable failure output. +/// Every `txmanifest.json` in an example's directory or one level below it, sorted for +/// stable failure output. An example may ship a second manifest in a subdirectory, as +/// `oracle_demo/issue_usdt` does, since `run` writes state files beside each manifest. fn example_manifests() -> Vec { - let mut found: Vec = std::fs::read_dir(examples_dir()) - .expect("examples/ should exist") - .filter_map(|entry| { - let path = entry.ok()?.path().join("txmanifest.json"); - path.is_file().then_some(path) + let mut found: Vec = example_dirs(&examples_dir()) + .into_iter() + .flat_map(|dir| { + let mut dirs = example_dirs(&dir); + dirs.push(dir); + dirs }) + .map(|dir| dir.join("txmanifest.json")) + .filter(|path| path.is_file()) .collect(); found.sort(); found } +/// The subdirectories of `dir`. +fn example_dirs(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("examples/ should be readable") + .filter_map(|entry| { + let path = entry.ok()?.path(); + path.is_dir().then_some(path) + }) + .collect() +} + #[test] fn every_example_manifest_parses() { let manifests = example_manifests(); diff --git a/txmanifest_lib/tests/oracle_demo.rs b/txmanifest_lib/tests/oracle_demo.rs new file mode 100644 index 0000000..a032d31 --- /dev/null +++ b/txmanifest_lib/tests/oracle_demo.rs @@ -0,0 +1,793 @@ +//! Runs every spending path of `examples/oracle_demo/oracle_mint.simf` on the BitMachine, +//! against transactions built here, with the fake oracle's signed statements from +//! `examples/oracle_demo/data/`. +//! +//! Each path has a passing case and the failures that matter for the challenge window: +//! a forged or stale statement, a challenge within tolerance, a finalize before the +//! window, and a second treasury UTXO riding along with a request. + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::Arc; + +use base64::Engine as _; +use lwk_wollet::elements::confidential::{Asset, Nonce, Value}; +use lwk_wollet::elements::hashes::{sha256, Hash}; +use lwk_wollet::elements::secp256k1_zkp::{ecdsa, Message, PublicKey, Secp256k1, SecretKey}; +use lwk_wollet::elements::{ + AssetId, AssetIssuance, BlockHash, LockTime, OutPoint, Script, Sequence, Transaction, TxIn, + TxInWitness, TxOut, TxOutWitness, Txid, +}; +use lwk_wollet::ElementsNetwork; +use serde_json::json; +use tx_manifest_lib::chain::ChainFamily; +use tx_manifest_lib::covenant; +use tx_manifest_lib::programs::ProgramSource; + +const LBTC: &str = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +/// A stand-in for the tUSDT asset id. +const USD: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const WINDOW_BLOCKS: u32 = 5; +const SUPPLY: u64 = 1_000_000_000_000_000; +const LBTC_AMOUNT: u64 = 100_000; + +fn example_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples/oracle_demo") +} + +fn program() -> ProgramSource { + ProgramSource::read_unpinned(example_dir().join("oracle_mint.simf")).unwrap() +} + +/// A statement from `data/`, split into the witnesses the program reads. +struct Statement { + price_cents: u64, + timestamp: u64, + pubkey: String, + witnesses: BTreeMap<&'static str, String>, +} + +fn simf_list(text: &str) -> String { + let bytes: Vec = text.bytes().map(|b| b.to_string()).collect(); + format!("list![{}]", bytes.join(", ")) +} + +/// A statement's witnesses, given the variable fields as the witness splits them. +fn split( + price: &str, + sources: &str, + method: &str, + timestamp: &str, + nonce: &str, + compact_sig: [u8; 64], + pubkey: String, +) -> Statement { + let (integer, fraction) = price.split_once('.').unwrap(); + let frac = fraction.as_bytes(); + let witnesses = BTreeMap::from([ + ("STATEMENT_PRICE_INT", simf_list(integer)), + ( + "STATEMENT_PRICE_FRAC", + format!("({}, {})", frac[0], frac[1]), + ), + ("STATEMENT_SOURCES", simf_list(sources)), + ("STATEMENT_METHOD", simf_list(method)), + ("STATEMENT_TIMESTAMP", simf_list(timestamp)), + ("STATEMENT_NONCE", simf_list(nonce)), + ( + "STATEMENT_SIG", + format!( + "(0x{}, 0x{})", + hex(&compact_sig[..32]), + hex(&compact_sig[32..]) + ), + ), + ]); + Statement { + price_cents: format!("{integer}{fraction}").parse().unwrap(), + timestamp: timestamp.parse().unwrap(), + pubkey, + witnesses, + } +} + +/// A statement the fake oracle signed, from `data/statement_.json`. +fn statement(name: &str) -> Statement { + let path = example_dir().join(format!("data/statement_{name}.json")); + let raw: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + let canonical = raw["canonical"].as_str().unwrap(); + let fields: Vec<&str> = canonical.split('|').collect(); + assert_eq!(fields.len(), 10, "{canonical}"); + + let der = base64::engine::general_purpose::STANDARD + .decode(raw["signature"].as_str().unwrap()) + .unwrap(); + let compact = ecdsa::Signature::from_der(&der) + .unwrap() + .serialize_compact(); + let pubkey = raw["pubkey"].as_str().unwrap().to_string(); + split( + fields[3], fields[6], fields[7], fields[8], fields[9], compact, pubkey, + ) +} + +/// Sign `v1|PRICE|BTCUSD||USD|2||||` with the +/// fake oracle's key, and split it into exactly those fields. +fn signed_split( + price: &str, + sources: &str, + method: &str, + timestamp: &str, + nonce: &str, +) -> Statement { + let key_file: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(example_dir().join("data/example_oracle_key.json")).unwrap(), + ) + .unwrap(); + let secret = SecretKey::from_str(key_file["secret"].as_str().unwrap()).unwrap(); + let canonical = format!("v1|PRICE|BTCUSD|{price}|USD|2|{sources}|{method}|{timestamp}|{nonce}"); + let digest = sha256::Hash::hash(canonical.as_bytes()).to_byte_array(); + let secp = Secp256k1::new(); + let sig = secp.sign_ecdsa(&Message::from_digest_slice(&digest).unwrap(), &secret); + let pubkey = hex(&PublicKey::from_secret_key(&secp, &secret).serialize()); + split( + price, + sources, + method, + timestamp, + nonce, + sig.serialize_compact(), + pubkey, + ) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +const STATEMENT_WITNESSES: [&str; 7] = [ + "STATEMENT_PRICE_INT", + "STATEMENT_PRICE_FRAC", + "STATEMENT_SOURCES", + "STATEMENT_METHOD", + "STATEMENT_TIMESTAMP", + "STATEMENT_NONCE", + "STATEMENT_SIG", +]; + +/// One mint service, compiled once per test. +struct Service { + program: ProgramSource, + params: HashMap, + hints: HashMap, +} + +impl Service { + fn new() -> Self { + let pubkey = statement("request").pubkey; + let params = HashMap::from([ + ( + "ORACLE_PUBKEY_PREFIX".into(), + u8::from_str_radix(&pubkey[..2], 16).unwrap().to_string(), + ), + ("ORACLE_PUBKEY_X".into(), pubkey[2..].to_string()), + ("LBTC_ASSET".into(), LBTC.into()), + ("USD_ASSET".into(), USD.into()), + ("WINDOW_BLOCKS".into(), WINDOW_BLOCKS.to_string()), + ("TOLERANCE_BPS".into(), "50".into()), + ]); + let hints = HashMap::from([ + ("ORACLE_PUBKEY_PREFIX".into(), "u8".into()), + ("ORACLE_PUBKEY_X".into(), "bytes32".into()), + ("LBTC_ASSET".into(), "liquid.asset_id".into()), + ("USD_ASSET".into(), "liquid.asset_id".into()), + ("WINDOW_BLOCKS".into(), "u16".into()), + ("TOLERANCE_BPS".into(), "u64".into()), + ]); + Self { + program: program(), + params, + hints, + } + } + + fn script(&self, leaf: &[u8]) -> Script { + covenant::compute_covenant_address( + &self.program, + &self.params, + &self.hints, + &[leaf.to_vec()], + ElementsNetwork::LiquidTestnet, + false, + ) + .unwrap() + .script_pubkey() + } + + fn treasury(&self) -> (Script, Vec) { + (self.script(&[0]), vec![0]) + } + + fn pending(&self, req: &Request) -> (Script, Vec) { + let mut leaf = vec![1]; + leaf.extend(req.price.to_be_bytes()); + leaf.extend(req.timestamp.to_be_bytes()); + leaf.extend(req.lbtc_amount.to_be_bytes()); + leaf.extend(sha256::Hash::hash(req.recipient.as_bytes()).to_byte_array()); + (self.script(&leaf), leaf) + } + + /// Dry-run input `index` of `tx`, which spends `utxos`, as the covenant with `leaf`. + fn run( + &self, + leaf: &[u8], + witnesses: serde_json::Value, + tx: &Transaction, + utxos: &[TxOut], + index: u32, + ) -> anyhow::Result<()> { + covenant::dry_run_covenant( + &self.program, + &self.params, + &self.hints, + &[leaf.to_vec()], + Some(&witnesses), + None, + Arc::new(tx.clone()), + utxos, + index, + BlockHash::all_zeros(), + ChainFamily::Elements, + false, + false, + ) + } +} + +#[derive(Clone)] +struct Request { + price: u64, + timestamp: u64, + lbtc_amount: u64, + recipient: Script, +} + +impl Request { + fn from_statement(st: &Statement) -> Self { + Self { + price: st.price_cents, + timestamp: st.timestamp, + lbtc_amount: LBTC_AMOUNT, + recipient: Script::from(Vec::from_iter([0x00, 0x14].into_iter().chain([0x42; 20]))), + } + } + + fn usd_amount(&self) -> u64 { + self.lbtc_amount * self.price / 100 + } + + fn recipient_hash(&self) -> String { + hex(&sha256::Hash::hash(self.recipient.as_bytes()).to_byte_array()) + } +} + +fn witness(value: &str) -> serde_json::Value { + json!({ "type": "simplicityhl", "value": value }) +} + +/// The witness map for one input: `path`, the request's fields or "unused", and the +/// statement's or "unused", padded when there is a statement. +fn witnesses(path: &str, req: Option<&Request>, st: Option<&Statement>) -> serde_json::Value { + let mut map = serde_json::Map::new(); + map.insert("PATH".into(), witness(path)); + let fields = req.map(|r| { + [ + r.price.to_string(), + r.timestamp.to_string(), + r.lbtc_amount.to_string(), + r.recipient_hash(), + ] + }); + for (i, name) in [ + "REQUEST_PRICE", + "REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT", + "REQUEST_RECIPIENT", + ] + .iter() + .enumerate() + { + let value = match &fields { + Some(f) => witness(&f[i]), + None => json!("unused"), + }; + map.insert((*name).into(), value); + } + for name in STATEMENT_WITNESSES { + let value = match st { + Some(s) => witness(&s.witnesses[name]), + None => json!("unused"), + }; + map.insert(name.into(), value); + } + // The paths that check a statement are the ones that need padding. + let pad = match st { + Some(_) => witness(BUDGET_PAD), + None => json!("unused"), + }; + map.insert("BUDGET_PAD".into(), pad); + serde_json::Value::Object(map) +} + +/// Assert that the program ran and refused the spend, rather than failing before it ran. +fn assert_rejected(result: anyhow::Result<()>) { + let err = format!("{:#}", result.expect_err("the spend was accepted")); + assert!( + err.contains("satisfaction failed") || err.contains("execution failed"), + "failed before the program ran: {err}" + ); +} + +fn asset(id: &str) -> AssetId { + AssetId::from_str(id).unwrap() +} + +fn txout(script: &Script, asset_id: &str, amount: u64) -> TxOut { + TxOut { + asset: Asset::Explicit(asset(asset_id)), + value: Value::Explicit(amount), + nonce: Nonce::Null, + script_pubkey: script.clone(), + witness: TxOutWitness::default(), + } +} + +fn fee(amount: u64) -> TxOut { + TxOut::new_fee(amount, asset(LBTC)) +} + +fn burn(amount: u64) -> TxOut { + txout(&Script::from(vec![0x6a]), LBTC, amount) +} + +fn wallet_script() -> Script { + Script::from(Vec::from_iter([0x00, 0x14].into_iter().chain([0x77; 20]))) +} + +fn txin(n: u8, sequence: u32) -> TxIn { + TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([n; 32]), 0), + is_pegin: false, + script_sig: Script::new(), + sequence: Sequence(sequence), + asset_issuance: AssetIssuance::default(), + witness: TxInWitness::default(), + } +} + +fn tx(inputs: Vec, outputs: Vec) -> Transaction { + Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: inputs, + output: outputs, + } +} + +// --------------------------------------------------------------------------- +// Request +// --------------------------------------------------------------------------- + +/// A request transaction, its spent outputs, and the treasury leaf. +fn request_tx(svc: &Service, req: &Request, usd_amount: u64) -> (Transaction, Vec, Vec) { + let (treasury, treasury_leaf) = svc.treasury(); + let (pending, _) = svc.pending(req); + let tx = tx( + vec![txin(1, 0xffff_ffff), txin(2, 0xffff_ffff)], + vec![ + txout(&pending, LBTC, req.lbtc_amount), + txout(&pending, USD, usd_amount), + txout(&treasury, USD, SUPPLY - usd_amount), + txout(&wallet_script(), LBTC, 400_000 - req.lbtc_amount - 500), + fee(500), + ], + ); + let utxos = vec![ + txout(&treasury, USD, SUPPLY), + txout(&wallet_script(), LBTC, 400_000), + ]; + (tx, utxos, treasury_leaf) +} + +#[test] +fn request_opens_a_pending_mint() { + let svc = Service::new(); + let st = statement("request"); + let req = Request::from_statement(&st); + let (tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); + svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + ) + .unwrap(); +} + +#[test] +fn request_refuses_more_tusdt_than_the_price_buys() { + let svc = Service::new(); + let st = statement("request"); + let req = Request::from_statement(&st); + let (tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount() + 1); + assert_rejected(svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + )); +} + +#[test] +fn request_refuses_a_forged_price() { + let svc = Service::new(); + let mut st = statement("request"); + // 94231.50 under the signature on 84231.50. + st.witnesses + .insert("STATEMENT_PRICE_INT", simf_list("94231")); + st.price_cents = 9_423_150; + let req = Request::from_statement(&st); + let (tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); + assert_rejected(svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + )); +} + +#[test] +fn request_refuses_a_separator_inside_a_text_field() { + // A signed string with two fields more than the format has. Read with the first + // three fields after the decimals squeezed into "sources", it hashes to the same + // bytes, so the signature verifies, and the nonce would read as a far-future + // timestamp. Only the '|' check stands in the way. + let svc = Service::new(); + let st = signed_split( + "84231.50", + "okx|median|1741514400", + "482910", + "9999999999", + "0", + ); + let req = Request::from_statement(&st); + assert_eq!(req.timestamp, 9_999_999_999); + let (tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); + assert_rejected(svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + )); + + // The same split without the separators is accepted, so it is the '|' that fails. + let st = signed_split( + "84231.50", + "okx,median,1741514400", + "482910", + "9999999999", + "0", + ); + let req = Request::from_statement(&st); + let (tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); + svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + ) + .unwrap(); +} + +#[test] +fn request_refuses_a_pending_address_for_another_price() { + let svc = Service::new(); + let st = statement("request"); + let req = Request::from_statement(&st); + let (mut tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); + let (other, _) = svc.pending(&Request { + price: req.price + 1, + ..req.clone() + }); + tx.output[0].script_pubkey = other.clone(); + tx.output[1].script_pubkey = other; + assert_rejected(svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + )); +} + +#[test] +fn a_second_treasury_utxo_cannot_ride_along() { + // Both treasury UTXOs hold the supply, so input 0's checks on outputs 0-2 are met for + // either. Input 1 would be free to pay its tUSDT anywhere if any path let it through. + let svc = Service::new(); + let st = statement("request"); + let req = Request::from_statement(&st); + let (treasury, leaf) = svc.treasury(); + let (mut tx, mut utxos, _) = request_tx(&svc, &req, req.usd_amount()); + utxos[1] = txout(&treasury, USD, SUPPLY); + tx.output[3] = txout(&wallet_script(), USD, SUPPLY); + + svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + ) + .unwrap(); + for path in [ + "Left(Left(()))", + "Left(Right(()))", + "Right(Left(()))", + "Right(Right(()))", + ] { + assert_rejected(svc.run( + &leaf, + witnesses(path, Some(&req), Some(&st)), + &tx, + &utxos, + 1, + )); + } +} + +// --------------------------------------------------------------------------- +// Finalize and challenge +// --------------------------------------------------------------------------- + +/// Spend a pending request's two UTXOs, plus a wallet fee input. +fn settle_tx( + svc: &Service, + req: &Request, + sequence: u32, + outputs: Vec, +) -> (Transaction, Vec, Vec) { + let (pending, leaf) = svc.pending(req); + let tx = tx( + vec![ + txin(3, sequence), + txin(4, 0xffff_ffff), + txin(5, 0xffff_ffff), + ], + outputs, + ); + let utxos = vec![ + txout(&pending, LBTC, req.lbtc_amount), + txout(&pending, USD, req.usd_amount()), + txout(&wallet_script(), LBTC, 10_000), + ]; + (tx, utxos, leaf) +} + +fn finalize_outputs(req: &Request) -> Vec { + vec![ + txout(&req.recipient, USD, req.usd_amount()), + burn(req.lbtc_amount), + txout(&wallet_script(), LBTC, 9_500), + fee(500), + ] +} + +fn finalize(svc: &Service, req: &Request, sequence: u32) -> anyhow::Result<()> { + let (tx, utxos, leaf) = settle_tx(svc, req, sequence, finalize_outputs(req)); + svc.run( + &leaf, + witnesses("Left(Right(()))", Some(req), None), + &tx, + &utxos, + 0, + )?; + svc.run( + &leaf, + witnesses("Right(Right(()))", None, None), + &tx, + &utxos, + 1, + ) +} + +#[test] +fn finalize_after_the_window_pays_out_and_burns() { + let svc = Service::new(); + let req = Request::from_statement(&statement("request")); + finalize(&svc, &req, WINDOW_BLOCKS).unwrap(); +} + +#[test] +fn finalize_waits_for_the_window() { + let svc = Service::new(); + let req = Request::from_statement(&statement("request")); + assert_rejected(finalize(&svc, &req, WINDOW_BLOCKS - 1)); + // Relative lock disabled, and a time-based lock of the same number. + assert_rejected(finalize(&svc, &req, 0x8000_0000 | WINDOW_BLOCKS)); + assert_rejected(finalize(&svc, &req, 0x0040_0000 | WINDOW_BLOCKS)); +} + +#[test] +fn finalize_pays_only_the_recipient() { + let svc = Service::new(); + let req = Request::from_statement(&statement("request")); + let mut outputs = finalize_outputs(&req); + outputs[0].script_pubkey = wallet_script(); + let (tx, utxos, leaf) = settle_tx(&svc, &req, WINDOW_BLOCKS, outputs); + assert_rejected(svc.run( + &leaf, + witnesses("Left(Right(()))", Some(&req), None), + &tx, + &utxos, + 0, + )); +} + +fn challenge(svc: &Service, req: &Request, newer: &str) -> anyhow::Result<()> { + let st = statement(newer); + let (treasury, _) = svc.treasury(); + let outputs = vec![ + txout(&req.recipient, LBTC, req.lbtc_amount), + txout(&treasury, USD, req.usd_amount()), + txout(&wallet_script(), LBTC, 9_500), + fee(500), + ]; + let (tx, utxos, leaf) = settle_tx(svc, req, 0xffff_ffff, outputs); + svc.run( + &leaf, + witnesses("Right(Left(()))", Some(req), Some(&st)), + &tx, + &utxos, + 0, + )?; + svc.run( + &leaf, + witnesses("Right(Right(()))", None, None), + &tx, + &utxos, + 1, + ) +} + +#[test] +fn challenge_with_a_newer_price_beyond_tolerance_refunds() { + let svc = Service::new(); + let req = Request::from_statement(&statement("request")); + // Both directions: the request's price was stale whichever way it moved. + challenge(&svc, &req, "rose").unwrap(); + challenge(&svc, &req, "fell").unwrap(); +} + +#[test] +fn challenge_within_tolerance_is_refused() { + let svc = Service::new(); + let req = Request::from_statement(&statement("request")); + assert_rejected(challenge(&svc, &req, "close")); +} + +#[test] +fn challenge_with_an_older_statement_is_refused() { + let svc = Service::new(); + let req = Request::from_statement(&statement("request")); + assert_rejected(challenge(&svc, &req, "older")); + assert_rejected(challenge(&svc, &req, "request")); +} + +/// Zero words of budget padding, as the manifest's RequestMint and Challenge supply them. +const BUDGET_PAD: &str = "list![0, 0, 0, 0, 0, 0, 0, 0]"; + +#[test] +fn every_path_fits_its_budget() { + // Finalizing checks the program's cost against the budget its witness buys, the check + // the node makes. Unpadded, the request path fits by about 5 weight units at the time + // of writing, so it carries BUDGET_PAD for margin; so does the challenge path. + let svc = Service::new(); + let finalize = + |leaf: &[u8], w: serde_json::Value, tx: &Transaction, utxos: &[TxOut], index: u32| { + let mut input = lwk_wollet::elements::pset::Input::default(); + covenant::finalize_covenant_input( + &svc.program, + &svc.params, + &svc.hints, + &[leaf.to_vec()], + Some(&w), + None, + Arc::new(tx.clone()), + utxos, + index, + BlockHash::all_zeros(), + ChainFamily::Elements, + &mut input, + false, + ) + }; + + let st = statement("request"); + let req = Request::from_statement(&st); + let (tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); + finalize( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + ) + .unwrap(); + + let (tx, utxos, leaf) = settle_tx(&svc, &req, WINDOW_BLOCKS, finalize_outputs(&req)); + finalize( + &leaf, + witnesses("Left(Right(()))", Some(&req), None), + &tx, + &utxos, + 0, + ) + .unwrap(); + finalize( + &leaf, + witnesses("Right(Right(()))", None, None), + &tx, + &utxos, + 1, + ) + .unwrap(); + + let newer = statement("rose"); + let (treasury, _) = svc.treasury(); + let outputs = vec![ + txout(&req.recipient, LBTC, req.lbtc_amount), + txout(&treasury, USD, req.usd_amount()), + fee(500), + ]; + let (tx, utxos, leaf) = settle_tx(&svc, &req, 0xffff_ffff, outputs); + finalize( + &leaf, + witnesses("Right(Left(()))", Some(&req), Some(&newer)), + &tx, + &utxos, + 0, + ) + .unwrap(); +} + +#[test] +fn the_testnet_treasury_address_is_unchanged() { + // The treasury from the README's testnet run. A change to oracle_mint.simf that moves + // its CMR moves this address, and strands that run's tUSDT. + let mut svc = Service::new(); + svc.params.insert( + "USD_ASSET".into(), + "6c7942976c798950efe84b7ba6461ee683cb85ecd837b440860e2590538864e9".into(), + ); + let address = covenant::compute_covenant_address( + &svc.program, + &svc.params, + &svc.hints, + &[vec![0]], + ElementsNetwork::LiquidTestnet, + false, + ) + .unwrap(); + assert_eq!( + address.to_string(), + "tex1p76c0hv73s9cq8g9phf9srqds6aenngn44yjmcsdwdgzu4rarl6aqezd48m" + ); +} diff --git a/txmanifest_lib/tests/schema.rs b/txmanifest_lib/tests/schema.rs index df37c5c..66dfcfb 100644 --- a/txmanifest_lib/tests/schema.rs +++ b/txmanifest_lib/tests/schema.rs @@ -20,19 +20,33 @@ fn checked_in_schema_path() -> PathBuf { workspace_root().join(SCHEMA_PATH) } -/// Every `examples/*/txmanifest.json`, sorted for stable output. +/// Every `txmanifest.json` in an example's directory or one level below it, sorted for +/// stable output. fn example_manifests() -> Vec { - let mut found: Vec = std::fs::read_dir(workspace_root().join("examples")) - .expect("examples/ should exist") - .filter_map(|entry| { - let path = entry.ok()?.path().join("txmanifest.json"); - path.is_file().then_some(path) + let mut found: Vec = subdirs(&workspace_root().join("examples")) + .into_iter() + .flat_map(|dir| { + let mut dirs = subdirs(&dir); + dirs.push(dir); + dirs }) + .map(|dir| dir.join("txmanifest.json")) + .filter(|path| path.is_file()) .collect(); found.sort(); found } +fn subdirs(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("examples/ should be readable") + .filter_map(|entry| { + let path = entry.ok()?.path(); + path.is_dir().then_some(path) + }) + .collect() +} + #[test] fn checked_in_schema_matches_the_model() { let path = checked_in_schema_path(); diff --git a/txmanifest_lib/tests/ui_coverage.rs b/txmanifest_lib/tests/ui_coverage.rs index fa2be19..4b13e06 100644 --- a/txmanifest_lib/tests/ui_coverage.rs +++ b/txmanifest_lib/tests/ui_coverage.rs @@ -33,17 +33,23 @@ fn examples_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples") } -/// `(example_name, parsed manifest)` for every example, sorted by name. +/// `(example_name, parsed manifest)` for every example, sorted by name. A manifest one +/// level down is named by its path, e.g. `oracle_demo/issue_usdt`. fn examples() -> Vec<(String, Manifest)> { - let mut found: Vec<(String, Manifest)> = std::fs::read_dir(examples_dir()) - .expect("examples/ should exist") - .filter_map(|entry| { - let dir = entry.ok()?.path(); + let root = examples_dir(); + let mut found: Vec<(String, Manifest)> = subdirs(&root) + .into_iter() + .flat_map(|dir| { + let mut dirs = subdirs(&dir); + dirs.push(dir); + dirs + }) + .filter_map(|dir| { let path = dir.join("txmanifest.json"); if !path.is_file() { return None; } - let name = dir.file_name()?.to_string_lossy().to_string(); + let name = dir.strip_prefix(&root).ok()?.to_string_lossy().to_string(); let raw = std::fs::read_to_string(&path).ok()?; let manifest = Manifest::from_json_str(&raw) .unwrap_or_else(|e| panic!("{name}/txmanifest.json should parse: {e}")); @@ -54,6 +60,16 @@ fn examples() -> Vec<(String, Manifest)> { found } +fn subdirs(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("examples/ should be readable") + .filter_map(|entry| { + let path = entry.ok()?.path(); + path.is_dir().then_some(path) + }) + .collect() +} + /// Every action in a manifest, standalone or template method, keyed by a dot-path. fn all_actions(manifest: &Manifest) -> BTreeMap { let mut out: BTreeMap = manifest diff --git a/txmanifest_wallet/tests/cli_parses_examples.rs b/txmanifest_wallet/tests/cli_parses_examples.rs index fb217d8..1f97299 100644 --- a/txmanifest_wallet/tests/cli_parses_examples.rs +++ b/txmanifest_wallet/tests/cli_parses_examples.rs @@ -22,19 +22,33 @@ fn workspace_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("..") } -/// Every `examples/*/txmanifest.json`, sorted for stable failure output. +/// Every `txmanifest.json` in an example's directory or one level below it, sorted for +/// stable failure output. fn example_manifests() -> Vec { - let mut found: Vec = std::fs::read_dir(workspace_root().join("examples")) - .expect("examples/ should exist") - .filter_map(|entry| { - let path = entry.ok()?.path().join("txmanifest.json"); - path.is_file().then_some(path) + let mut found: Vec = subdirs(&workspace_root().join("examples")) + .into_iter() + .flat_map(|dir| { + let mut dirs = subdirs(&dir); + dirs.push(dir); + dirs }) + .map(|dir| dir.join("txmanifest.json")) + .filter(|path| path.is_file()) .collect(); found.sort(); found } +fn subdirs(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("examples/ should be readable") + .filter_map(|entry| { + let path = entry.ok()?.path(); + path.is_dir().then_some(path) + }) + .collect() +} + fn run_cli(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_tx-manifest-wallet")) .args(args)