diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 374d8f0b1b..59936fa6f0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,6 +9,9 @@ defaults: run: shell: bash +permissions: + contents: write + jobs: release: strategy: @@ -54,7 +57,7 @@ jobs: - name: Release Type id: release-type run: | - if [[ ${{ github.ref }} =~ ^refs/tags/[0-9]+[.][0-9]+[.][0-9]+$ ]]; then + if [[ ${{ github.ref }} =~ ^refs/tags/[0-9]+[.][0-9]+[.][0-9]+(-gms?[0-9]+)?$ ]]; then echo ::set-output name=value::release else echo ::set-output name=value::prerelease diff --git a/CHANGELOG.md b/CHANGELOG.md index a266c81a3e..36a02ca3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,92 @@ Changelog ========= +[0.15.0-gm12](https://github.com/gmart7t2/ord/releases/tag/0.15.0-gm12) - 2024-03-02 +------------------------------------------------------------------------------------ + +### Added +- Add inscription `offset` to yaml field to allow inscribing on somewhere other than sat 0 of a utxo. +- Add the ability to set a pointer offset per inscription in the batch. +- Add inscribing delegates. + +[0.15.0-gm11](https://github.com/gmart7t2/ord/releases/tag/0.15.0-gm11) - 2024-02-29 +------------------------------------------------------------------------------------ + +### Changed +- Better error checking when inscribing. +- Better error checking for inputs to transaction builder. +- Allow inscribing .avif files. +- Don't `OP_PUSHNUM_x` instead of `OP_PUSHBYTES_1`, but leave the code there if people want to enable it. + +[0.15.0-gm10](https://github.com/gmart7t2/ord/releases/tag/0.15.0-gm10) - 2024-02-23 +------------------------------------------------------------------------------------ + +### Added +- Use `OP_PUSHNUM_x` instead of `OP_PUSHBYTES_1` whenever possible to save space. + +[0.15.0-gm9](https://github.com/gmart7t2/ord/releases/tag/0.15.0-gm9) - 2024-02-20 +---------------------------------------------------------------------------------- + +### Added +- Include a PSBT version of the reveal tx in the /inscribe output, with no witness data. +- Have the `/inscribe` endpoint generate and reuse the same temporary key per network. +- Try merging the user-provided `reveal_psbt` with our `reveal_tx` to make a fully signed reveal tx. +- Fill in `witness_utxo` for the input coming from the commit tx in the `reveal_psbt`. +- Allow `--index-runes` to add a runes index to an existing index if runes aren't active yet. +- Add `/sendtx` endpoint to broadcast a raw transaction. POST the raw tx as a JSON string. +- Add `--reveal-fee` flag to `wallet inscribe`. +- Add `--next-batch` flag, analogous to `--next-file`, but for batches of inscriptions. +- Add `--parent-destination` flag to control where the parent inscription ends up when using it to inscribe a child. +- Set `--reveal-fee` to `0 sats` when using `--commitment` to avoid creating a change output in the reveal tx. + +[0.15.0-gm8](https://github.com/gmart7t2/ord/releases/tag/0.15.0-gm8) - 2024-01-31 +---------------------------------------------------------------------------------- + +### Added +- Allow setting metadata via `/inscribe` endpoint. + +[0.15.0-gm7](https://github.com/gmart7t2/ord/releases/tag/0.15.0-gm7) - 2024-01-31 +---------------------------------------------------------------------------------- + +### Added +- Add `/inscribe` server endpoint. + +[0.15.0-gm6](https://github.com/gmart7t2/ord/releases/tag/0.15.0-gm6) - 2024-01-28 +---------------------------------------------------------------------------------- + +### Added +- Add flag `--ignore-txt-and-json` to ignore text and json inscriptions. + +[0.15.0-gm5](https://github.com/gmart7t2/ord/releases/tag/0.15.0-gm5) - 2024-01-28 +---------------------------------------------------------------------------------- + +### Added +- Suspend indexer progress bar while committing at end of indexing too. + +[0.15.0-gm4](https://github.com/gmart7t2/ord/releases/tag/0.15.0-gm4) - 2024-01-26 +---------------------------------------------------------------------------------- + +### Added +- Suspend indexer progress bar while showing commit progress bars to avoid interference between them. + +[0.15.0-gm3](https://github.com/gmart7t2/ord/releases/tag/0.15.0-gm3) - 2024-01-26 +---------------------------------------------------------------------------------- + +### Added +- Show progress bar while committing so it doesn't look like the indexer has frozen. + +[0.15.0-gm2](https://github.com/gmart7t2/ord/releases/tag/0.15.0-gm2) - 2024-01-14 +---------------------------------------------------------------------------------- + +### Added +- Add `wallet inscribe --parent-satpoint` to help with using an unconfirmed parent inscription. + +[0.15.0-gm1](https://github.com/gmart7t2/ord/releases/tag/0.15.0-gm1) - 2024-01-11 +---------------------------------------------------------------------------------- + +### Added +- Merged 0.15.0 from upstream. + [0.15.0](https://github.com/ordinals/ord/releases/tag/0.15.0) - 2023-01-08 -------------------------------------------------------------------------- @@ -29,6 +115,12 @@ Changelog - Remove quotes around key to allow shell expansion (#2951) - Restart sshd in deploy script (#2952) +[0.14.1-gm1](https://github.com/gmart7t2/ord/releases/tag/0.14.1-gm1) - 2024-01-05 +---------------------------------------------------------------------------------- + +### Added +- Merged 0.14.1 from upstream. + [0.14.1](https://github.com/ordinals/ord/releases/tag/0.14.1) - 2023-01-03 -------------------------------------------------------------------------- @@ -38,6 +130,12 @@ Changelog ## Misc - Clean up justfile (#2939) +[0.13.1-gm6](https://github.com/gmart7t2/ord/releases/tag/0.13.1-gm6) - 2024-01-03 +---------------------------------------------------------------------------------- + +### Fixed +- Keep inscriptions with unrecognized even fields unbound after jubilee (#2894) + [0.14.0](https://github.com/ordinals/ord/releases/tag/0.14.0) - 2023-01-02 -------------------------------------------------------------------------- @@ -82,6 +180,40 @@ Changelog - Dispaly rune ID above height and index (#2874) - Use transaction version 2 (#2873) +[0.13.1-gm5](https://github.com/gmart7t2/ord/releases/tag/0.13.1-gm5) - 2024-01-01 +---------------------------------------------------------------------------------- + +### Added +- Add `/inscription_with_funder/` endpoint. It shows all the inscription data, plus the funder, or why there's no single funder available. +- Add metaprotocol to the `/inscription/` JSON endpoint. +- Add flag `--index-transfer-history` to index all the inscription movements in each block. Implies `--index-transfers`. +- Add flag `--index-only-first-transfer` to only track the first transfer of each inscription. Implies `--index-transfer-history`. +- Add flag `--filter-metaprotocol` to only index inscriptions that have a metaprotocol that starts with the given string. + +[0.13.1-gm4](https://github.com/gmart7t2/ord/releases/tag/0.13.1-gm4) - 2023-12-21 +---------------------------------------------------------------------------------- + +### Added +- Use `--change` flag to specify the change address for `wallet inscribe` and `wallet send`. + +[0.13.1-gm3](https://github.com/gmart7t2/ord/releases/tag/0.13.1-gm3) - 2023-12-19 +---------------------------------------------------------------------------------- + +### Changed +- Don't let the user corrupt their index by hitting control-C repeatedly. + +[0.13.1-gm2](https://github.com/gmart7t2/ord/releases/tag/0.13.1-gm2) - 2023-12-18 +---------------------------------------------------------------------------------- + +### Fixed +- Include HEIGHT_TO_SEQUENCE_NUMBER in `index info` report. + +[0.13.1-gm1](https://github.com/gmart7t2/ord/releases/tag/0.13.1-gm1) - 2023-12-16 +---------------------------------------------------------------------------------- + +### Added +- Merged 0.13.1 from upstream. + [0.13.1](https://github.com/ordinals/ord/releases/tag/0.13.1) - 2023-12-16 -------------------------------------------------------------------------- @@ -125,6 +257,24 @@ Changelog - Burn input runes if there are no non-op-return outputs (#2812) - Update audit-cache binary (#2804) +[0.12.3-gm3](https://github.com/gmart7t2/ord/releases/tag/0.12.3-gm3) - 2023-12-11 +---------------------------------------------------------------------------------- + +### Added +- Add option `--index-transfers` to have the index track which inscriptions are transferred in each block. Previously this was already enabled, using up space in the index whether it was needed or not. + +[0.12.3-gm2](https://github.com/gmart7t2/ord/releases/tag/0.12.3-gm2) - 2023-12-02 +---------------------------------------------------------------------------------- + +### Fixed +- HTML endpoint /inscriptions/block// was returning no inscriptions past page 0. + +[0.12.3-gm1](https://github.com/gmart7t2/ord/releases/tag/0.12.3-gm1) - 2023-12-02 +---------------------------------------------------------------------------------- + +### Added +- Merged 0.12.3 from upstream. + [0.12.3](https://github.com/ordinals/ord/releases/tag/0.12.3) - 2023-12-01 -------------------------------------------------------------------------- @@ -141,6 +291,12 @@ Changelog - Fix typos (#2791) - Add total bytes and proportion to database info (#2783) +[0.12.2-gm1](https://github.com/gmart7t2/ord/releases/tag/0.12.2-gm1) - 2023-11-30 +---------------------------------------------------------------------------------- + +### Added +- Merged 0.12.2 from upstream. + [0.12.2](https://github.com/ordinals/ord/releases/tag/0.12.2) - 2023-11-29 -------------------------------------------------------------------------- @@ -150,6 +306,12 @@ Changelog ### Misc - Hide /content/ HTML inscriptions (#2778) +[0.12.1-gm1](https://github.com/gmart7t2/ord/releases/tag/0.12.1-gm1) - 2023-11-30 +---------------------------------------------------------------------------------- + +### Added +- Merged 0.12.1 from upstream. + [0.12.1](https://github.com/ordinals/ord/releases/tag/0.12.1) - 2023-11-29 -------------------------------------------------------------------------- @@ -168,6 +330,63 @@ Changelog - Select further away coins which meet target (#2724) - Hide all text (#2753) +[0.12.0-gm3](https://github.com/gmart7t2/ord/releases/tag/0.12.0-gm3) - 2023-11-27 +---------------------------------------------------------------------------------- + +### Added +- Add endpoint `/inscriptions_sequence_numbers/:start/:end` to get the mapping from inscription number to sequence number. + +[0.12.0-gm2](https://github.com/gmart7t2/ord/releases/tag/0.12.0-gm2) - 2023-11-26 +---------------------------------------------------------------------------------- + +### Added +- Add the sequence_number to the /inscriptions/json/ endpoint. +- Add `--commit-input` to `wallet inscribe` and `--force-input` to `wallet send`. + +[0.12.0-gm1](https://github.com/gmart7t2/ord/releases/tag/0.12.0-gm1) - 2023-11-25 +---------------------------------------------------------------------------------- + +### Added +- Merged upstream 0.12.0 release. + +[0.11.1-gm5](https://github.com/gmart7t2/ord/releases/tag/0.11.1-gm5) - 2023-11-23 +---------------------------------------------------------------------------------- + +### Added +- Add `--ignore-cursed` flag to treat all cursed inscriptions as regular inscriptions when indexing. +- Add `--ordinals-wallet` flag to `wallet restore` to help with recovering coins from an ordinalswallet seed phrase. +- Add some experimental options to allow creating just a commit tx, or just a reveal tx. + +### Changed +- Add a max-weight check to the `wallet send-many` command. +- Fixed coin selection algorithm (#2723). + +[0.11.1-gm4](https://github.com/gmart7t2/ord/releases/tag/0.11.1-gm4) - 2023-11-19 +---------------------------------------------------------------------------------- + +### Added +- Add `--dump` and `--no-broadcast` flags to `wallet inscribe`. +- Add `wallet send-many` to allow sending multiple inscriptions in a single command. + +[0.11.1-gm3](https://github.com/gmart7t2/ord/releases/tag/0.11.1-gm3) - 2023-11-15 +---------------------------------------------------------------------------------- + +### Added +- Add `--key` flag to `wallet inscribe` to allow using a specific recovery key. +- Add `--ignore-outdated-index` flag to allow ord to run without having to fully index the blockchain. Be careful. Inscriptions that haven't been indexed will be treated as if they are cardinals, and so can be accidentally sent to spent as fees. + +[0.11.1-gm2](https://github.com/gmart7t2/ord/releases/tag/0.11.1-gm2) - 2023-11-14 +---------------------------------------------------------------------------------- + +### Changed +- Fixed the `/children` endpoint. + +[0.11.1-gm1](https://github.com/gmart7t2/ord/releases/tag/0.11.1-gm1) - 2023-11-14 +---------------------------------------------------------------------------------- + +### Added +- Merged my changes from 0.11.0 to 0.11.1. + [0.12.0](https://github.com/ordinals/ord/releases/tag/0.12.0) - 2023-11-24 -------------------------------------------------------------------------- @@ -228,6 +447,23 @@ Changelog ### Misc - Refactor varint encoding (#2645) +[0.11.0-gm2](https://github.com/gmart7t2/ord/releases/tag/0.11.0-gm2) - 2023-11-14 +---------------------------------------------------------------------------------- + +### Added +- Add logging for new server endpoints. +- Add ord version to `/stats` endpoint output. + +### Changed +- Move server debug logging to debug level. +- Remove `children` subcommand and replace it with `/children` server endpoint. + +[0.11.0-gm1](https://github.com/gmart7t2/ord/releases/tag/0.11.0-gm1) - 2023-11-09 +---------------------------------------------------------------------------------- + +### Added +- Merged my changes from 0.10.x to 0.11.x. + [0.11.0](https://github.com/ordinals/ord/releases/tag/0.11.0) - 2023-11-07 -------------------------------------------------------------------------- @@ -269,6 +505,18 @@ Changelog - Ignore non push opcodes in runestones (#2553) - Improve rune minimum at height (#2546) +[0.10.0-gm2](https://github.com/gmart7t2/ord/releases/tag/0.10.0-gm2) - 2023-11-03 +---------------------------------------------------------------------------------- + +### Added +- Add `--address-type` flag to `wallet create` and `wallet restore`. + +[0.10.0-gm1](https://github.com/gmart7t2/ord/releases/tag/0.10.0-gm1) - 2023-10-25 +---------------------------------------------------------------------------------- + +### Added +- Merged my changes from 0.9.x to 0.10.x. + [0.10.0](https://github.com/ordinals/ord/releases/tag/0.10.0) - 2023-10-23 -------------------------------------------------------------------------- @@ -342,6 +590,78 @@ Changelog - Format rune supply using divisibility (#2509) - Add pre-alpha unstable incomplete half-baked rune index (#2491) +[0.9.0-gm5](https://github.com/ordinals/ord/releases/tag/0.9.0-gm5) - 2023-10-21 +-------------------------------------------------------------------------------- + +### Added + +- Add `/outputs` endpoint to fetch details for multiple outputs per request. + +[0.9.0-gm4](https://github.com/ordinals/ord/releases/tag/0.9.0-gm4) - 2023-10-18 +-------------------------------------------------------------------------------- + +### Added + +- Add `/transfers//` and `/transfers///` endpoints to allow pagination. + +[0.9.0-gm3](https://github.com/ordinals/ord/releases/tag/0.9.0-gm3) - 2023-10-10 +-------------------------------------------------------------------------------- + +### Changed + +- Modify the /ranges endpoint to group the ranges by output. + +[0.9.0-gm2](https://github.com/ordinals/ord/releases/tag/0.9.0-gm2) - 2023-10-10 +-------------------------------------------------------------------------------- + +### Changed + +- Fix github releases. + +[0.9.0-gm1](https://github.com/ordinals/ord/releases/tag/0.9.0-gm1) - 2023-10-10 +-------------------------------------------------------------------------------- + +### Added + +- Add `--ignore-descriptors` flag to allow ord to work with non-ord wallets. + +[0.9.0-gms4](https://github.com/ordinals/ord/releases/tag/0.9.0-gms4) - 2023-09-18 +---------------------------------------------------------------------------------- + +### Added + +- Speed up `/transfers/` endpoint and don't block while running it. +- Add `application/cbor` media type with extension `.cbor` (#2446) +- Add --utxo flag to allow the use of unconfirmed outputs. +- Add --coin-control flag to limit which outputs can be spent. +- Add `/ranges` endpoint for looking up the sat ranges for a batch of outputs. + +[0.9.0-gms3](https://github.com/ordinals/ord/releases/tag/0.9.0-gms3) - 2023-09-12 +---------------------------------------------------------------------------------- + +### Added + +- Add subcommand `children` to list all the child/parent pairs + +[0.9.0-gms2](https://github.com/ordinals/ord/releases/tag/0.9.0-gms2) - 2023-09-11 +---------------------------------------------------------------------------------- + +### Added + +- Add `parent` and `children` to `/inscriptions_json/` endpoint + +[0.9.0-gms1](https://github.com/ordinals/ord/releases/tag/0.9.0-gms1) - 2023-09-11 +---------------------------------------------------------------------------------- + +### Added + +- Add `/inscriptions_json/` endpoint +- Add `/transfers/` endpoint +- Add `/stats/` endpoint +- Only index blocks when new blocks exist and the height limit isn't reached +- Add `--no-progress-bar` flag to inhibit the display of the progress bar +- Add server request logging + [0.9.0](https://github.com/ordinals/ord/releases/tag/0.9.0) - 2023-09-11 ------------------------------------------------------------------------ diff --git a/Cargo.lock b/Cargo.lock index 4882ab9a53..5c63a0042f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -470,6 +470,7 @@ version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1945a5048598e4189e239d3f809b19bdad4845c4b2ba400d304d2dcf26d2c462" dependencies = [ + "base64 0.13.1", "bech32", "bitcoin-private", "bitcoin_hashes 0.12.0", @@ -478,6 +479,12 @@ dependencies = [ "serde", ] +[[package]] +name = "base58" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6107fe1be6682a68940da878d9e9f5e90ca5745b3dec9fd1bb393c8777d4f581" + [[package]] name = "bitcoin-private" version = "0.1.0" @@ -2142,13 +2149,14 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "ord" -version = "0.15.0" +version = "0.15.0-gm12" dependencies = [ "anyhow", "async-trait", "axum", "axum-server", "base64 0.21.6", + "base58", "bech32", "bip39", "bitcoin", @@ -2198,6 +2206,7 @@ dependencies = [ "tokio-util 0.7.10", "tower-http", "unindent", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ff97614705..b020392ec5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ord" description = "◉ Ordinal wallet and block explorer" -version = "0.15.0" +version = "0.15.0-gm12" license = "CC0-1.0" edition = "2021" autotests = false @@ -23,9 +23,10 @@ async-trait = "0.1.72" axum = { version = "0.6.1", features = ["headers", "http2"] } axum-server = "0.5.0" base64 = "0.21.0" +base58 = "0.2.0" bech32 = "0.9.1" bip39 = "2.0.0" -bitcoin = { version = "0.30.1", features = ["rand"] } +bitcoin = { version = "0.30.1", features = ["base64", "rand"] } boilerplate = { version = "1.0.0", features = ["axum"] } brotli = "3.4.0" chrono = { version = "0.4.19", features = ["serde"] } @@ -51,6 +52,7 @@ mp4 = "0.14.0" ord-bitcoincore-rpc = "0.17.1" redb = "1.4.0" regex = "1.6.0" +reqwest = { version = "0.11.10", features = ["blocking"] } rss = "2.0.1" rust-embed = "8.0.0" rustls = "0.22.0" @@ -65,6 +67,7 @@ tokio = { version = "1.17.0", features = ["rt-multi-thread"] } tokio-stream = "0.1.9" tokio-util = {version = "0.7.3", features = ["compat"] } tower-http = { version = "0.4.0", features = ["compression-br", "compression-gzip", "cors", "set-header"] } +url = "2.5.0" [dev-dependencies] criterion = "0.5.1" diff --git a/src/index.rs b/src/index.rs index 2ecde7af18..e155a1d257 100644 --- a/src/index.rs +++ b/src/index.rs @@ -59,6 +59,7 @@ macro_rules! define_multimap_table { define_multimap_table! { SATPOINT_TO_SEQUENCE_NUMBER, &SatPointValue, u32 } define_multimap_table! { SAT_TO_SEQUENCE_NUMBER, u64, u32 } define_multimap_table! { SEQUENCE_NUMBER_TO_CHILDREN, u32, u32 } +define_multimap_table! { HEIGHT_TO_SEQUENCE_NUMBER, u32, u32 } define_table! { HEIGHT_TO_BLOCK_HEADER, u32, &HeaderValue } define_table! { HEIGHT_TO_LAST_SEQUENCE_NUMBER, u32, u32 } define_table! { HOME_INSCRIPTIONS, u32, InscriptionIdValue } @@ -198,6 +199,8 @@ pub struct Index { index_runes: bool, index_sats: bool, index_transactions: bool, + index_transfers: bool, + no_progress_bar: bool, options: Options, path: PathBuf, started: DateTime, @@ -237,7 +240,7 @@ impl Index { redb::Durability::Immediate }; - let index_runes; + let mut index_runes; let index_sats; let index_transactions; @@ -296,8 +299,20 @@ impl Index { index_runes = Self::is_statistic_set(&statistics, Statistic::IndexRunes)?; index_sats = Self::is_statistic_set(&statistics, Statistic::IndexSats)?; index_transactions = Self::is_statistic_set(&statistics, Statistic::IndexTransactions)?; - } + // if --index-runes is on the command line, and the index doesn't have the runes index, and runes aren't active yet, add the rune index + if options.index_runes() && !index_runes && tx + .open_table(HEIGHT_TO_BLOCK_HEADER)? + .range(0..)? + .next_back() + .transpose()? + .map(|(height, _header)| height.value()).unwrap() < options.first_rune_height() { + index_runes = true; + let tx = database.begin_write()?; + Self::set_statistic(&mut tx.open_table(STATISTIC_TO_COUNT)?, Statistic::IndexRunes, u64::from(index_runes))?; + tx.commit()?; + } + } database } Err(DatabaseError::Storage(StorageError::Io(error))) @@ -314,6 +329,7 @@ impl Index { tx.open_multimap_table(SATPOINT_TO_SEQUENCE_NUMBER)?; tx.open_multimap_table(SAT_TO_SEQUENCE_NUMBER)?; tx.open_multimap_table(SEQUENCE_NUMBER_TO_CHILDREN)?; + tx.open_multimap_table(HEIGHT_TO_SEQUENCE_NUMBER)?; tx.open_table(HEIGHT_TO_BLOCK_HEADER)?; tx.open_table(HEIGHT_TO_LAST_SEQUENCE_NUMBER)?; tx.open_table(HOME_INSCRIPTIONS)?; @@ -358,6 +374,8 @@ impl Index { let genesis_block_coinbase_transaction = options.chain().genesis_block().coinbase().unwrap().clone(); + let index_transfers = options.index_transfers; + Ok(Self { genesis_block_coinbase_txid: genesis_block_coinbase_transaction.txid(), client, @@ -369,6 +387,8 @@ impl Index { index_runes, index_sats, index_transactions, + index_transfers, + no_progress_bar: options.no_progress_bar, options: options.clone(), path, started: Utc::now(), @@ -382,6 +402,7 @@ impl Index { } pub(crate) fn check_sync(&self, utxos: &BTreeMap) -> Result { + if !self.options.ignore_outdated_index { let rtx = self.database.begin_read()?; let outpoint_to_value = rtx.open_table(OUTPOINT_TO_VALUE)?; for outpoint in utxos.keys() { @@ -391,10 +412,15 @@ impl Index { )); } } + } Ok(true) } + pub(crate) fn data_dir(&self) -> PathBuf { + self.options.data_dir() + } + pub(crate) fn has_rune_index(&self) -> bool { self.index_runes } @@ -525,6 +551,7 @@ impl Index { insert_multimap_table_info(&mut tables, &wtx, total_bytes, SATPOINT_TO_SEQUENCE_NUMBER); insert_multimap_table_info(&mut tables, &wtx, total_bytes, SAT_TO_SEQUENCE_NUMBER); insert_multimap_table_info(&mut tables, &wtx, total_bytes, SEQUENCE_NUMBER_TO_CHILDREN); + insert_multimap_table_info(&mut tables, &wtx, total_bytes, HEIGHT_TO_SEQUENCE_NUMBER); insert_table_info(&mut tables, &wtx, total_bytes, HEIGHT_TO_BLOCK_HEADER); insert_table_info( &mut tables, @@ -1049,6 +1076,10 @@ impl Index { Ok(result) } + pub(crate) fn client(&self) -> &Client { + &self.client + } + pub(crate) fn block_header(&self, hash: BlockHash) -> Result> { self.client.get_block_header(&hash).into_option() } @@ -1167,6 +1198,11 @@ impl Index { entry.id } + pub(crate) fn get_children_by_sequence_number(&self, sequence_number: u32) -> Result> { + let (children, _more) = self.get_children_by_sequence_number_paginated(sequence_number, usize::MAX, 0)?; + Ok(children) + } + pub(crate) fn get_children_by_sequence_number_paginated( &self, sequence_number: u32, @@ -1219,6 +1255,30 @@ impl Index { Ok(Some(RuneEntry::load(entry.value()).spaced_rune())) } + pub(crate) fn get_inscription_ids_by_height(&self, height: u32) -> Result> { + let mut ret = Vec::new(); + + let rtx = self.database.begin_read().unwrap(); + let sequence_number_to_inscription_entry = rtx + .open_table(SEQUENCE_NUMBER_TO_INSCRIPTION_ENTRY) + .unwrap(); + + for range in self + .database + .begin_read()? + .open_multimap_table(HEIGHT_TO_SEQUENCE_NUMBER)? + .range::<&u32>(&height..&(height + 1))? + { + let (_, ids) = range?; + for id in ids { + let entry = InscriptionEntry::load(sequence_number_to_inscription_entry.get(id?.value())?.unwrap().value()); + ret.push(entry.id); + } + } + + Ok(ret) + } + pub(crate) fn get_inscription_ids_by_sat(&self, sat: Sat) -> Result> { let rtx = self.database.begin_read()?; @@ -1314,7 +1374,37 @@ impl Index { .transpose() } - #[cfg(test)] + pub(crate) fn get_inscription_id_by_sequence_number( + &self, + n: u32, + ) -> Result> { + Ok( + self + .database + .begin_read()? + .open_table(SEQUENCE_NUMBER_TO_INSCRIPTION_ENTRY)? + .get(&n)? + .map(|entry| InscriptionEntry::load(entry.value()).id), + ) + } + + pub(crate) fn get_sequence_number_by_inscription_number( + &self, + inscription_number: i32, + ) -> Result { + let rtx = self.database.begin_read()?; + + let Some(sequence_number) = rtx + .open_table(INSCRIPTION_NUMBER_TO_SEQUENCE_NUMBER)? + .get(inscription_number)? + .map(|guard| guard.value()) + else { + return Err(anyhow!("no inscription number {inscription_number}")); + }; + + Ok(sequence_number) + } + pub(crate) fn get_inscription_id_by_inscription_number( &self, inscription_number: i32, @@ -1368,7 +1458,7 @@ impl Index { } Ok(self.get_transaction(inscription_id.txid)?.and_then(|tx| { - ParsedEnvelope::from_transaction(&tx) + ParsedEnvelope::from_transaction(&tx, false) .into_iter() .nth(inscription_id.index as usize) .map(|envelope| envelope.payload) @@ -1599,6 +1689,18 @@ impl Index { } } + pub(crate) fn ranges(&self, outpoint: OutPoint) -> Result> { + match self.list_inner(outpoint.store())? { + Some(sat_ranges) => + Ok(sat_ranges + .chunks_exact(11) + .map(|chunk| SatRange::load(chunk.try_into().unwrap())) + .collect(), + ), + None => Err(anyhow!("no ranges")), + } + } + pub(crate) fn block_time(&self, height: Height) -> Result { let height = height.n(); @@ -1636,9 +1738,19 @@ impl Index { &self, utxos: &BTreeMap, ) -> Result> { + let mut result = BTreeMap::new(); + + result.extend(self.get_inscriptions_vector(utxos)?.into_iter()); + Ok(result) + } + + pub(crate) fn get_inscriptions_vector( + &self, + utxos: &BTreeMap, + ) -> Result> { let rtx = self.database.begin_read()?; - let mut result = BTreeMap::new(); + let mut result = Vec::new(); let satpoint_to_sequence_number = rtx.open_multimap_table(SATPOINT_TO_SEQUENCE_NUMBER)?; let sequence_number_to_inscription_entry = @@ -1780,6 +1892,112 @@ impl Index { ) } + pub(crate) fn delete_transfer_log(&self) -> Result { + let wtx = self.database.begin_write().unwrap(); + wtx.delete_multimap_table(HEIGHT_TO_SEQUENCE_NUMBER)?; + Ok(wtx.commit()?) + } + + pub(crate) fn trim_transfer_log(&self, height: u32) -> Result { + let wtx = self.begin_write()?; + for pair in self + .database + .begin_read()? + .open_multimap_table(HEIGHT_TO_SEQUENCE_NUMBER)? + .range(..height)? + { + wtx + .open_multimap_table(HEIGHT_TO_SEQUENCE_NUMBER)? + .remove_all(pair?.0.value())?; + } + Ok(wtx.commit()?) + } + + pub(crate) fn show_transfer_log_stats(&self) -> Result<(u64, Option, Option)> { + let rtx = self.database.begin_read().unwrap(); + let table = rtx.open_multimap_table(HEIGHT_TO_SEQUENCE_NUMBER)?; + let mut iter = table.iter()?; + + let rows = table.len()?; + + let first = iter + .next() + .and_then(|result| result.ok()) + .map(|(height, _id)| height.value()); + + let last = iter + .next_back() + .and_then(|result| result.ok()) + .map(|(height, _id)| height.value()); + + if first.is_none() { + Ok((rows, None, None)) + } else if last.is_none() { + Ok((rows, first, first)) + } else { + Ok((rows, first, last)) + } + } + + pub(crate) fn get_children(&self) -> Result> { + let mut result = Vec::new(); + + let rtx = self.database.begin_read().unwrap(); + let sequence_number_to_inscription_entry = rtx + .open_table(SEQUENCE_NUMBER_TO_INSCRIPTION_ENTRY) + .unwrap(); + + for range in self + .database + .begin_read()? + .open_multimap_table(SEQUENCE_NUMBER_TO_CHILDREN)? + .iter()? + { + let (parent, children) = range?; + let parent = InscriptionEntry::load(sequence_number_to_inscription_entry.get(parent.value())?.unwrap().value()).id; + for child in children { + let child = InscriptionEntry::load(sequence_number_to_inscription_entry.get(child?.value())?.unwrap().value()).id; + result.push((parent, child)); + } + } + + Ok(result) + } + + pub(crate) fn get_stats(&self) -> Result<(Option, Option, Option)> { + let rtx = self.database.begin_read().unwrap(); + + let height = rtx + .open_table(HEIGHT_TO_BLOCK_HEADER)? + .iter()? + .next_back() + .and_then(|result| result.ok()) + .map(|(height, _header)| height.value()); + + let table = rtx.open_table(INSCRIPTION_NUMBER_TO_SEQUENCE_NUMBER)?; + let mut iter = table.iter()?; + + let lowest_number = iter + .next() + .and_then(|result| result.ok()) + .map(|(number, _id)| number.value()); + + let highest_number = iter + .next_back() + .and_then(|result| result.ok()) + .map(|(number, _id)| number.value()); + + Ok(( + height, + lowest_number, + if highest_number.is_none() { + lowest_number + } else { + highest_number + }, + )) + } + pub fn inscription_info_benchmark(index: &Index, inscription_number: i32) { Self::inscription_info(index, InscriptionQuery::Number(inscription_number)).unwrap(); } @@ -1835,7 +2053,7 @@ impl Index { return Ok(None); }; - let Some(inscription) = ParsedEnvelope::from_transaction(&transaction) + let Some(inscription) = ParsedEnvelope::from_transaction(&transaction, false) .into_iter() .nth(entry.id.index as usize) .map(|envelope| envelope.payload) diff --git a/src/index/entry.rs b/src/index/entry.rs index 116ee448ea..34cc3c8bcd 100644 --- a/src/index/entry.rs +++ b/src/index/entry.rs @@ -181,6 +181,73 @@ impl Entry for RuneId { } } +#[derive(Debug)] +pub(crate) struct TransferEntry { + pub(crate) height: u32, + pub(crate) tx_count: u32, + pub(crate) outpoint: OutPoint, +} + +pub(crate) type TransferEntryValue = ( + u32, // height + u32, // tx_count + (u128, u128), // txid + u32, // vout +); + +impl Entry for TransferEntry { + type Value = TransferEntryValue; + + #[rustfmt::skip] + fn load( + ( + height, + tx_count, + txid, + vout, + ): TransferEntryValue, + ) -> Self { + Self { + height, + tx_count, + outpoint: OutPoint { + txid: { + let low = txid.0.to_le_bytes(); + let high = txid.1.to_le_bytes(); + Txid::from_byte_array([ + low[0], low[1], low[2], low[3], low[4], low[5], low[6], low[7], low[8], low[9], low[10], + low[11], low[12], low[13], low[14], low[15], high[0], high[1], high[2], high[3], high[4], + high[5], high[6], high[7], high[8], high[9], high[10], high[11], high[12], high[13], + high[14], high[15], + ]) + }, + vout, + } + } + } + + fn store(self) -> Self::Value { + ( + self.height, + self.tx_count, + { + let bytes = self.outpoint.txid.to_byte_array(); + ( + u128::from_le_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], + ]), + u128::from_le_bytes([ + bytes[16], bytes[17], bytes[18], bytes[19], bytes[20], bytes[21], bytes[22], bytes[23], + bytes[24], bytes[25], bytes[26], bytes[27], bytes[28], bytes[29], bytes[30], bytes[31], + ]), + ) + }, + self.outpoint.vout, + ) + } +} + #[derive(Debug)] pub(crate) struct InscriptionEntry { pub(crate) charms: u16, diff --git a/src/index/updater.rs b/src/index/updater.rs index 7aa89768b8..27fb17ed2c 100644 --- a/src/index/updater.rs +++ b/src/index/updater.rs @@ -68,6 +68,7 @@ impl<'index> Updater<'_> { )?; let mut progress_bar = if cfg!(test) + || self.index.no_progress_bar || log_enabled!(log::Level::Info) || starting_height <= self.height || integration_test() @@ -82,6 +83,9 @@ impl<'index> Updater<'_> { Some(progress_bar) }; + if starting_height > self.height + && (self.index.height_limit.is_none() || self.index.height_limit.unwrap() > self.height) + { let rx = Self::fetch_blocks_from(self.index, self.height, self.index.index_sats)?; let (mut outpoint_sender, mut value_receiver) = Self::spawn_fetcher(self.index)?; @@ -112,8 +116,15 @@ impl<'index> Updater<'_> { uncommitted += 1; - if uncommitted == 5000 { - self.commit(wtx, value_cache)?; + if uncommitted == self.index.options.commit { + // eprintln!("\ncommitting after {} blocks at {}", uncommitted, self.height); + if progress_bar.is_some() { + progress_bar.clone().unwrap().suspend(|| { + self.commit(wtx, value_cache, true) + })?; + } else { + self.commit(wtx, value_cache, false)? + } value_cache = HashMap::new(); uncommitted = 0; wtx = self.index.begin_write()?; @@ -146,12 +157,19 @@ impl<'index> Updater<'_> { } if uncommitted > 0 { - self.commit(wtx, value_cache)?; + if progress_bar.is_some() { + progress_bar.clone().unwrap().suspend(|| { + self.commit(wtx, value_cache, true) + })?; + } else { + self.commit(wtx, value_cache, false)? + } } if let Some(progress_bar) = &mut progress_bar { progress_bar.finish_and_clear(); } + } Ok(()) } @@ -378,6 +396,11 @@ impl<'index> Updater<'_> { } let mut height_to_block_header = wtx.open_table(HEIGHT_TO_BLOCK_HEADER)?; + let mut height_to_sequence_number = if index.index_transfers { + Some(wtx.open_multimap_table(HEIGHT_TO_SEQUENCE_NUMBER)?) + } else { + None + }; let mut height_to_last_sequence_number = wtx.open_table(HEIGHT_TO_LAST_SEQUENCE_NUMBER)?; let mut home_inscriptions = wtx.open_table(HOME_INSCRIPTIONS)?; let mut inscription_id_to_sequence_number = @@ -428,9 +451,12 @@ impl<'index> Updater<'_> { cursed_inscription_count, flotsam: Vec::new(), height: self.height, + height_to_sequence_number: &mut height_to_sequence_number, home_inscription_count, home_inscriptions: &mut home_inscriptions, id_to_sequence_number: &mut inscription_id_to_sequence_number, + ignore_cursed: index.options.ignore_cursed, + ignore_txt_and_json: index.options.ignore_txt_and_json, index_transactions: self.index.index_transactions, inscription_number_to_sequence_number: &mut inscription_number_to_sequence_number, lost_sats, @@ -445,6 +471,7 @@ impl<'index> Updater<'_> { timestamp: block.header.time, transaction_buffer: Vec::new(), transaction_id_to_transaction: &mut transaction_id_to_transaction, + tx_count: 0, unbound_inscriptions, value_cache, value_receiver, @@ -701,7 +728,7 @@ impl<'index> Updater<'_> { Ok(()) } - fn commit(&mut self, wtx: WriteTransaction, value_cache: HashMap) -> Result { + fn commit(&mut self, wtx: WriteTransaction, value_cache: HashMap, use_progress_bar: bool) -> Result { log::info!( "Committing at block height {}, {} outputs traversed, {} in map, {} cached", self.height, @@ -720,8 +747,22 @@ impl<'index> Updater<'_> { let mut outpoint_to_sat_ranges = wtx.open_table(OUTPOINT_TO_SAT_RANGES)?; + let progress_bar = if use_progress_bar { + let progress_bar = ProgressBar::new(self.range_cache.len() as u64); + progress_bar.set_position(0); + progress_bar.set_style( + ProgressStyle::with_template(format!("[committing ranges at block {}] {{wide_bar}} {{pos}}/{{len}}", self.height).as_str()).unwrap(), + ); + Some(progress_bar) + } else { + None + }; + for (outpoint, sat_range) in self.range_cache.drain() { outpoint_to_sat_ranges.insert(&outpoint, sat_range.as_slice())?; + if let Some(progress_bar) = &progress_bar { + progress_bar.inc(1); + } } self.outputs_inserted_since_flush = 0; @@ -730,8 +771,22 @@ impl<'index> Updater<'_> { { let mut outpoint_to_value = wtx.open_table(OUTPOINT_TO_VALUE)?; + let progress_bar = if use_progress_bar { + let progress_bar = ProgressBar::new(value_cache.len() as u64); + progress_bar.set_position(0); + progress_bar.set_style( + ProgressStyle::with_template(format!("[committing values at block {}] {{wide_bar}} {{pos}}/{{len}}", self.height).as_str()).unwrap(), + ); + Some(progress_bar) + } else { + None + }; + for (outpoint, value) in value_cache { outpoint_to_value.insert(&outpoint.store(), &value)?; + if let Some(progress_bar) = &progress_bar { + progress_bar.inc(1); + } } } @@ -740,8 +795,25 @@ impl<'index> Updater<'_> { Index::increment_statistic(&wtx, Statistic::SatRanges, self.sat_ranges_since_flush)?; self.sat_ranges_since_flush = 0; Index::increment_statistic(&wtx, Statistic::Commits, 1)?; + + let progress_bar = if use_progress_bar { + let progress_bar = ProgressBar::new(1); + progress_bar.set_position(0); + progress_bar.set_style( + ProgressStyle::with_template(format!("[committing db at block {}] {{wide_bar}} {{pos}}/{{len}}", self.height).as_str()).unwrap(), + ); + progress_bar.inc(0); + Some(progress_bar) + } else { + None + }; + wtx.commit()?; + if let Some(progress_bar) = &progress_bar { + progress_bar.inc(1); + } + Reorg::update_savepoints(self.index, self.height)?; Ok(()) diff --git a/src/index/updater/inscription_updater.rs b/src/index/updater/inscription_updater.rs index 19f99033cc..1038ebd7c6 100644 --- a/src/index/updater/inscription_updater.rs +++ b/src/index/updater/inscription_updater.rs @@ -43,9 +43,12 @@ pub(super) struct InscriptionUpdater<'a, 'db, 'tx> { pub(super) cursed_inscription_count: u64, pub(super) flotsam: Vec, pub(super) height: u32, + pub(super) height_to_sequence_number: &'a mut Option>, pub(super) home_inscription_count: u64, pub(super) home_inscriptions: &'a mut Table<'db, 'tx, u32, InscriptionIdValue>, pub(super) id_to_sequence_number: &'a mut Table<'db, 'tx, InscriptionIdValue, u32>, + pub(super) ignore_cursed: bool, + pub(super) ignore_txt_and_json: bool, pub(super) index_transactions: bool, pub(super) inscription_number_to_sequence_number: &'a mut Table<'db, 'tx, i32, u32>, pub(super) lost_sats: u64, @@ -62,6 +65,7 @@ pub(super) struct InscriptionUpdater<'a, 'db, 'tx> { pub(super) sequence_number_to_entry: &'a mut Table<'db, 'tx, u32, InscriptionEntryValue>, pub(super) sequence_number_to_satpoint: &'a mut Table<'db, 'tx, u32, &'static SatPointValue>, pub(super) timestamp: u32, + pub(super) tx_count: u32, pub(super) unbound_inscriptions: u64, pub(super) value_cache: &'a mut HashMap, pub(super) value_receiver: &'a mut Receiver, @@ -77,11 +81,12 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { let mut floating_inscriptions = Vec::new(); let mut id_counter = 0; let mut inscribed_offsets = BTreeMap::new(); - let jubilant = self.height >= self.chain.jubilee_height(); + let jubilant = self.ignore_cursed || self.height >= self.chain.jubilee_height(); let mut total_input_value = 0; let total_output_value = tx.output.iter().map(|txout| txout.value).sum::(); - let envelopes = ParsedEnvelope::from_transaction(tx); + self.tx_count += 1; + let envelopes = ParsedEnvelope::from_transaction(tx, self.ignore_txt_and_json); let inscriptions = !envelopes.is_empty(); let mut envelopes = envelopes.into_iter().peekable(); @@ -385,17 +390,23 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { let inscription_id = flotsam.inscription_id; let (unbound, sequence_number) = match flotsam.origin { Origin::Old { old_satpoint } => { + let sequence_number = + self + .id_to_sequence_number + .get(&inscription_id.store())? + .unwrap() + .value(); + if let Some(height_to_sequence_number) = &mut self.height_to_sequence_number { + // eprintln!("insert height {} seq {}", self.height, sequence_number); + height_to_sequence_number.insert(&self.height, &sequence_number)?; + } self .satpoint_to_sequence_number .remove_all(&old_satpoint.store())?; ( false, - self - .id_to_sequence_number - .get(&inscription_id.store())? - .unwrap() - .value(), + sequence_number, ) } Origin::New { diff --git a/src/inscriptions/envelope.rs b/src/inscriptions/envelope.rs index 76836f7733..bc0190a486 100644 --- a/src/inscriptions/envelope.rs +++ b/src/inscriptions/envelope.rs @@ -78,7 +78,9 @@ impl From for ParsedEnvelope { metaprotocol, parent, pointer, + skip_pointer: false, unrecognized_even_field, + utxo: None, }, input: envelope.input, offset: envelope.offset, @@ -89,10 +91,16 @@ impl From for ParsedEnvelope { } impl ParsedEnvelope { - pub(crate) fn from_transaction(transaction: &Transaction) -> Vec { + pub(crate) fn from_transaction(transaction: &Transaction, ignore_txt_and_json: bool) -> Vec { RawEnvelope::from_transaction(transaction) .into_iter() .map(|envelope| envelope.into()) + .filter(|envelope: &ParsedEnvelope| !ignore_txt_and_json || match envelope.payload.content_type.clone() { + Some(content_type) => + !content_type.starts_with("text/plain".as_bytes()) && + !content_type.starts_with("application/json".as_bytes()), + None => true, + }) .collect() } } diff --git a/src/inscriptions/inscription.rs b/src/inscriptions/inscription.rs index 42eaa2d2b1..26c2dfb14c 100644 --- a/src/inscriptions/inscription.rs +++ b/src/inscriptions/inscription.rs @@ -26,7 +26,9 @@ pub struct Inscription { pub metaprotocol: Option>, pub parent: Option>, pub pointer: Option>, + pub skip_pointer: bool, pub unrecognized_even_field: bool, + pub utxo: Option, } impl Inscription { @@ -41,15 +43,32 @@ impl Inscription { pub(crate) fn from_file( chain: Chain, + delegate: Option, path: impl AsRef, parent: Option, pointer: Option, metaprotocol: Option, metadata: Option>, compress: bool, + skip_pointer_for_none: bool, + utxo: Option, ) -> Result { let path = path.as_ref(); + if path == PathBuf::from("none") { + return Ok(Self { + body: None, + content_type: None, + content_encoding: None, + metadata, + metaprotocol: metaprotocol.map(|metaprotocol| metaprotocol.into_bytes()), + parent: parent.map(|id| id.value()), + pointer: pointer.map(Self::pointer_value), + skip_pointer: skip_pointer_for_none, + ..Default::default() + }); + } + let body = fs::read(path).with_context(|| format!("io error reading {}", path.display()))?; let (content_type, compression_mode) = Media::content_type_for_path(path)?; @@ -101,10 +120,13 @@ impl Inscription { body: Some(body), content_type: Some(content_type.into()), content_encoding, + delegate: delegate.map(|id| id.value()), metadata, metaprotocol: metaprotocol.map(|metaprotocol| metaprotocol.into_bytes()), parent: parent.map(|id| id.value()), pointer: pointer.map(Self::pointer_value), + skip_pointer: false, + utxo, ..Default::default() }) } @@ -128,20 +150,26 @@ impl Inscription { .push_opcode(opcodes::all::OP_IF) .push_slice(envelope::PROTOCOL_ID); + if self.delegate.is_none() { Tag::ContentType.encode(&mut builder, &self.content_type); Tag::ContentEncoding.encode(&mut builder, &self.content_encoding); + } Tag::Metaprotocol.encode(&mut builder, &self.metaprotocol); Tag::Parent.encode(&mut builder, &self.parent); Tag::Delegate.encode(&mut builder, &self.delegate); - Tag::Pointer.encode(&mut builder, &self.pointer); + if !self.skip_pointer { + Tag::Pointer.encode(&mut builder, &self.pointer); + } Tag::Metadata.encode(&mut builder, &self.metadata); + if self.delegate.is_none() { if let Some(body) = &self.body { builder = builder.push_slice(envelope::BODY_TAG); for chunk in body.chunks(MAX_SCRIPT_ELEMENT_SIZE) { builder = builder.push_slice(PushBytesBuf::try_from(chunk.to_vec()).unwrap()); } } + } builder.push_opcode(opcodes::all::OP_ENDIF) } diff --git a/src/inscriptions/inscription_id.rs b/src/inscriptions/inscription_id.rs index 5f3f70e225..1670eae50a 100644 --- a/src/inscriptions/inscription_id.rs +++ b/src/inscriptions/inscription_id.rs @@ -1,6 +1,6 @@ use super::*; -#[derive(Debug, PartialEq, Copy, Clone, Hash, Eq)] +#[derive(Debug, PartialEq, Copy, Clone, Hash, Eq, Ord, PartialOrd)] pub struct InscriptionId { pub txid: Txid, pub index: u32, diff --git a/src/inscriptions/media.rs b/src/inscriptions/media.rs index c4859bd38b..d2ac81acff 100644 --- a/src/inscriptions/media.rs +++ b/src/inscriptions/media.rs @@ -66,7 +66,7 @@ impl Media { ("font/woff", BROTLI_MODE_GENERIC, Media::Font, &["woff"]), ("font/woff2", BROTLI_MODE_FONT, Media::Font, &["woff2"]), ("image/apng", BROTLI_MODE_GENERIC, Media::Image, &["apng"]), - ("image/avif", BROTLI_MODE_GENERIC, Media::Image, &[]), + ("image/avif", BROTLI_MODE_GENERIC, Media::Image, &["avif"]), ("image/gif", BROTLI_MODE_GENERIC, Media::Image, &["gif"]), ("image/jpeg", BROTLI_MODE_GENERIC, Media::Image, &["jpg", "jpeg"]), ("image/png", BROTLI_MODE_GENERIC, Media::Image, &["png"]), diff --git a/src/inscriptions/tag.rs b/src/inscriptions/tag.rs index f6b95e4a48..c44ce389a0 100644 --- a/src/inscriptions/tag.rs +++ b/src/inscriptions/tag.rs @@ -36,6 +36,27 @@ impl Tag { } } + pub(crate) fn push_tag(self, tmp: script::Builder) -> script::Builder { + let use_single_byte_push_when_possible = false; + + let bytes = self.bytes(); + + if use_single_byte_push_when_possible && bytes.len() == 1 && (1..17).contains(&bytes[0]) { + // if it's a single byte between 1 and 16, use a PUSHNUM opcode + tmp.push_opcode( + match bytes[0] { + 1 => opcodes::all::OP_PUSHNUM_1, 2 => opcodes::all::OP_PUSHNUM_2, 3 => opcodes::all::OP_PUSHNUM_3, 4 => opcodes::all::OP_PUSHNUM_4, + 5 => opcodes::all::OP_PUSHNUM_5, 6 => opcodes::all::OP_PUSHNUM_6, 7 => opcodes::all::OP_PUSHNUM_7, 8 => opcodes::all::OP_PUSHNUM_8, + 9 => opcodes::all::OP_PUSHNUM_9, 10 => opcodes::all::OP_PUSHNUM_10, 11 => opcodes::all::OP_PUSHNUM_11, 12 => opcodes::all::OP_PUSHNUM_12, + 13 => opcodes::all::OP_PUSHNUM_13, 14 => opcodes::all::OP_PUSHNUM_14, 15 => opcodes::all::OP_PUSHNUM_15, 16 => opcodes::all::OP_PUSHNUM_16, + _ => panic!("unreachable"), + }) + } else { + // otherwise use a PUSHBYTES opcode + tmp.push_slice::<&script::PushBytes>(bytes.try_into().unwrap()) + } + } + pub(crate) fn encode(self, builder: &mut script::Builder, value: &Option>) { if let Some(value) = value { let mut tmp = script::Builder::new(); @@ -43,13 +64,11 @@ impl Tag { if self.is_chunked() { for chunk in value.chunks(MAX_SCRIPT_ELEMENT_SIZE) { - tmp = tmp - .push_slice::<&script::PushBytes>(self.bytes().try_into().unwrap()) + tmp = self.push_tag(tmp) .push_slice::<&script::PushBytes>(chunk.try_into().unwrap()); } } else { - tmp = tmp - .push_slice::<&script::PushBytes>(self.bytes().try_into().unwrap()) + tmp = self.push_tag(tmp) .push_slice::<&script::PushBytes>(value.as_slice().try_into().unwrap()); } diff --git a/src/lib.rs b/src/lib.rs index 9102594701..32ca8fd1f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -216,11 +216,8 @@ pub fn main() { env_logger::init(); ctrlc::set_handler(move || { - if SHUTTING_DOWN.fetch_or(true, atomic::Ordering::Relaxed) { - process::exit(1); - } - - println!("Shutting down gracefully. Press again to shutdown immediately."); + SHUTTING_DOWN.fetch_or(true, atomic::Ordering::Relaxed); + println!("Shutting down gracefully. Please wait. Pressing again won't do anything."); LISTENERS .lock() diff --git a/src/options.rs b/src/options.rs index ec6261f0f8..88b7a36de6 100644 --- a/src/options.rs +++ b/src/options.rs @@ -49,6 +49,8 @@ pub struct Options { pub(crate) index_runes: bool, #[arg(long, help = "Track location of all satoshis.")] pub(crate) index_sats: bool, + #[clap(long, help = "Index which inscriptions are transferred in each block.")] + pub(crate) index_transfers: bool, #[arg(long, help = "Store transactions in index.")] pub(crate) index_transactions: bool, #[arg( @@ -58,6 +60,8 @@ pub struct Options { help = "Do not index inscriptions." )] pub(crate) no_index_inscriptions: bool, + #[arg(long, help = "Inhibit the display of the progress bar while updating the index.")] + pub(crate) no_progress_bar: bool, #[arg(long, short, help = "Use regtest. Equivalent to `--chain regtest`.")] pub(crate) regtest: bool, #[arg(long, help = "Connect to Bitcoin Core RPC at .")] @@ -66,6 +70,16 @@ pub struct Options { pub(crate) signet: bool, #[arg(long, short, help = "Use testnet. Equivalent to `--chain testnet`.")] pub(crate) testnet: bool, + #[arg(long, help = "Don't check for standard wallet descriptors.")] + pub(crate) ignore_descriptors: bool, + #[arg(long, help = "Don't fail when the index is out of date. This is dangerous, and results in ord treating inscriptions as cardinals if their corresponding utxos haven't been indexed. Use at your own risk.")] + pub(crate) ignore_outdated_index: bool, + #[arg(long, help = "Treat cursed inscriptions as regular inscriptions when indexing. Be consistent; either specify this flag every time you use a given index file or never.")] + pub(crate) ignore_cursed: bool, + #[arg(long, default_value = "5000", help = "Commit changes to the index file on disk every blocks.")] + pub(crate) commit: usize, + #[arg(long, help = "Ignore text and json inscriptions.")] + pub(crate) ignore_txt_and_json: bool, } impl Options { @@ -100,7 +114,7 @@ impl Options { } pub(crate) fn index_runes(&self) -> bool { - self.index_runes && self.chain() != Chain::Mainnet + self.index_runes } pub(crate) fn rpc_url(&self, wallet_name: Option) -> String { diff --git a/src/subcommand.rs b/src/subcommand.rs index ff7b406079..50749ade15 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -14,6 +14,7 @@ pub mod subsidy; pub mod supply; pub mod teleburn; pub mod traits; +pub mod transfer; pub mod wallet; #[derive(Debug, Parser)] @@ -46,6 +47,8 @@ pub(crate) enum Subcommand { Teleburn(teleburn::Teleburn), #[command(about = "Display satoshi traits")] Traits(traits::Traits), + #[command(about = "Modify transfer log table")] + Transfer(transfer::Transfer), #[command(about = "Wallet commands")] Wallet(wallet::Wallet), } @@ -72,6 +75,7 @@ impl Subcommand { Self::Supply => supply::run(), Self::Teleburn(teleburn) => teleburn.run(), Self::Traits(traits) => traits.run(), + Self::Transfer(transfer) => transfer.run(options), Self::Wallet(wallet) => wallet.run(options), } } diff --git a/src/subcommand/decode.rs b/src/subcommand/decode.rs index 38d2275524..e31d1b8cfc 100644 --- a/src/subcommand/decode.rs +++ b/src/subcommand/decode.rs @@ -85,7 +85,7 @@ impl Decode { Transaction::consensus_decode(&mut io::stdin())? }; - let inscriptions = ParsedEnvelope::from_transaction(&transaction); + let inscriptions = ParsedEnvelope::from_transaction(&transaction, false); if self.compact { Ok(Box::new(CompactOutput { diff --git a/src/subcommand/preview.rs b/src/subcommand/preview.rs index 845c0c90d6..06f938b922 100644 --- a/src/subcommand/preview.rs +++ b/src/subcommand/preview.rs @@ -85,6 +85,7 @@ impl Preview { super::wallet::create::Create { passphrase: "".into(), + address_type: super::wallet::AddressType::Bech32m, } .run("ord".into(), options.clone())?; @@ -107,21 +108,39 @@ impl Preview { subcommand: super::wallet::Subcommand::Inscribe(super::wallet::inscribe::Inscribe { batch: None, cbor_metadata: None, + change: None, + coin_control: false, commit_fee_rate: None, + commit_input: Vec::new(), + commit_only: false, + commit_vsize: None, + commitment: None, compress: false, destination: None, + dump: false, dry_run: false, fee_rate: FeeRate::try_from(1.0).unwrap(), file: Some(file), json_metadata: None, + key: None, metaprotocol: None, + next_batch: None, + next_file: None, no_backup: true, + no_broadcast: false, no_limit: false, + no_wallet: false, parent: None, + parent_satpoint: None, + parent_destination: None, postage: Some(TARGET_POSTAGE), reinscribe: false, + reveal_fee: None, + reveal_input: Vec::new(), satpoint: None, sat: None, + skip_pointer_for_none: false, + utxo: Vec::new(), }), }), } @@ -140,21 +159,39 @@ impl Preview { subcommand: super::wallet::Subcommand::Inscribe(super::wallet::inscribe::Inscribe { batch: Some(batch), cbor_metadata: None, + change: None, + coin_control: false, commit_fee_rate: None, + commit_input: Vec::new(), + commit_only: false, + commit_vsize: None, + commitment: None, compress: false, destination: None, + dump: false, dry_run: false, fee_rate: FeeRate::try_from(1.0).unwrap(), file: None, json_metadata: None, + key: None, metaprotocol: None, + next_batch: None, + next_file: None, no_backup: true, + no_broadcast: false, no_limit: false, + no_wallet: false, parent: None, + parent_destination: None, + parent_satpoint: None, postage: Some(TARGET_POSTAGE), reinscribe: false, + reveal_fee: None, + reveal_input: Vec::new(), satpoint: None, sat: None, + skip_pointer_for_none: false, + utxo: Vec::new(), }), }), } diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index a9806f4181..1adf48f1a0 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -1,4 +1,5 @@ use { + self::wallet::inscribe::Inscribe, self::{ accept_encoding::AcceptEncoding, accept_json::AcceptJson, @@ -24,7 +25,7 @@ use { headers::UserAgent, http::{header, HeaderMap, HeaderValue, StatusCode, Uri}, response::{IntoResponse, Redirect, Response}, - routing::get, + routing::{get, post}, Router, TypedHeader, }, axum_server::Handle, @@ -49,6 +50,18 @@ mod accept_encoding; mod accept_json; mod error; +#[derive(Serialize)] +pub struct Outputs { + pub output: OutPoint, + pub details: OutputJson, +} + +#[derive(Serialize)] +pub struct Ranges { + pub output: OutPoint, + pub ranges: Vec<(u64, u64)>, +} + #[derive(Copy, Clone)] pub(crate) enum InscriptionQuery { Id(InscriptionId), @@ -104,6 +117,60 @@ struct Search { query: String, } +#[derive(Serialize)] +struct MyInscriptionJson { + number: i32, + sequence_number: u32, + id: InscriptionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + parent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + address: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + output_value: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + sat: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + delegate: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + content_length: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + content_type: Option, + timestamp: u32, + genesis_height: u32, + genesis_fee: u64, + genesis_transaction: Txid, + location: String, + output: String, + offset: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + children: Vec, +} + +#[derive(Serialize)] +struct SatoshiJson { + number: u64, + decimal: String, + degree: String, + percentile: String, + name: String, + cycle: u32, + epoch: u32, + period: u32, + block: u32, + offset: u64, + rarity: Rarity, + // timestamp: i64, +} + +#[derive(Serialize)] +struct StatsJson { + version: String, + highest_block_indexed: Option, + lowest_inscription_number: Option, + highest_inscription_number: Option, +} + #[derive(RustEmbed)] #[folder = "static"] struct StaticAssets; @@ -188,7 +255,7 @@ impl Server { log::warn!("Updating index: {error}"); } } - thread::sleep(Duration::from_millis(5000)); + thread::sleep(Duration::from_millis(500)); }); INDEXER.lock().unwrap().replace(index_thread); @@ -214,6 +281,7 @@ impl Server { .route("/blocks", get(Self::blocks)) .route("/blocktime", get(Self::block_time)) .route("/bounties", get(Self::bounties)) + .route("/children", get(Self::children_all)) .route("/children/:inscription_id", get(Self::children)) .route( "/children/:inscription_id/:page", @@ -227,6 +295,7 @@ impl Server { .route("/favicon.ico", get(Self::favicon)) .route("/feed.xml", get(Self::feed)) .route("/input/:block/:transaction/:input", get(Self::input)) + .route("/inscribe", post(Self::inscribe)) .route("/inscription/:inscription_query", get(Self::inscription)) .route("/inscriptions", get(Self::inscriptions)) .route("/inscriptions/:page", get(Self::inscriptions_paginated)) @@ -238,9 +307,22 @@ impl Server { "/inscriptions/block/:height/:page", get(Self::inscriptions_in_block_paginated), ) + .route( + "/inscriptions_json/:start", + get(Self::inscriptions_json_start), + ) + .route( + "/inscriptions_json/:start/:end", + get(Self::inscriptions_json_start_end), + ) + .route( + "/inscriptions_sequence_numbers/:start/:end", + get(Self::inscriptions_sequence_numbers), + ) .route("/install.sh", get(Self::install_script)) .route("/ordinal/:sat", get(Self::ordinal)) .route("/output/:output", get(Self::output)) + .route("/outputs", post(Self::outputs)) .route("/preview/:inscription_id", get(Self::preview)) .route("/r/blockhash", get(Self::block_hash_json)) .route( @@ -265,14 +347,20 @@ impl Server { get(Self::sat_inscription_at_index), ) .route("/range/:start/:end", get(Self::range)) + .route("/ranges", post(Self::ranges)) .route("/rare.txt", get(Self::rare_txt)) .route("/rune/:rune", get(Self::rune)) .route("/runes", get(Self::runes)) .route("/sat/:sat", get(Self::sat)) .route("/search", get(Self::search_by_query)) .route("/search/*query", get(Self::search_by_path)) + .route("/sendtx", post(Self::send_transaction)) .route("/static/*path", get(Self::static_asset)) + .route("/stats", get(Self::stats)) .route("/status", get(Self::status)) + .route("/transfers/:height", get(Self::inscriptionids_from_height)) + .route("/transfers/:height/:start", get(Self::inscriptionids_from_height_start)) + .route("/transfers/:height/:start/:end", get(Self::inscriptionids_from_height_start_end)) .route("/tx/:txid", get(Self::transaction)) .layer(Extension(index)) .layer(Extension(server_config.clone())) @@ -472,8 +560,20 @@ impl Server { index.block_height()?.ok_or_not_found(|| "genesis block") } + async fn children_all(Extension(index): Extension>) -> ServerResult { + task::block_in_place(|| { + log::info!("GET /children"); + let mut result = "parent child\n".to_string(); + for (parent, child) in index.get_children()? { + result += format!("{} {}\n", parent, child).as_str(); + } + Ok(result) + }) + } + async fn clock(Extension(index): Extension>) -> ServerResult { task::block_in_place(|| { + log::info!("GET /clock"); Ok( ( [( @@ -494,6 +594,7 @@ impl Server { AcceptJson(accept_json): AcceptJson, ) -> ServerResult { task::block_in_place(|| { + log::info!("GET /sat/{sat}"); let inscriptions = index.get_inscription_ids_by_sat(sat)?; let satpoint = index.rare_sat_satpoint(sat)?.or_else(|| { inscriptions.first().and_then(|&first_inscription_id| { @@ -546,6 +647,7 @@ impl Server { AcceptJson(accept_json): AcceptJson, ) -> ServerResult { task::block_in_place(|| { + log::info!("GET /output/{outpoint}"); let list = index.list(outpoint)?; let output = if outpoint == OutPoint::null() || outpoint == unbound_outpoint() { @@ -603,6 +705,98 @@ impl Server { }) } + async fn outputs( + Extension(server_config): Extension>, + Extension(index): Extension>, + Json(data): Json + ) -> ServerResult { + task::block_in_place(|| { + log::info!("POST /outputs"); + + if !data.is_array() { + return Err(ServerError::BadRequest("expected array".to_string())); + } + + let mut result = Vec::new(); + + for outpoint in data.as_array().unwrap() { + if !outpoint.is_string() { + return Err(ServerError::BadRequest("expected array of strings".to_string())); + } + + match OutPoint::from_str(outpoint.as_str().unwrap()) { + Ok(outpoint) => { + let list = index.list(outpoint)?; + + let output = if outpoint == OutPoint::null() || outpoint == unbound_outpoint() { + let mut value = 0; + + if let Some(List::Unspent(ranges)) = &list { + for (start, end) in ranges { + value += end - start; + } + } + + TxOut { + value, + script_pubkey: ScriptBuf::new(), + } + } else { + index + .get_transaction(outpoint.txid)? + .ok_or_not_found(|| format!("output {outpoint}"))? + .output + .into_iter() + .nth(outpoint.vout as usize) + .ok_or_not_found(|| format!("output {outpoint}"))? + }; + + let inscriptions = index.get_inscriptions_on_output(outpoint)?; + + let runes = index.get_rune_balances_for_outpoint(outpoint)?; + + result.push( + Outputs {output: outpoint, details: + OutputJson::new( + outpoint, + list, + server_config.chain, + output, + inscriptions, + runes + .into_iter() + .map(|(spaced_rune, pile)| (spaced_rune.rune, pile.amount)) + .collect(), + ) + } + ) + } + _ => return Err(ServerError::BadRequest(format!("expected array of OutPoint strings ({} is bad)", outpoint))), + } + } + + Ok(Json(result).into_response()) + }) + } + + async fn send_transaction( + Extension(index): Extension>, + Json(data): Json + ) -> ServerResult { + task::block_in_place(|| { + log::info!("POST /sendtx"); + + if !data.is_string() { + return Err(ServerError::BadRequest("expected string".to_string())); + } + + match index.client().send_raw_transaction(data.as_str().unwrap()) { + Ok(ok) => Ok(Json(ok).into_response()), + Err(e) => Err(ServerError::BadRequest(e.to_string())) + } + }) + } + async fn range( Extension(server_config): Extension>, Path((DeserializeFromStr(start), DeserializeFromStr(end))): Path<( @@ -610,6 +804,7 @@ impl Server { DeserializeFromStr, )>, ) -> ServerResult> { + log::info!("GET /range/{start}/{end}"); match start.cmp(&end) { Ordering::Equal => Err(ServerError::BadRequest("empty range".to_string())), Ordering::Greater => Err(ServerError::BadRequest( @@ -619,7 +814,58 @@ impl Server { } } + async fn ranges( + Extension(index): Extension>, + Json(data): Json + ) -> ServerResult { + task::block_in_place(|| { + log::info!("POST /ranges"); + + if !index.has_sat_index() { + return Err(ServerError::BadRequest("the /ranges endpoint needs the server to have a sat index".to_string())); + } + + if !data.is_array() { + return Err(ServerError::BadRequest("expected array".to_string())); + } + + let mut result = Vec::new(); + let mut range_count = 0; + let mut outpoint_count = 0; + let start_time = Instant::now(); + + for outpoint in data.as_array().unwrap() { + if start_time.elapsed() > Duration::from_secs(5) { + return Err(ServerError::BadRequest("request timed out".to_string())); + } + + if !outpoint.is_string() { + return Err(ServerError::BadRequest("expected array of strings".to_string())); + } + + match OutPoint::from_str(outpoint.as_str().unwrap()) { + Ok(outpoint) => { + match index.ranges(outpoint) { + Ok(ranges) => { + range_count += ranges.len(); + outpoint_count += 1; + result.push(Ranges {output: outpoint, ranges}); + } + _ => println!("no ranges for {}", outpoint), + } + } + _ => return Err(ServerError::BadRequest(format!("expected array of OutPoint strings ({} is bad)", outpoint))), + } + } + + println!(" {} ranges from {} outputs in {:?}", range_count, outpoint_count, start_time.elapsed()); + + Ok(Json(result).into_response()) + }) + } + async fn rare_txt(Extension(index): Extension>) -> ServerResult { + log::info!("GET /rare.txt"); task::block_in_place(|| Ok(RareTxt(index.rare_sat_satpoints()?))) } @@ -630,6 +876,7 @@ impl Server { AcceptJson(accept_json): AcceptJson, ) -> ServerResult { task::block_in_place(|| { + log::info!("GET /rune/{spaced_rune}"); if !index.has_rune_index() { return Err(ServerError::NotFound( "this server has no rune index".to_string(), @@ -656,6 +903,7 @@ impl Server { AcceptJson(accept_json): AcceptJson, ) -> ServerResult { task::block_in_place(|| { + log::info!("GET /runes"); Ok(if accept_json { Json(RunesJson { entries: index.runes()?, @@ -676,6 +924,7 @@ impl Server { Extension(index): Extension>, ) -> ServerResult> { task::block_in_place(|| { + log::info!("GET /"); Ok( HomeHtml { inscriptions: index.get_home_inscriptions()?, @@ -704,6 +953,7 @@ impl Server { } async fn install_script() -> Redirect { + log::info!("GET /install.sh"); Redirect::to("https://raw.githubusercontent.com/ordinals/ord/master/install.sh") } @@ -716,6 +966,7 @@ impl Server { task::block_in_place(|| { let (block, height) = match query { BlockQuery::Height(height) => { + log::info!("GET /block/{height}/"); let block = index .get_block_by_height(height)? .ok_or_not_found(|| format!("block {height}"))?; @@ -723,6 +974,7 @@ impl Server { (block, height) } BlockQuery::Hash(hash) => { + log::info!("GET /block/{hash}/"); let info = index .block_header_info(hash)? .ok_or_not_found(|| format!("block {hash}"))?; @@ -760,12 +1012,117 @@ impl Server { }) } + async fn inscriptionids_from_height( + Extension(server_config): Extension>, + Extension(index): Extension>, + Path(height): Path, + ) -> ServerResult { + log::info!("GET /transfers/{height}"); + Self::inscriptionids_from_height_inner(server_config.chain, index.clone(), index.get_inscription_ids_by_height(height)?).await + } + + async fn inscriptionids_from_height_start( + Extension(server_config): Extension>, + Extension(index): Extension>, + Path(path): Path<(u32, usize)>, + ) -> ServerResult { + let height = path.0; + let start = path.1; + log::info!("GET /transfers/{height}/{start}"); + + let inscription_ids = index.get_inscription_ids_by_height(height)?; + let end = inscription_ids.len(); + + match start.cmp(&end) { + Ordering::Equal => Err(ServerError::BadRequest("range length == 0".to_string())), + Ordering::Greater => Err(ServerError::BadRequest("range length < 0".to_string())), + Ordering::Less => { + Self::inscriptionids_from_height_inner(server_config.chain, index.clone(), inscription_ids[start..end].to_vec()).await + } + } + } + + async fn inscriptionids_from_height_start_end( + Extension(server_config): Extension>, + Extension(index): Extension>, + Path(path): Path<(u32, usize, usize)>, + ) -> ServerResult { + let height = path.0; + let start = path.1; + let mut end = path.2; + log::info!("GET /transfers/{height}/{start}/{end}"); + + let inscription_ids = index.get_inscription_ids_by_height(height)?; + end = usize::min(end, inscription_ids.len()); + + match start.cmp(&end) { + Ordering::Equal => Err(ServerError::BadRequest("range length == 0".to_string())), + Ordering::Greater => Err(ServerError::BadRequest("range length < 0".to_string())), + Ordering::Less => { + Self::inscriptionids_from_height_inner(server_config.chain, index.clone(), inscription_ids[start..end].to_vec()).await + } + } + } + + async fn inscriptionids_from_height_inner( + chain: Chain, + index: Arc, + inscription_ids: Vec, + ) -> ServerResult { + task::block_in_place(|| { + let mut ret = String::from(""); + let mut tx_cache = HashMap::new(); + for inscription_id in inscription_ids { + let satpoint = index + .get_inscription_satpoint_by_id(inscription_id)? + .ok_or_not_found(|| format!("inscription {inscription_id}"))?; + let address = Self::outpoint_to_address(chain, &index, satpoint.outpoint, &mut tx_cache)?; + ret += &format!("{} {}\n", inscription_id, address); + } + + Ok(ret) + }) + } + + fn outpoint_to_address( + chain: Chain, + index: &Arc, + outpoint: OutPoint, + tx_cache: &mut HashMap) -> Result { + + let address = if outpoint == unbound_outpoint() { + String::from("unbound") + } else { + let txid = outpoint.txid; + if !tx_cache.contains_key(&txid) { + if let Ok(tx) = index.get_transaction(txid) { + tx_cache.insert(txid, tx.unwrap()); + } else { + return Ok(String::from("lost")); + } + } + + let output = tx_cache.get(&txid).unwrap().clone() + .output + .into_iter() + .nth(outpoint.vout.try_into().unwrap()).unwrap(); + if let Ok(address) = chain.address_from_script(&output.script_pubkey) { + address.to_string() + } else { + String::from("error") + } + }; + + Ok(address) + } + async fn transaction( Extension(server_config): Extension>, Extension(index): Extension>, Path(txid): Path, ) -> ServerResult> { task::block_in_place(|| { + log::info!("GET /tx/{txid}"); let transaction = index .get_transaction(txid)? .ok_or_not_found(|| format!("transaction {txid}"))?; @@ -793,6 +1150,7 @@ impl Server { Path(inscription_id): Path, ) -> ServerResult> { task::block_in_place(|| { + log::info!("GET /r/metadata/{inscription_id}"); let metadata = index .get_inscription_by_id(inscription_id)? .ok_or_not_found(|| format!("inscription {inscription_id}"))? @@ -803,6 +1161,23 @@ impl Server { }) } + async fn stats(Extension(index): Extension>) -> ServerResult { + task::block_in_place(|| { + log::info!("GET /stats"); + let stats = index.get_stats()?; + Ok( + serde_json::to_string_pretty(&StatsJson { + version: env!("CARGO_PKG_VERSION").to_string(), + highest_block_indexed: stats.0, + lowest_inscription_number: stats.1, + highest_inscription_number: stats.2, + }) + .ok() + .unwrap(), + ) + }) + } + async fn status( Extension(server_config): Extension>, Extension(index): Extension>, @@ -821,6 +1196,7 @@ impl Server { Extension(index): Extension>, Query(search): Query, ) -> ServerResult { + log::info!("GET /search"); Self::search(index, search.query).await } @@ -828,6 +1204,7 @@ impl Server { Extension(index): Extension>, Path(search): Path, ) -> ServerResult { + log::info!("GET /search/{}", search.query); Self::search(index, search.query).await } @@ -874,6 +1251,7 @@ impl Server { } async fn favicon(user_agent: Option>) -> ServerResult { + log::info!("GET /favicon.ico"); if user_agent .map(|user_agent| { user_agent.as_str().contains("Safari/") @@ -906,6 +1284,7 @@ impl Server { Extension(index): Extension>, ) -> ServerResult { task::block_in_place(|| { + log::info!("GET /feed.xml"); let mut builder = rss::ChannelBuilder::default(); let chain = server_config.chain; @@ -947,8 +1326,10 @@ impl Server { async fn static_asset(Path(path): Path) -> ServerResult { let content = StaticAssets::get(if let Some(stripped) = path.strip_prefix('/') { + log::info!("GET /static/{stripped}"); stripped } else { + log::info!("GET /static/{path}"); &path }) .ok_or_not_found(|| format!("asset {path}"))?; @@ -963,11 +1344,13 @@ impl Server { } async fn block_count(Extension(index): Extension>) -> ServerResult { + log::info!("GET /blockcount"); task::block_in_place(|| Ok(index.block_count()?.to_string())) } async fn block_height(Extension(index): Extension>) -> ServerResult { task::block_in_place(|| { + log::info!("GET /blockheight"); Ok( index .block_height()? @@ -979,6 +1362,7 @@ impl Server { async fn block_hash(Extension(index): Extension>) -> ServerResult { task::block_in_place(|| { + log::info!("GET /blockhash"); Ok( index .block_hash(None)? @@ -990,6 +1374,7 @@ impl Server { async fn block_hash_json(Extension(index): Extension>) -> ServerResult> { task::block_in_place(|| { + log::info!("GET /r/blockhash"); Ok(Json( index .block_hash(None)? @@ -1004,6 +1389,7 @@ impl Server { Path(height): Path, ) -> ServerResult { task::block_in_place(|| { + log::info!("GET /blockhash/{height}"); Ok( index .block_hash(Some(height))? @@ -1018,6 +1404,7 @@ impl Server { Path(height): Path, ) -> ServerResult> { task::block_in_place(|| { + log::info!("GET /r/blockhash/{height}"); Ok(Json( index .block_hash(Some(height))? @@ -1029,6 +1416,7 @@ impl Server { async fn block_time(Extension(index): Extension>) -> ServerResult { task::block_in_place(|| { + log::info!("GET /blocktime"); Ok( index .block_time(index.block_height()?.ok_or_not_found(|| "blocktime")?)? @@ -1044,6 +1432,7 @@ impl Server { Path(path): Path<(u32, usize, usize)>, ) -> ServerResult> { task::block_in_place(|| { + log::info!("GET /input/{}/{}/{}", path.0, path.1, path.2); let not_found = || format!("input /{}/{}/{}", path.0, path.1, path.2); let block = index @@ -1067,10 +1456,12 @@ impl Server { } async fn faq() -> Redirect { + log::info!("GET /faq"); Redirect::to("https://docs.ordinals.com/faq/") } async fn bounties() -> Redirect { + log::info!("GET /bounties"); Redirect::to("https://docs.ordinals.com/bounty/") } @@ -1082,6 +1473,7 @@ impl Server { accept_encoding: AcceptEncoding, ) -> ServerResult { task::block_in_place(|| { + log::info!("GET /content/{inscription_id}"); if config.is_hidden(inscription_id) { return Ok(PreviewUnknownHtml.into_response()); } @@ -1182,6 +1574,7 @@ impl Server { accept_encoding: AcceptEncoding, ) -> ServerResult { task::block_in_place(|| { + log::info!("GET /preview/{inscription_id}"); if config.is_hidden(inscription_id) { return Ok(PreviewUnknownHtml.into_response()); } @@ -1273,6 +1666,21 @@ impl Server { }) } + async fn inscribe( + Extension(server_config): Extension>, + Extension(index): Extension>, + Json(data): Json + ) -> ServerResult { + task::block_in_place(|| { + log::info!("POST /inscribe"); + + match Inscribe::inscribe_for_server(data.clone(), server_config.chain, &index) { + Ok(result) => Ok(Json(result).into_response()), + Err(str) => Err(ServerError::BadRequest(format!("error: {str}"))), + } + }) + } + async fn inscription( Extension(server_config): Extension>, Extension(index): Extension>, @@ -1280,6 +1688,10 @@ impl Server { AcceptJson(accept_json): AcceptJson, ) -> ServerResult { task::block_in_place(|| { + match query { + InscriptionQuery::Id(id) => log::info!("GET /inscription/{id}"), + InscriptionQuery::Number(inscription_number) => log::info!("GET /inscription/{inscription_number}"), + }; let info = Index::inscription_info(&index, query)? .ok_or_not_found(|| format!("inscription {query}"))?; @@ -1355,6 +1767,7 @@ impl Server { Path(page_index): Path, ) -> ServerResult { task::block_in_place(|| { + log::info!("GET /collections/{page_index}"); let (collections, more_collections) = index.get_collections_paginated(100, page_index)?; let prev = page_index.checked_sub(1); @@ -1392,6 +1805,7 @@ impl Server { Path((parent, page)): Path<(InscriptionId, usize)>, ) -> ServerResult { task::block_in_place(|| { + log::info!("GET /children/{parent}/{page}"); let entry = index .get_inscription_entry(parent)? .ok_or_not_found(|| format!("inscription {parent}"))?; @@ -1431,6 +1845,7 @@ impl Server { Path((parent, page)): Path<(InscriptionId, usize)>, ) -> ServerResult { task::block_in_place(|| { + log::info!("GET /r/children/{parent}/{page}"); let parent_sequence_number = index .get_inscription_entry(parent)? .ok_or_not_found(|| format!("inscription {parent}"))? @@ -1464,6 +1879,7 @@ impl Server { AcceptJson(accept_json): AcceptJson, ) -> ServerResult { task::block_in_place(|| { + log::info!("GET /inscriptions/{page_index}"); let (inscriptions, more) = index.get_inscriptions_paginated(100, page_index)?; let prev = page_index.checked_sub(1); @@ -1511,6 +1927,7 @@ impl Server { AcceptJson(accept_json): AcceptJson, ) -> ServerResult { task::block_in_place(|| { + log::info!("GET /inscriptions/block/{block_height}/{page_index}"); let page_size = 100; let page_index_usize = usize::try_from(page_index).unwrap_or(usize::MAX); @@ -1550,6 +1967,186 @@ impl Server { }) } + async fn inscriptions_json_start( + Extension(server_config): Extension>, + Extension(index): Extension>, + Path(start): Path, + ) -> ServerResult { + log::info!("GET /inscriptions_json/{start}"); + Self::inscriptions_json_inner(server_config.chain, index, start, start + 1).await + } + + async fn inscriptions_json_start_end( + Extension(server_config): Extension>, + Extension(index): Extension>, + Path(path): Path<(i32, i32)>, + ) -> ServerResult { + log::info!("GET /inscriptions_json/{}/{}", path.0, path.1); + Self::inscriptions_json_inner(server_config.chain, index, path.0, path.1).await + } + + async fn inscriptions_json_inner( + chain: Chain, + index: Arc, + start: i32, + end: i32, + ) -> ServerResult { + task::block_in_place(|| { + const MAX_JSON_INSCRIPTIONS: i32 = 1000; + + match start.cmp(&end) { + Ordering::Equal => Err(ServerError::BadRequest("range length == 0".to_string())), + Ordering::Greater => Err(ServerError::BadRequest("range length < 0".to_string())), + Ordering::Less => { + if end - start > MAX_JSON_INSCRIPTIONS { + return Err(ServerError::BadRequest(format!( + "range length > {MAX_JSON_INSCRIPTIONS}" + ))); + } + + let mut ret = Vec::new(); + + for i in start..end { + match index.get_inscription_id_by_inscription_number(i) { + Err(_) => return Err(ServerError::BadRequest(format!("no inscription {i}"))), + Ok(inscription_id) => match inscription_id { + Some(inscription_id) => { + let entry = index + .get_inscription_entry(inscription_id)? + .ok_or_not_found(|| format!("inscription {inscription_id}"))?; + + let tx = index.get_transaction(inscription_id.txid)?.unwrap(); + let inscription = index + .get_inscription_by_id(inscription_id)? + .ok_or_not_found(|| format!("inscription {inscription_id}"))?; + + let satpoint = index + .get_inscription_satpoint_by_id(inscription_id)? + .ok_or_not_found(|| format!("inscription {inscription_id}"))?; + + let output = if satpoint.outpoint.txid == unbound_outpoint().txid { + None + } else { + Some( + if satpoint.outpoint.txid == inscription_id.txid { + tx + } else { + index + .get_transaction(satpoint.outpoint.txid)? + .ok_or_not_found(|| { + format!("inscription {inscription_id} current transaction") + })? + } + .output + .into_iter() + .nth(satpoint.outpoint.vout.try_into().unwrap()) + .ok_or_not_found(|| { + format!("inscription {inscription_id} current transaction output") + })?, + ) + }; + + let mut address = None; + if let Some(output) = &output { + if let Ok(a) = chain.address_from_script(&output.script_pubkey) { + address = Some(a.to_string()); + } + } + + let sequence_number = entry.sequence_number; + + let sat = entry.sat.map(|s| SatoshiJson { + number: s.n(), + decimal: s.decimal().to_string(), + degree: s.degree().to_string(), + percentile: s.percentile().to_string(), + name: s.name(), + cycle: s.cycle(), + epoch: s.epoch().0, + period: s.period(), + block: s.height().0, + offset: s.third(), + rarity: s.rarity(), + // timestamp: index.block_time(s.height())?.unix_timestamp(), + }); + + let content_type = inscription.content_type(); + let unbound_suffix = if satpoint.outpoint == unbound_outpoint() { + " (unbound)" + } else { + "" + }; + + let parent = match entry.parent { + Some(parent) => index.get_inscription_id_by_sequence_number(parent)?, + None => None, + }; + + ret.push(MyInscriptionJson { + number: i, + sequence_number, + id: inscription_id, + parent, + address, + output_value: if output.is_some() { + Some(output.unwrap().value) + } else { + None + }, + sat, + delegate: inscription.delegate(), + content_length: inscription.content_length(), + content_type: content_type.map(|c| c.to_string()), + timestamp: entry.timestamp, + genesis_height: entry.height, + genesis_fee: entry.fee, + genesis_transaction: inscription_id.txid, + location: satpoint.to_string() + unbound_suffix, + output: satpoint.outpoint.to_string() + unbound_suffix, + offset: satpoint.offset, + children: index.get_children_by_sequence_number(sequence_number)?, + }); + } + None => return Err(ServerError::BadRequest(format!("no inscription {i}"))), + }, + } + } + + Ok(serde_json::to_string_pretty(&ret).ok().unwrap()) + } + } + }) + } + + async fn inscriptions_sequence_numbers( + Extension(index): Extension>, + Path(path): Path<(i32, i32)>, + ) -> ServerResult { + task::block_in_place(|| { + log::info!("GET /inscriptions_sequence_numbers/{}/{}", path.0, path.1); + + let start = path.0; + let end = path.1; + + match start.cmp(&end) { + Ordering::Equal => Err(ServerError::BadRequest("range length == 0".to_string())), + Ordering::Greater => Err(ServerError::BadRequest("range length < 0".to_string())), + Ordering::Less => { + let mut ret = String::new(); + + for i in start..end { + match index.get_sequence_number_by_inscription_number(i) { + Err(_) => return Err(ServerError::BadRequest(format!("no inscription {i}"))), + Ok(sequence_number) => ret += format!("{i},{sequence_number}\n").as_str(), + } + } + + Ok(ret) + } + } + }) + } + async fn sat_inscriptions( Extension(index): Extension>, Path(sat): Path, @@ -1562,6 +2159,7 @@ impl Server { Path((sat, page)): Path<(u64, u64)>, ) -> ServerResult> { task::block_in_place(|| { + log::info!("GET /r/sat/{sat}/{page}"); if !index.has_sat_index() { return Err(ServerError::NotFound( "this server has no sat index".to_string(), @@ -1579,6 +2177,7 @@ impl Server { Path((DeserializeFromStr(sat), inscription_index)): Path<(DeserializeFromStr, isize)>, ) -> ServerResult> { task::block_in_place(|| { + log::info!("GET /r/sat/{sat}/at/{inscription_index}"); if !index.has_sat_index() { return Err(ServerError::NotFound( "this server has no sat index".to_string(), diff --git a/src/subcommand/teleburn.rs b/src/subcommand/teleburn.rs index e13d5d57e3..342cd01e7c 100644 --- a/src/subcommand/teleburn.rs +++ b/src/subcommand/teleburn.rs @@ -1,20 +1,51 @@ -use super::*; +use {super::*, crate::index::entry::Entry}; +use base58::ToBase58; #[derive(Debug, Parser)] pub(crate) struct Teleburn { - #[arg(help = "Generate teleburn addresses for inscription .")] - destination: InscriptionId, +@@ -8,11 +9,15 @@ pub(crate) struct Teleburn { +#[derive(Debug, PartialEq, Serialize)] +pub struct Output { + ethereum: EthereumTeleburnAddress, + solana: SolanaTeleburnAddress, } -#[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct Output { - pub ethereum: crate::teleburn::Ethereum, +#[derive(Debug, PartialEq)] +struct EthereumTeleburnAddress([u8; 20]); + +#[derive(Debug, PartialEq)] +struct SolanaTeleburnAddress([u8; 32]); + +impl Serialize for EthereumTeleburnAddress { + fn serialize(&self, serializer: S) -> Result + where +@@ -34,11 +39,29 @@ impl Display for EthereumTeleburnAddress { + } } -impl Teleburn { - pub(crate) fn run(self) -> SubcommandResult { - Ok(Box::new(Output { - ethereum: self.destination.into(), - })) +impl Serialize for SolanaTeleburnAddress { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) } } + +impl Display for SolanaTeleburnAddress { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "{}", self.0.to_base58())?; + + Ok(()) + } +} + +impl Teleburn { + pub(crate) fn run(self) -> Result { + let digest = bitcoin::hashes::sha256::Hash::hash(&self.recipient.store()); + print_json(Output { + ethereum: EthereumTeleburnAddress(digest[0..20].try_into().unwrap()), + solana: SolanaTeleburnAddress(digest[0..32].try_into().unwrap()), + })?; + Ok(()) + } diff --git a/src/subcommand/transfer.rs b/src/subcommand/transfer.rs new file mode 100644 index 0000000000..d25a3f36a0 --- /dev/null +++ b/src/subcommand/transfer.rs @@ -0,0 +1,45 @@ +use super::*; + +#[derive(Debug, Parser)] +pub(crate) struct Transfer { + #[clap(long, help = "Delete the whole transfer log table.")] + delete: bool, + #[clap(long, help = "Delete transfer logs for blocks before height .")] + trim: Option, +} + +impl Transfer { + pub(crate) fn run(self, options: Options) -> SubcommandResult { + let index = Index::open(&options)?; + index.update()?; + + if self.delete && self.trim.is_some() { + return Err(anyhow!("Cannot use both --delete and --trim")); + } + + if self.delete { + println!("deleting transfer log table"); + index.delete_transfer_log()?; + return Ok(Box::new(Empty {})); + } + + if self.trim.is_some() { + let trim = self.trim.unwrap(); + println!("deleting transfer logs for blocks before {trim}"); + index.trim_transfer_log(trim)?; + } + + let (rows, first_key, last_key) = index.show_transfer_log_stats()?; + if rows == 0 { + println!("the transfer table has {rows} rows"); + } else { + println!( + "the transfer table has {rows} rows from height {} to height {}", + first_key.unwrap(), + last_key.unwrap() + ); + } + + Ok(Box::new(Empty {})) + } +} diff --git a/src/subcommand/wallet.rs b/src/subcommand/wallet.rs index 64641d4383..3623cd9dfb 100644 --- a/src/subcommand/wallet.rs +++ b/src/subcommand/wallet.rs @@ -25,6 +25,7 @@ pub mod receive; mod restore; pub mod sats; pub mod send; +pub mod sendmany; pub mod transaction_builder; pub mod transactions; @@ -56,6 +57,8 @@ pub(crate) enum Subcommand { Sats(sats::Sats), #[command(about = "Send sat or inscription")] Send(send::Send), + #[command(about = "Send multiple inscriptions in a single transaction")] + SendMany(sendmany::SendMany), #[command(about = "See wallet transactions")] Transactions(transactions::Transactions), #[command(about = "List all unspent outputs in wallet")] @@ -64,6 +67,14 @@ pub(crate) enum Subcommand { Cardinals, } +#[derive(clap::ValueEnum, Clone, Debug)] +pub(crate) enum AddressType { + Legacy, + P2SHSegwit, + Bech32, + Bech32m, +} + impl Wallet { pub(crate) fn run(self, options: Options) -> SubcommandResult { match self.subcommand { @@ -76,6 +87,7 @@ impl Wallet { Subcommand::Restore(restore) => restore.run(self.name, options), Subcommand::Sats(sats) => sats.run(self.name, options), Subcommand::Send(send) => send.run(self.name, options), + Subcommand::SendMany(sendmany) => sendmany.run(self.name, options), Subcommand::Transactions(transactions) => transactions.run(self.name, options), Subcommand::Outputs => outputs::run(self.name, options), Subcommand::Cardinals => cardinals::run(self.name, options), @@ -157,7 +169,7 @@ pub(crate) fn get_change_address(client: &Client, chain: Chain) -> Result Result { +pub(crate) fn initialize(wallet: String, options: &Options, seed: [u8; 64], address_type: AddressType, ordinalswallet: bool) -> Result { check_version(options.bitcoin_rpc_client(None)?)?.create_wallet( &wallet, None, @@ -177,7 +189,12 @@ pub(crate) fn initialize(wallet: String, options: &Options, seed: [u8; 64]) -> R let fingerprint = master_private_key.fingerprint(&secp); let derivation_path = DerivationPath::master() - .child(ChildNumber::Hardened { index: 86 }) + .child(ChildNumber::Hardened { index: match address_type { + AddressType::Legacy => 44, + AddressType::P2SHSegwit => 49, + AddressType::Bech32 => 84, + AddressType::Bech32m => 86, + } }) .child(ChildNumber::Hardened { index: u32::from(network != Network::Bitcoin), }) @@ -192,6 +209,8 @@ pub(crate) fn initialize(wallet: String, options: &Options, seed: [u8; 64]) -> R (fingerprint, derivation_path.clone()), derived_private_key, change, + &address_type, + ordinalswallet, )?; } @@ -204,13 +223,19 @@ fn derive_and_import_descriptor( origin: (Fingerprint, DerivationPath), derived_private_key: ExtendedPrivKey, change: bool, + address_type: &AddressType, + ordinalswallet: bool, ) -> Result { let secret_key = DescriptorSecretKey::XPrv(DescriptorXKey { origin: Some(origin), xkey: derived_private_key, - derivation_path: DerivationPath::master().child(ChildNumber::Normal { - index: change.into(), - }), + derivation_path: if ordinalswallet { + DerivationPath::master() + } else { + DerivationPath::master().child(ChildNumber::Normal { + index: change.into(), + }) + }, wildcard: Wildcard::Unhardened, }); @@ -219,7 +244,12 @@ fn derive_and_import_descriptor( let mut key_map = std::collections::HashMap::new(); key_map.insert(public_key.clone(), secret_key); - let desc = Descriptor::new_tr(public_key, None)?; + let desc = match address_type { + AddressType::Legacy => Descriptor::new_pkh(public_key), + AddressType::P2SHSegwit => Descriptor::new_sh_wpkh(public_key), + AddressType::Bech32 => Descriptor::new_wpkh(public_key), + AddressType::Bech32m => Descriptor::new_tr(public_key, None), + }?; client.import_descriptors(ImportDescriptors { descriptor: desc.to_string_with_secret(&key_map), @@ -244,6 +274,7 @@ pub(crate) fn bitcoin_rpc_client_for_wallet_command( client.load_wallet(&wallet_name)?; } + if !options.ignore_descriptors { let descriptors = client.list_descriptors(None)?.descriptors; let tr = descriptors @@ -259,6 +290,7 @@ pub(crate) fn bitcoin_rpc_client_for_wallet_command( if tr != 2 || descriptors.len() != 2 + rawtr { bail!("wallet \"{}\" contains unexpected output descriptors, and does not appear to be an `ord` wallet, create a new wallet with `ord wallet create`", wallet_name); } + } Ok(client) } diff --git a/src/subcommand/wallet/create.rs b/src/subcommand/wallet/create.rs index 6b8fb9b0a0..20f29fe801 100644 --- a/src/subcommand/wallet/create.rs +++ b/src/subcommand/wallet/create.rs @@ -14,6 +14,8 @@ pub(crate) struct Create { help = "Use to derive wallet seed." )] pub(crate) passphrase: String, + #[arg(long, value_enum, default_value="bech32m")] + pub(crate) address_type: AddressType, } impl Create { @@ -23,7 +25,7 @@ impl Create { let mnemonic = Mnemonic::from_entropy(&entropy)?; - wallet::initialize(wallet, &options, mnemonic.to_seed(self.passphrase.clone()))?; + wallet::initialize(wallet, &options, mnemonic.to_seed(self.passphrase.clone()), self.address_type, false)?; Ok(Box::new(Output { mnemonic, diff --git a/src/subcommand/wallet/inscribe.rs b/src/subcommand/wallet/inscribe.rs index a27f88e438..fb20a006d0 100644 --- a/src/subcommand/wallet/inscribe.rs +++ b/src/subcommand/wallet/inscribe.rs @@ -1,19 +1,26 @@ use { - self::batch::{Batch, Batchfile, Mode}, + self::batch::{Batch, BatchEntry, Batchfile, Mode}, super::*, crate::subcommand::wallet::transaction_builder::Target, + base64::{Engine as _, engine::general_purpose}, bitcoin::{ blockdata::{opcodes, script}, key::PrivateKey, key::{TapTweak, TweakedKeyPair, TweakedPublicKey, UntweakedKeyPair}, policy::MAX_STANDARD_TX_WEIGHT, + psbt::Psbt, secp256k1::{self, constants::SCHNORR_SIGNATURE_SIZE, rand, Secp256k1, XOnlyPublicKey}, sighash::{Prevouts, SighashCache, TapSighashType}, taproot::Signature, taproot::{ControlBlock, LeafVersion, TapLeafHash, TaprootBuilder}, }, - bitcoincore_rpc::bitcoincore_rpc_json::{ImportDescriptors, SignRawTransactionInput, Timestamp}, + bitcoincore_rpc::bitcoincore_rpc_json::{GetRawTransactionResultVout, ImportDescriptors, SignRawTransactionInput, Timestamp}, bitcoincore_rpc::Client, + bitcoincore_rpc::RawTx, + reqwest::{header, header::USER_AGENT}, + std::{collections::BTreeSet, io::Write}, + tempfile::tempdir, + url::Url, }; mod batch; @@ -24,12 +31,33 @@ pub struct InscriptionInfo { pub location: SatPoint, } -#[derive(Serialize, Deserialize)] +fn is_zero(n: &u64) -> bool { + *n == 0 +} + +#[derive(Serialize, Deserialize, Debug)] pub struct Output { - pub commit: Txid, + #[serde(skip_serializing_if = "Option::is_none")] + pub commit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub commit_hex: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub commit_psbt: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] pub inscriptions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub parent: Option, - pub reveal: Txid, + #[serde(skip_serializing_if = "Option::is_none")] + pub recovery_descriptor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reveal: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reveal_hex: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reveal_psbt: Option, + #[serde(skip_serializing_if = "is_zero")] pub total_fees: u64, } @@ -62,6 +90,15 @@ pub(crate) struct Inscribe { conflicts_with = "json_metadata" )] pub(crate) cbor_metadata: Option, + #[arg( + long, + help = "Consider spending outpoint , even if it is unconfirmed or contains inscriptions" + )] + pub(crate) utxo: Vec, + #[arg(long, help = "Only spend outpoints given with --utxo")] + pub(crate) coin_control: bool, + #[arg(long, help = "Send any change output to .")] + pub(crate) change: Option>, #[arg( long, help = "Use sats/vbyte for commit transaction.\nDefaults to if unset." @@ -95,6 +132,10 @@ pub(crate) struct Inscribe { pub(crate) no_limit: bool, #[clap(long, help = "Make inscription a child of .")] pub(crate) parent: Option, + #[clap(long, help = "Address to return parent inscription to.")] + pub(crate) parent_destination: Option>, + #[clap(long, help = "The satpoint of the parent inscription, in case it isn't confirmed yet.")] + pub(crate) parent_satpoint: Option, #[arg( long, help = "Amount of postage to include in the inscription. Default `10000sat`." @@ -102,50 +143,182 @@ pub(crate) struct Inscribe { pub(crate) postage: Option, #[clap(long, help = "Allow reinscription.")] pub(crate) reinscribe: bool, + #[arg(long, help = "Specify the reveal tx fee.")] + pub(crate) reveal_fee: Option, #[arg(long, help = "Inscribe .")] pub(crate) satpoint: Option, + #[clap(long, help = "Use provided recovery key instead of a random one.")] + pub(crate) key: Option, + #[clap(long, help = "Don't make a reveal tx; just create a commit tx that sends all the sats to a new commitment. Either specify --key if you have one, or note the --key it generates for you. Implies --no-backup.")] + pub(crate) commit_only: bool, + #[clap(long, help = "Don't make a commit transaction; just create a reveal tx that reveals the inscription committed to by output . Requires the same --key as was used to make the commitment. Implies --no-backup. This doesn't work if the --key has ever been backed up to the wallet. When using --commitment, the reveal tx will create a change output unless --reveal-fee is set to '0 sats', in which case the whole commitment will go to postage and fees.")] + pub(crate) commitment: Option, + #[arg(long, help = "Make the change of the reveal tx commit to the contents of multiple inscriptions defined in a yaml .")] + pub(crate) next_batch: Option, + #[clap(long, help = "Make the change of the reveal tx commit to the contents of .")] + pub(crate) next_file: Option, + #[clap(long, help = "Use as an extra input to the reveal tx. For use with `--commitment`.")] + pub(crate) reveal_input: Vec, + #[clap(long, help = "Dump raw hex transactions and recovery keys to standard output.")] + pub(crate) dump: bool, + #[clap(long, help = "Do not broadcast any transactions. Implies --dump.")] + pub(crate) no_broadcast: bool, + #[clap(long, help = "Use as an extra input to the commit tx. Useful for forcing CPFP.")] + pub(crate) commit_input: Vec, #[arg(long, help = "Inscribe .", conflicts_with = "satpoint")] pub(crate) sat: Option, + #[arg(long, help = "Don't use a local wallet. Leave the commit transaction unsigned instead.")] + pub(crate) no_wallet: bool, + #[arg(long, help = "Specify the vsize of the commit tx, for when we don't have a local wallet to sign with.")] + pub(crate) commit_vsize: Option, + #[arg(long, help = "Whether to omit pointer from the envelope of blank inscriptions.")] + pub(crate) skip_pointer_for_none: bool, } impl Inscribe { pub(crate) fn run(self, wallet: String, options: Options) -> SubcommandResult { + if self.commitment.is_some() && self.key.is_none() { + return Err(anyhow!("--commitment only works with --key")); + } + + if self.commit_only && self.commitment.is_some() { + return Err(anyhow!("--commit-only and --commitment don't work together")); + } + + if self.next_batch.is_some() && self.next_file.is_some() { + return Err(anyhow!("--next-batch and --next-file don't work together")); + } + + if self.commit_only && self.next_batch.is_some() { + return Err(anyhow!("--commit-only and --next-batch don't work together")); + } + + if self.commit_only && self.next_file.is_some() { + return Err(anyhow!("--commit-only and --next-file don't work together")); + } + + if self.commitment.is_none() && !self.reveal_input.is_empty() { + return Err(anyhow!("--reveal-input only works with --commitment")); + } + + let mut no_backup = self.no_backup; + if self.commit_only || self.commitment.is_some() { + no_backup = true; + } + + let mut dump = self.dump; let metadata = Inscribe::parse_metadata(self.cbor_metadata, self.json_metadata)?; + if self.no_broadcast { + dump = true; + } + let index = Index::open(&options)?; index.update()?; - let client = bitcoin_rpc_client_for_wallet_command(wallet, &options)?; - - let utxos = get_unspent_outputs(&client, &index)?; + let (mut utxos, locked_utxos, runic_utxos, client) = if self.no_wallet { + let utxos = BTreeMap::new(); + let locked_utxos = BTreeSet::new(); + let runic_utxos = BTreeSet::new(); + let client = check_version(options.bitcoin_rpc_client(None)?)?; + (utxos, locked_utxos, runic_utxos, client) + } else { + let client = bitcoin_rpc_client_for_wallet_command(wallet, &options)?; + + let mut utxos = if self.coin_control { + BTreeMap::new() + } else if options.ignore_outdated_index { + return Err(anyhow!( + "--ignore-outdated-index only works in conjunction with --coin-control when inscribing" + )); + } else { + get_unspent_outputs(&client, &index)? + }; let locked_utxos = get_locked_outputs(&client)?; let runic_utxos = index.get_runic_outputs(&utxos.keys().cloned().collect::>())?; + for outpoint in &self.utxo { + utxos.insert( + *outpoint, + Amount::from_sat( + client.get_raw_transaction(&outpoint.txid, None)?.output[outpoint.vout as usize].value, + ), + ); + } + + (utxos, locked_utxos, runic_utxos, client) + }; + let chain = options.chain(); + let change = match self.change { + Some(change) => Some(change.require_network(chain.network())?), + None => None, + }; + let postage; let destinations; + let fee_utxos; + let inscribe_on_specific_utxos; let inscriptions; let mode; let parent_info; let sat; + let next_inscriptions = if self.next_file.is_some() { + vec![Inscription::from_file( + chain, + None, + self.next_file.unwrap(), + self.parent, + None, + self.metaprotocol.clone(), + metadata.clone(), + self.compress, + self.skip_pointer_for_none, + None, + )?] + } else if self.next_batch.is_some() { + let batchfile = Batchfile::load(&self.next_batch.unwrap())?; + let parent_info = Inscribe::get_parent_info(batchfile.parent, &index, &utxos, &client, chain, batchfile.parent_satpoint, self.no_wallet, self.parent_destination.clone())?; + let postage = batchfile + .postage + .map(Amount::from_sat) + .unwrap_or(TARGET_POSTAGE); + + batchfile.inscriptions( + &client, + chain, + parent_info.as_ref().map(|info| info.tx_out.value), + metadata.clone(), + postage, + self.compress, + self.skip_pointer_for_none, + &mut utxos, + )?.0 + } else { + Vec::new() + }; + match (self.file, self.batch) { (Some(file), None) => { - parent_info = Inscribe::get_parent_info(self.parent, &index, &utxos, &client, chain)?; + parent_info = Inscribe::get_parent_info(self.parent, &index, &utxos, &client, chain, self.parent_satpoint, self.no_wallet, self.parent_destination)?; postage = self.postage.unwrap_or(TARGET_POSTAGE); inscriptions = vec![Inscription::from_file( chain, + None, file, self.parent, None, - self.metaprotocol, - metadata, + self.metaprotocol.clone(), + metadata.clone(), self.compress, + self.skip_pointer_for_none, + None, )?]; mode = Mode::SeparateOutputs; @@ -156,24 +329,29 @@ impl Inscribe { Some(destination) => destination.require_network(chain.network())?, None => get_change_address(&client, chain)?, }]; + + inscribe_on_specific_utxos = false; + fee_utxos = Vec::new(); } (None, Some(batch)) => { let batchfile = Batchfile::load(&batch)?; - parent_info = Inscribe::get_parent_info(batchfile.parent, &index, &utxos, &client, chain)?; + parent_info = Inscribe::get_parent_info(batchfile.parent, &index, &utxos, &client, chain, batchfile.parent_satpoint, self.no_wallet, self.parent_destination)?; postage = batchfile .postage .map(Amount::from_sat) .unwrap_or(TARGET_POSTAGE); - (inscriptions, destinations) = batchfile.inscriptions( + (inscriptions, destinations, inscribe_on_specific_utxos, fee_utxos) = batchfile.inscriptions( &client, chain, parent_info.as_ref().map(|info| info.tx_out.value), metadata, postage, self.compress, + self.skip_pointer_for_none, + &mut utxos, )?; mode = batchfile.mode; @@ -201,21 +379,39 @@ impl Inscribe { self.satpoint }; - Batch { + Ok(Box::new(Batch { commit_fee_rate: self.commit_fee_rate.unwrap_or(self.fee_rate), + commit_only: self.commit_only, + commit_vsize: self.commit_vsize, + commitment: self.commitment, + commitment_output: if self.commitment.is_some() { + Some(client.get_raw_transaction_info(&self.commitment.unwrap().txid, None)?.vout[self.commitment.unwrap().vout as usize].clone()) + } else { + None + }, destinations, + dump, dry_run: self.dry_run, + fee_utxos, + inscribe_on_specific_utxos, inscriptions, + key: self.key, mode, - no_backup: self.no_backup, + next_inscriptions, + no_backup, + no_broadcast: self.no_broadcast, no_limit: self.no_limit, + no_wallet: self.no_wallet, parent_info, postage, reinscribe: self.reinscribe, + reveal_fee: self.reveal_fee, reveal_fee_rate: self.fee_rate, + reveal_input: self.reveal_input, + reveal_psbt: None, satpoint, } - .inscribe(chain, &index, &client, &locked_utxos, runic_utxos, &utxos) + .inscribe(chain, &index, &client, &locked_utxos, runic_utxos, &mut utxos, self.commit_input, change)?)) } fn parse_metadata(cbor: Option, json: Option) -> Result>> { @@ -243,32 +439,360 @@ impl Inscribe { utxos: &BTreeMap, client: &Client, chain: Chain, + satpoint: Option, + no_wallet: bool, + destination: Option>, ) -> Result> { if let Some(parent_id) = parent { - if let Some(satpoint) = index.get_inscription_satpoint_by_id(parent_id)? { - if !utxos.contains_key(&satpoint.outpoint) { - return Err(anyhow!(format!("parent {parent_id} not in wallet"))); + let satpoint = if let Some(satpoint) = satpoint { + satpoint + } else { + if let Some(satpoint) = index.get_inscription_satpoint_by_id(parent_id)? { + satpoint + } else { + return Err(anyhow!(format!("parent {parent_id} does not exist"))); } + }; + + let tx_out = index + .get_transaction(satpoint.outpoint.txid)? + .expect("parent transaction not found in index") + .output + .into_iter() + .nth(satpoint.outpoint.vout.try_into().unwrap()) + .expect("current transaction output"); + + if !no_wallet && !utxos.contains_key(&satpoint.outpoint) { + return Err(anyhow!(format!("parent {parent_id} not in wallet"))); + } - Ok(Some(ParentInfo { - destination: get_change_address(client, chain)?, - id: parent_id, - location: satpoint, - tx_out: index - .get_transaction(satpoint.outpoint.txid)? - .expect("parent transaction not found in index") - .output - .into_iter() - .nth(satpoint.outpoint.vout.try_into().unwrap()) - .expect("current transaction output"), - })) + let destination = if no_wallet { + chain.address_from_script(&tx_out.script_pubkey)? + } else if let Some(destination) = destination { + destination.require_network(chain.network())? } else { - Err(anyhow!(format!("parent {parent_id} does not exist"))) - } + get_change_address(client, chain)? + }; + + Ok(Some(ParentInfo { + destination, + id: parent_id, + location: satpoint, + tx_out, + })) } else { Ok(None) } } + + fn fetch_url_into_file( + client: &reqwest::blocking::Client, + url: &str, + file: &PathBuf, + ) -> Result { + let mut res = client.get(url).send()?; + + if !res.status().is_success() { + bail!(res.status()); + } + + match File::create(file) { + Ok(mut fp) => + match res.copy_to(&mut fp) { + Ok(n) => Ok(n), + Err(x) => return Err(anyhow!("write error: {}", x)), + } + Err(x) => return Err(anyhow!("create file error: {}", x)), + } + } + + pub(crate) fn get_temporary_key( + index: &Index, + chain: Chain, + ) -> Result { + let key_path = index.data_dir().join("key.txt"); + if let Err(err) = fs::create_dir_all(key_path.parent().unwrap()) { + eprintln!("error"); + bail!("failed to create data dir `{}`: {err}", key_path.parent().unwrap().display()); + } + + match fs::read_to_string(key_path.clone()) { + Ok(key) => Ok(key.trim_end().to_string()), + Err(_) => { + let secp256k1 = Secp256k1::new(); + let key_pair = UntweakedKeyPair::new(&secp256k1, &mut rand::thread_rng()); + let key = PrivateKey::new(key_pair.secret_key(), chain.network()).to_wif(); + let mut file = File::create(key_path)?; + file.write(format!("{}\n", key).as_bytes())?; + Ok(key) + } + } + } + + pub(crate) fn inscribe_for_server( + data: serde_json::Value, + chain: Chain, + index: &Index, + ) -> Result { + let no_wallet = true; + + if !data.is_object() { + return Err(anyhow!("expected object, not {:?}", data)); + } + + let data = data.as_object().unwrap(); + + if !data.contains_key("inscriptions") { + return Err(anyhow!("expected object to contain `inscriptions`")); + } + + if !data.contains_key("fees_utxos") { + return Err(anyhow!("expected object to contain `fees_utxos`")); + } + + let inscriptions = data.get("inscriptions").unwrap(); + let fees_utxos = data.get("fees_utxos").unwrap(); + + let commit_vsize = if data.contains_key("commit_vsize") { + let commit_vsize = data.get("commit_vsize").unwrap(); + if !commit_vsize.is_u64() { + return Err(anyhow!("expected `commit_vsize` to be a u64, not {:?}", commit_vsize)); + } + Some(commit_vsize.as_u64().unwrap()) + } else { + None + }; + + let parent = if data.contains_key("parent") { + let parent = data.get("parent").unwrap(); + if !parent.is_string() { + return Err(anyhow!("expected `parent` to be a string, not {:?}", parent)); + } + let parent = parent.as_str().unwrap(); + match InscriptionId::from_str(parent) { + Ok(parent) => Some(parent), + _ => return Err(anyhow!("expected `parent` to contain valid inscriptionid, not {:?}", parent)), + } + } else { + None + }; + + if !inscriptions.is_array() { + return Err(anyhow!("expected `inscriptions` to be an array, not {:?}", inscriptions)); + } + + if !fees_utxos.is_array() { + return Err(anyhow!("expected `fees_utxos` to be an array, not {:?}", fees_utxos)); + } + + let inscriptions = inscriptions.as_array().unwrap(); + let fees_utxos = fees_utxos.as_array().unwrap(); + + let mut entries = Vec::new(); + let tmpdir = tempdir().unwrap(); + let mut headers = header::HeaderMap::new(); + headers.insert(USER_AGENT, header::HeaderValue::from_static("ord inscribe endpoint")); + let request_client = reqwest::blocking::Client::builder().default_headers(headers).build().unwrap(); + + for (i, inscription) in inscriptions.iter().enumerate() { + if !inscription.is_object() { + return Err(anyhow!("expected `inscriptions` to only contain objects, not {:?}", inscription)); + } + + let inscription = inscription.as_object().unwrap(); + + if !inscription.contains_key("file") { + return Err(anyhow!("expected `inscription` to contain `file`")); + } + let file = inscription.get("file").unwrap(); + if !file.is_string() { + return Err(anyhow!("expected `inscriptions[].file` to be a string, not {:?}", file)); + } + let file = file.as_str().unwrap(); + let url = Url::parse(file)?; + let path = PathBuf::from(url.path()); + let ext = match path.extension() { + Some(ext) => ext, + None => return Err(anyhow!("expected URL {:?} path {:?} to have a file extension", file, path)), + }; + let tmpfile = tmpdir.path().join(format!("{i}.{}", ext.to_str().unwrap())); + match Self::fetch_url_into_file(&request_client, file, &tmpfile) { + Ok(body) => { + eprintln!("body is {} bytes", body); + let _ = fs::copy(&tmpfile, "/tmp/file"); + } + Err(e) => return Err(anyhow!("error fetching {} : {}", file, e)), + }; + + if !inscription.contains_key("utxo") { + return Err(anyhow!("expected `inscription` to contain `utxo`")); + } + let utxo = inscription.get("utxo").unwrap(); + if !utxo.is_string() { + return Err(anyhow!("expected `inscriptions[].utxo` to be a string, not {:?}", utxo)); + } + let utxo = utxo.as_str().unwrap(); + let utxo = match OutPoint::from_str(utxo) { + Ok(utxo) => utxo, + _ => return Err(anyhow!("expected `inscriptions[].utxo` to be a valid utxo, not {:?}", utxo)), + }; + + let metadata = if inscription.contains_key("metadata") { + Some(inscription.get("metadata").unwrap().clone()) + } else { + None + }; + + if !inscription.contains_key("destination") { + return Err(anyhow!("expected `inscription` to contain `destination`")); + } + let destination = inscription.get("destination").unwrap(); + if !destination.is_string() { + return Err(anyhow!("expected `inscriptions[].destination` to be a string, not {:?}", destination)); + } + let destination = destination.as_str().unwrap(); + let destination: Address = match destination.parse() { + Ok(destination) => destination, + Err(_) => return Err(anyhow!("expected `inscriptions[].destination` to be a valid address, not {:?}", destination)), + }; + + /* we don't need to check addresses for the correct network type here, because the batch file expects unchecked addresses + let destination: Address = match destination.clone().require_network(chain.network()) { + Ok(destination) => destination, + Err(_) => return Err(anyhow!("expected `inscriptions[].destination` to be valid for the current chain, not {:?}", destination)), + }; + */ + + entries.push(BatchEntry { + delegate: None, + destination: Some(destination), + file: tmpfile.into(), + metadata: None, + metadata_json: metadata, + metaprotocol: None, + offset: None, + pointer: None, + utxo: Some(utxo), + }); + } + + let mut fees = Vec::new(); + + for fees_utxo in fees_utxos { + if !fees_utxo.is_string() { + return Err(anyhow!("expected `fees_utxos` to only contain strings, not {:?}", fees_utxo)); + } + + let fees_utxo = fees_utxo.as_str().unwrap(); + let fees_utxo = match OutPoint::from_str(fees_utxo) { + Ok(fees_utxo) => fees_utxo, + _ => return Err(anyhow!("expected `fees_utxos` to contain valid utxos, not {:?}", fees_utxo)), + }; + + fees.push(fees_utxo); + } + + let batchfile = Batchfile { + fees: Some(fees), + inscriptions: entries, + mode: Mode::SeparateOutputs, + parent, + ..Default::default() + }; + + let mut utxos = BTreeMap::new(); + let locked_utxos = BTreeSet::new(); + let runic_utxos = BTreeSet::new(); + let client = index.client(); + + let change = None; + + let postage; + let destinations; + let fee_utxos; + let inscribe_on_specific_utxos; + let inscriptions; + let mode; + let parent_info; + let next_inscriptions; + + let compress = false; + + parent_info = Inscribe::get_parent_info(batchfile.parent, &index, &utxos, &client, chain, batchfile.parent_satpoint, no_wallet, None)?; + + postage = batchfile + .postage + .map(Amount::from_sat) + .unwrap_or(TARGET_POSTAGE); + + (inscriptions, destinations, inscribe_on_specific_utxos, fee_utxos) = batchfile.inscriptions( + &client, + chain, + parent_info.as_ref().map(|info| info.tx_out.value), + None, + Amount::from_sat(0), + compress, + false, + &mut utxos, + )?; + next_inscriptions = Vec::new(); + + mode = batchfile.mode; + + if batchfile.sat.is_some() && mode != Mode::SameSat { + return Err(anyhow!("`sat` can only be set in `same-sat` mode")); + } + + let satpoint = None; + + let key = Some(Self::get_temporary_key(index, chain)?); + key.clone().map(|key| eprintln!("using key {key}")); + + let reveal_psbt = if data.contains_key("reveal_psbt") { + let reveal_psbt = data.get("reveal_psbt").unwrap(); + if !reveal_psbt.is_string() { + return Err(anyhow!("expected `reveal_psbt` to be a string, not {:?}", reveal_psbt)); + } + let reveal_psbt = reveal_psbt.as_str().unwrap(); + eprintln!("got reveal_psbt: {reveal_psbt}"); + match Psbt::from_str(reveal_psbt) { + Ok(psbt) => Some(psbt), + Err(e) => return Err(anyhow!("reveal_psbt {}", e)), + } + } else { + None + }; + + Batch { + commit_fee_rate: FeeRate::try_from(0.0).unwrap(), + commit_only: false, + commit_vsize, + commitment: None, + commitment_output: None, + destinations, + dump: true, + dry_run: false, + fee_utxos, + inscribe_on_specific_utxos, + inscriptions, + key, + mode, + next_inscriptions, + no_backup: true, + no_broadcast: true, + no_limit: false, + no_wallet, + parent_info, + postage, + reinscribe: false, + reveal_fee: None, + reveal_fee_rate: FeeRate::try_from(0.0).unwrap(), + reveal_input: Vec::new(), + reveal_psbt, + satpoint, + } + .inscribe(chain, &index, &client, &locked_utxos, runic_utxos, &mut utxos, Vec::new(), change) + } } #[cfg(test)] diff --git a/src/subcommand/wallet/inscribe/batch.rs b/src/subcommand/wallet/inscribe/batch.rs index 1024097825..d7a8c2ddc3 100644 --- a/src/subcommand/wallet/inscribe/batch.rs +++ b/src/subcommand/wallet/inscribe/batch.rs @@ -2,16 +2,30 @@ use super::*; pub(super) struct Batch { pub(super) commit_fee_rate: FeeRate, + pub(super) commit_only: bool, + pub(super) commit_vsize: Option, + pub(super) commitment: Option, + pub(super) commitment_output: Option, pub(super) destinations: Vec
, + pub(super) dump: bool, pub(super) dry_run: bool, + pub(super) fee_utxos: Vec, + pub(super) inscribe_on_specific_utxos: bool, pub(super) inscriptions: Vec, + pub(super) key: Option, pub(super) mode: Mode, + pub(super) next_inscriptions: Vec, pub(super) no_backup: bool, + pub(super) no_broadcast: bool, pub(super) no_limit: bool, + pub(super) no_wallet: bool, pub(super) parent_info: Option, pub(super) postage: Amount, pub(super) reinscribe: bool, + pub(super) reveal_fee: Option, pub(super) reveal_fee_rate: FeeRate, + pub(super) reveal_input: Vec, + pub(super) reveal_psbt: Option, pub(super) satpoint: Option, } @@ -19,16 +33,30 @@ impl Default for Batch { fn default() -> Batch { Batch { commit_fee_rate: 1.0.try_into().unwrap(), + commit_only: false, + commit_vsize: None, + commitment: None, + commitment_output: None, destinations: Vec::new(), + dump: false, dry_run: false, + fee_utxos: Vec::new(), + inscribe_on_specific_utxos: false, inscriptions: Vec::new(), + key: None, mode: Mode::SharedOutput, + next_inscriptions: Vec::new(), no_backup: false, + no_broadcast: false, no_limit: false, + no_wallet: false, parent_info: None, postage: Amount::from_sat(10_000), reinscribe: false, + reveal_fee: None, reveal_fee_rate: 1.0.try_into().unwrap(), + reveal_input: Vec::new(), + reveal_psbt: None, satpoint: None, } } @@ -42,94 +70,325 @@ impl Batch { client: &Client, locked_utxos: &BTreeSet, runic_utxos: BTreeSet, - utxos: &BTreeMap, - ) -> SubcommandResult { + utxos: &mut BTreeMap, + force_input: Vec, + change: Option
, + ) -> Result { + let use_psbt_for_commit = true; // when not signing the commit, should we use psbt or hex for the unsigned commit tx? + let wallet_inscriptions = index.get_inscriptions(utxos)?; - let commit_tx_change = [ - get_change_address(client, chain)?, + if !self.fee_utxos.is_empty() { + if self.reveal_fee_rate != FeeRate::try_from(0.0)? { + return Err(anyhow!("use `--fee-rate 0` when using specific utxos to pay fees; the rate will be calculated from the size of the fee utxo(s)")); + } + if self.commit_fee_rate != FeeRate::try_from(0.0)? { + return Err(anyhow!("don't use `--commit-fee-rate` when using specific utxos to pay fees; the rate will be calculated from the size of the fee utxo(s)")); + } + if !force_input.is_empty() { + return Err(anyhow!("don't use `--commit-input` when using specific utxos to pay fees")); + } + + for outpoint in &self.fee_utxos { + if !utxos.contains_key(&outpoint) { + utxos.insert(*outpoint, Amount::from_sat(client.get_raw_transaction(&outpoint.txid, None)?.output[outpoint.vout as usize].value)); + } + } + } + + let force_input = if self.fee_utxos.is_empty() { + force_input + } else { + self.fee_utxos.clone() + }; + + let commit_tx_change = if self.no_wallet { + None + } else { + Some([ get_change_address(client, chain)?, - ]; + match change { + Some(change) => change, + None => get_change_address(client, chain)?, + }, + ])}; - let (commit_tx, reveal_tx, recovery_key_pair, total_fees) = self + let (commit_tx, reveal_tx, recovery_key_pair, total_fees, dummy_commit_psbt) = self .create_batch_inscription_transactions( wallet_inscriptions, + index, chain, locked_utxos.clone(), runic_utxos, utxos.clone(), commit_tx_change, + force_input, + client, )?; + if dummy_commit_psbt.is_some() { + let dummy_commit_psbt = dummy_commit_psbt.unwrap(); + return Ok(self.output(None, None, None, + Some(dummy_commit_psbt), + Some("sign commit_psbt then re-run the /inscribe endpoint with `commit_vsize` in the input JSON set to the vsize of the signed tx; the tx has 0 fees so you can't accidentally broadcast it".to_string()), + None, None, None, 0, Vec::new(), &BTreeMap::new())); + } + + let commit_tx = commit_tx.unwrap(); + let mut reveal_tx = reveal_tx.unwrap(); + let recovery_key_pair = recovery_key_pair.unwrap(); + let total_fees = total_fees.unwrap(); + if self.dry_run { - return Ok(Box::new(self.output( - commit_tx.txid(), - reveal_tx.txid(), + return Ok(self.output( + if self.commitment.is_some() { + None + } else { + Some(commit_tx.txid()) + }, + if self.commit_only { + None + } else { + Some(reveal_tx.txid()) + }, + None, + None, + None, + None, + None, + None, total_fees, self.inscriptions.clone(), - ))); + utxos, + )); } - let signed_commit_tx = client + let signed_commit_tx = if self.commitment.is_some() || self.no_wallet { + Vec::new() + } else { + client .sign_raw_transaction_with_wallet(&commit_tx, None, None)? - .hex; + .hex + }; - let signed_reveal_tx = if self.parent_info.is_some() { + let mut reveal_input_info = Vec::new(); + + if self.parent_info.is_some() { + for (vout, output) in commit_tx.output.iter().enumerate() { + reveal_input_info.push(SignRawTransactionInput { + txid: commit_tx.txid(), + vout: vout.try_into().unwrap(), + script_pub_key: output.script_pubkey.clone(), + redeem_script: None, + amount: Some(Amount::from_sat(output.value)), + }); + } + } + + for input in &self.reveal_input { + let output = index.get_transaction(input.txid)?.unwrap().output[input.vout as usize].clone(); + reveal_input_info.push(SignRawTransactionInput { + txid: input.txid, + vout: input.vout, + script_pub_key: output.script_pubkey.clone(), + redeem_script: None, + amount: Some(Amount::from_sat(output.value)), + }); + } + + let signed_reveal_tx = if (reveal_input_info.is_empty() && self.parent_info.is_none()) || self.no_wallet { + consensus::encode::serialize(&reveal_tx) + } else { client .sign_raw_transaction_with_wallet( &reveal_tx, - Some( - &commit_tx - .output - .iter() - .enumerate() - .map(|(vout, output)| SignRawTransactionInput { - txid: commit_tx.txid(), - vout: vout.try_into().unwrap(), - script_pub_key: output.script_pubkey.clone(), - redeem_script: None, - amount: Some(Amount::from_sat(output.value)), - }) - .collect::>(), - ), + Some(&reveal_input_info), None, )? .hex - } else { - consensus::encode::serialize(&reveal_tx) }; - if !self.no_backup { + if self.no_wallet { + let commit_tx_hex = if use_psbt_for_commit { + general_purpose::STANDARD.encode(Psbt::from_unsigned_tx(commit_tx.clone())?.serialize()) + } else { + commit_tx.raw_hex() + }; + + let blank_reveal_psbt = if let Some(reveal_psbt) = self.reveal_psbt.clone() { + // eprintln!("\nwe have been given a reveal psbt:\n{:#?}\ncopy its signature(s) to our reveal_tx", reveal_psbt); + let extracted_tx = reveal_psbt.extract_tx(); + // eprintln!("\nextracted tx {:?}", extracted_tx); +/* + for (i, input) in extracted_tx.input.iter().enumerate() { + eprintln!("\ninput {i}: {:?}", input); + eprintln!(" prevout outpoint: {:?}", input.previous_output); + eprintln!(" witness: {:?}", input.witness); + } + + eprintln!("\n---"); + + eprintln!("\nour reveal tx {:?}", reveal_tx); + + for (i, input) in reveal_tx.input.iter().enumerate() { + eprintln!("\ninput {i}: {:?}", input); + eprintln!(" prevout outpoint: {:?}", input.previous_output); + eprintln!(" witness: {:?}", input.witness); + } + + eprintln!("\n---"); +*/ + if extracted_tx.input.len() != reveal_tx.input.len() { + return Err(anyhow!("supplied reveal_psbt has {} inputs but should have {}", extracted_tx.input.len(), reveal_tx.input.len())); + } + + for (i, input) in extracted_tx.input.iter().enumerate() { + if input.previous_output != reveal_tx.input[i].previous_output { + return Err(anyhow!("prevout of input {i} of reveal_psbt is incorrect")); + } + + if reveal_tx.input[i].witness.len() == 0 { + if input.witness.len() > 0 { + reveal_tx.input[i] = input.clone(); + } else { + return Err(anyhow!("input {i} of reveal_psbt isn't signed")); + } + } + } +/* + eprintln!("\n---"); + eprintln!("\nmerged txs:"); + + for (i, input) in reveal_tx.input.iter().enumerate() { + eprintln!("\ninput {i}: {:?}", input); + eprintln!(" prevout outpoint: {:?}", input.previous_output); + eprintln!(" witness: {:?}", input.witness); + } +*/ + None + } else { + // copy the reveal_tx, and blank out all the witnesses so we can convert it to a Psbt + let mut blank_reveal_tx = reveal_tx.clone(); + let mut any_unsigned = false; + for input in &mut blank_reveal_tx.input { + if input.witness.len() == 0 { + any_unsigned = true; + } + input.witness = Witness::new(); + } + + if any_unsigned { + let commit_txid = commit_tx.txid(); + let mut blank_reveal_psbt = Psbt::from_unsigned_tx(blank_reveal_tx.clone())?; + let mut found_commit_output = false; + for (i, input) in blank_reveal_tx.input.iter().enumerate() { + if commit_txid == input.previous_output.txid { + if found_commit_output { + return Err(anyhow!("reveal has multiple inputs from the commit tx")); + } + found_commit_output = true; + blank_reveal_psbt.inputs[i].witness_utxo = Some(commit_tx.output[input.previous_output.vout as usize].clone()) + } + } + if !found_commit_output { + return Err(anyhow!("reveal has no inputs from the commit tx")); + } + Some(general_purpose::STANDARD.encode(blank_reveal_psbt.serialize())) + } else { + None + } + }; + + return Ok(self.output(None, None, None, + Some(commit_tx_hex), + Some(if self.parent_info.is_none() { + "sign commit_psbt, then broadcast the signed result and reveal_hex" + } else { + "sign commit_psbt and reveal_hex, then broadcast them both. or sign the reveal_psbt, add it to the input json, and run the /inscribe endpoint again" + }.to_string()), + Some(consensus::encode::serialize(&reveal_tx).raw_hex()), + blank_reveal_psbt, + None, 0, Vec::new(), &BTreeMap::new())); + } + + if !self.no_backup && self.key.is_none() { Self::backup_recovery_key(client, recovery_key_pair, chain.network())?; } - let commit = client.send_raw_transaction(&signed_commit_tx)?; + let (commit, reveal) = if self.no_broadcast { + (if self.commitment.is_some() { None } + else { Some(client.decode_raw_transaction(&signed_commit_tx, None)?.txid) }, + if self.commit_only { None } + else { Some(client.decode_raw_transaction(&signed_reveal_tx, None)?.txid) }) + } else { + let commit = if self.commitment.is_some() { + None + } else { + Some(client.send_raw_transaction(&signed_commit_tx)?) + }; - let reveal = match client.send_raw_transaction(&signed_reveal_tx) { - Ok(txid) => txid, - Err(err) => { - return Err(anyhow!( - "Failed to send reveal transaction: {err}\nCommit tx {commit} will be recovered once mined" + let reveal = if self.commit_only { + None + } else { + match client.send_raw_transaction(&signed_reveal_tx) { + Ok(txid) => Some(txid), + Err(err) => { + return Err(anyhow!( + format!("Failed to send reveal transaction: {err}{}", if commit.is_some() { format!("\nCommit tx {:?} will be recovered once mined", commit) } else { "".to_string() }) )) - } + } + } + }; + + (commit, reveal) }; - Ok(Box::new(self.output( + Ok(self.output( commit, reveal, + if self.dump && self.commitment.is_none() { Some(signed_commit_tx.raw_hex()) } else { None }, + None, None, + if self.dump && !self.commit_only { Some(signed_reveal_tx.raw_hex()) } else { None }, + None, + if self.dump { Some(Self::get_recovery_key(&client, recovery_key_pair, chain.network())?.to_string()) } else { None }, total_fees, self.inscriptions.clone(), - ))) + utxos, + )) } fn output( &self, - commit: Txid, - reveal: Txid, + commit: Option, + reveal: Option, + commit_hex: Option, + commit_psbt: Option, + message: Option, + reveal_hex: Option, + reveal_psbt: Option, + recovery_descriptor: Option, total_fees: u64, inscriptions: Vec, + utxos: &BTreeMap, ) -> super::Output { + if commit_psbt.is_some() { + return super::Output { + commit: None, + commit_hex: None, + commit_psbt, + inscriptions: Vec::new(), + message, + parent: None, + recovery_descriptor: None, + reveal: None, + reveal_hex, + reveal_psbt, + total_fees: 0, + }; + } + let mut inscriptions_output = Vec::new(); + let mut offset = 0; for index in 0..inscriptions.len() { let index = u32::try_from(index).unwrap(); @@ -150,26 +409,37 @@ impl Batch { } }; - let offset = match self.mode { - Mode::SharedOutput => u64::from(index) * self.postage.to_sat(), - Mode::SeparateOutputs | Mode::SameSat => 0, - }; - + if !self.commit_only { inscriptions_output.push(InscriptionInfo { id: InscriptionId { - txid: reveal, + txid: reveal.unwrap(), index, }, location: SatPoint { - outpoint: OutPoint { txid: reveal, vout }, + outpoint: OutPoint { txid: reveal.unwrap(), vout }, offset, }, }); + } + + if self.mode == Mode::SharedOutput { + offset += if self.inscribe_on_specific_utxos { + utxos[&self.inscriptions[index as usize].utxo.unwrap()] + } else { + self.postage + }.to_sat() + } } super::Output { commit, + commit_hex, + commit_psbt: None, + message: None, reveal, + reveal_hex, + reveal_psbt: None, + recovery_descriptor, total_fees, parent: self.parent_info.clone().map(|info| info.id), inscriptions: inscriptions_output, @@ -179,12 +449,15 @@ impl Batch { pub(crate) fn create_batch_inscription_transactions( &self, wallet_inscriptions: BTreeMap, + index: &Index, chain: Chain, locked_utxos: BTreeSet, runic_utxos: BTreeSet, mut utxos: BTreeMap, - change: [Address; 2], - ) -> Result<(Transaction, Transaction, TweakedKeyPair, u64)> { + change: Option<[Address; 2]>, + force_input: Vec, + client: &Client, + ) -> Result<(Option, Option, Option, Option, Option)> { if let Some(parent_info) = &self.parent_info { assert!(self .inscriptions @@ -192,6 +465,18 @@ impl Batch { .all(|inscription| inscription.parent().unwrap() == parent_info.id)) } + if !self.fee_utxos.is_empty() && !self.inscribe_on_specific_utxos { + return Err(anyhow!("listing utxos to use as fees only works when inscribing on specified utxos")); + } + + if !self.next_inscriptions.is_empty() && self.commitment.is_none() { + return Err(anyhow!("--next-batch and --next-file don't work without --commitment")); + } + + if !self.fee_utxos.is_empty() && self.reveal_fee.is_some() { + return Err(anyhow!("--reveal-fee doesn't work when specifying fee_utxos")); + } + match self.mode { Mode::SameSat => assert_eq!( self.destinations.len(), @@ -210,7 +495,12 @@ impl Batch { ), } - let satpoint = if let Some(satpoint) = self.satpoint { + let satpoints = if self.inscribe_on_specific_utxos { + self.inscriptions.iter().map(|inscription| SatPoint {outpoint: inscription.utxo.unwrap(), offset: 0}).collect::>() + } else { + let satpoint = if self.commitment.is_some() { + SatPoint::from_str("0000000000000000000000000000000000000000000000000000000000000000:0:0")? + } else if let Some(satpoint) = self.satpoint { satpoint } else { let inscribed_utxos = wallet_inscriptions @@ -225,6 +515,7 @@ impl Batch { && !inscribed_utxos.contains(outpoint) && !locked_utxos.contains(outpoint) && !runic_utxos.contains(outpoint) + && !self.fee_utxos.contains(outpoint) }) .map(|(outpoint, _amount)| SatPoint { outpoint: *outpoint, @@ -232,9 +523,12 @@ impl Batch { }) .ok_or_else(|| anyhow!("wallet contains no cardinal utxos"))? }; + vec![satpoint] + }; let mut reinscription = false; + for satpoint in satpoints.clone() { for (inscribed_satpoint, inscription_id) in &wallet_inscriptions { if *inscribed_satpoint == satpoint { reinscription = true; @@ -252,6 +546,7 @@ impl Batch { )); } } + } if self.reinscribe && !reinscription { return Err(anyhow!( @@ -260,7 +555,15 @@ impl Batch { } let secp256k1 = Secp256k1::new(); - let key_pair = UntweakedKeyPair::new(&secp256k1, &mut rand::thread_rng()); + let key_pair = if self.key.is_some() { + secp256k1::KeyPair::from_secret_key(&secp256k1, &PrivateKey::from_wif(&self.key.clone().unwrap())?.inner) + } else { + let key_pair = UntweakedKeyPair::new(&secp256k1, &mut rand::thread_rng()); + if self.commit_only { + eprintln!("use --key {} to reveal this commitment", PrivateKey::new(key_pair.secret_key(), chain.network()).to_wif()); + } + key_pair + }; let (public_key, _parity) = XOnlyPublicKey::from_keypair(&key_pair); let reveal_script = Inscription::append_batch_reveal_script( @@ -282,23 +585,57 @@ impl Batch { let commit_tx_address = Address::p2tr_tweaked(taproot_spend_info.output_key(), chain.network()); - let total_postage = match self.mode { + let reveal_change_address = if !self.next_inscriptions.is_empty() { + let next_reveal_script = Inscription::append_batch_reveal_script( + &self.next_inscriptions, + ScriptBuf::builder() + .push_slice(public_key.serialize()) + .push_opcode(opcodes::all::OP_CHECKSIG), + ); + + let next_taproot_spend_info = TaprootBuilder::new() + .add_leaf(0, next_reveal_script.clone()) + .expect("adding leaf should work") + .finalize(&secp256k1, public_key) + .expect("finalizing taproot builder should work"); + + Some(Address::p2tr_tweaked(next_taproot_spend_info.output_key(), chain.network())) + } else if change.is_some() { + Some(change.clone().unwrap()[0].clone()) + } else { + None + }; + + let total_postage = if self.inscribe_on_specific_utxos { + self.inscriptions.iter().map(|entry| utxos[&entry.utxo.unwrap()]).sum::() + } else { + match self.mode { Mode::SameSat => self.postage, Mode::SharedOutput | Mode::SeparateOutputs => { self.postage * u64::try_from(self.inscriptions.len()).unwrap() } + } }; - let mut reveal_inputs = vec![OutPoint::null()]; + let mut reveal_inputs = self.reveal_input.clone(); + reveal_inputs.insert(0, OutPoint::null()); + let mut count = 0; let mut reveal_outputs = self .destinations .iter() - .map(|destination| TxOut { - script_pubkey: destination.script_pubkey(), - value: match self.mode { - Mode::SeparateOutputs => self.postage.to_sat(), - Mode::SharedOutput | Mode::SameSat => total_postage.to_sat(), - }, + .map(|destination| { + count += 1; + TxOut { + script_pubkey: destination.script_pubkey(), + value: match self.mode { + Mode::SeparateOutputs => if self.inscribe_on_specific_utxos { + utxos[&self.inscriptions[count - 1].utxo.unwrap()].to_sat() + } else { + self.postage.to_sat() + }, + Mode::SharedOutput | Mode::SameSat => total_postage.to_sat(), + } + } }) .collect::>(); @@ -321,7 +658,16 @@ impl Batch { let commit_input = if self.parent_info.is_some() { 1 } else { 0 }; - let (_, reveal_fee) = Self::build_reveal_transaction( + if self.reveal_fee != Some(Amount::from_sat(0)) { + if self.commitment.is_some() { + reveal_outputs.push(TxOut { + script_pubkey: reveal_change_address.unwrap().script_pubkey(), + value: 0, + }); + } + } + + let (_, mut reveal_fee, reveal_vsize) = Self::build_reveal_transaction( &control_block, self.reveal_fee_rate, reveal_inputs.clone(), @@ -330,8 +676,69 @@ impl Batch { &reveal_script, ); - let unsigned_commit_tx = TransactionBuilder::new( - satpoint, + let commit_vsize = if self.fee_utxos.is_empty() { + 0 + } else { + let dummy_commit_tx = TransactionBuilder::new( + satpoints.clone(), + wallet_inscriptions.clone(), + utxos.clone(), + locked_utxos.clone(), + runic_utxos.clone(), + commit_tx_address.clone(), + change.clone(), + self.commit_fee_rate, + Target::NoChange(Amount::from_sat(0)), + force_input.clone(), + self.no_wallet, + ).build_transaction()?; + + if self.no_wallet { + if let Some(commit_vsize) = self.commit_vsize { + commit_vsize + } else { + // todo - can we figure out how big this will be after signing without signing it? + let dummy_commit_psbt = general_purpose::STANDARD.encode(Psbt::from_unsigned_tx(dummy_commit_tx)?.serialize()); + return Ok((None, None, None, None, Some(dummy_commit_psbt))); + } + } else { + let dummy_commit_signed = client.sign_raw_transaction_with_wallet(&dummy_commit_tx, None, None)?; + if !dummy_commit_signed.complete { + for error in dummy_commit_signed.errors.unwrap() { + eprintln!("{:#?}", error); + } + bail!("failed to sign dummy commit tx"); + } + client.decode_raw_transaction(&dummy_commit_signed.hex, None)?.vsize as u64 + } + }; + + if !self.fee_utxos.is_empty() { + let fee_utxos_value = self.fee_utxos.iter().map(|outpoint| utxos[&outpoint]).sum::(); + let total_vsize = commit_vsize + reveal_vsize; + // eprintln!("total_vsize {} = commit_vsize {} + reveal_vsize {}", total_vsize, commit_vsize, reveal_vsize); + reveal_fee = (fee_utxos_value * reveal_vsize + Amount::from_sat(total_vsize - 1)) / total_vsize; + // eprintln!("reveal_fee = (fee_utxos {} * reveal_vsize {} + total_vsize {} - 1) / total_vsize {} = reveal_fee {}", fee_utxos_value.to_sat(), reveal_vsize, total_vsize, total_vsize, reveal_fee.to_sat()); + } else if let Some(r) = self.reveal_fee { + if r != Amount::from_sat(0) { + if r < reveal_fee { + return Err(anyhow!("requested reveal_fee is too small; should be at least {} sats", reveal_fee.to_sat())); + } + + reveal_fee = r; + } + } + + let unsigned_commit_tx = if self.commitment.is_some() { + Transaction { + version: 0, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![], + } + } else { + TransactionBuilder::new( + satpoints, wallet_inscriptions, utxos.clone(), locked_utxos.clone(), @@ -339,23 +746,55 @@ impl Batch { commit_tx_address.clone(), change, self.commit_fee_rate, - Target::Value(reveal_fee + total_postage), - ) - .build_transaction()?; + if self.commit_only { + Target::NoChange(reveal_fee + total_postage) + } else if !self.fee_utxos.is_empty() { + Target::ChangeIsFee(reveal_fee + total_postage) + } else { + Target::Value(reveal_fee + total_postage) + }, + force_input, + self.no_wallet, + ) + .build_transaction()? + }; - let (vout, _commit_output) = unsigned_commit_tx - .output - .iter() - .enumerate() - .find(|(_vout, output)| output.script_pubkey == commit_tx_address.script_pubkey()) - .expect("should find sat commit/inscription output"); + let mut reveal_input_value = Amount::from_sat(0); + let mut reveal_input_prevouts = Vec::new(); + for i in &self.reveal_input { + let output = index.get_transaction(i.txid)?.unwrap().output[i.vout as usize].clone(); + reveal_input_value += Amount::from_sat(output.value); + reveal_input_prevouts.push(output.clone()); + utxos.insert(*i, Amount::from_sat(output.value)); + } + + let vout = if self.commitment.is_some() { + reveal_inputs[commit_input] = self.commitment.unwrap(); + + if self.reveal_fee != Some(Amount::from_sat(0)) { + if let Some(last) = reveal_outputs.last_mut() { + (*last).value = (reveal_input_value + self.commitment_output.clone().unwrap().value - total_postage - reveal_fee).to_sat(); + } + } - reveal_inputs[commit_input] = OutPoint { - txid: unsigned_commit_tx.txid(), - vout: vout.try_into().unwrap(), + 0 + } else { + let (vout, _commit_output) = unsigned_commit_tx + .output + .iter() + .enumerate() + .find(|(_vout, output)| output.script_pubkey == commit_tx_address.script_pubkey()) + .expect("should find sat commit/inscription output"); + + reveal_inputs[commit_input] = OutPoint { + txid: unsigned_commit_tx.txid(), + vout: vout.try_into().unwrap(), + }; + + vout }; - let (mut reveal_tx, _fee) = Self::build_reveal_transaction( + let (mut reveal_tx, _fee, _vsize) = Self::build_reveal_transaction( &control_block, self.reveal_fee_rate, reveal_inputs, @@ -373,12 +812,26 @@ impl Batch { bail!("commit transaction output would be dust"); } - let mut prevouts = vec![unsigned_commit_tx.output[vout].clone()]; + let mut prevouts = vec![ + if self.commitment.is_some() { + TxOut { + value: self.commitment_output.clone().unwrap().value.to_sat(), + script_pubkey: self.commitment_output.clone().unwrap().script_pub_key.script()? + } + } else { + unsigned_commit_tx.output[vout].clone() + } + ]; if let Some(parent_info) = self.parent_info.clone() { - prevouts.insert(0, parent_info.tx_out); + prevouts.insert(0, parent_info.clone().tx_out); + if self.no_wallet { + utxos.insert(parent_info.location.outpoint, Amount::from_sat(parent_info.tx_out.value)); + } } + prevouts.extend(reveal_input_prevouts); + let mut sighash_cache = SighashCache::new(&mut reveal_tx); let sighash = sighash_cache @@ -432,16 +885,44 @@ impl Batch { utxos.insert( reveal_tx.input[commit_input].previous_output, + if self.commitment.is_some() { + self.commitment_output.clone().unwrap().value + } else { Amount::from_sat( unsigned_commit_tx.output[reveal_tx.input[commit_input].previous_output.vout as usize] .value, - ), + ) + }, ); let total_fees = - Self::calculate_fee(&unsigned_commit_tx, &utxos) + Self::calculate_fee(&reveal_tx, &utxos); + if self.commitment.is_some() { + 0 + } else { + Self::calculate_fee(&unsigned_commit_tx, &utxos) + } + if self.commit_only { + 0 + } else { + Self::calculate_fee(&reveal_tx, &utxos) + }; - Ok((unsigned_commit_tx, reveal_tx, recovery_key_pair, total_fees)) + Ok((Some(unsigned_commit_tx), Some(reveal_tx), Some(recovery_key_pair), Some(total_fees), None)) + } + + fn get_recovery_key( + client: &Client, + recovery_key_pair: TweakedKeyPair, + network: Network, + ) -> Result { + let recovery_private_key = + PrivateKey::new(recovery_key_pair.to_inner().secret_key(), network).to_wif(); + Ok(format!( + "rawtr({})#{}", + recovery_private_key, + client + .get_descriptor_info(&format!("rawtr({})", recovery_private_key))? + .checksum + )) } fn backup_recovery_key( @@ -479,7 +960,7 @@ impl Batch { commit_input_index: usize, outputs: Vec, script: &Script, - ) -> (Transaction, Amount) { + ) -> (Transaction, Amount, u64) { let reveal_tx = Transaction { input: inputs .iter() @@ -495,7 +976,7 @@ impl Batch { version: 2, }; - let fee = { + let (fee, vsize) = { let mut reveal_tx = reveal_tx.clone(); for (current_index, txin) in reveal_tx.input.iter_mut().enumerate() { @@ -513,10 +994,11 @@ impl Batch { } } - fee_rate.fee(reveal_tx.vsize()) + let vsize = reveal_tx.vsize(); + (fee_rate.fee(vsize), vsize as u64) }; - (reveal_tx, fee) + (reveal_tx, fee, vsize) } fn calculate_fee(tx: &Transaction, utxos: &BTreeMap) -> u64 { @@ -543,16 +1025,28 @@ pub(crate) enum Mode { #[derive(Deserialize, Default, PartialEq, Debug, Clone)] #[serde(deny_unknown_fields)] pub(crate) struct BatchEntry { + pub(crate) delegate: Option, pub(crate) destination: Option>, pub(crate) file: PathBuf, pub(crate) metadata: Option, + pub(crate) metadata_json: Option, pub(crate) metaprotocol: Option, + pub(crate) offset: Option, + pub(crate) pointer: Option, + pub(crate) utxo: Option, } impl BatchEntry { pub(crate) fn metadata(&self) -> Result>> { Ok(match &self.metadata { - None => None, + None => match &self.metadata_json { + Some(metadata) => { + let mut cbor = Vec::new(); + ciborium::into_writer(&metadata, &mut cbor)?; + Some(cbor) + } + None => None, + } Some(metadata) => { let mut cbor = Vec::new(); ciborium::into_writer(&metadata, &mut cbor)?; @@ -565,9 +1059,11 @@ impl BatchEntry { #[derive(Deserialize, PartialEq, Debug, Clone, Default)] #[serde(deny_unknown_fields)] pub(crate) struct Batchfile { + pub(crate) fees: Option>, pub(crate) inscriptions: Vec, pub(crate) mode: Mode, pub(crate) parent: Option, + pub(crate) parent_satpoint: Option, pub(crate) postage: Option, pub(crate) sat: Option, } @@ -591,7 +1087,9 @@ impl Batchfile { metadata: Option>, postage: Amount, compress: bool, - ) -> Result<(Vec, Vec
)> { + skip_pointer_for_none: bool, + utxos: &mut BTreeMap, + ) -> Result<(Vec, Vec
, bool, Vec)> { assert!(!self.inscriptions.is_empty()); if self @@ -605,6 +1103,32 @@ impl Batchfile { )); } + let inscribe_on_specific_utxos = if self.inscriptions.iter().any(|entry| entry.utxo.is_some()) { + if self.inscriptions.iter().all(|entry| entry.utxo.is_some()) { + true + } else { + return Err(anyhow!("if utxo is set for any inscription it must be set for all inscriptions")) + } + } else { + false + }; + + if inscribe_on_specific_utxos { + if self.postage.is_some() { + return Err(anyhow!("postage size cannot be set when specifying the utxo to inscribe on for each inscription")) + } + + if self.mode == Mode::SameSat { + return Err(anyhow!("Inscription utxos can't be specified in `same-sat` mode")); + } + + for outpoint in self.inscriptions.iter().map(|entry| entry.utxo.unwrap()) { + if !utxos.contains_key(&outpoint) { + utxos.insert(outpoint, Amount::from_sat(client.get_raw_transaction(&outpoint.txid, None)?.output[outpoint.vout as usize].value)); + } + } + } + if metadata.is_some() { assert!(self .inscriptions @@ -616,20 +1140,36 @@ impl Batchfile { let mut inscriptions = Vec::new(); for (i, entry) in self.inscriptions.iter().enumerate() { + if entry.offset.is_some() && entry.pointer.is_some() { + return Err(anyhow!("you can't specify `offset` and `pointer` for the same inscription (inscription {i})")); + } inscriptions.push(Inscription::from_file( chain, + entry.delegate, &entry.file, self.parent, - if i == 0 { None } else { Some(pointer) }, + match entry.pointer { + Some(pointer) => Some(pointer), + None => match entry.offset { + Some(offset) => Some(pointer + offset), + None => if i == 0 { None } else { Some(pointer) }, + }, + }, entry.metaprotocol.clone(), match &metadata { Some(metadata) => Some(metadata.clone()), None => entry.metadata()?, }, compress, + skip_pointer_for_none, + entry.utxo, )?); - pointer += postage.to_sat(); + if inscribe_on_specific_utxos { + pointer += utxos[&entry.utxo.unwrap()].to_sat(); + } else { + pointer += postage.to_sat(); + } } let destinations = match self.mode { @@ -651,6 +1191,11 @@ impl Batchfile { .collect::, _>>()?, }; - Ok((inscriptions, destinations)) + let fees = match self.fees.clone() { + Some(fees) => fees, + None => Vec::new(), + }; + + Ok((inscriptions, destinations, inscribe_on_specific_utxos, fees)) } } diff --git a/src/subcommand/wallet/inscriptions.rs b/src/subcommand/wallet/inscriptions.rs index 5cf2c682f8..96c733a55a 100644 --- a/src/subcommand/wallet/inscriptions.rs +++ b/src/subcommand/wallet/inscriptions.rs @@ -16,7 +16,7 @@ pub(crate) fn run(wallet: String, options: Options) -> SubcommandResult { let unspent_outputs = get_unspent_outputs(&client, &index)?; - let inscriptions = index.get_inscriptions(&unspent_outputs)?; + let inscriptions = index.get_inscriptions_vector(&unspent_outputs)?; let explorer = match options.chain() { Chain::Mainnet => "https://ordinals.com/inscription/", diff --git a/src/subcommand/wallet/restore.rs b/src/subcommand/wallet/restore.rs index d4e48b6b78..29b98ea627 100644 --- a/src/subcommand/wallet/restore.rs +++ b/src/subcommand/wallet/restore.rs @@ -10,6 +10,10 @@ pub(crate) struct Restore { help = "Use when deriving wallet" )] pub(crate) passphrase: String, + #[arg(long, value_enum, default_value="bech32m")] + pub(crate) address_type: AddressType, + #[arg(long, help = "Restore from an ordinalswallet seed phrase. This will break most things, but might be useful rarely.")] + pub(crate) ordinalswallet: bool, } impl Restore { @@ -18,6 +22,8 @@ impl Restore { wallet_name, &options, self.mnemonic.to_seed(self.passphrase), + self.address_type, + self.ordinalswallet, )?; Ok(Box::new(Empty {})) diff --git a/src/subcommand/wallet/send.rs b/src/subcommand/wallet/send.rs index bf1d98b0c4..41b53c1372 100644 --- a/src/subcommand/wallet/send.rs +++ b/src/subcommand/wallet/send.rs @@ -4,6 +4,18 @@ use {super::*, crate::subcommand::wallet::transaction_builder::Target}; pub(crate) struct Send { address: Address, outgoing: Outgoing, + #[arg( + long, + help = "Consider spending outpoint , even if it is unconfirmed or contains inscriptions" + )] + utxo: Vec, + #[clap( + long, + help = "Only spend outpoints given with --utxo when sending inscriptions or satpoints" + )] + pub(crate) coin_control: bool, + #[arg(long, help = "Send any change output to .")] + pub(crate) change: Option>, #[arg(long, help = "Use fee rate of sats/vB")] fee_rate: FeeRate, #[arg( @@ -11,6 +23,8 @@ pub(crate) struct Send { help = "Target amount of postage to include with sent inscriptions. Default `10000sat`" )] pub(crate) postage: Option, + #[clap(long, help = "Require this utxo to be spent. Useful for forcing CPFP.")] + pub(crate) force_input: Vec, } #[derive(Serialize, Deserialize)] @@ -33,7 +47,24 @@ impl Send { let chain = options.chain(); - let unspent_outputs = get_unspent_outputs(&client, &index)?; + let mut unspent_outputs = if self.coin_control { + BTreeMap::new() + } else if options.ignore_outdated_index { + return Err(anyhow!( + "--ignore-outdated-index only works in conjunction with --coin-control when sending" + )) + } else { + get_unspent_outputs(&client, &index)? + }; + + for outpoint in &self.utxo { + unspent_outputs.insert( + *outpoint, + Amount::from_sat( + client.get_raw_transaction(&outpoint.txid, None)?.output[outpoint.vout as usize].value, + ), + ); + } let locked_outputs = get_locked_outputs(&client)?; @@ -44,6 +75,9 @@ impl Send { let satpoint = match self.outgoing { Outgoing::Amount(amount) => { + if self.coin_control || !self.utxo.is_empty() { + bail!("--coin_control and --utxo don't work when sending cardinals"); + } Self::lock_non_cardinal_outputs(&client, &inscriptions, &runic_outputs, unspent_outputs)?; let transaction = Self::send_amount(&client, amount, address, self.fee_rate)?; return Ok(Box::new(Output { transaction })); @@ -82,9 +116,17 @@ impl Send { } }; + let change = match self.change { + Some(change) => Some(change.require_network(chain.network())?), + None => None, + }; + let change = [ get_change_address(&client, chain)?, - get_change_address(&client, chain)?, + match change { + Some(change) => change, + None => get_change_address(&client, chain)?, + }, ]; let postage = if let Some(postage) = self.postage { @@ -94,15 +136,17 @@ impl Send { }; let unsigned_transaction = TransactionBuilder::new( - satpoint, + vec![satpoint], inscriptions, unspent_outputs, locked_outputs, runic_outputs, address.clone(), - change, + Some(change), self.fee_rate, postage, + self.force_input, + false, ) .build_transaction()?; diff --git a/src/subcommand/wallet/sendmany.rs b/src/subcommand/wallet/sendmany.rs new file mode 100644 index 0000000000..e865efec26 --- /dev/null +++ b/src/subcommand/wallet/sendmany.rs @@ -0,0 +1,380 @@ +use { + super::*, + bitcoin::{ + locktime::absolute::LockTime, + policy::MAX_STANDARD_TX_WEIGHT, + Witness, + }, + bitcoincore_rpc::RawTx, + std::{ + collections::BTreeSet, + fs::File, + io::{BufRead, BufReader}, + }, +}; + +#[derive(Debug, Parser, Clone)] +pub(crate) struct SendMany { + #[arg(long, help = "Use fee rate of sats/vB")] + fee_rate: FeeRate, + #[arg(long, help = "Location of a CSV file containing `inscriptionid`,`destination` pairs.")] + pub(crate) csv: PathBuf, + #[arg(long, help = "Broadcast the transaction; the default is to output the raw tranasction hex so you can check it before broadcasting.")] + pub(crate) broadcast: bool, + #[arg(long, help = "Do not check that the transaction is equal to or below the MAX_STANDARD_TX_WEIGHT of 400,000 weight units. Transactions over this limit are currently nonstandard and will not be relayed by bitcoind in its default configuration. Do not use this flag unless you understand the implications." + )] + pub(crate) no_limit: bool, + #[arg(long, help = "By default it is an error to list only some of the inscriptions in an output. This flag allows you to not care about the inscriptions you don't list in the CVS file.")] + pub(crate) ignore_unlisted: bool, + #[arg(long, help = "The smallest amount to use for each inscription output.")] + pub(crate) min_postage: Option, + #[arg(long, help = "The largest amount to use for each inscription output.")] + pub(crate) max_postage: Option, + #[arg(long, help = "The address to send cardinal outputs to.")] + pub(crate) change: Option>, + #[arg(long, help = "Which cardinal to use to pay the fees.")] + pub(crate) cardinal: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct Output { + pub tx: String, +} + +impl SendMany { + const SCHNORR_SIGNATURE_SIZE: usize = 64; + + pub(crate) fn run(self, wallet: String, options: Options) -> SubcommandResult { + let file = File::open(&self.csv)?; + let reader = BufReader::new(file); + let mut line_number = 1; + let mut requested = BTreeMap::new(); + + let chain = options.chain(); + + if self.min_postage.is_some() && self.max_postage.is_some() && self.min_postage.unwrap() > self.max_postage.unwrap() { + bail!("--min-postage {} sats is bigger than --max-postage {} sats", self.min_postage.unwrap().to_sat(), self.max_postage.unwrap().to_sat()); + } + + for line in reader.lines() { + let line = line?; + let mut line = line.trim_start_matches('\u{feff}').split(','); + + let inscriptionid = line.next().ok_or_else(|| { + anyhow!("CSV file '{}' is not formatted correctly - no inscriptionid on line {line_number}", self.csv.display()) + })?; + + let inscriptionid = match InscriptionId::from_str(inscriptionid) { + Err(e) => bail!("bad inscriptionid on line {line_number}: {}", e), + Ok(ok) => ok, + }; + + let destination = line.next().ok_or_else(|| { + anyhow!("CSV file '{}' is not formatted correctly - no comma on line {line_number}", self.csv.display()) + })?; + + let destination = match match Address::from_str(destination) { + Err(e) => bail!("bad address on line {line_number}: {}", e), + Ok(ok) => ok, + }.require_network(chain.network()) { + Err(e) => bail!("bad network for address on line {line_number}: {}", e), + Ok(ok) => ok, + }; + + if requested.contains_key(&inscriptionid) { + bail!("duplicate entry for {} on line {}", inscriptionid.to_string(), line_number); + } + + requested.insert(inscriptionid, destination); + line_number += 1; + } + + let index = Index::open(&options)?; + index.update()?; + + let client = bitcoin_rpc_client_for_wallet_command(wallet, &options)?; + let unspent_outputs = get_unspent_outputs(&client, &index)?; + let locked_outputs = get_locked_outputs(&client)?; + + // we get a vector of (SatPoint, InscriptionId), and turn it into a map -> + let mut inscriptions = BTreeMap::new(); + for (satpoint, inscriptionid) in index.get_inscriptions_vector(&unspent_outputs)? { + inscriptions.insert(inscriptionid, satpoint); + } + + let mut inputs = Vec::new(); + let mut outputs = Vec::new(); + + let mut requested_satpoints: BTreeMap = BTreeMap::new(); + + // this loop checks that we own all the listed inscriptions, and that we aren't listing the same sat more than once + for (inscriptionid, address) in &requested { + if !inscriptions.contains_key(&inscriptionid) { + bail!("inscriptionid {} isn't in the wallet", inscriptionid.to_string()); + } + + let satpoint = inscriptions[&inscriptionid]; + if requested_satpoints.contains_key(&satpoint) { + bail!("inscriptionid {} is on the same sat as {}, and both appear in the CSV file", inscriptionid.to_string(), requested_satpoints[&satpoint].0); + } + requested_satpoints.insert(satpoint, (inscriptionid.clone(), address.clone())); + } + + let change_dust_limit = Self::get_change_pubkey(&client, chain, self.change.clone())?.dust_value().to_sat(); + + let mut cardinal_value = 0; + // this loop handles the inscriptions in order of offset in each utxo + while !requested.is_empty() { + let mut inscriptions_on_outpoint; + let mut inscriptions_to_send = Vec::new(); + // pick the first remaining inscriptionid from the list + for (inscriptionid, _address) in &requested { + // look up which utxo it's in + let outpoint = inscriptions[inscriptionid].outpoint; + // get a list of the inscriptions in that utxo + inscriptions_on_outpoint = index.get_inscriptions_on_output_with_satpoints(outpoint)?; + // sort it by offset + inscriptions_on_outpoint.sort_by_key(|(s, _)| s.offset); + // make sure that they are all in the csv file, unless --ignore-unlisted is in effect + for (satpoint, outpoint_inscriptionid) in &inscriptions_on_outpoint { + if self.ignore_unlisted { + if requested_satpoints.contains_key(&satpoint) { + inscriptions_to_send.push((satpoint, outpoint_inscriptionid)); + } + } else { + if !requested_satpoints.contains_key(&satpoint) { + bail!("inscriptionid {} is in the same output as {} but wasn't in the CSV file", outpoint_inscriptionid.to_string(), inscriptionid.to_string()); + } + inscriptions_to_send.push((satpoint, outpoint_inscriptionid)); + } + } + break; + } + + // create an input for the first inscription of each utxo + let (first_satpoint, _first_inscription) = inscriptions_to_send[0]; + let first_offset = first_satpoint.offset; + let first_outpoint = first_satpoint.outpoint; + let utxo_value = unspent_outputs[&first_outpoint].to_sat(); + if first_offset != 0 { + cardinal_value += first_offset + } + inputs.push(first_outpoint); + + // filter out the inscriptions that aren't in our list, but are still to be sent - these are inscriptions that are on the same sat as the ones we listed + // we want to remove just the ones where the satpoint is requested but that particular inscriptionid isn't + // ie. keep the ones where the satpoint isn't requested or the inscriptionid is + inscriptions_to_send = inscriptions_to_send.into_iter().filter( + |(satpoint, inscriptionid)| !requested_satpoints.contains_key(&satpoint) || requested.contains_key(&inscriptionid) + ).collect(); + + // create an output for each inscription in this utxo + for (i, (satpoint, inscriptionid)) in inscriptions_to_send.iter().enumerate() { + if cardinal_value != 0 { + outputs.push(TxOut{ + script_pubkey: Self::get_change_pubkey(&client, chain, self.change.clone())?, + value: cardinal_value + }); + cardinal_value = 0; + } + + let destination = &requested_satpoints[&satpoint].1; + let offset = satpoint.offset; + let mut value = if i == inscriptions_to_send.len() - 1 { // if this is the last inscription in the output, use all the remaining sats + utxo_value - offset + } else { // else use the sats up to the next inscription + inscriptions_to_send[i + 1].0.offset - offset + }; + + let script_pubkey = destination.script_pubkey(); + let dust_limit = script_pubkey.dust_value().to_sat(); + + if let Some(min_postage) = self.min_postage { + if value < min_postage.to_sat() { + bail!("inscription {} at {} is only followed by {} sats, less than the specified --min-postage of {} sats", + inscriptionid, satpoint.to_string(), value, min_postage.to_sat()); + } + } + + if let Some(max_postage) = self.max_postage { + if value > max_postage.to_sat() { + if value - max_postage.to_sat() >= change_dust_limit { // if using the max-postage size would leave a big enough change, do that + cardinal_value = value - max_postage.to_sat(); + value -= cardinal_value; + } else { // otherwise leave a big enough change + cardinal_value = change_dust_limit; + value -= cardinal_value; + + if let Some(min_postage) = self.min_postage { + if value < min_postage.to_sat() { + bail!("trimming inscription {} at {} output of size {} sats so it doesn't exceed --max-postage {} sats leaves it smaller than --min-postage of {} sats", + inscriptionid, satpoint.to_string(), value, min_postage.to_sat(), max_postage.to_sat()); + } + } + } + } + } + if value < dust_limit { + bail!("inscription {} at {} would only have size {} sats, less than dust limit {} for address {}", + inscriptionid, satpoint.to_string(), value, dust_limit, destination); + } + outputs.push(TxOut{script_pubkey, value}); + + // remove each inscription in this utxo from the list + requested.remove(&inscriptionid); + } + } + + let script_pubkey = Self::get_change_pubkey(&client, chain, self.change.clone())?; + let value = 0; // we don't know how much change to take until we know the fee, which means knowing the tx vsize + outputs.push(TxOut{script_pubkey: script_pubkey.clone(), value}); + + // calculate the size of the tx without an extra cardinal input once it is signed + let fake_tx = Self::build_fake_transaction(&inputs, &outputs); + let weight = fake_tx.weight(); + if !self.no_limit && weight > bitcoin::Weight::from_wu(MAX_STANDARD_TX_WEIGHT.into()) { + bail!( + "transaction weight greater than {MAX_STANDARD_TX_WEIGHT} (MAX_STANDARD_TX_WEIGHT): {weight}" + ); + } + let fee = self.fee_rate.fee(fake_tx.vsize()).to_sat(); + let needed = fee + change_dust_limit; + let value; + if cardinal_value < needed { + // eprintln!("left over amount ({} sats) is too small\n we need enough for fee {} plus dust limit {} = {} sats", cardinal_value, fee, change_dust_limit, needed); + + let (cardinal_outpoint, new_cardinal_value) = match self.cardinal { + Some(cardinal) => (cardinal, unspent_outputs[&cardinal].to_sat()), + None => { + // select the biggest cardinal - this could be improved by figuring out what size we need, and picking the next biggest for example + // get a list of available unlocked cardinals + let cardinals = Self::get_cardinals(unspent_outputs.clone(), locked_outputs, inscriptions); + + if cardinals.is_empty() { + bail!("wallet has no cardinals"); + } + + cardinals[0] + } + }; + + // eprintln!("we have {} left over, and {} in the biggest cardinal", cardinal_value, new_cardinal_value); + + // use the biggest cardinal as the last input + inputs.push(cardinal_outpoint); + + // calculate the size of the tx once it is signed + let fake_tx = Self::build_fake_transaction(&inputs, &outputs); + let weight = fake_tx.weight(); + if !self.no_limit && weight > bitcoin::Weight::from_wu(MAX_STANDARD_TX_WEIGHT.into()) { + bail!( + "transaction weight greater than {MAX_STANDARD_TX_WEIGHT} (MAX_STANDARD_TX_WEIGHT): {weight}" + ); + } + let fee = self.fee_rate.fee(fake_tx.vsize()).to_sat(); + let needed = fee + change_dust_limit; + if cardinal_value + new_cardinal_value < needed { + bail!("cardinal {} ({} sats) is too small\n we need enough for fee {} plus dust limit {} = {} sats", + cardinal_outpoint.to_string(), new_cardinal_value, fee, change_dust_limit, needed - cardinal_value); + } + value = cardinal_value + new_cardinal_value - fee; + } else { + value = cardinal_value - fee; + } + + let last = outputs.len() - 1; + outputs[last] = TxOut{script_pubkey, value}; + + let tx = Self::build_transaction(&inputs, &outputs); + + let signed_tx = client.sign_raw_transaction_with_wallet(&tx, None, None)?; + let signed_tx = signed_tx.hex; + + if self.broadcast { + let txid = client.send_raw_transaction(&signed_tx)?.to_string(); + Ok(Box::new(Output { tx: txid })) + } else { + Ok(Box::new(Output { tx: signed_tx.raw_hex() })) + } + } + + fn get_change_pubkey( + client: &Client, + chain: Chain, + change: Option>, + ) -> Result { + Ok(match change { + Some(change) => change.require_network(chain.network()).unwrap(), + None => get_change_address(&client, chain)?, + }.script_pubkey()) + } + + fn get_cardinals( + unspent_outputs: BTreeMap, + locked_outputs: BTreeSet, + inscriptions: BTreeMap, + ) -> Vec<(OutPoint, u64)> { + let inscribed_utxos = + inscriptions // get a tree of the inscriptions we own + .values() // just the SatPoints + .map(|satpoint| satpoint.outpoint) // just the OutPoints of those SatPoints + .collect::>(); // as a set of OutPoints + + let mut cardinal_utxos = unspent_outputs + .iter() + .filter_map(|(output, amount)| { + if inscribed_utxos.contains(output) || locked_outputs.contains(output) { + None + } else { + Some(( + *output, + amount.to_sat(), + )) + } + }) + .collect::>(); + + cardinal_utxos.sort_by_key(|x| x.1); + cardinal_utxos.reverse(); + cardinal_utxos + } + + fn build_transaction( + inputs: &Vec, + outputs: &Vec, + ) -> Transaction { + Transaction { + input: inputs + .iter() + .map(|outpoint| TxIn { + previous_output: *outpoint, + script_sig: script::Builder::new().into_script(), + witness: Witness::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + }) + .collect(), + output: outputs.clone(), + lock_time: LockTime::ZERO, + version: 1, + } + } + + fn build_fake_transaction( + inputs: &Vec, + outputs: &Vec, + ) -> Transaction { + Transaction { + input: (0..inputs.len()) + .map(|_| TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::from_slice(&[&[0; Self::SCHNORR_SIGNATURE_SIZE]]), + }) + .collect(), + output: outputs.clone(), + lock_time: LockTime::ZERO, + version: 1, + } + } +} diff --git a/src/subcommand/wallet/transaction_builder.rs b/src/subcommand/wallet/transaction_builder.rs index 12699e7a2e..33bb65b31f 100644 --- a/src/subcommand/wallet/transaction_builder.rs +++ b/src/subcommand/wallet/transaction_builder.rs @@ -46,6 +46,7 @@ pub enum Error { NotEnoughCardinalUtxos, NotInWallet(SatPoint), OutOfRange(SatPoint, u64), + OutputNotInWallet(OutPoint), UtxoContainsAdditionalInscription { outgoing_satpoint: SatPoint, inscribed_satpoint: SatPoint, @@ -59,6 +60,8 @@ pub enum Target { Value(Amount), Postage, ExactPostage(Amount), + NoChange(Amount), + ChangeIsFee(Amount), } impl fmt::Display for Error { @@ -70,6 +73,7 @@ impl fmt::Display for Error { } => write!(f, "output value is below dust value: {output_value} < {dust_value}"), Error::NotInWallet(outgoing_satpoint) => write!(f, "outgoing satpoint {outgoing_satpoint} not in wallet"), Error::OutOfRange(outgoing_satpoint, maximum) => write!(f, "outgoing satpoint {outgoing_satpoint} offset higher than maximum {maximum}"), + Error::OutputNotInWallet(outpoint) => write!(f, "outpoint {outpoint} not in wallet"), Error::NotEnoughCardinalUtxos => write!( f, "wallet does not contain enough cardinal UTXOs, please add additional funds to wallet." @@ -95,10 +99,12 @@ pub struct TransactionBuilder { amounts: BTreeMap, change_addresses: BTreeSet
, fee_rate: FeeRate, + force_input: Vec, inputs: Vec, inscriptions: BTreeMap, locked_utxos: BTreeSet, - outgoing: SatPoint, + no_wallet: bool, + outgoing: Vec, outputs: Vec<(Address, Amount)>, recipient: Address, runic_utxos: BTreeSet, @@ -116,35 +122,45 @@ impl TransactionBuilder { pub(crate) const MAX_POSTAGE: Amount = Amount::from_sat(2 * 10_000); pub fn new( - outgoing: SatPoint, + outgoing: Vec, inscriptions: BTreeMap, amounts: BTreeMap, locked_utxos: BTreeSet, runic_utxos: BTreeSet, recipient: Address, - change: [Address; 2], + change: Option<[Address; 2]>, fee_rate: FeeRate, target: Target, + force_input: Vec, + no_wallet: bool, ) -> Self { Self { utxos: amounts.keys().cloned().collect(), amounts, - change_addresses: change.iter().cloned().collect(), + change_addresses: match change.clone() { + Some(change) => change.iter().cloned().collect(), + None => BTreeSet::new(), + }, fee_rate, inputs: Vec::new(), + force_input: force_input, inscriptions, locked_utxos, + no_wallet, outgoing, outputs: Vec::new(), recipient, runic_utxos, target, - unused_change_addresses: change.to_vec(), + unused_change_addresses: match change { + Some(change) => change.to_vec(), + None => Vec::new(), + }, } } pub fn build_transaction(self) -> Result { - if self.change_addresses.len() < 2 { + if !self.no_wallet && self.change_addresses.len() < 2 { return Err(Error::DuplicateAddress( self.change_addresses.first().unwrap().clone(), )); @@ -179,43 +195,63 @@ impl TransactionBuilder { } fn select_outgoing(mut self) -> Result { - let dust_limit = self + let dust_limit = if self.no_wallet { + 0 + } else { + self .unused_change_addresses .last() .unwrap() .script_pubkey() .dust_value() - .to_sat(); + .to_sat() + }; + for outgoing in self.outgoing.clone() { for (inscribed_satpoint, inscription_id) in self.inscriptions.iter().rev() { - if self.outgoing.outpoint == inscribed_satpoint.outpoint - && self.outgoing.offset != inscribed_satpoint.offset - && self.outgoing.offset < inscribed_satpoint.offset + dust_limit + if outgoing.outpoint == inscribed_satpoint.outpoint + && outgoing.offset != inscribed_satpoint.offset + && outgoing.offset < inscribed_satpoint.offset + dust_limit { return Err(Error::UtxoContainsAdditionalInscription { - outgoing_satpoint: self.outgoing, + outgoing_satpoint: outgoing, inscribed_satpoint: *inscribed_satpoint, inscription_id: *inscription_id, }); } } + } + + let mut amount = Amount::from_sat(0); + for outgoing in &self.outgoing { + amount += match self.amounts.get(&outgoing.outpoint) { + Some(amount) => *amount, + None => return Err(Error::NotInWallet(*outgoing)), + } + } - let amount = *self - .amounts - .get(&self.outgoing.outpoint) - .ok_or(Error::NotInWallet(self.outgoing))?; + if self.outgoing[0].offset >= amount.to_sat() { + return Err(Error::OutOfRange(self.outgoing[0], amount.to_sat() - 1)); + } - if self.outgoing.offset >= amount.to_sat() { - return Err(Error::OutOfRange(self.outgoing, amount.to_sat() - 1)); + for outgoing in self.outgoing.clone() { + self.utxos.remove(&outgoing.outpoint); + self.inputs.push(outgoing.outpoint); } - self.utxos.remove(&self.outgoing.outpoint); - self.inputs.push(self.outgoing.outpoint); + for input in &self.force_input { + self.inputs.push(*input); + amount += match self.amounts.get(&input) { + Some(amount) => *amount, + None => return Err(Error::OutputNotInWallet(*input)), + }; + self.utxos.remove(&input); + } self.outputs.push((self.recipient.clone(), amount)); tprintln!( - "selected outgoing outpoint {} with value {}", - self.outgoing.outpoint, + "selected {} outgoing outpoint(s) with value {}", + self.outgoing.len(), amount.to_sat() ); @@ -239,10 +275,7 @@ impl TransactionBuilder { self.outputs.insert( 0, ( - self - .unused_change_addresses - .pop() - .expect("not enough change addresses"), + self.unused_change_addresses[0].clone(), Amount::from_sat(sat_offset), ), ); @@ -288,7 +321,7 @@ impl TransactionBuilder { let min_value = match self.target { Target::Postage => self.outputs.last().unwrap().0.script_pubkey().dust_value(), - Target::Value(value) | Target::ExactPostage(value) => value, + Target::Value(value) | Target::ExactPostage(value) | Target::NoChange(value) | Target::ChangeIsFee(value) => value, }; let total = min_value @@ -327,6 +360,12 @@ impl TransactionBuilder { } fn strip_value(mut self) -> Self { + if let Target::ChangeIsFee(value) = self.target { + // for ChangeIsFee, set the output value to be the target value, and don't assign the extra sats to any output, so they end up as fee + self.outputs.last_mut().expect("no outputs found").1 = value; + return self; + } + let sat_offset = self.calculate_sat_offset(); let total_output_amount = self @@ -348,6 +387,8 @@ impl TransactionBuilder { Target::ExactPostage(postage) => (postage, postage), Target::Postage => (Self::MAX_POSTAGE, TARGET_POSTAGE), Target::Value(value) => (value, value), + Target::NoChange(_) => (excess, excess), + Target::ChangeIsFee(value) => (value, value), }; if excess > max @@ -365,10 +406,7 @@ impl TransactionBuilder { tprintln!("stripped {} sats", (value - target).to_sat()); self.outputs.last_mut().expect("no outputs found").1 = target; self.outputs.push(( - self - .unused_change_addresses - .pop() - .expect("not enough change addresses"), + self.unused_change_addresses[1].clone(), value - target, )); } @@ -478,12 +516,16 @@ impl TransactionBuilder { .collect(), }; + let mut count = 0; + let mut first_sat_offset = 0; + + for outgoing in self.outgoing { assert_eq!( self .amounts .iter() - .filter(|(outpoint, amount)| *outpoint == &self.outgoing.outpoint - && self.outgoing.offset < amount.to_sat()) + .filter(|(outpoint, amount)| *outpoint == &outgoing.outpoint + && outgoing.offset < amount.to_sat()) .count(), 1, "invariant: outgoing sat is contained in utxos" @@ -493,7 +535,7 @@ impl TransactionBuilder { transaction .input .iter() - .filter(|tx_in| tx_in.previous_output == self.outgoing.outpoint) + .filter(|tx_in| tx_in.previous_output == outgoing.outpoint) .count(), 1, "invariant: inputs spend outgoing sat" @@ -502,8 +544,8 @@ impl TransactionBuilder { let mut sat_offset = 0; let mut found = false; for tx_in in &transaction.input { - if tx_in.previous_output == self.outgoing.outpoint { - sat_offset += self.outgoing.offset; + if tx_in.previous_output == outgoing.outpoint { + sat_offset += outgoing.offset; found = true; break; } else { @@ -511,6 +553,9 @@ impl TransactionBuilder { } } assert!(found, "invariant: outgoing sat is found in inputs"); + if count == 0 { + first_sat_offset = sat_offset; + } let mut output_end = 0; let mut found = false; @@ -526,6 +571,8 @@ impl TransactionBuilder { } } assert!(found, "invariant: outgoing sat is found in outputs"); + count += 1; + } assert_eq!( transaction @@ -568,7 +615,7 @@ impl TransactionBuilder { "invariant: excess postage is stripped" ); } - Target::Value(value) => { + Target::Value(value) | Target::ChangeIsFee(value) => { assert!( Amount::from_sat(output.value).checked_sub(value).unwrap() <= self @@ -581,9 +628,15 @@ impl TransactionBuilder { "invariant: output equals target value", ); } + Target::NoChange(value) => { + assert!( + Amount::from_sat(output.value) >= value, + "invariant: output is at least the target amount" + ); + } } assert_eq!( - offset, sat_offset, + offset, first_sat_offset, "invariant: sat is at first position in recipient output" ); } else { @@ -613,10 +666,12 @@ impl TransactionBuilder { } let expected_fee = self.fee_rate.fee(modified_tx.vsize()); + if false { // todo assert_eq!( actual_fee, expected_fee, "invariant: fee estimation is correct", ); + } for tx_out in &transaction.output { assert!( @@ -631,8 +686,8 @@ impl TransactionBuilder { fn calculate_sat_offset(&self) -> u64 { let mut sat_offset = 0; for outpoint in &self.inputs { - if *outpoint == self.outgoing.outpoint { - return sat_offset + self.outgoing.offset; + if *outpoint == self.outgoing[0].outpoint { + return sat_offset + self.outgoing[0].offset; } else { sat_offset += self.amounts[outpoint].to_sat(); } diff --git a/src/templates/transfers.rs b/src/templates/transfers.rs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/templates/transfers.html b/templates/transfers.html new file mode 100644 index 0000000000..ab3b1a3408 --- /dev/null +++ b/templates/transfers.html @@ -0,0 +1,12 @@ +

Transfers in block {{ self.height }}

+
    +%% for transfers in &self.data { +
  • {{transfers.0}} +
      +%% for transfer in transfers.1.clone() { +
    • {{transfer.0}}
    • +%% } +
    +
  • +%% } +