From c78e776105a5a527fb8935f109898f6cfca39edc Mon Sep 17 00:00:00 2001 From: Ivan Mikushin Date: Tue, 7 Jan 2025 10:31:01 -0800 Subject: [PATCH] CLI: charms wallet cast, charms wallet list Also, - Unify calculation of app inputs - Use funding UTXO to fund both the commit and spell transactions - Use "string of charms" as a term for sets of charms (tokens, NFTs, apps) bundled together - Use secret input in the example app contract --- Cargo.lock | 75 +------- Cargo.toml | 3 +- charms-data/Cargo.toml | 1 - charms-data/src/lib.rs | 26 +-- examples/toad-token/spells/mint-nft.yaml | 16 ++ examples/toad-token/spells/mint-nft.yml | 12 -- examples/toad-token/spells/mint-token.yaml | 21 ++ examples/toad-token/spells/mint-token.yml | 15 -- examples/toad-token/spells/send.yaml | 27 +++ examples/toad-token/spells/send.yml | 23 --- examples/toad-token/spells/send420.yaml | 14 ++ examples/toad-token/spells/send420.yml | 13 -- examples/toad-token/src/lib.rs | 44 +++-- hello-world.md | 92 +++++---- src/cli/mod.rs | 46 +++-- src/cli/spell.rs | 80 +++++--- src/cli/tx.rs | 27 ++- src/cli/wallet.rs | 214 ++++++++++++++++++--- src/lib.rs | 1 + src/spell.rs | 91 +++++---- src/tx.rs | 165 ++++++++-------- src/utils/logger.rs | 52 +++++ src/utils/mod.rs | 1 + 23 files changed, 644 insertions(+), 415 deletions(-) create mode 100644 examples/toad-token/spells/mint-nft.yaml delete mode 100644 examples/toad-token/spells/mint-nft.yml create mode 100644 examples/toad-token/spells/mint-token.yaml delete mode 100644 examples/toad-token/spells/mint-token.yml create mode 100644 examples/toad-token/spells/send.yaml delete mode 100644 examples/toad-token/spells/send.yml create mode 100644 examples/toad-token/spells/send420.yaml delete mode 100644 examples/toad-token/spells/send420.yml create mode 100644 src/utils/logger.rs create mode 100644 src/utils/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 1affebf..73b5385 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -399,15 +399,6 @@ dependencies = [ "rustc_version 0.4.1", ] -[[package]] -name = "atomic-polyfill" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" -dependencies = [ - "critical-section", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -943,7 +934,6 @@ dependencies = [ "ciborium", "clap", "hex", - "postcard", "proptest", "proptest-derive", "rand", @@ -953,6 +943,7 @@ dependencies = [ "sp1-sdk", "tokio", "tracing", + "tracing-forest", "tracing-subscriber", ] @@ -965,7 +956,6 @@ dependencies = [ "ciborium", "ciborium-io", "hex", - "postcard", "proptest", "proptest-derive", "serde", @@ -1090,12 +1080,6 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" -[[package]] -name = "cobs" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15" - [[package]] name = "coins-bip32" version = "0.8.7" @@ -1223,12 +1207,6 @@ dependencies = [ "libc", ] -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1557,18 +1535,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "embedded-io" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" - -[[package]] -name = "embedded-io" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" - [[package]] name = "encode_unicode" version = "1.0.0" @@ -2313,15 +2279,6 @@ dependencies = [ "rayon", ] -[[package]] -name = "hash32" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" -dependencies = [ - "byteorder", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -2353,20 +2310,6 @@ dependencies = [ "fxhash", ] -[[package]] -name = "heapless" -version = "0.7.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" -dependencies = [ - "atomic-polyfill", - "hash32", - "rustc_version 0.4.1", - "serde", - "spin 0.9.8", - "stable_deref_trait", -] - [[package]] name = "heck" version = "0.5.0" @@ -3915,19 +3858,6 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" -[[package]] -name = "postcard" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170a2601f67cc9dba8edd8c4870b15f71a6a2dc196daec8c83f72b59dff628a8" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "heapless", - "serde", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -5459,9 +5389,6 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] [[package]] name = "spki" diff --git a/Cargo.toml b/Cargo.toml index 066747c..f1105ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,6 @@ charms-spell-checker = { path = "charms-spell-checker", version = "0.2.0-dev" } ciborium = { workspace = true } clap = { version = "4.5.26", features = ["derive"] } hex = { workspace = true } -postcard = { workspace = true, features = ["use-std"] } rand = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -32,6 +31,7 @@ serde_yaml = { workspace = true } sp1-sdk = { workspace = true } tokio = { version = "1.43", features = ["full"] } tracing = { version = "0.1" } +tracing-forest = { version = "0.1.0" } tracing-subscriber = { version = "0.3", features = ["env-filter"] } [dev-dependencies] @@ -54,7 +54,6 @@ bitcoin = { version = "0.32.5" } ciborium = { version = "0.2.2" } ciborium-io = { version = "0.2.2" } hex = { version = "0.4.3" } -postcard = { version = "1.1.1" } proptest = { version = "1.6.0" } proptest-derive = { version = "0.5.1" } rand = { version = "0.8.5" } diff --git a/charms-data/Cargo.toml b/charms-data/Cargo.toml index 1667758..131f8ba 100644 --- a/charms-data/Cargo.toml +++ b/charms-data/Cargo.toml @@ -16,7 +16,6 @@ hex = { workspace = true } serde = { workspace = true, features = ["derive"] } [dev-dependencies] -postcard = { workspace = true, features = ["use-std"] } proptest = { workspace = true } proptest-derive = { workspace = true } test-strategy = { workspace = true } diff --git a/charms-data/src/lib.rs b/charms-data/src/lib.rs index 19c3c98..a47ddf4 100644 --- a/charms-data/src/lib.rs +++ b/charms-data/src/lib.rs @@ -449,10 +449,10 @@ pub fn nft_state_preserved(app: &App, tx: &Transaction) -> bool { pub fn app_state_multiset<'a>( app: &App, - strands: impl Iterator, + strings_of_charms: impl Iterator, ) -> BTreeMap<&'a Data, usize> { - strands - .filter_map(|strand| strand.get(app)) + strings_of_charms + .filter_map(|charms| charms.get(app)) .fold(BTreeMap::new(), |mut r, s| { match r.get_mut(&s) { Some(count) => *count += 1, @@ -466,9 +466,9 @@ pub fn app_state_multiset<'a>( pub fn sum_token_amount<'a>( self_app: &App, - strands: impl Iterator, + strings_of_charms: impl Iterator, ) -> Result { - strands.fold(Ok(0u64), |amount, strand| match strand.get(self_app) { + strings_of_charms.fold(Ok(0u64), |amount, charms| match charms.get(self_app) { Some(state) => Ok(amount? + state.value::()?), None => amount, }) @@ -495,32 +495,32 @@ mod tests { #[proptest] fn vk_serde_roundtrip(vk: B32) { - let bytes = postcard::to_stdvec(&vk).unwrap(); - let deserialize_result = postcard::from_bytes::(&bytes); + let bytes = util::write(&vk).unwrap(); + let deserialize_result = util::read::(&bytes); let vk2 = deserialize_result.unwrap(); prop_assert_eq!(vk, vk2); } #[proptest] fn app_serde_roundtrip(app: App) { - let bytes = postcard::to_stdvec(&app).unwrap(); - let deserialize_result = postcard::from_bytes::(&bytes); + let bytes = util::write(&app).unwrap(); + let deserialize_result = util::read::(&bytes); let app2 = deserialize_result.unwrap(); prop_assert_eq!(app, app2); } #[proptest] fn utxo_id_serde_roundtrip(utxo_id: UtxoId) { - let bytes = postcard::to_stdvec(&utxo_id).unwrap(); - let deserialize_result = postcard::from_bytes::(&bytes); + let bytes = util::write(&utxo_id).unwrap(); + let deserialize_result = util::read::(&bytes); let utxo_id2 = deserialize_result.unwrap(); prop_assert_eq!(utxo_id, utxo_id2); } #[proptest] fn tx_id_serde_roundtrip(tx_id: TxId) { - let bytes = postcard::to_stdvec(&tx_id).unwrap(); - let deserialize_result = postcard::from_bytes::(&bytes); + let bytes = util::write(&tx_id).unwrap(); + let deserialize_result = util::read::(&bytes); let tx_id2 = deserialize_result.unwrap(); prop_assert_eq!(tx_id, tx_id2); } diff --git a/examples/toad-token/spells/mint-nft.yaml b/examples/toad-token/spells/mint-nft.yaml new file mode 100644 index 0000000..f9cbd87 --- /dev/null +++ b/examples/toad-token/spells/mint-nft.yaml @@ -0,0 +1,16 @@ +version: 0 + +apps: + $00: n/${app_id}/${app_vk} + +private_inputs: + $00: "${in_utxo_0}" + +ins: + - utxo_id: ${in_utxo_0} + charms: {} + +outs: + - address: ${addr_0} + charms: + $00: 100000 diff --git a/examples/toad-token/spells/mint-nft.yml b/examples/toad-token/spells/mint-nft.yml deleted file mode 100644 index a5b848a..0000000 --- a/examples/toad-token/spells/mint-nft.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: 0 - -apps: - $TOAD_MAN: n/312de6129de1a2a3de9dd22bca0bbb351853e7a5b4acb4b48676816055f08bb1:0/8e877d70518a5b28f5221e70bd7ff7692a603f3a26d7076a5253e21c304a354f - -ins: - - utxo_id: 312de6129de1a2a3de9dd22bca0bbb351853e7a5b4acb4b48676816055f08bb1:0 - charm: {} - -outs: - - charm: - $TOAD_MAN: 100000 diff --git a/examples/toad-token/spells/mint-token.yaml b/examples/toad-token/spells/mint-token.yaml new file mode 100644 index 0000000..2c53b49 --- /dev/null +++ b/examples/toad-token/spells/mint-token.yaml @@ -0,0 +1,21 @@ +version: 0 + +apps: + $00: n/${app_id}/${app_vk} + $01: t/${app_id}/${app_vk} + +private_inputs: + $00: "" + +ins: + - utxo_id: ${in_utxo_0} + charms: + $00: 100000 + +outs: + - address: ${addr_0} + charms: + $01: 69420 + - address: ${addr_1} + charms: + $00: 30580 diff --git a/examples/toad-token/spells/mint-token.yml b/examples/toad-token/spells/mint-token.yml deleted file mode 100644 index b208d36..0000000 --- a/examples/toad-token/spells/mint-token.yml +++ /dev/null @@ -1,15 +0,0 @@ -version: 0 - -apps: - $TOAD_MAN: n/312de6129de1a2a3de9dd22bca0bbb351853e7a5b4acb4b48676816055f08bb1:0/8e877d70518a5b28f5221e70bd7ff7692a603f3a26d7076a5253e21c304a354f - $TOAD: t/312de6129de1a2a3de9dd22bca0bbb351853e7a5b4acb4b48676816055f08bb1:0/8e877d70518a5b28f5221e70bd7ff7692a603f3a26d7076a5253e21c304a354f - -ins: - - utxo_id: 3b2f66f97f8b14e5e59f15bcaffc911a4d7c794fef3ac274c69510da8c73f229:0 - charm: - $TOAD_MAN: 100000 - -outs: - - charm: - $TOAD_MAN: 30580 - $TOAD: 69420 diff --git a/examples/toad-token/spells/send.yaml b/examples/toad-token/spells/send.yaml new file mode 100644 index 0000000..1166f2b --- /dev/null +++ b/examples/toad-token/spells/send.yaml @@ -0,0 +1,27 @@ +version: 0 + +apps: + $00: n/${app_id}/${app_vk} + $01: t/${app_id}/${app_vk} + +ins: + - utxo_id: ${in_utxo_0} + charms: + $01: 69420 + + - utxo_id: ${in_utxo_1} + charms: + $00: 30580 + +outs: + - address: ${addr_0} + charms: + $00: 30580 + $01: 420 + + - address: ${addr_1} + charms: {} + + - address: ${addr_2} + charms: + $01: 69000 diff --git a/examples/toad-token/spells/send.yml b/examples/toad-token/spells/send.yml deleted file mode 100644 index 91cb7c0..0000000 --- a/examples/toad-token/spells/send.yml +++ /dev/null @@ -1,23 +0,0 @@ -version: 0 - -apps: - $TOAD_MAN: n/312de6129de1a2a3de9dd22bca0bbb351853e7a5b4acb4b48676816055f08bb1:0/8e877d70518a5b28f5221e70bd7ff7692a603f3a26d7076a5253e21c304a354f - $TOAD: t/312de6129de1a2a3de9dd22bca0bbb351853e7a5b4acb4b48676816055f08bb1:0/8e877d70518a5b28f5221e70bd7ff7692a603f3a26d7076a5253e21c304a354f - -ins: - - utxo_id: db094547cea83ff571e73bb750cc6e8225485c0b65929d723324dd03e4542477:0 - charm: - $TOAD_MAN: 30580 - $TOAD: 69420 - -outs: - - charm: - $TOAD_MAN: 30580 - - - charm: {} - - - charm: - $TOAD: 69000 - - - charm: - $TOAD: 420 diff --git a/examples/toad-token/spells/send420.yaml b/examples/toad-token/spells/send420.yaml new file mode 100644 index 0000000..16d05dc --- /dev/null +++ b/examples/toad-token/spells/send420.yaml @@ -0,0 +1,14 @@ +version: 0 + +apps: + $00: t/${app_id}/${app_vk} + +ins: + - utxo_id: ${in_utxo_0} + charms: + $00: 420 + +outs: + - address: ${addr_0} + charms: + $00: 420 diff --git a/examples/toad-token/spells/send420.yml b/examples/toad-token/spells/send420.yml deleted file mode 100644 index 365d6e5..0000000 --- a/examples/toad-token/spells/send420.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: 0 - -apps: - $TOAD: t/312de6129de1a2a3de9dd22bca0bbb351853e7a5b4acb4b48676816055f08bb1:0/8e877d70518a5b28f5221e70bd7ff7692a603f3a26d7076a5253e21c304a354f - -ins: - - utxo_id: a2889190343435c86cd1c2b70e58efed0d101437a753e154dff1879008898cd2:3 - charm: - $TOAD: 420 - -outs: - - charm: - $TOAD: 420 diff --git a/examples/toad-token/src/lib.rs b/examples/toad-token/src/lib.rs index ed9d614..ba2cf22 100644 --- a/examples/toad-token/src/lib.rs +++ b/examples/toad-token/src/lib.rs @@ -1,16 +1,15 @@ use charms_sdk::data::{ check, nft_state_preserved, sum_token_amount, token_amounts_balanced, App, Data, Transaction, - B32, NFT, TOKEN, + UtxoId, B32, NFT, TOKEN, }; use sha2::{Digest, Sha256}; pub fn app_contract(app: &App, tx: &Transaction, x: &Data, w: &Data) -> bool { let empty = Data::empty(); assert_eq!(x, &empty); - assert_eq!(w, &empty); match app.tag { NFT => { - check!(nft_contract_satisfied(app, tx)) + check!(nft_contract_satisfied(app, tx, w)) } TOKEN => { check!(token_contract_satisfied(app, tx)) @@ -20,22 +19,28 @@ pub fn app_contract(app: &App, tx: &Transaction, x: &Data, w: &Data) -> bool { true } -fn nft_contract_satisfied(app: &App, tx: &Transaction) -> bool { +fn nft_contract_satisfied(app: &App, tx: &Transaction, w: &Data) -> bool { let token_app = &App { tag: TOKEN, identity: app.identity.clone(), vk: app.vk.clone(), }; - check!(nft_state_preserved(app, tx) || can_mint_nft(app, tx) || can_mint_token(&token_app, tx)); + check!( + nft_state_preserved(app, tx) || can_mint_nft(app, tx, w) || can_mint_token(&token_app, tx) + ); true } -fn can_mint_nft(nft_app: &App, tx: &Transaction) -> bool { - // can only mint an NFT with this contract if spending a UTXO with the same ID. - check!(tx - .ins - .iter() - .any(|(utxo_id, _)| &hash(&Data::from(utxo_id)) == &nft_app.identity)); +fn can_mint_nft(nft_app: &App, tx: &Transaction, w: &Data) -> bool { + let w_str: String = w.value().unwrap(); + + // can only mint an NFT with this contract if the hash of `w` is the identity of the NFT. + check!(hash(&w_str) == nft_app.identity); + + // can only mint an NFT with this contract if spending a UTXO with the same ID as passed in `w`. + let w_utxo_id = UtxoId::from_str(&w_str).unwrap(); + check!(tx.ins.iter().any(|(utxo_id, _)| utxo_id == &w_utxo_id)); + // can mint no more than one NFT. check!( tx.outs @@ -47,8 +52,8 @@ fn can_mint_nft(nft_app: &App, tx: &Transaction) -> bool { true } -fn hash(data: &Data) -> B32 { - let hash = Sha256::digest(data.bytes()); +pub(crate) fn hash(data: &str) -> B32 { + let hash = Sha256::digest(data); B32(hash.into()) } @@ -104,6 +109,19 @@ fn can_mint_token(token_app: &App, tx: &Transaction) -> bool { #[cfg(test)] mod test { + use super::*; + use charms_sdk::data::UtxoId; + #[test] fn dummy() {} + + #[test] + fn test_hash() { + let utxo_id = + UtxoId::from_str("dc78b09d767c8565c4a58a95e7ad5ee22b28fc1685535056a395dc94929cdd5f:1") + .unwrap(); + let data = dbg!(utxo_id.to_string()); + let expected = "f54f6d40bd4ba808b188963ae5d72769ad5212dd1d29517ecc4063dd9f033faa"; + assert_eq!(&hash(&data).to_string(), expected); + } } diff --git a/hello-world.md b/hello-world.md index 471ff4b..de2aea7 100644 --- a/hello-world.md +++ b/hello-world.md @@ -18,22 +18,33 @@ changetype=bech32m On macOS, `bitcoin.conf` is usually located at `~/Library/Application Support/Bitcoin/bitcoin.conf`. -You will need to have `bitcoin-cli` aliased as `b`: +Alias `bitcoin-cli` as `b` (it's annoying to type `bitcoin-cli` all the time): ```sh alias b=bitcoin-cli ``` -You will also need to have `jq` installed: +Make sure you have a wallet loaded: ```sh -brew install jq +b createwallet testwallet # create a wallet (you might already have one) +b loadwallet testwallet # load the wallet (bitcoind doesn't do it automatically when it starts) +``` + +Get some test BTC: + +```sh +b getnewaddress # prints out a new address associated with your wallet ``` -Make sure you have nightly Rust installed: +Visit https://mempool.space/testnet4/faucet and get some test BTC to the address you just created. Get at least 50000 +sats (0.0005 (test) BTC). Also, get more than one UTXO, so either tap the faucet more than once or send some sats within +your wallet to get some small UTXOs and at least one larger one (>= 10000 sats). + +You will need to have `jq` installed (bitcoin-cli output is mostly JSON): ```sh -rustup toolchain install nightly +brew install jq ``` ## Installation @@ -41,11 +52,13 @@ rustup toolchain install nightly Install Charms CLI: ```sh -cargo +nightly install charms +cargo install charms ``` ## Create an app +Run this **outside** the `charms` repo: + ```sh charms app new my-token cd ./my-token @@ -58,59 +71,44 @@ This will print out the verification key for the Toad Token app, that looks some 8e877d70518a5b28f5221e70bd7ff7692a603f3a26d7076a5253e21c304a354f ``` -Test the app for a spell with: +Test the app for a spell with a simple NFT mint example: ```sh -charms app run <./spells/send.yaml -``` +export app_vk=$(charms app vk) -This runs the app contract against a concrete spell. - -## Walkthrough - -```sh -recipient="$(b getnewaddress)" +# set to a UTXO you're spending to mint the NFT (you can see what you have by `b listunspent`) +export in_utxo_0="dc78b09d767c8565c4a58a95e7ad5ee22b28fc1685535056a395dc94929cdd5f:1" -rawtxhex=$(b createrawtransaction '''[]''' '''[{ "'$recipient'": 0.00010000 }]''') +export app_id=$(sha256 -s "${in_utxo_0}") +export addr_0=$(b getnewaddress) -# or something like this if you want to spend an existing Charm -# rawtxhex=$(b createrawtransaction '''[ { "txid": "dafd94568e0d8fb0e72c9bb84e54b227c9cad28168611fe3d37f06276125e247", "vout": 0 } ]''' '''[{ "'$recipient'": 0.00010000 }]''') - -fee_rate=2 # per vB - -draft_tx_hex=$(b -named fundrawtransaction $rawtxhex changePosition=1 fee_rate=$fee_rate | jq -r '.hex') - -# now choose a funding output with a few thousand sats (10000 should be more than enough) -b listunspent - -# MUST NOT be one of those being spent by $draft_tx_hex (printed out by this) -b decoderawtransaction $(echo $draft_tx_hex) | jq -r '.vin[] | "\(.txid):\(.vout)"' - -funding_utxo_id=acbef6b2f3808ad4fe36fff4d70ba1d0ccc05ce254d8096a8591de76683af8d0:0 -funding_utxo_value=10000 -# value in sats - -change_address=$(b getrawchangeaddress) - -b decoderawtransaction $draft_tx_hex +cat ./spells/mint-nft.yaml | envsubst | charms app run +``` -# now get the hex representation of $draft_tx_hex's input transactions +If all is well, you should see that the app contract for minting an NFT has been satisfied. -prev_txs=$(b decoderawtransaction $draft_tx_hex | jq -r '.vin[].txid' | sort | uniq | xargs -I {} bitcoin-cli getrawtransaction {} | paste -sd, -) +To continue playing with the other spells, keep the same `app_id` value: you create the `app_id` value for a newly +minted NFT, and then keep using it for the lifetime of the NFT and any associated fungible tokens (if the app supports +them). -spell_source=./examples/toad-token/spells/mint-token.yml -toad_app_bin=./examples/toad-token/elf/riscv32im-succinct-zkvm-elf +## Using an app -RUST_LOG=info charms spell prove --spell=$spell_source --tx=$draft_tx_hex --prev-txs=$prev_txs --app-bins=$toad_app_bin --funding-utxo-id=$funding_utxo_id --funding-utxo-value=$funding_utxo_value --change-address=$change_address --fee-rate=$fee_rate +We've just tested the app with an NFT-minting spell. Let's use it on Bitcoin `testnet4`. -# sign the resulting transactions -# copy the output from the previous command into spell_prove_result: +```sh +app_bins=$(charms app build) +cat ./spells/mint-nft.yaml | envsubst | charms wallet cast --app-bins=${app_bins} --funding-utxo-id=${funding_utxo_id} +``` -spell_prove_result='["0200000001d0...000","020000000001041...000"]' +This will create and sign (but not yet submit to the network) two Bitcoin transactions: commit tx and execute tx. The +commit transaction creates an output (committing to a spell and its proof) which is spent by the execute transaction. +The execute transaction is the one that creates the NFT (but it can't exist without the commit tx). -signed_commit_tx=$(b signrawtransactionwithwallet $(echo $spell_prove_result | jq -r '.[0]') | jq -r '.hex') +Note: currently, `charms wallet cast` takes a pretty long time (about 27 minutes on MBP M2 64GB) and requires Docker to +run. We're working on improving this. -signed_spell_tx=$(b signrawtransactionwithwallet $(echo $spell_prove_result | jq -r '.[1]') $(b decoderawtransaction $signed_commit_tx | jq -c '[{txid: .txid, vout: .vout[0].n, scriptPubKey: .vout[0].scriptPubKey.hex, amount: .vout[0].value}]') | jq -r '.hex') +You submit both transaction to the network as a package, which looks like the following command: -b submitpackage '["'$signed_commit_tx'","'$signed_spell_tx'"]' +```sh +b submitpackage '["020000000001015f...57505efa00000000", "020000000001025f...e14c656300000000"]' ``` diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 521b4b1..19c9797 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -114,20 +114,25 @@ pub enum SpellCommands { Render(#[command(flatten)] SpellRenderParams), } +#[derive(Args)] +pub struct TxAddSpellParams { + #[arg(long)] + tx: String, + #[arg(long, value_delimiter = ',')] + prev_txs: Vec, + #[arg(long)] + funding_utxo_id: String, + #[arg(long)] + funding_utxo_value: u64, + #[arg(long)] + change_address: String, + #[arg(long)] + fee_rate: f64, +} + #[derive(Subcommand)] pub enum TxCommands { - AddSpell { - #[arg(long)] - tx: String, - #[arg(long)] - funding_utxo_id: String, - #[arg(long)] - funding_utxo_value: u64, - #[arg(long)] - change_address: String, - #[arg(long)] - fee_rate: f64, - }, + AddSpell(#[command(flatten)] TxAddSpellParams), ShowSpell { #[arg(long)] tx: String, @@ -166,6 +171,7 @@ pub enum AppCommands { pub enum WalletCommands { /// List outputs with charms List(#[command(flatten)] WalletListParams), + Cast(#[command(flatten)] WalletCastParams), } #[derive(Args)] @@ -174,6 +180,19 @@ pub struct WalletListParams { json: bool, } +#[derive(Args)] +pub struct WalletCastParams { + /// Path to spell source file (YAML/JSON) + #[arg(long, default_value = "/dev/stdin")] + spell: PathBuf, + #[arg(long, value_delimiter = ',')] + app_bins: Vec, + #[arg(long)] + funding_utxo_id: String, + #[arg(long, default_value = "2.0")] + fee_rate: f64, +} + pub async fn run() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -184,7 +203,7 @@ pub async fn run() -> anyhow::Result<()> { SpellCommands::Render(params) => spell::render(params), }, Commands::Tx { command } => match command { - TxCommands::AddSpell { .. } => tx::tx_add_spell(command), + TxCommands::AddSpell(params) => tx::tx_add_spell(params), TxCommands::ShowSpell { tx } => tx::tx_show_spell(tx), }, Commands::App { command } => match command { @@ -195,6 +214,7 @@ pub async fn run() -> anyhow::Result<()> { }, Commands::Wallet { command } => match command { WalletCommands::List(params) => wallet::list(params), + WalletCommands::Cast(params) => wallet::cast(params), }, } } diff --git a/src/cli/spell.rs b/src/cli/spell.rs index 2f8faf6..fbcd3bc 100644 --- a/src/cli/spell.rs +++ b/src/cli/spell.rs @@ -3,18 +3,18 @@ use crate::{ cli::{SpellProveParams, SpellRenderParams}, spell, spell::Spell, - tx::add_spell, - SPELL_VK, + tx::{add_spell, txs_by_txid}, + utils, SPELL_VK, }; use anyhow::{anyhow, ensure, Result}; use bitcoin::{ consensus::encode::{deserialize_hex, serialize_hex}, hashes::Hash, - Amount, FeeRate, Transaction, + Amount, FeeRate, OutPoint, Transaction, Txid, }; use charms_data::{util, TxId, UtxoId, B32}; use charms_spell_checker::NormalizedSpell; -use std::{collections::BTreeMap, str::FromStr}; +use std::{collections::BTreeMap, path::PathBuf, str::FromStr}; pub fn prove( SpellProveParams { @@ -28,29 +28,54 @@ pub fn prove( fee_rate, }: SpellProveParams, ) -> Result<()> { - dbg!(&tx); - dbg!(&prev_txs); - dbg!(&app_bins); + utils::logger::setup_logger(); - sp1_sdk::utils::setup_logger(); // TODO configure the logger to print to stderr (vs stdout) + // Parse funding UTXO early: to fail fast + let funding_utxo = crate::cli::tx::parse_outpoint(&funding_utxo_id)?; + + ensure!(fee_rate >= 1.0, "fee rate must be >= 1.0"); let spell: Spell = serde_yaml::from_slice(&std::fs::read(spell)?)?; let tx = deserialize_hex::(&tx)?; + let prev_txs = txs_by_txid(prev_txs)?; + ensure!(tx + .input + .iter() + .all(|input| prev_txs.contains_key(&input.previous_output.txid))); - let (mut norm_spell, app_private_inputs) = spell.normalized()?; - align_spell_to_tx(&mut norm_spell, &tx)?; + let transactions = do_prove( + spell, + tx, + app_bins, + prev_txs, + funding_utxo, + funding_utxo_value, + change_address, + fee_rate, + )?; - let prev_txs = prev_txs - .iter() - .map(|prev_tx| { - let prev_tx = deserialize_hex::(prev_tx)?; + // Convert transactions to hex and create JSON array + let hex_txs: Vec = transactions.iter().map(|tx| serialize_hex(tx)).collect(); - Ok((TxId(prev_tx.compute_txid().to_byte_array()), prev_tx)) - }) - .collect::>>()? - .into_values() - .collect(); + // Print JSON array of transaction hexes + println!("{}", serde_json::to_string(&hex_txs)?); + + Ok(()) +} + +pub fn do_prove( + spell: Spell, + tx: Transaction, + app_bins: Vec, + prev_txs: BTreeMap, + funding_utxo: OutPoint, + funding_utxo_value: u64, + change_address: String, + fee_rate: f64, +) -> Result<[Transaction; 2]> { + let (mut norm_spell, app_private_inputs) = spell.normalized()?; + align_spell_to_tx(&mut norm_spell, &tx)?; let app_prover = app::Prover::new(); @@ -67,16 +92,13 @@ pub fn prove( norm_spell, &binaries, app_private_inputs, - prev_txs, + prev_txs.values().cloned().collect(), SPELL_VK, )?; // Serialize spell into CBOR let spell_data = util::write(&(&norm_spell, &proof))?; - // Parse funding UTXO - let funding_utxo = crate::cli::tx::parse_outpoint(&funding_utxo_id)?; - // Parse amount let funding_utxo_value = Amount::from_sat(funding_utxo_value); @@ -86,7 +108,7 @@ pub fn prove( .script_pubkey(); // Parse fee rate - let fee_rate = FeeRate::from_sat_per_kwu((fee_rate * 1000.0 / 4.0) as u64); + let fee_rate = FeeRate::from_sat_per_kwu((fee_rate * 250.0) as u64); // Call the add_spell function let transactions = add_spell( @@ -96,15 +118,9 @@ pub fn prove( funding_utxo_value, change_script_pubkey, fee_rate, + &prev_txs, ); - - // Convert transactions to hex and create JSON array - let hex_txs: Vec = transactions.iter().map(|tx| serialize_hex(tx)).collect(); - - // Print JSON array of transaction hexes - println!("{}", serde_json::to_string(&hex_txs)?); - - Ok(()) + Ok(transactions) } fn align_spell_to_tx(norm_spell: &mut NormalizedSpell, tx: &Transaction) -> Result<()> { diff --git a/src/cli/tx.rs b/src/cli/tx.rs index ce183cf..f1486c8 100644 --- a/src/cli/tx.rs +++ b/src/cli/tx.rs @@ -1,5 +1,9 @@ -use crate::{cli::TxCommands, tx, tx::add_spell}; -use anyhow::{anyhow, Result}; +use crate::{ + cli::TxAddSpellParams, + tx, + tx::{add_spell, txs_by_txid}, +}; +use anyhow::{anyhow, ensure, Result}; use bitcoin::{ consensus::encode::{deserialize_hex, serialize_hex}, Amount, FeeRate, OutPoint, Transaction, @@ -17,18 +21,16 @@ pub(crate) fn parse_outpoint(s: &str) -> Result { Ok(OutPoint::new(parts[0].parse()?, parts[1].parse()?)) } -pub fn tx_add_spell(command: TxCommands) -> Result<()> { - let TxCommands::AddSpell { +pub fn tx_add_spell( + TxAddSpellParams { tx, + prev_txs, funding_utxo_id, funding_utxo_value, change_address, fee_rate, - } = command - else { - unreachable!() - }; - + }: TxAddSpellParams, +) -> Result<()> { // Read spell data from stdin let spell_and_proof: (NormalizedSpell, Proof) = util::read(std::io::stdin())?; @@ -52,6 +54,12 @@ pub fn tx_add_spell(command: TxCommands) -> Result<()> { // Parse fee rate let fee_rate = FeeRate::from_sat_per_kwu((fee_rate * 1000.0 / 4.0) as u64); + let prev_txs = txs_by_txid(prev_txs)?; + ensure!(tx + .input + .iter() + .all(|input| prev_txs.contains_key(&input.previous_output.txid))); + // Call the add_spell function let transactions = add_spell( tx, @@ -60,6 +68,7 @@ pub fn tx_add_spell(command: TxCommands) -> Result<()> { funding_utxo_value, change_script_pubkey, fee_rate, + &prev_txs, ); // Convert transactions to hex and create JSON array diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 3c9ff55..39b452a 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -1,10 +1,15 @@ use crate::{ - cli::WalletListParams, - spell::{str_index, KeyedCharms, Spell}, + cli::{spell::do_prove, WalletCastParams, WalletListParams}, + spell::{str_index, Input, KeyedCharms, Output, Spell}, tx, + tx::txs_by_txid, + utils, }; use anyhow::{ensure, Result}; -use bitcoin::{hashes::Hash, Transaction}; +use bitcoin::{ + absolute::LockTime, consensus::encode::serialize_hex, hashes::Hash, transaction::Version, + Amount, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, +}; use charms_data::{App, TxId, UtxoId}; use ciborium::Value; use serde::{Deserialize, Serialize}; @@ -18,11 +23,13 @@ struct BListUnspentItem { txid: String, vout: u32, amount: f64, + confirmations: u32, solvable: bool, } #[derive(Debug, Serialize)] struct OutputWithCharms { + confirmations: u32, sats: u64, charms: BTreeMap, } @@ -37,7 +44,7 @@ struct AppsAndCharmsOutputs { pub fn list(params: WalletListParams) -> Result<()> { let b_cli = Command::new("bitcoin-cli") - .args(&["listunspent"]) + .args(&["listunspent", "0"]) // include outputs with 0 confirmations .stdout(Stdio::piped()) .spawn()?; let output = b_cli.wait_with_output()?; @@ -63,7 +70,7 @@ fn outputs_with_charms(b_list_unspent: Vec) -> Result>(); let spells = txs_with_spells(txid_set.into_iter())?; - let utxos_with_charms: BTreeMap = + let utxos_with_charms: BTreeMap = utxos_with_charms(spells, b_list_unspent); let apps = collect_apps(&utxos_with_charms); @@ -97,24 +104,20 @@ fn txs_with_spells(txid_iter: impl Iterator) -> Result, b_list_unspent: Vec, -) -> BTreeMap { +) -> BTreeMap { b_list_unspent - .iter() + .into_iter() .filter(|item| item.solvable) - .filter_map(|item| { - let txid = TxId::from_str(&item.txid).expect("txids from bitcoin-cli should be valid"); - let i = item.vout; - let sats = (item.amount * 100000000f64) as u64; + .filter_map(|b_utxo| { + let txid = + TxId::from_str(&b_utxo.txid).expect("txids from bitcoin-cli should be valid"); + let i = b_utxo.vout; spells .get(&txid) - .and_then(|spell| spell.outs.get(i as usize).map(|utxo| (utxo, &spell.apps))) - .and_then(|(utxo, apps)| { - utxo.charms - .as_ref() - .map(|keyed_charms| (keyed_charms, apps)) - }) + .and_then(|spell| spell.outs.get(i as usize).map(|u| (u, &spell.apps))) + .and_then(|(u, apps)| u.charms.as_ref().map(|keyed_charms| (keyed_charms, apps))) .map(|(keyed_charms, apps)| { - (UtxoId(txid, i), (sats, parsed_charms(keyed_charms, apps))) + (UtxoId(txid, i), (b_utxo, parsed_charms(keyed_charms, apps))) }) }) .collect() @@ -128,7 +131,7 @@ fn parsed_charms(keyed_charms: &KeyedCharms, apps: &BTreeMap) -> Pa } fn collect_apps( - strings_of_charms: &BTreeMap, + strings_of_charms: &BTreeMap, ) -> BTreeMap { let apps: BTreeSet = strings_of_charms .iter() @@ -148,17 +151,26 @@ fn enumerate_apps(apps: &BTreeMap) -> BTreeMap { } fn pretty_outputs( - utxos_with_charms: BTreeMap, + utxos_with_charms: BTreeMap, apps: &BTreeMap, ) -> BTreeMap { utxos_with_charms .into_iter() - .map(|(utxo_id, (sats, charms))| { + .map(|(utxo_id, (utxo, charms))| { let charms = charms .iter() .map(|(app, value)| (apps[app].clone(), value.clone())) .collect(); - (utxo_id.clone(), OutputWithCharms { sats, charms }) + let confirmations = utxo.confirmations; + let sats = (utxo.amount * 100000000f64) as u64; + ( + utxo_id.clone(), + OutputWithCharms { + confirmations, + sats, + charms, + }, + ) }) .collect() } @@ -178,3 +190,161 @@ fn get_tx(txid: &str) -> Result { let tx = bitcoin::consensus::encode::deserialize_hex(&(tx_hex))?; Ok(tx) } + +const MIN_SATS: u64 = 1000; + +pub fn cast( + WalletCastParams { + spell, + app_bins, + funding_utxo_id, + fee_rate, + }: WalletCastParams, +) -> Result<()> { + utils::logger::setup_logger(); + + // Parse funding UTXO early: to fail fast + let funding_utxo = crate::cli::tx::parse_outpoint(&funding_utxo_id)?; + + ensure!(fee_rate >= 1.0, "fee rate must be >= 1.0"); + let mut spell: Spell = serde_yaml::from_slice(&std::fs::read(spell)?)?; + + // make sure spell inputs all have utxo_id + ensure!( + spell.ins.iter().all(|u| u.utxo_id.is_some()), + "all spell inputs must have utxo_id" + ); + + // make sure spell outputs all have addresses + ensure!( + spell.outs.iter().all(|u| u.address.is_some()), + "all spell outputs must have addresses" + ); + + for u in spell.outs.iter_mut() { + u.sats.get_or_insert(MIN_SATS); + } + + let input = tx_input(&spell.ins); + let output = tx_output(&spell.outs); + + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input, + output, + }; + + let prev_txs = txs_by_txid(get_prev_txs(&tx)?)?; + let funding_utxo_value = funding_utxo_value(&funding_utxo)?; + let change_address = new_change_address()?; + + let [commit_tx, spell_tx] = do_prove( + spell, + tx, + app_bins, + prev_txs, + funding_utxo, + funding_utxo_value, + change_address, + fee_rate, + )?; + + let signed_commit_tx_hex = sign_tx(&serialize_hex(&commit_tx))?; + let signed_spell_tx_hex = sign_spell_tx(&serialize_hex(&spell_tx), &commit_tx)?; + + // Print JSON array of transaction hexes + println!( + "{}", + serde_json::to_string(&[signed_commit_tx_hex, signed_spell_tx_hex])? + ); + + Ok(()) +} + +fn sign_spell_tx(spell_tx_hex: &String, commit_tx: &Transaction) -> Result { + let cmd_line = format!( + r#"bitcoin-cli signrawtransactionwithwallet {} '[{{"txid":"{}","vout":0,"scriptPubKey":"{}","amount":{}}}]' | jq -r '.hex'"#, + spell_tx_hex, + commit_tx.compute_txid(), + &commit_tx.output[0].script_pubkey.to_hex_string(), + commit_tx.output[0].value.to_btc() + ); + let cmd_out = Command::new("bash") + .args(&["-c", cmd_line.as_str()]) + .output()?; + Ok(String::from_utf8(cmd_out.stdout)?.trim().to_string()) +} + +fn sign_tx(tx_hex: &str) -> Result { + let cmd_out = Command::new("bash") + .args(&[ + "-c", + format!( + "bitcoin-cli signrawtransactionwithwallet {} | jq -r '.hex'", + tx_hex + ) + .as_str(), + ]) + .output()?; + Ok(String::from_utf8(cmd_out.stdout)?.trim().to_string()) +} + +fn new_change_address() -> Result { + let cmd_out = Command::new("bitcoin-cli") + .args(&["getrawchangeaddress"]) + .output()?; + Ok(String::from_utf8(cmd_out.stdout)?.trim().to_string()) +} + +fn funding_utxo_value(utxo: &OutPoint) -> Result { + let cmd = format!( + "bitcoin-cli gettxout {} {} | jq -r '.value*100000000 | round'", + utxo.txid, utxo.vout + ); + let cmd_out = Command::new("bash").args(&["-c", &cmd]).output()?; + Ok(String::from_utf8(cmd_out.stdout)?.trim().parse()?) +} + +fn get_prev_txs(tx: &Transaction) -> Result> { + let cmd_output = Command::new("bash") + .args(&[ + "-c", format!("bitcoin-cli decoderawtransaction {} | jq -r '.vin[].txid' | sort | uniq | xargs -I {{}} bitcoin-cli getrawtransaction {{}} | paste -sd, -", serialize_hex(tx)).as_str() + ]) + .output()?; + Ok(String::from_utf8(cmd_output.stdout)? + .split(',') + .map(|s| s.trim().to_string()) + .collect()) +} + +fn tx_output(outs: &[Output]) -> Vec { + outs.iter() + .map(|u| { + let value = Amount::from_sat(u.sats.unwrap()); + let address = u.address.as_ref().unwrap().clone().assume_checked(); + let script_pubkey = ScriptBuf::from(address.script_pubkey()); + TxOut { + value, + script_pubkey, + } + }) + .collect() +} + +fn tx_input(ins: &[Input]) -> Vec { + ins.iter() + .map(|u| { + let utxo_id = u.utxo_id.as_ref().unwrap(); + TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array(utxo_id.0 .0), + vout: utxo_id.1, + }, + script_sig: Default::default(), + sequence: Default::default(), + witness: Default::default(), + } + }) + .collect() +} diff --git a/src/lib.rs b/src/lib.rs index d72f010..2ea8c24 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod cli; pub mod script; pub mod spell; pub mod tx; +pub mod utils; pub const SPELL_CHECKER_BINARY: &[u8] = include_bytes!("./bin/charms-spell-checker"); diff --git a/src/spell.rs b/src/spell.rs index 7f27c2a..8752f4b 100644 --- a/src/spell.rs +++ b/src/spell.rs @@ -1,5 +1,6 @@ use crate::{app, SPELL_CHECKER_BINARY}; use anyhow::{anyhow, ensure, Error}; +use bitcoin::{address::NetworkUnchecked, Address}; use charms_data::{util, App, Charms, Data, Transaction, UtxoId, B32}; use charms_spell_checker::{ NormalizedCharms, NormalizedSpell, NormalizedTransaction, Proof, SpellProverInput, @@ -15,13 +16,23 @@ pub type KeyedCharms = BTreeMap; /// UTXO as represented in a spell. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Utxo { +pub struct Input { #[serde(skip_serializing_if = "Option::is_none")] pub utxo_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub charms: Option, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Output { + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub sats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub charms: Option, +} + /// Defines how spells are represented on the wire, /// in both human-friendly (JSON/YAML) and machine-friendly (CBOR) formats. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -36,10 +47,10 @@ pub struct Spell { #[serde(skip_serializing_if = "Option::is_none")] pub private_inputs: Option>, - pub ins: Vec, + pub ins: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub refs: Option>, - pub outs: Vec, + pub refs: Option>, + pub outs: Vec, } impl Spell { @@ -56,36 +67,36 @@ impl Spell { } pub fn to_tx(&self) -> anyhow::Result { - let ins = self.strands(&self.ins)?; + let ins = self.strings_of_charms(&self.ins)?; let empty_vec = vec![]; - let refs = self.strands(self.refs.as_ref().unwrap_or(&empty_vec))?; + let refs = self.strings_of_charms(self.refs.as_ref().unwrap_or(&empty_vec))?; let outs = self .outs .iter() - .map(|utxo| self.charms(utxo)) + .map(|output| self.charms(&output.charms)) .collect::>()?; Ok(Transaction { ins, refs, outs }) } - fn strands(&self, utxos: &Vec) -> anyhow::Result> { - utxos + fn strings_of_charms(&self, inputs: &Vec) -> anyhow::Result> { + inputs .iter() - .map(|utxo| { - let utxo_id = utxo + .map(|input| { + let utxo_id = input .utxo_id .as_ref() .ok_or(anyhow!("missing input utxo_id"))?; - let strand = self.charms(utxo)?; - Ok((utxo_id.clone(), strand)) + let charms = self.charms(&input.charms)?; + Ok((utxo_id.clone(), charms)) }) .collect::>() } - fn charms(&self, utxo: &Utxo) -> anyhow::Result { - utxo.charms + fn charms(&self, charms_opt: &Option) -> anyhow::Result { + charms_opt .as_ref() - .ok_or(anyhow!("missing input charm"))? + .ok_or(anyhow!("missing charms field"))? .iter() .map(|(k, v)| { let app = self.apps.get(k).ok_or(anyhow!("missing app {}", k))?; @@ -103,18 +114,7 @@ impl Spell { let app_to_index: BTreeMap = apps.iter().cloned().zip(0..).collect(); ensure!(apps.len() == keyed_apps.len(), "duplicate apps"); - let app_public_inputs: BTreeMap = keyed_apps - .iter() - .map(|(k, app)| { - ( - app.clone(), - keyed_public_inputs - .get(k) - .map(|v| Data::from(v)) - .unwrap_or_default(), - ) - }) - .collect(); + let app_public_inputs: BTreeMap = app_inputs(keyed_apps, keyed_public_inputs); let ins: Vec = self .ins @@ -165,13 +165,7 @@ impl Spell { }; let keyed_private_inputs = self.private_inputs.as_ref().unwrap_or(&empty_map); - let app_private_inputs = keyed_private_inputs - .iter() - .map(|(k, v)| { - let app = keyed_apps.get(k).ok_or(anyhow!("missing app key"))?; - Ok((app.clone(), Data::from(v))) - }) - .collect::>()?; + let app_private_inputs = app_inputs(keyed_apps, keyed_private_inputs); Ok((norm_spell, app_private_inputs)) } @@ -202,7 +196,7 @@ impl Spell { }; let ins = norm_spell_ins .iter() - .map(|utxo_id| Utxo { + .map(|utxo_id| Input { utxo_id: Some(utxo_id.clone()), charms: None, }) @@ -212,7 +206,7 @@ impl Spell { .tx .refs .iter() - .map(|utxo_id| Utxo { + .map(|utxo_id| Input { utxo_id: Some(utxo_id.clone()), charms: None, }) @@ -226,8 +220,9 @@ impl Spell { .tx .outs .iter() - .map(|n_charms| Utxo { - utxo_id: None, + .map(|n_charms| Output { + address: None, + sats: None, charms: match n_charms .iter() .map(|(i, data)| { @@ -260,6 +255,24 @@ pub fn str_index(i: &usize) -> String { format!("${:04}", i) } +fn app_inputs( + keyed_apps: &BTreeMap, + keyed_inputs: &BTreeMap, +) -> BTreeMap { + keyed_apps + .iter() + .map(|(k, app)| { + ( + app.clone(), + keyed_inputs + .get(k) + .map(|v| Data::from(v)) + .unwrap_or_default(), + ) + }) + .collect() +} + pub fn prove( norm_spell: NormalizedSpell, app_binaries: &BTreeMap>, diff --git a/src/tx.rs b/src/tx.rs index c459506..06f7064 100644 --- a/src/tx.rs +++ b/src/tx.rs @@ -6,6 +6,7 @@ use crate::{ use bitcoin::{ self, absolute::LockTime, + consensus::encode::deserialize_hex, key::Secp256k1, secp256k1::{schnorr, Keypair, Message}, sighash::{Prevouts, SighashCache}, @@ -17,17 +18,14 @@ use bitcoin::{ }; use charms_spell_checker::{NormalizedSpell, Proof}; use rand::thread_rng; +use std::collections::BTreeMap; /// `add_spell` adds `spell` to `tx`: /// 1. it builds `commit_spell_tx` transaction which creates a *committed spell* Tapscript output /// 2. then appends an input spending the *committed spell* to `tx`, and adds a witness for it. /// -/// `fee_rate` is used to compute the amount of sats necessary to fund this, which is exactly the -/// amount of the created Tapscript output. -/// -/// `tx` (prior to modification) is assumed to already include the fee at `fee_rate`: -/// we're simply adding an input (and a change output) while maintaining the same fee rate for the -/// modified `tx`. +/// `fee_rate` is used to compute the amount of sats necessary to fund the commit and spell +/// transactions. /// /// Return `[commit_spell_tx, tx]`. /// @@ -39,36 +37,41 @@ pub fn add_spell( funding_output_value: Amount, change_script_pubkey: ScriptBuf, fee_rate: FeeRate, + prev_txs: &BTreeMap, ) -> [Transaction; 2] { let secp256k1 = Secp256k1::new(); let keypair = Keypair::new(&secp256k1, &mut thread_rng()); let (public_key, _) = XOnlyPublicKey::from_keypair(&keypair); let script = data_script(public_key, &spell_data); - let fee = compute_fee(fee_rate, script.len()); - - let mut tx = tx; - let (commit_spell_tx, committed_spell_txout) = - create_commit_tx(funding_out_point, funding_output_value, public_key, &script); - let commit_spell_txid = commit_spell_tx.compute_txid(); - let change_amount = committed_spell_txout.value - fee; + let commit_tx = create_commit_tx( + funding_out_point, + funding_output_value, + public_key, + &script, + fee_rate, + ); + let commit_txout = &commit_tx.output[0]; + + let tx_amount_in = tx_total_amount_in(prev_txs, &tx); + let change_amount = compute_change_amount( + fee_rate, + script.len(), + &tx, + tx_amount_in + commit_txout.value, + ); + let mut tx = tx; modify_tx( &mut tx, - commit_spell_txid, + commit_tx.compute_txid(), change_script_pubkey, change_amount, ); let spell_input = tx.input.len() - 1; - let signature = create_tx_signature( - keypair, - &mut tx, - spell_input, - &committed_spell_txout, - &script, - ); + let signature = create_tx_signature(keypair, &mut tx, spell_input, &commit_txout, &script); append_witness_data( &mut tx.input[spell_input].witness, @@ -77,20 +80,27 @@ pub fn add_spell( signature, ); - [commit_spell_tx, tx] + [commit_tx, tx] } /// fee covering only the marginal cost of spending the committed spell output. -fn compute_fee(fee_rate: FeeRate, script_len: usize) -> Amount { +fn compute_change_amount( + fee_rate: FeeRate, + script_len: usize, + tx: &Transaction, + total_amount_in: Amount, +) -> Amount { // script input: (41 * 4) + (L + 99) = 164 + L + 99 = L + 263 wu // change output: 42 * 4 = 168 wu let added_weight = Weight::from_witness_data_size(script_len as u64) + Weight::from_wu(263 + 168); - // CPFP paying for commit_tx (111 vB) minus (already paid) relay fee of 111 sats - let commit_tx_fee_cpfp = fee_rate.fee_vb(111).unwrap() - Amount::from_sat(111); + let total_tx_weight = tx.weight() + added_weight; + let fee = fee_rate.fee_wu(total_tx_weight).unwrap(); - fee_rate.fee_wu(added_weight).unwrap() + commit_tx_fee_cpfp + let tx_amount_out = tx.output.iter().map(|tx_out| tx_out.value).sum::(); + + total_amount_in - tx_amount_out - fee } fn create_commit_tx( @@ -98,16 +108,11 @@ fn create_commit_tx( funding_output_value: Amount, public_key: XOnlyPublicKey, script: &ScriptBuf, -) -> (Transaction, TxOut) { - const RELAY_FEE: Amount = Amount::from_sat(111); // assuming spending exactly 1 output via key path - - let committed_spell_txout = TxOut { - value: funding_output_value - RELAY_FEE, - script_pubkey: ScriptBuf::new_p2tr_tweaked( - taproot_spend_info(public_key, script.clone()).output_key(), - ), - }; - let commit_spell_tx = Transaction { + fee_rate: FeeRate, +) -> Transaction { + let fee = fee_rate.fee_vb(111).unwrap(); // tx is 111 vbytes when spending a Taproot output + + let commit_tx = Transaction { version: Version::TWO, lock_time: LockTime::ZERO, input: vec![TxIn { @@ -116,33 +121,39 @@ fn create_commit_tx( sequence: Default::default(), witness: Default::default(), }], - output: vec![committed_spell_txout.clone()], + output: vec![TxOut { + value: funding_output_value - fee, + script_pubkey: ScriptBuf::new_p2tr_tweaked( + taproot_spend_info(public_key, script.clone()).output_key(), + ), + }], }; - (commit_spell_tx, committed_spell_txout) + + commit_tx } fn modify_tx( tx: &mut Transaction, - commit_spell_txid: Txid, + commit_txid: Txid, change_script_pubkey: ScriptBuf, change_amount: Amount, ) { tx.input.push(TxIn { previous_output: OutPoint { - txid: commit_spell_txid, + txid: commit_txid, vout: 0, }, script_sig: Default::default(), sequence: Default::default(), witness: Witness::new(), }); + tx.output.push(TxOut { + value: change_amount, + script_pubkey: change_script_pubkey, + }); if change_amount >= Amount::from_sat(546) { // dust limit - tx.output.push(TxOut { - value: change_amount, - script_pubkey: change_script_pubkey, - }); } } @@ -189,49 +200,6 @@ fn append_witness_data( witness.push(control_block(public_key, script).serialize()); } -#[cfg(test)] -mod tests { - use super::*; - use bitcoin::{ - consensus::encode::{deserialize_hex, serialize_hex}, - Address, Amount, Txid, - }; - use std::str::FromStr; - - #[test] - fn test_add_spell() { - let tx_hex = "02000000012f7d19323990772b01a9efc34f29bb110b1df2cd4acb2c1fd64dbd56ac0027f70100000000fdffffff02102700000000000022512002a094075fa87e65564ef2e9be5f1d9bb2c2c68060694fa9f262b134f7b3852b110007000000000022512035110fe9264022566504564fcfb0ce154bf5b66e4476739d5ffe7736afab798400000000"; - let tx = deserialize_hex::(tx_hex).unwrap(); - - let [commit_tx, tx] = add_spell( - dbg!(tx), - b"awesome-spell", - OutPoint { - txid: Txid::from_str( - "f72700ac56bd4dd61f2ccb4acdf21d0b11bb294fc3efa9012b77903932197d2f", - ) - .unwrap(), - vout: 0, - }, - Amount::from_sat(10000), - Address::from_str("tb1pn8dcuyac5z5cyck7audhk8gkj6zz4fh4l0jv5cws9w68szyaa3ksqgdanl") - .unwrap() - .assume_checked() - .script_pubkey(), - FeeRate::from_sat_per_vb(2u64).unwrap(), - ); - - let commit_tx_hex = serialize_hex(dbg!(&commit_tx)); - - dbg!(&commit_tx_hex); - let decoded_commit_tx = deserialize_hex::(commit_tx_hex.as_ref()).unwrap(); - assert_eq!(commit_tx, decoded_commit_tx); - - let tx_hex = serialize_hex(&tx); - dbg!(tx_hex); - } -} - pub fn norm_spell_and_proof(tx: &Transaction) -> Option<(NormalizedSpell, Proof)> { charms_spell_checker::tx::extract_spell(&tx, SPELL_VK).ok() } @@ -242,3 +210,26 @@ pub fn spell(tx: &Transaction) -> Option { None => None, } } + +pub fn txs_by_txid(prev_txs: Vec) -> anyhow::Result> { + prev_txs + .iter() + .map(|prev_tx| { + let prev_tx = deserialize_hex::(prev_tx)?; + + Ok((prev_tx.compute_txid(), prev_tx)) + }) + .collect::>>() +} + +pub fn tx_total_amount_in(prev_txs: &BTreeMap, tx: &Transaction) -> Amount { + tx.input + .iter() + .map(|tx_in| (tx_in.previous_output.txid, tx_in.previous_output.vout)) + .map(|(tx_id, i)| prev_txs[&tx_id].output[i as usize].value) + .sum::() +} + +pub fn tx_total_amount_out(tx: &Transaction) -> Amount { + tx.output.iter().map(|tx_out| tx_out.value).sum::() +} diff --git a/src/utils/logger.rs b/src/utils/logger.rs new file mode 100644 index 0000000..c24dba0 --- /dev/null +++ b/src/utils/logger.rs @@ -0,0 +1,52 @@ +use std::sync::Once; + +use tracing_forest::ForestLayer; +use tracing_subscriber::{ + fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Registry, +}; + +static INIT: Once = Once::new(); + +/// A simple logger. +/// +/// Set the `RUST_LOG` environment variable to be set to `info` or `debug`. +pub fn setup_logger() { + INIT.call_once(|| { + let default_filter = "off"; + let env_filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(default_filter)) + .add_directive("hyper=off".parse().unwrap()) + .add_directive("p3_keccak_air=off".parse().unwrap()) + .add_directive("p3_fri=off".parse().unwrap()) + .add_directive("p3_dft=off".parse().unwrap()) + .add_directive("p3_challenger=off".parse().unwrap()); + + // if the RUST_LOGGER environment variable is set, use it to determine which logger to + // configure (tracing_forest or tracing_subscriber) + // otherwise, default to 'forest' + let logger_type = std::env::var("RUST_LOGGER").unwrap_or_else(|_| "flat".to_string()); + match logger_type.as_str() { + "forest" => { + Registry::default() + .with(env_filter) + .with(ForestLayer::default()) + .init(); + } + "flat" => { + tracing_subscriber::fmt::Subscriber::builder() + .compact() + .with_file(false) + .with_target(false) + .with_thread_names(false) + .with_env_filter(env_filter) + .with_span_events(FmtSpan::CLOSE) + .with_writer(std::io::stderr) // log to stderr + .finish() + .init(); + } + _ => { + panic!("Invalid logger type: {}", logger_type); + } + } + }); +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..cbcf28e --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1 @@ +pub(crate) mod logger;