diff --git a/.github/workflows/bitcoin-tests.yml b/.github/workflows/bitcoin-tests.yml index fee0d91639..698b1dec41 100644 --- a/.github/workflows/bitcoin-tests.yml +++ b/.github/workflows/bitcoin-tests.yml @@ -94,6 +94,7 @@ jobs: - tests::neon_integrations::test_problematic_microblocks_are_not_mined - tests::neon_integrations::test_problematic_microblocks_are_not_relayed_or_stored - tests::neon_integrations::push_boot_receipts + - tests::neon_integrations::test_submit_and_observe_sbtc_ops - tests::epoch_205::test_dynamic_db_method_costs - tests::epoch_205::transition_empty_blocks - tests::epoch_205::test_cost_limit_switch_version205 diff --git a/Cargo.lock b/Cargo.lock index 841302aee0..f00f2873f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2136,6 +2136,10 @@ version = "0.6.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" +[[package]] +name = "relay-server" +version = "0.0.1" + [[package]] name = "reqwest" version = "0.11.14" diff --git a/Cargo.toml b/Cargo.toml index 0b7ce13203..70d0f97379 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,10 @@ path = "src/clarity_cli_main.rs" name = "blockstack-cli" path = "src/blockstack_cli.rs" +[[bin]] +name = "relay-server" +path = "contrib/tools/relay-server/src/main.rs" + [dependencies] rand = "0.7.3" rand_chacha = "=0.2.2" @@ -131,4 +135,5 @@ members = [ ".", "clarity", "stx-genesis", - "testnet/stacks-node"] + "testnet/stacks-node", + "contrib/tools/relay-server"] diff --git a/clarity/src/vm/types/mod.rs b/clarity/src/vm/types/mod.rs index e51bbe9f45..bfc6968acd 100644 --- a/clarity/src/vm/types/mod.rs +++ b/clarity/src/vm/types/mod.rs @@ -1425,6 +1425,15 @@ impl From for StacksAddress { } } +impl From for StacksAddress { + fn from(principal: PrincipalData) -> Self { + match principal { + PrincipalData::Standard(standard_principal) => standard_principal.into(), + PrincipalData::Contract(contract_principal) => contract_principal.issuer.into(), + } + } +} + impl From for Value { fn from(principal: StandardPrincipalData) -> Self { Value::Principal(PrincipalData::from(principal)) diff --git a/contrib/tools/relay-server/Cargo.toml b/contrib/tools/relay-server/Cargo.toml new file mode 100644 index 0000000000..8ccbd55d63 --- /dev/null +++ b/contrib/tools/relay-server/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "relay-server" +version = "0.0.1" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] diff --git a/contrib/tools/relay-server/README.md b/contrib/tools/relay-server/README.md new file mode 100644 index 0000000000..a6c6127739 --- /dev/null +++ b/contrib/tools/relay-server/README.md @@ -0,0 +1,22 @@ +# A Relay Server + +The `relay` server is an HTTP service that has two functions: + +- Accepting messages and storing all of them. `POST` method. + For example, `curl 'http://127.0.0.1:9776' -X POST -d 'message'`. +- Returning the messages in the same order as received for each client. + For example, `curl 'http://127.0.0.1:9776/?id=alice'`. + +## Start the `relay-server` server + +```sh +cargo run --bin relay-server +``` + +The default address is `http://127.0.0.1:9776`. + +## Integration Test + +1. Start the server `cargo run --bit relay-server` +2. Run `./test.sh` in another terminal. +3. Close the server using `Ctrl+C`. diff --git a/contrib/tools/relay-server/src/http.rs b/contrib/tools/relay-server/src/http.rs new file mode 100644 index 0000000000..248d1edb85 --- /dev/null +++ b/contrib/tools/relay-server/src/http.rs @@ -0,0 +1,150 @@ +use std::{ + collections::HashMap, + io::{Error, Read}, +}; + +use crate::to_io_result::ToIoResult; + +#[derive(Debug)] +pub struct Request { + pub method: String, + pub url: String, + pub protocol: String, + pub headers: HashMap, + pub content: Vec, +} + +pub trait RequestEx: Read { + fn read_http_request(&mut self) -> Result { + let mut read_byte = || -> Result { + let mut buf = [0; 1]; + self.read_exact(&mut buf)?; + Ok(buf[0]) + }; + + let mut read_line = || -> Result { + let mut result = String::default(); + loop { + let b = read_byte()?; + if b == 13 { + break; + }; + result.push(b as char); + } + assert_eq!(read_byte()?, 10); + Ok(result) + }; + + // read and parse the request line + let request_line = read_line()?; + let mut split = request_line.split(' '); + let mut next = || { + split + .next() + .to_io_result("invalid HTTP request line") + .map(|x| x.to_string()) + }; + let method = next()?; + let url = next()?; + let protocol = next()?; + + // read and parse headers + let mut headers = HashMap::default(); + loop { + let line = read_line()?; + if line.is_empty() { + break; + } + let (name, value) = line.split_once(':').to_io_result("")?; + headers.insert(name.to_lowercase(), value.trim().to_string()); + } + + // read content + let content_length = headers.get("content-length").map_or(Ok(0), |v| { + v.parse::().to_io_result("invalid content-length") + })?; + let mut content = vec![0; content_length]; + self.read_exact(content.as_mut_slice())?; + + // return the message + Ok(Request { + method, + url, + protocol, + headers, + content, + }) + } +} + +impl RequestEx for T {} + +#[cfg(test)] +mod tests { + use std::{io::Cursor, str::from_utf8}; + + use crate::http::RequestEx; + + #[test] + fn test() { + const REQUEST: &str = "\ + POST / HTTP/1.1\r\n\ + Content-Length: 6\r\n\ + \r\n\ + Hello!"; + let mut read = Cursor::new(REQUEST); + let rm = read.read_http_request().unwrap(); + assert_eq!(rm.method, "POST"); + assert_eq!(rm.url, "/"); + assert_eq!(rm.protocol, "HTTP/1.1"); + assert_eq!(rm.headers.len(), 1); + assert_eq!(rm.headers["content-length"], "6"); + assert_eq!(from_utf8(&rm.content), Ok("Hello!")); + assert_eq!(read.position(), REQUEST.len() as u64); + } + + #[test] + fn incomplete_message_test() { + const REQUEST: &str = "\ + POST / HTTP/1.1\r\n\ + Content-Leng"; + let mut read = Cursor::new(REQUEST); + let _ = read.read_http_request().unwrap_err(); + } + + #[test] + fn incomplete_content_test() { + const REQUEST: &str = "\ + POST / HTTP/1.1\r\n\ + Content-Length: 6\r\n\ + \r\n"; + let mut read = Cursor::new(REQUEST); + let _ = read.read_http_request().unwrap_err(); + } + + #[test] + fn invalid_message_test() { + const REQUEST: &str = "\ + POST / HTTP/1.1\r\n\ + Content-Length 6\r\n\ + \r\n\ + Hello!"; + let mut read = Cursor::new(REQUEST); + let _ = read.read_http_request().unwrap_err(); + } + + #[test] + fn no_content_test() { + const REQUEST: &str = "\ + GET /images/logo.png HTTP/1.1\r\n\ + \r\n"; + let mut read = Cursor::new(REQUEST); + let rm = read.read_http_request().unwrap(); + assert_eq!(rm.method, "GET"); + assert_eq!(rm.url, "/images/logo.png"); + assert_eq!(rm.protocol, "HTTP/1.1"); + assert!(rm.headers.is_empty()); + assert!(rm.content.is_empty()); + assert_eq!(read.position(), REQUEST.len() as u64); + } +} diff --git a/contrib/tools/relay-server/src/main.rs b/contrib/tools/relay-server/src/main.rs new file mode 100644 index 0000000000..8337ceb68a --- /dev/null +++ b/contrib/tools/relay-server/src/main.rs @@ -0,0 +1,22 @@ +use std::net::TcpListener; + +use server::ServerEx; + +use crate::state::State; + +mod http; +mod server; +mod state; +mod to_io_result; +mod url; + +fn main() { + let mut state = State::default(); + let listner = TcpListener::bind("127.0.0.1:9776").unwrap(); + for stream_or_error in listner.incoming() { + let f = || stream_or_error?.update_state(&mut state); + if let Err(e) = f() { + eprintln!("IO error: {e}"); + } + } +} diff --git a/contrib/tools/relay-server/src/server.rs b/contrib/tools/relay-server/src/server.rs new file mode 100644 index 0000000000..cae3c6f2e3 --- /dev/null +++ b/contrib/tools/relay-server/src/server.rs @@ -0,0 +1,143 @@ +use std::{ + io::{Error, ErrorKind, Read, Write}, + net::TcpStream, +}; + +use crate::{http::RequestEx, state::State, to_io_result::ToIoResult, url::QueryEx}; + +pub trait Stream { + type Read: Read; + type Write: Write; + fn istream(&mut self) -> &mut Self::Read; + fn ostream(&mut self) -> &mut Self::Write; +} + +impl Stream for TcpStream { + type Read = TcpStream; + type Write = TcpStream; + fn istream(&mut self) -> &mut Self::Read { + self + } + fn ostream(&mut self) -> &mut Self::Write { + self + } +} + +pub trait ServerEx: Stream { + fn update_state(&mut self, state: &mut State) -> Result<(), Error> { + let rm = self.istream().read_http_request()?; + let ostream = self.ostream(); + let mut write = |text: &str| ostream.write(text.as_bytes()); + let mut write_line = |line: &str| { + write(line)?; + write("\r\n")?; + Ok::<(), Error>(()) + }; + let mut write_response_line = || write_line("HTTP/1.1 200 OK"); + match rm.method.as_str() { + "GET" => { + let query = *rm.url.url_query().get("id").to_io_result("no id")?; + let msg = state + .get(query.to_string()) + .map_or([].as_slice(), |v| v.as_slice()); + let len = msg.len(); + write_response_line()?; + write_line(format!("content-length:{len}").as_str())?; + write_line("")?; + ostream.write(msg)?; + } + "POST" => { + state.post(rm.content); + write_response_line()?; + write_line("")?; + } + _ => return Err(Error::new(ErrorKind::InvalidData, "unknown HTTP method")), + }; + Ok(()) + } +} + +impl ServerEx for T {} + +#[cfg(test)] +mod test { + use crate::{server::ServerEx, state::State}; + use std::{io::Cursor, str::from_utf8}; + + use super::Stream; + + struct MockStream { + i: Cursor<&'static str>, + o: Cursor>, + } + + trait MockStreamEx { + fn mock_stream(self) -> MockStream; + } + + impl MockStreamEx for &'static str { + fn mock_stream(self) -> MockStream { + MockStream { + i: Cursor::new(self), + o: Default::default(), + } + } + } + + impl Stream for MockStream { + type Read = Cursor<&'static str>; + type Write = Cursor>; + fn istream(&mut self) -> &mut Self::Read { + &mut self.i + } + fn ostream(&mut self) -> &mut Self::Write { + &mut self.o + } + } + + #[test] + fn test() { + let mut state = State::default(); + { + const REQUEST: &str = "\ + POST / HTTP/1.1\r\n\ + Content-Length: 6\r\n\ + \r\n\ + Hello!"; + let mut stream = REQUEST.mock_stream(); + stream.update_state(&mut state).unwrap(); + assert_eq!(stream.i.position(), REQUEST.len() as u64); + const RESPONSE: &str = "\ + HTTP/1.1 200 OK\r\n\ + \r\n"; + assert_eq!(from_utf8(stream.o.get_ref()).unwrap(), RESPONSE); + } + { + const REQUEST: &str = "\ + GET /?id=x HTTP/1.1\r\n\ + \r\n"; + let mut stream = REQUEST.mock_stream(); + stream.update_state(&mut state).unwrap(); + assert_eq!(stream.i.position(), REQUEST.len() as u64); + const RESPONSE: &str = "\ + HTTP/1.1 200 OK\r\n\ + content-length:6\r\n\ + \r\n\ + Hello!"; + assert_eq!(from_utf8(stream.o.get_ref()).unwrap(), RESPONSE); + } + { + const REQUEST: &str = "\ + GET /?id=x HTTP/1.1\r\n\ + \r\n"; + let mut stream = REQUEST.mock_stream(); + stream.update_state(&mut state).unwrap(); + assert_eq!(stream.i.position(), REQUEST.len() as u64); + const RESPONSE: &str = "\ + HTTP/1.1 200 OK\r\n\ + content-length:0\r\n\ + \r\n"; + assert_eq!(from_utf8(stream.o.get_ref()).unwrap(), RESPONSE); + } + } +} diff --git a/contrib/tools/relay-server/src/state.rs b/contrib/tools/relay-server/src/state.rs new file mode 100644 index 0000000000..caaea7984d --- /dev/null +++ b/contrib/tools/relay-server/src/state.rs @@ -0,0 +1,77 @@ +use std::collections::HashMap; + +#[derive(Default)] +pub struct State { + /// The value for this map is an index for the last read message for this node. + highwaters: HashMap, + queue: Vec>, +} + +impl State { + pub fn get(&mut self, node_id: String) -> Option<&Vec> { + let first_unread = self + .highwaters + .get(&node_id) + .map_or(0, |last_read| *last_read + 1); + let result = self.queue.get(first_unread); + if result != None { + self.highwaters.insert(node_id, first_unread); + }; + result + } + pub fn post(&mut self, msg: Vec) { + self.queue.push(msg); + } +} + +#[cfg(test)] +mod tests { + use super::State; + #[test] + fn state_test() { + let mut state = State::default(); + assert_eq!(None, state.get(1.to_string())); + assert_eq!(None, state.get(3.to_string())); + assert_eq!(0, state.highwaters.len()); + state.post("Msg # 0".as_bytes().to_vec()); + assert_eq!( + Some(&"Msg # 0".as_bytes().to_vec()), + state.get(1.to_string()) + ); + assert_eq!( + Some(&"Msg # 0".as_bytes().to_vec()), + state.get(5.to_string()) + ); + assert_eq!( + Some(&"Msg # 0".as_bytes().to_vec()), + state.get(4.to_string()) + ); + assert_eq!(None, state.get(1.to_string())); + state.post("Msg # 1".as_bytes().to_vec()); + assert_eq!( + Some(&"Msg # 1".as_bytes().to_vec()), + state.get(1.to_string()) + ); + assert_eq!( + Some(&"Msg # 0".as_bytes().to_vec()), + state.get(3.to_string()) + ); + assert_eq!( + Some(&"Msg # 1".as_bytes().to_vec()), + state.get(5.to_string()) + ); + state.post("Msg # 2".as_bytes().to_vec()); + assert_eq!( + Some(&"Msg # 2".as_bytes().to_vec()), + state.get(1.to_string()) + ); + assert_eq!( + Some(&"Msg # 1".as_bytes().to_vec()), + state.get(4.to_string()) + ); + assert_eq!( + Some(&"Msg # 2".as_bytes().to_vec()), + state.get(4.to_string()) + ); + } +} diff --git a/contrib/tools/relay-server/src/to_io_result.rs b/contrib/tools/relay-server/src/to_io_result.rs new file mode 100644 index 0000000000..c4798457f4 --- /dev/null +++ b/contrib/tools/relay-server/src/to_io_result.rs @@ -0,0 +1,24 @@ +use std::io::{Error, ErrorKind}; + +pub trait ToIoResult { + type T; + fn to_io_result(self, msg: &'static str) -> Result; +} + +fn io_error(msg: &'static str) -> Error { + Error::new(ErrorKind::InvalidData, msg) +} + +impl ToIoResult for Option { + type T = T; + fn to_io_result(self, msg: &'static str) -> Result { + self.ok_or_else(|| io_error(msg)) + } +} + +impl ToIoResult for Result { + type T = T; + fn to_io_result(self, msg: &'static str) -> Result { + self.map_err(|_| io_error(msg)) + } +} diff --git a/contrib/tools/relay-server/src/url.rs b/contrib/tools/relay-server/src/url.rs new file mode 100644 index 0000000000..8c562c8ef1 --- /dev/null +++ b/contrib/tools/relay-server/src/url.rs @@ -0,0 +1,56 @@ +use std::collections::HashMap; + +pub trait QueryEx { + fn url_query(&self) -> HashMap<&str, &str>; +} + +impl QueryEx for str { + fn url_query(&self) -> HashMap<&str, &str> { + match self.split_once('?') { + Some((_, right)) if !right.is_empty() => right + .split('&') + .map(|v| v.split_once('=').unwrap_or((v, &""))) + .collect(), + _ => HashMap::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::QueryEx; + + #[test] + fn no_query_test() { + assert!("".url_query().is_empty()); + } + + #[test] + fn empty_test() { + assert!("?".url_query().is_empty()); + } + + #[test] + fn one_item_test() { + let x = "locahost:8080/?xyz".url_query(); + assert_eq!(x.len(), 1); + assert!(x.get("xyz").unwrap().is_empty()); + } + + #[test] + fn two_items_test() { + let x = "?xyz&azx".url_query(); + assert_eq!(x.len(), 2); + assert!(x.get("xyz").unwrap().is_empty()); + assert!(x.get("azx").unwrap().is_empty()); + } + + #[test] + fn three_items_test() { + let x = "something.example?xyz=5&azx&id=hello".url_query(); + assert_eq!(x.len(), 3); + assert_eq!(x.get("xyz").unwrap().to_owned(), "5"); + assert!(x.get("azx").unwrap().is_empty()); + assert_eq!(x.get("id").unwrap().to_owned(), "hello"); + } +} diff --git a/contrib/tools/relay-server/test.sh b/contrib/tools/relay-server/test.sh new file mode 100755 index 0000000000..d3b570b29c --- /dev/null +++ b/contrib/tools/relay-server/test.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +curl 'http://127.0.0.1:9776/?id=1' +curl 'http://127.0.0.1:9776/?id=3' +curl 'http://127.0.0.1:9776' -X POST -d 'Msg # 0' +curl 'http://127.0.0.1:9776/?id=1' +curl 'http://127.0.0.1:9776/?id=5' +curl 'http://127.0.0.1:9776/?id=4' +curl 'http://127.0.0.1:9776/?id=1' +curl 'http://127.0.0.1:9776' -X POST -d 'Msg # 1' +curl 'http://127.0.0.1:9776/?id=1' +curl 'http://127.0.0.1:9776/?id=3' +curl 'http://127.0.0.1:9776/?id=5' +curl 'http://127.0.0.1:9776' -X POST -d 'Msg # 2' +curl 'http://127.0.0.1:9776/?id=1' +curl 'http://127.0.0.1:9776/?id=4' +curl 'http://127.0.0.1:9776/?id=4' +# try an empty message +curl 'http://127.0.0.1:9776' -X POST +curl 'http://127.0.0.1:9776/?id=1' +# try invalid GET +curl 'http://127.0.0.1:9776' +curl 'http://127.0.0.1:9776/?z=5&f=6' +curl 'http://127.0.0.1:9776/?za' \ No newline at end of file diff --git a/docs/rpc/api/core-node/get-burn-ops-peg-in.example.json b/docs/rpc/api/core-node/get-burn-ops-peg-in.example.json new file mode 100644 index 0000000000..5302a3b624 --- /dev/null +++ b/docs/rpc/api/core-node/get-burn-ops-peg-in.example.json @@ -0,0 +1,14 @@ +{ + "peg_in": [ + { + "amount": 1337, + "block_height": 218, + "burn_header_hash": "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + "memo": "", + "peg_wallet_address": "tb1pqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqkgkkf5", + "recipient": "S0000000000000000000002AA028H.awesome_contract", + "txid": "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + "vtxindex": 2 + } + ], +} diff --git a/docs/rpc/api/core-node/get-burn-ops-peg-out-fulfill.example.json b/docs/rpc/api/core-node/get-burn-ops-peg-out-fulfill.example.json new file mode 100644 index 0000000000..45fca8a329 --- /dev/null +++ b/docs/rpc/api/core-node/get-burn-ops-peg-out-fulfill.example.json @@ -0,0 +1,15 @@ +{ + "peg_out_fulfill": [ + { + "chain_tip": "0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e", + "amount": 1337, + "recipient": "1BixGeiRyKT7NTkJAHpWuP197KXUNqhCU9", + "request_ref": "e81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157772", + "burn_header_hash": "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + "txid": "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + "block_height": 218, + "vtxindex": 2, + "memo": "00010203" + } + ] +} diff --git a/docs/rpc/api/core-node/get-burn-ops-peg-out-request.example.json b/docs/rpc/api/core-node/get-burn-ops-peg-out-request.example.json new file mode 100644 index 0000000000..0e6efa958b --- /dev/null +++ b/docs/rpc/api/core-node/get-burn-ops-peg-out-request.example.json @@ -0,0 +1,16 @@ +{ + "peg_out_request": [ + { + "amount": 1337, + "recipient": "1BixGeiRyKT7NTkJAHpWuP197KXUNqhCU9", + "block_height": 218, + "burn_header_hash": "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + "peg_wallet_address": "tb1qqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvps3f3cyq", + "txid": "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + "vtxindex": 2, + "signature": "0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d", + "fulfillment_fee": 0, + "memo": "00010203" + } + ], +} diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index 0c3d7c09e5..8f339045e4 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -39,7 +39,28 @@ paths: $ref: ./api/transaction/post-core-node-transactions-error.schema.json example: $ref: ./api/transaction/post-core-node-transactions-error.example.json - + /v2/burn_ops/{burn_height}/{op_type}: + get: + summary: Get burn operations + description: Get all burn operations of type `op_type` successfully read at `burn_height`. Valid `op_type`s are `peg_in`, `peg_out_request` and `peg_out_fulfill`. + tags: + - Info + operationId: get_burn_ops + responses: + 200: + description: Burn operations list + content: + application/json: + examples: + peg_in: + value: + $ref: ./api/core-node/get-burn-ops-peg-in.example.json + peg_out_request: + value: + $ref: ./api/core-node/get-burn-ops-peg-out-request.example.json + peg_out_fulfill: + value: + $ref: ./api/core-node/get-burn-ops-peg-out-fulfill.example.json /v2/contracts/interface/{contract_address}/{contract_name}: get: summary: Get contract interface diff --git a/src/burnchains/burnchain.rs b/src/burnchains/burnchain.rs index dc0b75de61..6a7fdf01cc 100644 --- a/src/burnchains/burnchain.rs +++ b/src/burnchains/burnchain.rs @@ -52,8 +52,8 @@ use crate::chainstate::burn::db::sortdb::{SortitionDB, SortitionHandleConn, Sort use crate::chainstate::burn::distribution::BurnSamplePoint; use crate::chainstate::burn::operations::{ leader_block_commit::MissedBlockCommit, BlockstackOperationType, DelegateStxOp, - LeaderBlockCommitOp, LeaderKeyRegisterOp, PreStxOp, StackStxOp, TransferStxOp, - UserBurnSupportOp, + LeaderBlockCommitOp, LeaderKeyRegisterOp, PegInOp, PegOutFulfillOp, PegOutRequestOp, PreStxOp, + StackStxOp, TransferStxOp, UserBurnSupportOp, }; use crate::chainstate::burn::{BlockSnapshot, Opcodes}; use crate::chainstate::coordinator::comm::CoordinatorChannels; @@ -150,6 +150,15 @@ impl BurnchainStateTransition { BlockstackOperationType::LeaderKeyRegister(_) => { accepted_ops.push(block_ops[i].clone()); } + BlockstackOperationType::PegIn(_) => { + accepted_ops.push(block_ops[i].clone()); + } + BlockstackOperationType::PegOutRequest(_) => { + accepted_ops.push(block_ops[i].clone()); + } + BlockstackOperationType::PegOutFulfill(_) => { + accepted_ops.push(block_ops[i].clone()); + } BlockstackOperationType::LeaderBlockCommit(ref op) => { // we don't yet know which block commits are going to be accepted until we have // the burn distribution, so just account for them for now. @@ -895,6 +904,47 @@ impl Burnchain { None } } + + x if x == Opcodes::PegIn as u8 => match PegInOp::from_tx(block_header, burn_tx) { + Ok(op) => Some(BlockstackOperationType::PegIn(op)), + Err(e) => { + warn!("Failed to parse peg in tx"; + "txid" => %burn_tx.txid(), + "data" => %to_hex(&burn_tx.data()), + "error" => ?e, + ); + None + } + }, + + x if x == Opcodes::PegOutRequest as u8 => { + match PegOutRequestOp::from_tx(block_header, burn_tx) { + Ok(op) => Some(BlockstackOperationType::PegOutRequest(op)), + Err(e) => { + warn!("Failed to parse peg out request tx"; + "txid" => %burn_tx.txid(), + "data" => %to_hex(&burn_tx.data()), + "error" => ?e, + ); + None + } + } + } + + x if x == Opcodes::PegOutFulfill as u8 => { + match PegOutFulfillOp::from_tx(block_header, burn_tx) { + Ok(op) => Some(BlockstackOperationType::PegOutFulfill(op)), + Err(e) => { + warn!("Failed to parse peg in tx"; + "txid" => %burn_tx.txid(), + "data" => %to_hex(&burn_tx.data()), + "error" => ?e, + ); + None + } + } + } + _ => None, } } diff --git a/src/chainstate/burn/db/mod.rs b/src/chainstate/burn/db/mod.rs index 6023f3b6e8..c68ffe7515 100644 --- a/src/chainstate/burn/db/mod.rs +++ b/src/chainstate/burn/db/mod.rs @@ -31,6 +31,7 @@ use crate::types::chainstate::TrieHash; use crate::util_lib::db; use crate::util_lib::db::Error as db_error; use crate::util_lib::db::FromColumn; +use clarity::vm::types::PrincipalData; use stacks_common::util::hash::{hex_bytes, Hash160, Sha512Trunc256Sum}; use stacks_common::util::secp256k1::MessageSignature; use stacks_common::util::vrf::*; @@ -76,6 +77,13 @@ impl FromColumn for StacksAddress { } } +impl FromColumn for PrincipalData { + fn from_column<'a>(row: &'a Row, column_name: &str) -> Result { + let address_str: String = row.get_unwrap(column_name); + Self::parse(&address_str).map_err(|_| db_error::ParseError) + } +} + impl FromColumn for PoxAddress { fn from_column<'a>(row: &'a Row, column_name: &str) -> Result { let address_str: String = row.get_unwrap(column_name); diff --git a/src/chainstate/burn/db/processing.rs b/src/chainstate/burn/db/processing.rs index 7b2317eb9f..308790c0c1 100644 --- a/src/chainstate/burn/db/processing.rs +++ b/src/chainstate/burn/db/processing.rs @@ -106,6 +106,27 @@ impl<'a> SortitionHandleTx<'a> { ); BurnchainError::OpError(e) }), + BlockstackOperationType::PegIn(ref op) => op.check().map_err(|e| { + warn!( + "REJECTED({}) peg in op {} at {},{}: {:?}", + op.block_height, &op.txid, op.block_height, op.vtxindex, &e + ); + BurnchainError::OpError(e) + }), + BlockstackOperationType::PegOutRequest(ref op) => op.check().map_err(|e| { + warn!( + "REJECTED({}) peg out request op {} at {},{}: {:?}", + op.block_height, &op.txid, op.block_height, op.vtxindex, &e + ); + BurnchainError::OpError(e) + }), + BlockstackOperationType::PegOutFulfill(ref op) => op.check().map_err(|e| { + warn!( + "REJECTED({}) peg out fulfill op {} at {},{}: {:?}", + op.block_height, &op.txid, op.block_height, op.vtxindex, &e + ); + BurnchainError::OpError(e) + }), } } @@ -155,6 +176,14 @@ impl<'a> SortitionHandleTx<'a> { let txids = state_transition .accepted_ops .iter() + .filter(|op| { + !matches!( + op, + BlockstackOperationType::PegIn(_) + | BlockstackOperationType::PegOutRequest(_) + | BlockstackOperationType::PegOutFulfill(_) + ) + }) .map(|ref op| op.txid()) .collect(); diff --git a/src/chainstate/burn/db/sortdb.rs b/src/chainstate/burn/db/sortdb.rs index 59b39240e7..0c4f710852 100644 --- a/src/chainstate/burn/db/sortdb.rs +++ b/src/chainstate/burn/db/sortdb.rs @@ -24,6 +24,7 @@ use std::ops::DerefMut; use std::{cmp, fmt, fs, str::FromStr}; use clarity::vm::costs::ExecutionCost; +use clarity::vm::types::PrincipalData; use rand; use rand::RngCore; use rusqlite::types::ToSql; @@ -45,8 +46,8 @@ use crate::burnchains::{ use crate::chainstate::burn::operations::DelegateStxOp; use crate::chainstate::burn::operations::{ leader_block_commit::{MissedBlockCommit, RewardSetInfo, OUTPUTS_PER_COMMIT}, - BlockstackOperationType, LeaderBlockCommitOp, LeaderKeyRegisterOp, PreStxOp, StackStxOp, - TransferStxOp, UserBurnSupportOp, + BlockstackOperationType, LeaderBlockCommitOp, LeaderKeyRegisterOp, PegInOp, PegOutFulfillOp, + PegOutRequestOp, PreStxOp, StackStxOp, TransferStxOp, UserBurnSupportOp, }; use crate::chainstate::burn::Opcodes; use crate::chainstate::burn::{BlockSnapshot, ConsensusHash, OpsHash, SortitionHash}; @@ -407,6 +408,112 @@ impl FromRow for DelegateStxOp { } } +impl FromRow for PegInOp { + fn from_row<'a>(row: &'a Row) -> Result { + let txid = Txid::from_column(row, "txid")?; + let vtxindex: u32 = row.get("vtxindex")?; + let block_height = u64::from_column(row, "block_height")?; + let burn_header_hash = BurnchainHeaderHash::from_column(row, "burn_header_hash")?; + + let recipient = PrincipalData::from_column(row, "recipient")?; + let peg_wallet_address = PoxAddress::from_column(row, "peg_wallet_address")?; + let amount = row + .get::<_, String>("amount")? + .parse() + .map_err(|_| db_error::ParseError)?; + + let memo_hex: String = row.get_unwrap("memo"); + let memo_bytes = hex_bytes(&memo_hex).map_err(|_e| db_error::ParseError)?; + let memo = memo_bytes.to_vec(); + + Ok(Self { + txid, + vtxindex, + block_height, + burn_header_hash, + recipient, + peg_wallet_address, + amount, + memo, + }) + } +} + +impl FromRow for PegOutRequestOp { + fn from_row<'a>(row: &'a Row) -> Result { + let txid = Txid::from_column(row, "txid")?; + let vtxindex: u32 = row.get("vtxindex")?; + let block_height = u64::from_column(row, "block_height")?; + let burn_header_hash = BurnchainHeaderHash::from_column(row, "burn_header_hash")?; + + let recipient = PoxAddress::from_column(row, "recipient")?; + let amount = row + .get::<_, String>("amount")? + .parse() + .map_err(|_| db_error::ParseError)?; + + let signature = MessageSignature::from_column(row, "signature")?; + + let peg_wallet_address = PoxAddress::from_column(row, "peg_wallet_address")?; + let fulfillment_fee = row + .get::<_, String>("fulfillment_fee")? + .parse() + .map_err(|_| db_error::ParseError)?; + + let memo_hex: String = row.get_unwrap("memo"); + let memo_bytes = hex_bytes(&memo_hex).map_err(|_e| db_error::ParseError)?; + let memo = memo_bytes.to_vec(); + + Ok(Self { + txid, + vtxindex, + block_height, + burn_header_hash, + recipient, + amount, + signature, + peg_wallet_address, + fulfillment_fee, + memo, + }) + } +} + +impl FromRow for PegOutFulfillOp { + fn from_row<'a>(row: &'a Row) -> Result { + let txid = Txid::from_column(row, "txid")?; + let vtxindex: u32 = row.get("vtxindex")?; + let block_height = u64::from_column(row, "block_height")?; + let burn_header_hash = BurnchainHeaderHash::from_column(row, "burn_header_hash")?; + + let recipient = PoxAddress::from_column(row, "recipient")?; + let amount = row + .get::<_, String>("amount")? + .parse() + .map_err(|_| db_error::ParseError)?; + + let chain_tip = StacksBlockId::from_column(row, "chain_tip")?; + + let memo_hex: String = row.get_unwrap("memo"); + let memo_bytes = hex_bytes(&memo_hex).map_err(|_e| db_error::ParseError)?; + let memo = memo_bytes.to_vec(); + + let request_ref = Txid::from_column(row, "request_ref")?; + + Ok(Self { + txid, + vtxindex, + block_height, + burn_header_hash, + chain_tip, + recipient, + amount, + memo, + request_ref, + }) + } +} + impl FromRow for TransferStxOp { fn from_row<'a>(row: &'a Row) -> Result { let txid = Txid::from_column(row, "txid")?; @@ -501,7 +608,7 @@ impl FromRow for StacksEpoch { } } -pub const SORTITION_DB_VERSION: &'static str = "7"; +pub const SORTITION_DB_VERSION: &'static str = "8"; const SORTITION_DB_INITIAL_SCHEMA: &'static [&'static str] = &[ r#" @@ -702,8 +809,6 @@ const SORTITION_DB_SCHEMA_4: &'static [&'static str] = &[ );"#, ]; -/// The changes for version five *just* replace the existing epochs table -/// by deleting all the current entries and inserting the new epochs definition. const SORTITION_DB_SCHEMA_5: &'static [&'static str] = &[r#" DELETE FROM epochs;"#]; @@ -713,8 +818,56 @@ const SORTITION_DB_SCHEMA_6: &'static [&'static str] = &[r#" const SORTITION_DB_SCHEMA_7: &'static [&'static str] = &[r#" DELETE FROM epochs;"#]; +const SORTITION_DB_SCHEMA_8: &'static [&'static str] = &[ + r#" + CREATE TABLE peg_in ( + txid TEXT NOT NULL, + vtxindex INTEGER NOT NULL, + block_height INTEGER NOT NULL, + burn_header_hash TEXT NOT NULL, + + recipient TEXT NOT NULL, -- Stacks principal to receive the sBTC, can also be a contract principal + peg_wallet_address TEXT NOT NULL, + amount TEXT NOT NULL, + memo TEXT, + + PRIMARY KEY(txid, burn_header_hash) + );"#, + r#" + CREATE TABLE peg_out_requests ( + txid TEXT NOT NULL, + vtxindex INTEGER NOT NULL, + block_height INTEGER NOT NULL, + burn_header_hash TEXT NOT NULL, + + amount TEXT NOT NULL, + recipient TEXT NOT NULL, + signature TEXT NOT NULL, + peg_wallet_address TEXT NOT NULL, + fulfillment_fee TEXT NOT NULL, + memo TEXT, + + PRIMARY KEY(txid, burn_header_hash) + );"#, + r#" + CREATE TABLE peg_out_fulfillments ( + txid TEXT NOT NULL, + vtxindex INTEGER NOT NULL, + block_height INTEGER NOT NULL, + burn_header_hash TEXT NOT NULL, + + chain_tip TEXT NOT NULL, + amount TEXT NOT NULL, + recipient TEXT NOT NULL, + request_ref TEXT NOT NULL, + memo TEXT, + + PRIMARY KEY(txid, burn_header_hash) + );"#, +]; + // update this to add new indexes -const LAST_SORTITION_DB_INDEX: &'static str = "index_delegate_stx_burn_header_hash"; +const LAST_SORTITION_DB_INDEX: &'static str = "index_peg_out_fulfill_burn_header_hash"; const SORTITION_DB_INDEXES: &'static [&'static str] = &[ "CREATE INDEX IF NOT EXISTS snapshots_block_hashes ON snapshots(block_height,index_root,winning_stacks_block_hash);", @@ -739,6 +892,9 @@ const SORTITION_DB_INDEXES: &'static [&'static str] = &[ "CREATE INDEX IF NOT EXISTS index_pox_payouts ON snapshots(pox_payouts);", "CREATE INDEX IF NOT EXISTS index_burn_header_hash_pox_valid ON snapshots(burn_header_hash,pox_valid);", "CREATE INDEX IF NOT EXISTS index_delegate_stx_burn_header_hash ON delegate_stx(burn_header_hash);", + "CREATE INDEX IF NOT EXISTS index_peg_in_burn_header_hash ON peg_in(burn_header_hash);", + "CREATE INDEX IF NOT EXISTS index_peg_out_request_burn_header_hash ON peg_out_requests(burn_header_hash);", + "CREATE INDEX IF NOT EXISTS index_peg_out_fulfill_burn_header_hash ON peg_out_fulfillments(burn_header_hash);", ]; pub struct SortitionDB { @@ -2671,6 +2827,7 @@ impl SortitionDB { SortitionDB::apply_schema_5(&db_tx, epochs_ref)?; SortitionDB::apply_schema_6(&db_tx, epochs_ref)?; SortitionDB::apply_schema_7(&db_tx, epochs_ref)?; + SortitionDB::apply_schema_8(&db_tx)?; db_tx.instantiate_index()?; @@ -2866,6 +3023,7 @@ impl SortitionDB { || version == "5" || version == "6" || version == "7" + || version == "8" } StacksEpochId::Epoch2_05 => { version == "2" @@ -2874,6 +3032,7 @@ impl SortitionDB { || version == "5" || version == "6" || version == "7" + || version == "8" } StacksEpochId::Epoch21 => { version == "3" @@ -2881,6 +3040,7 @@ impl SortitionDB { || version == "5" || version == "6" || version == "7" + || version == "8" } StacksEpochId::Epoch22 => { version == "3" @@ -2888,6 +3048,7 @@ impl SortitionDB { || version == "5" || version == "6" || version == "7" + || version == "8" } StacksEpochId::Epoch23 => { version == "3" @@ -2895,6 +3056,7 @@ impl SortitionDB { || version == "5" || version == "6" || version == "7" + || version == "8" } StacksEpochId::Epoch24 => { version == "3" @@ -2902,6 +3064,7 @@ impl SortitionDB { || version == "5" || version == "6" || version == "7" + || version == "8" } } } @@ -3019,6 +3182,19 @@ impl SortitionDB { Ok(()) } + fn apply_schema_8(tx: &DBTx) -> Result<(), db_error> { + for sql_exec in SORTITION_DB_SCHEMA_8 { + tx.execute_batch(sql_exec)?; + } + + tx.execute( + "INSERT OR REPLACE INTO db_config (version) VALUES (?1)", + &["8"], + )?; + + Ok(()) + } + fn check_schema_version_or_error(&mut self) -> Result<(), db_error> { match SortitionDB::get_schema_version(self.conn()) { Ok(Some(version)) => { @@ -3069,6 +3245,10 @@ impl SortitionDB { let tx = self.tx_begin()?; SortitionDB::apply_schema_7(&tx.deref(), epochs)?; tx.commit()?; + } else if version == "7" { + let tx = self.tx_begin()?; + SortitionDB::apply_schema_8(&tx.deref())?; + tx.commit()?; } else if version == expected_version { return Ok(()); } else { @@ -3997,6 +4177,48 @@ impl SortitionDB { ) } + /// Get the list of Peg-In operations processed in a given burnchain block. + /// This will be the same list in each PoX fork; it's up to the Stacks block-processing logic + /// to reject them. + pub fn get_peg_in_ops( + conn: &Connection, + burn_header_hash: &BurnchainHeaderHash, + ) -> Result, db_error> { + query_rows( + conn, + "SELECT * FROM peg_in WHERE burn_header_hash = ?", + &[burn_header_hash], + ) + } + + /// Get the list of Peg-Out Request operations processed in a given burnchain block. + /// This will be the same list in each PoX fork; it's up to the Stacks block-processing logic + /// to reject them. + pub fn get_peg_out_request_ops( + conn: &Connection, + burn_header_hash: &BurnchainHeaderHash, + ) -> Result, db_error> { + query_rows( + conn, + "SELECT * FROM peg_out_requests WHERE burn_header_hash = ?", + &[burn_header_hash], + ) + } + + /// Get the list of Peg-Out Fulfill operations processed in a given burnchain block. + /// This will be the same list in each PoX fork; it's up to the Stacks block-processing logic + /// to reject them. + pub fn get_peg_out_fulfill_ops( + conn: &Connection, + burn_header_hash: &BurnchainHeaderHash, + ) -> Result, db_error> { + query_rows( + conn, + "SELECT * FROM peg_out_fulfillments WHERE burn_header_hash = ?", + &[burn_header_hash], + ) + } + /// Get the list of Transfer-STX operations processed in a given burnchain block. /// This will be the same list in each PoX fork; it's up to the Stacks block-processing logic /// to reject them. @@ -4857,6 +5079,27 @@ impl<'a> SortitionHandleTx<'a> { ); self.insert_delegate_stx(op) } + BlockstackOperationType::PegIn(ref op) => { + info!( + "ACCEPTED({}) sBTC peg in opt {} at {},{}", + op.block_height, &op.txid, op.block_height, op.vtxindex + ); + self.insert_peg_in_sbtc(op) + } + BlockstackOperationType::PegOutRequest(ref op) => { + info!( + "ACCEPTED({}) sBTC peg out request opt {} at {},{}", + op.block_height, &op.txid, op.block_height, op.vtxindex + ); + self.insert_sbtc_peg_out_request(op) + } + BlockstackOperationType::PegOutFulfill(ref op) => { + info!( + "ACCEPTED({}) sBTC peg out fulfill op {} at {},{}", + op.block_height, &op.txid, op.block_height, op.vtxindex + ); + self.insert_sbtc_peg_out_fulfill(op) + } } } @@ -4924,6 +5167,63 @@ impl<'a> SortitionHandleTx<'a> { Ok(()) } + /// Insert a peg-in op + fn insert_peg_in_sbtc(&mut self, op: &PegInOp) -> Result<(), db_error> { + let args: &[&dyn ToSql] = &[ + &op.txid, + &op.vtxindex, + &u64_to_sql(op.block_height)?, + &op.burn_header_hash, + &op.recipient.to_string(), + &op.peg_wallet_address.to_string(), + &op.amount.to_string(), + &to_hex(&op.memo), + ]; + + self.execute("REPLACE INTO peg_in (txid, vtxindex, block_height, burn_header_hash, recipient, peg_wallet_address, amount, memo) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", args)?; + + Ok(()) + } + + /// Insert a peg-out request op + fn insert_sbtc_peg_out_request(&mut self, op: &PegOutRequestOp) -> Result<(), db_error> { + let args: &[&dyn ToSql] = &[ + &op.txid, + &op.vtxindex, + &u64_to_sql(op.block_height)?, + &op.burn_header_hash, + &op.amount.to_string(), + &op.recipient.to_string(), + &op.signature, + &op.peg_wallet_address.to_string(), + &op.fulfillment_fee.to_string(), + &to_hex(&op.memo), + ]; + + self.execute("REPLACE INTO peg_out_requests (txid, vtxindex, block_height, burn_header_hash, amount, recipient, signature, peg_wallet_address, fulfillment_fee, memo) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", args)?; + + Ok(()) + } + + /// Insert a peg-out fulfillment op + fn insert_sbtc_peg_out_fulfill(&mut self, op: &PegOutFulfillOp) -> Result<(), db_error> { + let args: &[&dyn ToSql] = &[ + &op.txid, + &op.vtxindex, + &u64_to_sql(op.block_height)?, + &op.burn_header_hash, + &op.chain_tip, + &op.amount.to_string(), + &op.recipient.to_string(), + &op.request_ref.to_string(), + &to_hex(&op.memo), + ]; + + self.execute("REPLACE INTO peg_out_fulfillments (txid, vtxindex, block_height, burn_header_hash, chain_tip, amount, recipient, request_ref, memo) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", args)?; + + Ok(()) + } + /// Insert a transfer-stx op fn insert_transfer_stx(&mut self, op: &TransferStxOp) -> Result<(), db_error> { let args: &[&dyn ToSql] = &[ @@ -6341,6 +6641,225 @@ pub mod tests { } } + #[test] + fn test_insert_peg_in() { + let block_height = 123; + + let peg_in_op = |burn_header_hash, amount| { + let txid = Txid([0; 32]); + let vtxindex = 456; + let recipient = StacksAddress::new(1, Hash160([1u8; 20])).into(); + let peg_wallet_address = + PoxAddress::Addr32(false, address::PoxAddressType32::P2TR, [0; 32]); + let memo = vec![1, 3, 3, 7]; + + PegInOp { + recipient, + peg_wallet_address, + amount, + memo, + + txid, + vtxindex, + block_height, + burn_header_hash, + } + }; + + let burn_header_hash_1 = BurnchainHeaderHash([0x01; 32]); + let burn_header_hash_2 = BurnchainHeaderHash([0x02; 32]); + + let peg_in_1 = peg_in_op(burn_header_hash_1, 1337); + let peg_in_2 = peg_in_op(burn_header_hash_2, 42); + + let first_burn_hash = BurnchainHeaderHash::from_hex( + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .unwrap(); + + let epochs = StacksEpoch::unit_test(StacksEpochId::Epoch21, block_height); + let mut db = + SortitionDB::connect_test_with_epochs(block_height, &first_burn_hash, epochs).unwrap(); + + let snapshot_1 = test_append_snapshot( + &mut db, + burn_header_hash_1, + &vec![BlockstackOperationType::PegIn(peg_in_1.clone())], + ); + + let snapshot_2 = test_append_snapshot( + &mut db, + burn_header_hash_2, + &vec![BlockstackOperationType::PegIn(peg_in_2.clone())], + ); + + let res_peg_ins_1 = SortitionDB::get_peg_in_ops(db.conn(), &snapshot_1.burn_header_hash) + .expect("Failed to get peg-in ops from sortition DB"); + + assert_eq!(res_peg_ins_1.len(), 1); + assert_eq!(res_peg_ins_1[0], peg_in_1); + + let res_peg_ins_2 = SortitionDB::get_peg_in_ops(db.conn(), &snapshot_2.burn_header_hash) + .expect("Failed to get peg-in ops from sortition DB"); + + assert_eq!(res_peg_ins_2.len(), 1); + assert_eq!(res_peg_ins_2[0], peg_in_2); + } + + #[test] + fn test_insert_peg_out_request() { + let block_height = 123; + + let peg_out_request_op = |burn_header_hash, amount| { + let txid = Txid([0; 32]); + let vtxindex = 456; + let amount = 1337; + let recipient = PoxAddress::Addr32(false, address::PoxAddressType32::P2TR, [0; 32]); + let signature = MessageSignature([0; 65]); + let peg_wallet_address = + PoxAddress::Addr32(false, address::PoxAddressType32::P2TR, [0; 32]); + let fulfillment_fee = 3; + let memo = vec![1, 3, 3, 7]; + + PegOutRequestOp { + recipient, + amount, + signature, + peg_wallet_address, + fulfillment_fee, + memo, + + txid, + vtxindex, + block_height, + burn_header_hash, + } + }; + + let burn_header_hash_1 = BurnchainHeaderHash([0x01; 32]); + let burn_header_hash_2 = BurnchainHeaderHash([0x02; 32]); + + let peg_out_request_1 = peg_out_request_op(burn_header_hash_1, 1337); + let peg_out_request_2 = peg_out_request_op(burn_header_hash_2, 42); + + let first_burn_hash = BurnchainHeaderHash::from_hex( + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .unwrap(); + + let epochs = StacksEpoch::unit_test(StacksEpochId::Epoch21, block_height); + let mut db = + SortitionDB::connect_test_with_epochs(block_height, &first_burn_hash, epochs).unwrap(); + + let snapshot_1 = test_append_snapshot( + &mut db, + burn_header_hash_1, + &vec![BlockstackOperationType::PegOutRequest( + peg_out_request_1.clone(), + )], + ); + + let snapshot_2 = test_append_snapshot( + &mut db, + burn_header_hash_2, + &vec![BlockstackOperationType::PegOutRequest( + peg_out_request_2.clone(), + )], + ); + + let res_peg_out_requests_1 = + SortitionDB::get_peg_out_request_ops(db.conn(), &burn_header_hash_1) + .expect("Failed to get peg-out request ops from sortition DB"); + + assert_eq!(res_peg_out_requests_1.len(), 1); + assert_eq!(res_peg_out_requests_1[0], peg_out_request_1); + + let res_peg_out_requests_2 = + SortitionDB::get_peg_out_request_ops(db.conn(), &burn_header_hash_2) + .expect("Failed to get peg-out request ops from sortition DB"); + + assert_eq!(res_peg_out_requests_2.len(), 1); + assert_eq!(res_peg_out_requests_2[0], peg_out_request_2); + } + + #[test] + fn test_insert_peg_out_fulfill() { + let txid = Txid([0; 32]); + + let peg_out_fulfill_op = |burn_header_hash, amount| { + let block_height = 123; + let vtxindex = 456; + let recipient = PoxAddress::Addr32(false, address::PoxAddressType32::P2TR, [0; 32]); + let chain_tip = StacksBlockId([0; 32]); + let request_ref = Txid([1; 32]); + let memo = vec![1, 3, 3, 7]; + + PegOutFulfillOp { + recipient, + amount, + chain_tip, + request_ref, + memo, + + txid, + vtxindex, + block_height, + burn_header_hash, + } + }; + + let block_height = 123; + let vtxindex = 456; + let recipient = PoxAddress::Addr32(false, address::PoxAddressType32::P2TR, [0; 32]); + let chain_tip = StacksBlockId([0; 32]); + let burn_header_hash = BurnchainHeaderHash([0x03; 32]); + + let burn_header_hash_1 = BurnchainHeaderHash([0x01; 32]); + let burn_header_hash_2 = BurnchainHeaderHash([0x02; 32]); + + let peg_out_fulfill_1 = peg_out_fulfill_op(burn_header_hash_1, 1337); + let peg_out_fulfill_2 = peg_out_fulfill_op(burn_header_hash_2, 42); + + let first_burn_hash = BurnchainHeaderHash::from_hex( + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .unwrap(); + + let epochs = StacksEpoch::unit_test(StacksEpochId::Epoch21, block_height); + let mut db = + SortitionDB::connect_test_with_epochs(block_height, &first_burn_hash, epochs).unwrap(); + + let snapshot_1 = test_append_snapshot( + &mut db, + burn_header_hash_1, + &vec![BlockstackOperationType::PegOutFulfill( + peg_out_fulfill_1.clone(), + )], + ); + + let snapshot_2 = test_append_snapshot( + &mut db, + burn_header_hash_2, + &vec![BlockstackOperationType::PegOutFulfill( + peg_out_fulfill_2.clone(), + )], + ); + + let res_peg_out_fulfillments_1 = + SortitionDB::get_peg_out_fulfill_ops(db.conn(), &burn_header_hash_1) + .expect("Failed to get peg-out fulfill ops from sortition DB"); + + assert_eq!(res_peg_out_fulfillments_1.len(), 1); + assert_eq!(res_peg_out_fulfillments_1[0], peg_out_fulfill_1); + + let res_peg_out_fulfillments_2 = + SortitionDB::get_peg_out_fulfill_ops(db.conn(), &burn_header_hash_2) + .expect("Failed to get peg-out fulfill ops from sortition DB"); + + assert_eq!(res_peg_out_fulfillments_2.len(), 1); + assert_eq!(res_peg_out_fulfillments_2[0], peg_out_fulfill_2); + } + #[test] fn has_VRF_public_key() { let public_key = VRFPublicKey::from_bytes( diff --git a/src/chainstate/burn/mod.rs b/src/chainstate/burn/mod.rs index b9b9317614..76047eb62a 100644 --- a/src/chainstate/burn/mod.rs +++ b/src/chainstate/burn/mod.rs @@ -75,6 +75,9 @@ pub enum Opcodes { PreStx = 'p' as u8, TransferStx = '$' as u8, DelegateStx = '#' as u8, + PegIn = '<' as u8, + PegOutRequest = '>' as u8, + PegOutFulfill = '!' as u8, } // a burnchain block snapshot @@ -187,6 +190,52 @@ impl SortitionHash { } } +impl Opcodes { + const HTTP_BLOCK_COMMIT: &'static str = "block_commit"; + const HTTP_KEY_REGISTER: &'static str = "key_register"; + const HTTP_BURN_SUPPORT: &'static str = "burn_support"; + const HTTP_STACK_STX: &'static str = "stack_stx"; + const HTTP_PRE_STX: &'static str = "pre_stx"; + const HTTP_TRANSFER_STX: &'static str = "transfer_stx"; + const HTTP_DELEGATE_STX: &'static str = "delegate_stx"; + const HTTP_PEG_IN: &'static str = "peg_in"; + const HTTP_PEG_OUT_REQUEST: &'static str = "peg_out_request"; + const HTTP_PEG_OUT_FULFILL: &'static str = "peg_out_fulfill"; + + pub fn to_http_str(&self) -> &'static str { + match self { + Opcodes::LeaderBlockCommit => Self::HTTP_BLOCK_COMMIT, + Opcodes::LeaderKeyRegister => Self::HTTP_KEY_REGISTER, + Opcodes::UserBurnSupport => Self::HTTP_BURN_SUPPORT, + Opcodes::StackStx => Self::HTTP_STACK_STX, + Opcodes::PreStx => Self::HTTP_PRE_STX, + Opcodes::TransferStx => Self::HTTP_TRANSFER_STX, + Opcodes::DelegateStx => Self::HTTP_DELEGATE_STX, + Opcodes::PegIn => Self::HTTP_PEG_IN, + Opcodes::PegOutRequest => Self::HTTP_PEG_OUT_REQUEST, + Opcodes::PegOutFulfill => Self::HTTP_PEG_OUT_FULFILL, + } + } + + pub fn from_http_str(input: &str) -> Option { + let opcode = match input { + Self::HTTP_PEG_IN => Opcodes::PegIn, + Self::HTTP_PEG_OUT_REQUEST => Opcodes::PegOutRequest, + Self::HTTP_PEG_OUT_FULFILL => Opcodes::PegOutFulfill, + Self::HTTP_BLOCK_COMMIT => Opcodes::LeaderBlockCommit, + Self::HTTP_KEY_REGISTER => Opcodes::LeaderKeyRegister, + Self::HTTP_BURN_SUPPORT => Opcodes::UserBurnSupport, + Self::HTTP_STACK_STX => Opcodes::StackStx, + Self::HTTP_PRE_STX => Opcodes::PreStx, + Self::HTTP_TRANSFER_STX => Opcodes::TransferStx, + Self::HTTP_DELEGATE_STX => Opcodes::DelegateStx, + _ => return None, + }; + + Some(opcode) + } +} + impl OpsHash { pub fn from_txids(txids: &Vec) -> OpsHash { // NOTE: unlike stacks v1, we calculate the ops hash simply diff --git a/src/chainstate/burn/operations/mod.rs b/src/chainstate/burn/operations/mod.rs index 331f4815f0..1e38488df4 100644 --- a/src/chainstate/burn/operations/mod.rs +++ b/src/chainstate/burn/operations/mod.rs @@ -14,6 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use serde::Deserialize; use std::convert::From; use std::convert::TryInto; use std::error; @@ -42,21 +43,31 @@ use crate::chainstate::stacks::address::PoxAddress; use crate::util_lib::db::DBConn; use crate::util_lib::db::DBTx; use crate::util_lib::db::Error as db_error; + +use stacks_common::types::chainstate::StacksBlockId; use stacks_common::util::hash::Sha512Trunc256Sum; use stacks_common::util::hash::{hex_bytes, to_hex, Hash160}; use stacks_common::util::secp256k1::MessageSignature; use stacks_common::util::vrf::VRFPublicKey; +use clarity::vm::types::PrincipalData; + use crate::types::chainstate::BurnchainHeaderHash; pub mod delegate_stx; pub mod leader_block_commit; /// This module contains all burn-chain operations pub mod leader_key_register; +pub mod peg_in; +pub mod peg_out_fulfill; +pub mod peg_out_request; pub mod stack_stx; pub mod transfer_stx; pub mod user_burn_support; +#[cfg(test)] +mod test; + #[derive(Debug)] pub enum Error { /// Failed to parse the operation from the burnchain transaction @@ -97,6 +108,9 @@ pub enum Error { // errors associated with delegate stx DelegateStxMustBePositive, + + // sBTC errors + AmountMustBePositive, } impl fmt::Display for Error { @@ -159,6 +173,7 @@ impl fmt::Display for Error { "Stack STX must set num cycles between 1 and max num cycles" ), Error::DelegateStxMustBePositive => write!(f, "Delegate STX must be positive amount"), + Self::AmountMustBePositive => write!(f, "Peg in amount must be positive"), } } } @@ -308,7 +323,106 @@ pub struct DelegateStxOp { pub burn_header_hash: BurnchainHeaderHash, // hash of the burn chain block header } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +fn hex_ser_memo(bytes: &[u8], s: S) -> Result { + let inst = to_hex(bytes); + s.serialize_str(inst.as_str()) +} + +fn hex_deser_memo<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let inst_str = String::deserialize(d)?; + hex_bytes(&inst_str).map_err(serde::de::Error::custom) +} + +fn hex_serialize(bhh: &BurnchainHeaderHash, s: S) -> Result { + let inst = bhh.to_hex(); + s.serialize_str(inst.as_str()) +} + +fn hex_deserialize<'de, D: serde::Deserializer<'de>>( + d: D, +) -> Result { + let inst_str = String::deserialize(d)?; + BurnchainHeaderHash::from_hex(&inst_str).map_err(serde::de::Error::custom) +} + +fn principal_serialize(pd: &PrincipalData, s: S) -> Result { + let inst = pd.to_string(); + s.serialize_str(inst.as_str()) +} + +fn principal_deserialize<'de, D: serde::Deserializer<'de>>( + d: D, +) -> Result { + let inst_str = String::deserialize(d)?; + PrincipalData::parse(&inst_str).map_err(serde::de::Error::custom) +} + +#[derive(Debug, PartialEq, Clone, Eq, Serialize, Deserialize)] +pub struct PegInOp { + #[serde(serialize_with = "principal_serialize")] + #[serde(deserialize_with = "principal_deserialize")] + pub recipient: PrincipalData, + #[serde(serialize_with = "crate::chainstate::stacks::address::pox_addr_b58_serialize")] + #[serde(deserialize_with = "crate::chainstate::stacks::address::pox_addr_b58_deser")] + pub peg_wallet_address: PoxAddress, + pub amount: u64, // BTC amount to peg in, in satoshis + #[serde(serialize_with = "hex_ser_memo")] + #[serde(deserialize_with = "hex_deser_memo")] + pub memo: Vec, // extra unused bytes + + // common to all transactions + pub txid: Txid, // transaction ID + pub vtxindex: u32, // index in the block where this tx occurs + pub block_height: u64, // block height at which this tx occurs + #[serde(deserialize_with = "hex_deserialize", serialize_with = "hex_serialize")] + pub burn_header_hash: BurnchainHeaderHash, // hash of the burn chain block header +} + +#[derive(Debug, PartialEq, Clone, Eq, Serialize, Deserialize)] +pub struct PegOutRequestOp { + pub amount: u64, // sBTC amount to peg out, in satoshis + #[serde(serialize_with = "crate::chainstate::stacks::address::pox_addr_b58_serialize")] + #[serde(deserialize_with = "crate::chainstate::stacks::address::pox_addr_b58_deser")] + pub recipient: PoxAddress, // Address to receive the BTC when the request is fulfilled + pub signature: MessageSignature, // Signature from sBTC owner as per SIP-021 + #[serde(serialize_with = "crate::chainstate::stacks::address::pox_addr_b58_serialize")] + #[serde(deserialize_with = "crate::chainstate::stacks::address::pox_addr_b58_deser")] + pub peg_wallet_address: PoxAddress, + pub fulfillment_fee: u64, // Funding the fulfillment tx fee + #[serde(serialize_with = "hex_ser_memo")] + #[serde(deserialize_with = "hex_deser_memo")] + pub memo: Vec, // extra unused bytes + + // common to all transactions + pub txid: Txid, // transaction ID + pub vtxindex: u32, // index in the block where this tx occurs + pub block_height: u64, // block height at which this tx occurs + #[serde(deserialize_with = "hex_deserialize", serialize_with = "hex_serialize")] + pub burn_header_hash: BurnchainHeaderHash, // hash of the burn chain block header +} + +#[derive(Debug, PartialEq, Clone, Eq, Serialize, Deserialize)] +pub struct PegOutFulfillOp { + pub chain_tip: StacksBlockId, // The Stacks chain tip whose state view was used to validate the peg-out request + + pub amount: u64, // Transferred BTC amount, in satoshis + #[serde(serialize_with = "crate::chainstate::stacks::address::pox_addr_b58_serialize")] + #[serde(deserialize_with = "crate::chainstate::stacks::address::pox_addr_b58_deser")] + pub recipient: PoxAddress, // Address to receive the BTC + pub request_ref: Txid, // The peg out request which is fulfilled by this op + #[serde(serialize_with = "hex_ser_memo")] + #[serde(deserialize_with = "hex_deser_memo")] + pub memo: Vec, // extra unused bytes + + // common to all transactions + pub txid: Txid, // transaction ID + pub vtxindex: u32, // index in the block where this tx occurs + pub block_height: u64, // block height at which this tx occurs + #[serde(deserialize_with = "hex_deserialize", serialize_with = "hex_serialize")] + pub burn_header_hash: BurnchainHeaderHash, // hash of the burn chain block header +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum BlockstackOperationType { LeaderKeyRegister(LeaderKeyRegisterOp), LeaderBlockCommit(LeaderBlockCommitOp), @@ -317,6 +431,9 @@ pub enum BlockstackOperationType { StackStx(StackStxOp), TransferStx(TransferStxOp), DelegateStx(DelegateStxOp), + PegIn(PegInOp), + PegOutRequest(PegOutRequestOp), + PegOutFulfill(PegOutFulfillOp), } // serialization helpers for blockstack_op_to_json function @@ -344,6 +461,9 @@ impl BlockstackOperationType { BlockstackOperationType::PreStx(_) => Opcodes::PreStx, BlockstackOperationType::TransferStx(_) => Opcodes::TransferStx, BlockstackOperationType::DelegateStx(_) => Opcodes::DelegateStx, + BlockstackOperationType::PegIn(_) => Opcodes::PegIn, + BlockstackOperationType::PegOutRequest(_) => Opcodes::PegOutRequest, + BlockstackOperationType::PegOutFulfill(_) => Opcodes::PegOutFulfill, } } @@ -360,6 +480,9 @@ impl BlockstackOperationType { BlockstackOperationType::PreStx(ref data) => &data.txid, BlockstackOperationType::TransferStx(ref data) => &data.txid, BlockstackOperationType::DelegateStx(ref data) => &data.txid, + BlockstackOperationType::PegIn(ref data) => &data.txid, + BlockstackOperationType::PegOutRequest(ref data) => &data.txid, + BlockstackOperationType::PegOutFulfill(ref data) => &data.txid, } } @@ -372,6 +495,9 @@ impl BlockstackOperationType { BlockstackOperationType::PreStx(ref data) => data.vtxindex, BlockstackOperationType::TransferStx(ref data) => data.vtxindex, BlockstackOperationType::DelegateStx(ref data) => data.vtxindex, + BlockstackOperationType::PegIn(ref data) => data.vtxindex, + BlockstackOperationType::PegOutRequest(ref data) => data.vtxindex, + BlockstackOperationType::PegOutFulfill(ref data) => data.vtxindex, } } @@ -384,6 +510,9 @@ impl BlockstackOperationType { BlockstackOperationType::PreStx(ref data) => data.block_height, BlockstackOperationType::TransferStx(ref data) => data.block_height, BlockstackOperationType::DelegateStx(ref data) => data.block_height, + BlockstackOperationType::PegIn(ref data) => data.block_height, + BlockstackOperationType::PegOutRequest(ref data) => data.block_height, + BlockstackOperationType::PegOutFulfill(ref data) => data.block_height, } } @@ -396,6 +525,9 @@ impl BlockstackOperationType { BlockstackOperationType::PreStx(ref data) => data.burn_header_hash.clone(), BlockstackOperationType::TransferStx(ref data) => data.burn_header_hash.clone(), BlockstackOperationType::DelegateStx(ref data) => data.burn_header_hash.clone(), + BlockstackOperationType::PegIn(ref data) => data.burn_header_hash.clone(), + BlockstackOperationType::PegOutRequest(ref data) => data.burn_header_hash.clone(), + BlockstackOperationType::PegOutFulfill(ref data) => data.burn_header_hash.clone(), } } @@ -411,6 +543,9 @@ impl BlockstackOperationType { BlockstackOperationType::PreStx(ref mut data) => data.block_height = height, BlockstackOperationType::TransferStx(ref mut data) => data.block_height = height, BlockstackOperationType::DelegateStx(ref mut data) => data.block_height = height, + BlockstackOperationType::PegIn(ref mut data) => data.block_height = height, + BlockstackOperationType::PegOutRequest(ref mut data) => data.block_height = height, + BlockstackOperationType::PegOutFulfill(ref mut data) => data.block_height = height, }; } @@ -428,6 +563,9 @@ impl BlockstackOperationType { BlockstackOperationType::PreStx(ref mut data) => data.burn_header_hash = hash, BlockstackOperationType::TransferStx(ref mut data) => data.burn_header_hash = hash, BlockstackOperationType::DelegateStx(ref mut data) => data.burn_header_hash = hash, + BlockstackOperationType::PegIn(ref mut data) => data.burn_header_hash = hash, + BlockstackOperationType::PegOutRequest(ref mut data) => data.burn_header_hash = hash, + BlockstackOperationType::PegOutFulfill(ref mut data) => data.burn_header_hash = hash, }; } @@ -501,6 +639,9 @@ impl BlockstackOperationType { BlockstackOperationType::StackStx(op) => Self::stack_stx_to_json(op), BlockstackOperationType::TransferStx(op) => Self::transfer_stx_to_json(op), BlockstackOperationType::DelegateStx(op) => Self::delegate_stx_to_json(op), + BlockstackOperationType::PegIn(op) => json!({ "peg_in": op }), + BlockstackOperationType::PegOutRequest(op) => json!({ "peg_out_request": op }), + BlockstackOperationType::PegOutFulfill(op) => json!({ "peg_out_fulfill": op }), // json serialization for the remaining op types is not implemented for now. This function // is currently only used to json-ify burnchain ops executed as Stacks transactions (so, // stack_stx, transfer_stx, and delegate_stx). @@ -519,6 +660,9 @@ impl fmt::Display for BlockstackOperationType { BlockstackOperationType::UserBurnSupport(ref op) => write!(f, "{:?}", op), BlockstackOperationType::TransferStx(ref op) => write!(f, "{:?}", op), BlockstackOperationType::DelegateStx(ref op) => write!(f, "{:?}", op), + BlockstackOperationType::PegIn(ref op) => write!(f, "{:?}", op), + BlockstackOperationType::PegOutRequest(ref op) => write!(f, "{:?}", op), + BlockstackOperationType::PegOutFulfill(ref op) => write!(f, "{:?}", op), } } } @@ -539,181 +683,3 @@ pub fn parse_u32_from_be(bytes: &[u8]) -> Option { pub fn parse_u16_from_be(bytes: &[u8]) -> Option { bytes.try_into().ok().map(u16::from_be_bytes) } - -mod test { - use crate::burnchains::Txid; - use crate::chainstate::burn::operations::{ - BlockstackOperationType, DelegateStxOp, PreStxOp, StackStxOp, TransferStxOp, - }; - use crate::chainstate::stacks::address::PoxAddress; - use stacks_common::address::C32_ADDRESS_VERSION_MAINNET_SINGLESIG; - use stacks_common::types::chainstate::{ - BlockHeaderHash, BurnchainHeaderHash, ConsensusHash, StacksAddress, VRFSeed, - }; - use stacks_common::types::Address; - use stacks_common::util::hash::Hash160; - - #[test] - fn test_serialization_transfer_stx_op() { - let sender_addr = "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2"; - let sender = StacksAddress::from_string(sender_addr).unwrap(); - let recipient_addr = "SP24ZBZ8ZE6F48JE9G3F3HRTG9FK7E2H6K2QZ3Q1K"; - let recipient = StacksAddress::from_string(recipient_addr).unwrap(); - let op = TransferStxOp { - sender, - recipient, - transfered_ustx: 10, - memo: vec![0x00, 0x01, 0x02, 0x03, 0x04, 0x05], - txid: Txid([10u8; 32]), - vtxindex: 10, - block_height: 10, - burn_header_hash: BurnchainHeaderHash([0x10; 32]), - }; - let serialized_json = BlockstackOperationType::transfer_stx_to_json(&op); - let constructed_json = json!({ - "transfer_stx": { - "burn_block_height": 10, - "burn_header_hash": "1010101010101010101010101010101010101010101010101010101010101010", - "memo": "0x000102030405", - "recipient": { - "address": "SP24ZBZ8ZE6F48JE9G3F3HRTG9FK7E2H6K2QZ3Q1K", - "address_hash_bytes": "0x89f5fd1f719e4449c980de38e3504be6770a2698", - "address_version": 22, - }, - "sender": { - "address": "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2", - "address_hash_bytes": "0xaf3f91f38aa21ade7e9f95efdbc4201eeb4cf0f8", - "address_version": 26, - }, - "transfered_ustx": 10, - "burn_txid": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a", - "vtxindex": 10, - } - }); - - assert_json_eq!(serialized_json, constructed_json); - } - - #[test] - fn test_serialization_stack_stx_op() { - let sender_addr = "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2"; - let sender = StacksAddress::from_string(sender_addr).unwrap(); - let reward_addr = PoxAddress::Standard( - StacksAddress { - version: C32_ADDRESS_VERSION_MAINNET_SINGLESIG, - bytes: Hash160([0x01; 20]), - }, - None, - ); - - let op = StackStxOp { - sender, - reward_addr, - stacked_ustx: 10, - txid: Txid([10u8; 32]), - vtxindex: 10, - block_height: 10, - burn_header_hash: BurnchainHeaderHash([0x10; 32]), - num_cycles: 10, - }; - let serialized_json = BlockstackOperationType::stack_stx_to_json(&op); - let constructed_json = json!({ - "stack_stx": { - "burn_block_height": 10, - "burn_header_hash": "1010101010101010101010101010101010101010101010101010101010101010", - "num_cycles": 10, - "reward_addr": "16Jswqk47s9PUcyCc88MMVwzgvHPvtEpf", - "sender": { - "address": "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2", - "address_hash_bytes": "0xaf3f91f38aa21ade7e9f95efdbc4201eeb4cf0f8", - "address_version": 26, - }, - "stacked_ustx": 10, - "burn_txid": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a", - "vtxindex": 10, - } - }); - - assert_json_eq!(serialized_json, constructed_json); - } - - #[test] - fn test_serialization_pre_stx_op() { - let output_addr = "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2"; - let output = StacksAddress::from_string(output_addr).unwrap(); - - let op = PreStxOp { - output, - txid: Txid([10u8; 32]), - vtxindex: 10, - block_height: 10, - burn_header_hash: BurnchainHeaderHash([0x10; 32]), - }; - let serialized_json = BlockstackOperationType::pre_stx_to_json(&op); - let constructed_json = json!({ - "pre_stx": { - "burn_block_height": 10, - "burn_header_hash": "1010101010101010101010101010101010101010101010101010101010101010", - "output": { - "address": "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2", - "address_hash_bytes": "0xaf3f91f38aa21ade7e9f95efdbc4201eeb4cf0f8", - "address_version": 26, - }, - "burn_txid": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a", - "vtxindex": 10, - } - }); - - assert_json_eq!(serialized_json, constructed_json); - } - - #[test] - fn test_serialization_delegate_stx_op() { - let sender_addr = "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2"; - let sender = StacksAddress::from_string(sender_addr).unwrap(); - let delegate_to_addr = "SP24ZBZ8ZE6F48JE9G3F3HRTG9FK7E2H6K2QZ3Q1K"; - let delegate_to = StacksAddress::from_string(delegate_to_addr).unwrap(); - let pox_addr = PoxAddress::Standard( - StacksAddress { - version: C32_ADDRESS_VERSION_MAINNET_SINGLESIG, - bytes: Hash160([0x01; 20]), - }, - None, - ); - let op = DelegateStxOp { - sender, - delegate_to, - reward_addr: Some((10, pox_addr)), - delegated_ustx: 10, - until_burn_height: None, - txid: Txid([10u8; 32]), - vtxindex: 10, - block_height: 10, - burn_header_hash: BurnchainHeaderHash([0x10; 32]), - }; - let serialized_json = BlockstackOperationType::delegate_stx_to_json(&op); - let constructed_json = json!({ - "delegate_stx": { - "burn_block_height": 10, - "burn_header_hash": "1010101010101010101010101010101010101010101010101010101010101010", - "delegate_to": { - "address": "SP24ZBZ8ZE6F48JE9G3F3HRTG9FK7E2H6K2QZ3Q1K", - "address_hash_bytes": "0x89f5fd1f719e4449c980de38e3504be6770a2698", - "address_version": 22, - }, - "delegated_ustx": 10, - "sender": { - "address": "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2", - "address_hash_bytes": "0xaf3f91f38aa21ade7e9f95efdbc4201eeb4cf0f8", - "address_version": 26, - }, - "reward_addr": [10, "16Jswqk47s9PUcyCc88MMVwzgvHPvtEpf"], - "burn_txid": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a", - "until_burn_height": null, - "vtxindex": 10, - } - }); - - assert_json_eq!(serialized_json, constructed_json); - } -} diff --git a/src/chainstate/burn/operations/peg_in.rs b/src/chainstate/burn/operations/peg_in.rs new file mode 100644 index 0000000000..89f259d60c --- /dev/null +++ b/src/chainstate/burn/operations/peg_in.rs @@ -0,0 +1,458 @@ +// Copyright (C) 2020 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use stacks_common::codec::StacksMessageCodec; + +use crate::burnchains::BurnchainBlockHeader; +use crate::burnchains::BurnchainTransaction; +use crate::chainstate::burn::Opcodes; +use crate::types::chainstate::StacksAddress; +use crate::types::Address; + +use crate::chainstate::burn::operations::Error as OpError; +use crate::chainstate::burn::operations::PegInOp; + +use crate::vm::errors::RuntimeErrorType as ClarityRuntimeError; +use crate::vm::types::PrincipalData; +use crate::vm::types::QualifiedContractIdentifier; +use crate::vm::types::StandardPrincipalData; +use crate::vm::ContractName; + +/// Transaction structure: +/// +/// Output 0: data output (see PegInOp::parse_data()) +/// Output 1: payment to peg wallet address +/// +impl PegInOp { + pub fn from_tx( + block_header: &BurnchainBlockHeader, + tx: &BurnchainTransaction, + ) -> Result { + if tx.opcode() != Opcodes::PegIn as u8 { + warn!("Invalid tx: invalid opcode {}", tx.opcode()); + return Err(OpError::InvalidInput); + } + + let (amount, peg_wallet_address) = + if let Some(Some(recipient)) = tx.get_recipients().first() { + (recipient.amount, recipient.address.clone()) + } else { + warn!("Invalid tx: First output not recognized"); + return Err(OpError::InvalidInput); + }; + + let parsed_data = Self::parse_data(&tx.data())?; + + let txid = tx.txid(); + let vtxindex = tx.vtxindex(); + let block_height = block_header.block_height; + let burn_header_hash = block_header.block_hash; + + Ok(Self { + recipient: parsed_data.recipient, + peg_wallet_address, + amount, + memo: parsed_data.memo, + txid, + vtxindex, + block_height, + burn_header_hash, + }) + } + + fn parse_data(data: &[u8]) -> Result { + /* + Wire format: + + 0 2 3 24 64 80 + |------|--|------------------|-----------------------------|--------| + magic op Stacks address Contract name (optional) memo + + Note that `data` is missing the first 3 bytes -- the magic and op must + be stripped before this method is called. At the time of writing, + this is done in `burnchains::bitcoin::blocks::BitcoinBlockParser::parse_data`. + */ + + if data.len() < 21 { + warn!( + "PegInOp payload is malformed ({} bytes, expected at least {})", + data.len(), + 21 + ); + return Err(ParseError::MalformedData); + } + + let version = *data.get(0).expect("No version byte"); + let address_data: [u8; 20] = data + .get(1..21) + .ok_or(ParseError::MalformedData)? + .try_into()?; + + let standard_principal_data = StandardPrincipalData(version, address_data); + + let memo = data.get(61..).unwrap_or(&[]).to_vec(); + + let recipient: PrincipalData = + if let Some(contract_bytes) = Self::leading_non_zero_bytes(data, 21, 61) { + let contract_name: String = std::str::from_utf8(contract_bytes)?.to_owned(); + + QualifiedContractIdentifier::new(standard_principal_data, contract_name.try_into()?) + .into() + } else { + standard_principal_data.into() + }; + + Ok(ParsedData { recipient, memo }) + } + + pub fn check(&self) -> Result<(), OpError> { + if self.amount == 0 { + warn!("PEG_IN Invalid: Peg amount must be positive"); + return Err(OpError::AmountMustBePositive); + } + + Ok(()) + } + + /// Returns the leading non-zero bytes of the subslice `data[from..to]` + /// + /// # Panics + /// + /// Panics if `from` is larger than or equal to `to` + fn leading_non_zero_bytes(data: &[u8], from: usize, to: usize) -> Option<&[u8]> { + assert!(from < to); + + let end_of_non_zero_slice = { + let mut end = to.min(data.len()); + for i in from..end { + if data[i] == 0 { + end = i; + break; + } + } + end + }; + + if from == end_of_non_zero_slice { + return None; + } + + data.get(from..end_of_non_zero_slice) + } +} + +struct ParsedData { + recipient: PrincipalData, + memo: Vec, +} + +enum ParseError { + BadContractName, + MalformedData, + Utf8Error, +} + +impl From for OpError { + fn from(_: ParseError) -> Self { + Self::ParseError + } +} + +impl From for ParseError { + fn from(_: std::str::Utf8Error) -> Self { + Self::Utf8Error + } +} + +impl From for ParseError { + fn from(_: std::array::TryFromSliceError) -> Self { + Self::MalformedData + } +} + +impl From for ParseError { + fn from(_: ClarityRuntimeError) -> Self { + Self::BadContractName + } +} + +#[cfg(test)] +mod tests { + use crate::chainstate::burn::operations::test; + + use super::*; + + #[test] + fn test_parse_peg_in_should_succeed_given_a_conforming_transaction_without_memo() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegIn; + + let peg_wallet_address = test::random_bytes(&mut rng); + let amount = 10; + let output2 = test::Output::new(amount, peg_wallet_address); + + let mut data = vec![1]; + let addr_bytes = test::random_bytes(&mut rng); + let stx_address = StacksAddress::new(1, addr_bytes.into()); + data.extend_from_slice(&addr_bytes); + + let tx = test::burnchain_transaction(data, Some(output2), opcode); + let header = test::burnchain_block_header(); + + let op = PegInOp::from_tx(&header, &tx).expect("Failed to construct peg-in operation"); + + assert_eq!(op.recipient, stx_address.into()); + assert_eq!(op.amount, amount); + assert_eq!(op.peg_wallet_address.bytes(), peg_wallet_address); + } + + #[test] + fn test_parse_peg_in_should_succeed_given_a_conforming_transaction_with_memo() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegIn; + + let peg_wallet_address = test::random_bytes(&mut rng); + let amount = 10; + let output2 = test::Output::new(amount, peg_wallet_address); + let memo: [u8; 6] = test::random_bytes(&mut rng); + + let mut data = vec![1]; + let addr_bytes = test::random_bytes(&mut rng); + let stx_address = StacksAddress::new(1, addr_bytes.into()); + data.extend_from_slice(&addr_bytes); + data.extend_from_slice(&[0; 40]); // Padding contract name + data.extend_from_slice(&memo); + + let tx = test::burnchain_transaction(data, Some(output2), opcode); + let header = test::burnchain_block_header(); + + let op = PegInOp::from_tx(&header, &tx).expect("Failed to construct peg-in operation"); + + assert_eq!(op.recipient, stx_address.into()); + assert_eq!(op.amount, amount); + assert_eq!(op.peg_wallet_address.bytes(), peg_wallet_address); + assert_eq!(op.memo.as_slice(), memo) + } + + #[test] + fn test_parse_peg_in_should_succeed_given_a_contract_recipient() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegIn; + + let contract_name = "This_is_a_valid_contract_name"; + let peg_wallet_address = test::random_bytes(&mut rng); + let amount = 10; + let output2 = test::Output::new(amount, peg_wallet_address); + let memo: [u8; 6] = test::random_bytes(&mut rng); + + let mut data = vec![1]; + let addr_bytes = test::random_bytes(&mut rng); + let stx_address = StacksAddress::new(1, addr_bytes.into()); + data.extend_from_slice(&addr_bytes); + data.extend_from_slice(contract_name.as_bytes()); + data.extend_from_slice(&[0; 11]); // Padding contract name + data.extend_from_slice(&memo); + + let tx = test::burnchain_transaction(data, Some(output2), opcode); + let header = test::burnchain_block_header(); + + let op = PegInOp::from_tx(&header, &tx).expect("Failed to construct peg-in operation"); + + let expected_principal = + QualifiedContractIdentifier::new(stx_address.into(), contract_name.into()).into(); + + assert_eq!(op.recipient, expected_principal); + assert_eq!(op.amount, amount); + assert_eq!(op.peg_wallet_address.bytes(), peg_wallet_address); + assert_eq!(op.memo.as_slice(), memo) + } + + #[test] + fn test_parse_peg_in_should_return_error_given_invalid_contract_name() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegIn; + + let contract_name = "MÃ¥rten_is_not_a_valid_contract_name"; + let peg_wallet_address = test::random_bytes(&mut rng); + let amount = 10; + let output2 = test::Output::new(amount, peg_wallet_address); + let memo: [u8; 6] = test::random_bytes(&mut rng); + + let mut data = vec![1]; + let addr_bytes = test::random_bytes(&mut rng); + let stx_address = StacksAddress::new(1, addr_bytes.into()); + data.extend_from_slice(&addr_bytes); + data.extend_from_slice(contract_name.as_bytes()); + data.extend_from_slice(&[0; 4]); // Padding contract name + data.extend_from_slice(&memo); + + let tx = test::burnchain_transaction(data, Some(output2), opcode); + let header = test::burnchain_block_header(); + + let op = PegInOp::from_tx(&header, &tx); + + match op { + Err(OpError::ParseError) => (), + result => panic!("Expected OpError::ParseError, got {:?}", result), + } + } + + #[test] + fn test_parse_peg_in_should_return_error_given_wrong_opcode() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::StackStx; + + let peg_wallet_address = test::random_bytes(&mut rng); + let amount = 10; + + let output2 = test::Output::new(amount, peg_wallet_address); + let memo: [u8; 6] = test::random_bytes(&mut rng); + + let mut data = vec![1]; + let addr_bytes: [u8; 20] = test::random_bytes(&mut rng); + data.extend_from_slice(&addr_bytes); + data.extend_from_slice(&[0; 40]); // Padding contract name + data.extend_from_slice(&memo); + + let tx = test::burnchain_transaction(data, Some(output2), opcode); + let header = test::burnchain_block_header(); + + let op = PegInOp::from_tx(&header, &tx); + + match op { + Err(OpError::InvalidInput) => (), + result => panic!("Expected OpError::InvalidInput, got {:?}", result), + } + } + + #[test] + fn test_parse_peg_in_should_return_error_given_invalid_utf8_contract_name() { + let invalid_utf8_byte_sequence = [255, 255]; + + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegIn; + + let peg_wallet_address = test::random_bytes(&mut rng); + let amount = 10; + let output2 = test::Output::new(amount, peg_wallet_address); + let memo: [u8; 6] = test::random_bytes(&mut rng); + + let mut data = vec![1]; + let addr_bytes: [u8; 20] = test::random_bytes(&mut rng); + data.extend_from_slice(&addr_bytes); + data.extend_from_slice(&invalid_utf8_byte_sequence); + data.extend_from_slice(&[0; 40]); // Padding contract name + data.extend_from_slice(&memo); + + let tx = test::burnchain_transaction(data, Some(output2), opcode); + let header = test::burnchain_block_header(); + + let op = PegInOp::from_tx(&header, &tx); + + match op { + Err(OpError::ParseError) => (), + result => panic!("Expected OpError::ParseError, got {:?}", result), + } + } + + #[test] + fn test_parse_peg_in_should_return_error_given_no_second_output() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegIn; + + let memo: [u8; 6] = test::random_bytes(&mut rng); + + let mut data = vec![1]; + let addr_bytes: [u8; 20] = test::random_bytes(&mut rng); + data.extend_from_slice(&addr_bytes); + data.extend_from_slice(&[0; 40]); // Padding contract name + data.extend_from_slice(&memo); + + let tx = test::burnchain_transaction(data, None, opcode); + let header = test::burnchain_block_header(); + + let op = PegInOp::from_tx(&header, &tx); + + match op { + Err(OpError::InvalidInput) => (), + result => panic!("Expected OpError::InvalidInput, got {:?}", result), + } + } + + #[test] + fn test_parse_peg_in_should_return_error_given_too_short_data_array() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegIn; + + let peg_wallet_address = test::random_bytes(&mut rng); + let amount = 10; + let output2 = test::Output::new(amount, peg_wallet_address); + + let mut data = vec![1]; + let addr_bytes: [u8; 19] = test::random_bytes(&mut rng); + data.extend_from_slice(&addr_bytes); + + let tx = test::burnchain_transaction(data, Some(output2), opcode); + let header = test::burnchain_block_header(); + + let op = PegInOp::from_tx(&header, &tx); + + match op { + Err(OpError::ParseError) => (), + result => panic!("Expected OpError::InvalidInput, got {:?}", result), + } + } + + #[test] + fn test_check_should_return_error_on_zero_amount_and_ok_on_any_other_values() { + let mut rng = test::seeded_rng(); + + let peg_wallet_address = test::random_bytes(&mut rng); + let memo: [u8; 6] = test::random_bytes(&mut rng); + + let mut data = vec![1]; + let addr_bytes = test::random_bytes(&mut rng); + let stx_address = StacksAddress::new(1, addr_bytes.into()); + data.extend_from_slice(&addr_bytes); + data.extend_from_slice(&[0; 40]); // Padding contract name + data.extend_from_slice(&memo); + + let create_op = move |amount| { + let opcode = Opcodes::PegIn; + let output2 = test::Output::new(amount, peg_wallet_address.clone()); + + let tx = test::burnchain_transaction(data.clone(), Some(output2), opcode); + let header = test::burnchain_block_header(); + + PegInOp::from_tx(&header, &tx).expect("Failed to construct peg-in operation") + }; + + match create_op(0).check() { + Err(OpError::AmountMustBePositive) => (), + result => panic!( + "Expected OpError::PegInAmountMustBePositive, got {:?}", + result + ), + }; + + create_op(1) + .check() + .expect("Any strictly positive amounts should be ok"); + + create_op(u64::MAX) + .check() + .expect("Any strictly positive amounts should be ok"); + } +} diff --git a/src/chainstate/burn/operations/peg_out_fulfill.rs b/src/chainstate/burn/operations/peg_out_fulfill.rs new file mode 100644 index 0000000000..72d50a584f --- /dev/null +++ b/src/chainstate/burn/operations/peg_out_fulfill.rs @@ -0,0 +1,313 @@ +// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation +// Copyright (C) 2020 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use stacks_common::codec::StacksMessageCodec; +use stacks_common::types::chainstate::StacksBlockId; + +use crate::burnchains::BurnchainBlockHeader; +use crate::burnchains::BurnchainTransaction; +use crate::burnchains::Txid; +use crate::chainstate::burn::Opcodes; +use crate::types::chainstate::StacksAddress; +use crate::types::Address; + +use crate::chainstate::burn::operations::Error as OpError; +use crate::chainstate::burn::operations::PegOutFulfillOp; + +/// Transaction structure: +/// +/// Input 0: The 2nd output of a PegOutRequestOp, spent by the peg wallet (to pay the tx fee) +/// +/// Output 0: data output (see PegOutFulfillOp::parse_data()) +/// Output 1: Bitcoin address to send the BTC to +/// +impl PegOutFulfillOp { + pub fn from_tx( + block_header: &BurnchainBlockHeader, + tx: &BurnchainTransaction, + ) -> Result { + if tx.opcode() != Opcodes::PegOutFulfill as u8 { + warn!("Invalid tx: invalid opcode {}", tx.opcode()); + return Err(OpError::InvalidInput); + } + + let (amount, recipient) = if let Some(Some(recipient)) = tx.get_recipients().first() { + (recipient.amount, recipient.address.clone()) + } else { + warn!("Invalid tx: First output not recognized"); + return Err(OpError::InvalidInput); + }; + + let ParsedData { chain_tip, memo } = Self::parse_data(&tx.data())?; + + let txid = tx.txid(); + let vtxindex = tx.vtxindex(); + let block_height = block_header.block_height; + let burn_header_hash = block_header.block_hash; + + let request_ref = Self::get_sender_txid(tx)?; + + Ok(Self { + chain_tip, + amount, + recipient, + memo, + request_ref, + txid, + vtxindex, + block_height, + burn_header_hash, + }) + } + + fn parse_data(data: &[u8]) -> Result { + /* + Wire format: + + 0 2 3 35 80 + |------|--|---------------------|------------------------| + magic op Chain tip Memo + + Note that `data` is missing the first 3 bytes -- the magic and op must + be stripped before this method is called. At the time of writing, + this is done in `burnchains::bitcoin::blocks::BitcoinBlockParser::parse_data`. + */ + + if data.len() < 32 { + warn!( + "PegInOp payload is malformed ({} bytes, expected at least {})", + data.len(), + 32 + ); + return Err(ParseError::MalformedData); + } + + let chain_tip = StacksBlockId::from_bytes(&data[..32]) + .expect("PegOutFulfillment chain tip data failed to convert to block ID"); + let memo = data.get(32..).unwrap_or(&[]).to_vec(); + + Ok(ParsedData { chain_tip, memo }) + } + + fn get_sender_txid(tx: &BurnchainTransaction) -> Result { + match tx.get_input_tx_ref(0) { + Some(&(tx_ref, vout)) => { + if vout != 2 { + warn!( + "Invalid tx: PegOutFulfillOp must spend the third output of the PegOutRequestOp" + ); + Err(ParseError::InvalidInput) + } else { + Ok(tx_ref) + } + } + None => { + warn!("Invalid tx: PegOutFulfillOp must have at least one input"); + Err(ParseError::InvalidInput) + } + } + } + + pub fn check(&self) -> Result<(), OpError> { + if self.amount == 0 { + warn!("PEG_OUT_FULFILLMENT Invalid: Transferred amount must be positive"); + return Err(OpError::AmountMustBePositive); + } + + Ok(()) + } +} + +struct ParsedData { + chain_tip: StacksBlockId, + memo: Vec, +} + +enum ParseError { + MalformedData, + InvalidInput, +} + +impl From for OpError { + fn from(_: ParseError) -> Self { + Self::ParseError + } +} + +#[cfg(test)] +mod tests { + use crate::chainstate::burn::operations::test; + + use super::*; + + #[test] + fn test_parse_peg_out_fulfill_should_succeed_given_a_conforming_transaction() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegOutFulfill; + + let amount = 1; + let recipient_address_bytes = test::random_bytes(&mut rng); + let output2 = test::Output::new(amount, recipient_address_bytes); + + let mut data = vec![]; + let chain_tip_bytes: [u8; 32] = test::random_bytes(&mut rng); + data.extend_from_slice(&chain_tip_bytes); + + let tx = test::burnchain_transaction(data, Some(output2), opcode); + let header = test::burnchain_block_header(); + + let op = + PegOutFulfillOp::from_tx(&header, &tx).expect("Failed to construct peg-out operation"); + + assert_eq!(op.recipient.bytes(), recipient_address_bytes); + assert_eq!(op.chain_tip.as_bytes(), &chain_tip_bytes); + assert_eq!(op.amount, amount); + } + + #[test] + fn test_parse_peg_out_fulfill_should_succeed_given_a_conforming_transaction_with_extra_memo_bytes( + ) { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegOutFulfill; + + let amount = 1; + let recipient_address_bytes = test::random_bytes(&mut rng); + let output2 = test::Output::new(amount, recipient_address_bytes); + + let mut data = vec![]; + let chain_tip_bytes: [u8; 32] = test::random_bytes(&mut rng); + data.extend_from_slice(&chain_tip_bytes); + let memo_bytes: [u8; 17] = test::random_bytes(&mut rng); + data.extend_from_slice(&memo_bytes); + + let tx = test::burnchain_transaction(data, Some(output2), opcode); + let header = test::burnchain_block_header(); + + let op = + PegOutFulfillOp::from_tx(&header, &tx).expect("Failed to construct peg-out operation"); + + assert_eq!(op.recipient.bytes(), recipient_address_bytes); + assert_eq!(op.chain_tip.as_bytes(), &chain_tip_bytes); + assert_eq!(&op.memo, &memo_bytes); + assert_eq!(op.amount, amount); + } + + #[test] + fn test_parse_peg_out_fulfill_should_return_error_given_wrong_opcode() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::LeaderKeyRegister; + + let amount = 1; + let recipient_address_bytes = test::random_bytes(&mut rng); + let output2 = test::Output::new(amount, recipient_address_bytes); + + let mut data = vec![]; + let chain_tip_bytes: [u8; 32] = test::random_bytes(&mut rng); + data.extend_from_slice(&chain_tip_bytes); + + let tx = test::burnchain_transaction(data, Some(output2), opcode); + let header = test::burnchain_block_header(); + + let op = PegOutFulfillOp::from_tx(&header, &tx); + + match op { + Err(OpError::InvalidInput) => (), + result => panic!("Expected OpError::InvalidInput, got {:?}", result), + } + } + + #[test] + fn test_parse_peg_out_fulfill_should_return_error_given_no_second_output() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegOutFulfill; + + let output2 = None; + + let mut data = vec![]; + let chain_tip_bytes: [u8; 32] = test::random_bytes(&mut rng); + data.extend_from_slice(&chain_tip_bytes); + + let tx = test::burnchain_transaction(data, output2, opcode); + let header = test::burnchain_block_header(); + + let op = PegOutFulfillOp::from_tx(&header, &tx); + + match op { + Err(OpError::InvalidInput) => (), + result => panic!("Expected OpError::InvalidInput, got {:?}", result), + } + } + + #[test] + fn test_parse_peg_out_fulfill_should_return_error_given_too_small_header_hash() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegOutFulfill; + + let amount = 1; + let recipient_address_bytes = test::random_bytes(&mut rng); + let output2 = test::Output::new(amount, recipient_address_bytes); + + let mut data = vec![]; + let chain_tip_bytes: [u8; 31] = test::random_bytes(&mut rng); + data.extend_from_slice(&chain_tip_bytes); + + let tx = test::burnchain_transaction(data, Some(output2), opcode); + let header = test::burnchain_block_header(); + + let op = PegOutFulfillOp::from_tx(&header, &tx); + + match op { + Err(OpError::ParseError) => (), + result => panic!("Expected OpError::ParseError, got {:?}", result), + } + } + + #[test] + fn test_parse_peg_out_fulfill_should_return_error_on_zero_amount_and_ok_on_any_other_values() { + let mut rng = test::seeded_rng(); + + let mut data = vec![]; + let chain_tip_bytes: [u8; 32] = test::random_bytes(&mut rng); + data.extend_from_slice(&chain_tip_bytes); + + let mut create_op = move |amount| { + let opcode = Opcodes::PegOutFulfill; + let recipient_address_bytes = test::random_bytes(&mut rng); + let output2 = test::Output::new(amount, recipient_address_bytes); + + let tx = test::burnchain_transaction(data.clone(), Some(output2), opcode); + let header = test::burnchain_block_header(); + + PegOutFulfillOp::from_tx(&header, &tx).expect("Failed to construct peg-in operation") + }; + + match create_op(0).check() { + Err(OpError::AmountMustBePositive) => (), + result => panic!( + "Expected OpError::PegInAmountMustBePositive, got {:?}", + result + ), + }; + + create_op(1) + .check() + .expect("Any strictly positive amounts should be ok"); + + create_op(u64::MAX) + .check() + .expect("Any strictly positive amounts should be ok"); + } +} diff --git a/src/chainstate/burn/operations/peg_out_request.rs b/src/chainstate/burn/operations/peg_out_request.rs new file mode 100644 index 0000000000..61e21410ef --- /dev/null +++ b/src/chainstate/burn/operations/peg_out_request.rs @@ -0,0 +1,628 @@ +// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation +// Copyright (C) 2020 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use stacks_common::address::public_keys_to_address_hash; +use stacks_common::address::AddressHashMode; +use stacks_common::codec::StacksMessageCodec; +use stacks_common::types::chainstate::StacksPublicKey; +use stacks_common::util::hash::Sha256Sum; +use stacks_common::util::secp256k1::MessageSignature; + +use crate::burnchains::BurnchainBlockHeader; +use crate::burnchains::BurnchainTransaction; +use crate::chainstate::burn::Opcodes; +use crate::types::chainstate::StacksAddress; +use crate::types::Address; + +use crate::chainstate::burn::operations::Error as OpError; +use crate::chainstate::burn::operations::PegOutRequestOp; + +/// Transaction structure: +/// +/// Output 0: data output (see PegOutRequestOp::parse_data()) +/// Output 1: Bitcoin address to send the BTC to +/// Output 2: Bitcoin fee payment to the peg wallet (which the peg wallet will spend on fulfillment) +/// +impl PegOutRequestOp { + pub fn from_tx( + block_header: &BurnchainBlockHeader, + tx: &BurnchainTransaction, + ) -> Result { + if tx.opcode() != Opcodes::PegOutRequest as u8 { + warn!("Invalid tx: invalid opcode {}", tx.opcode()); + return Err(OpError::InvalidInput); + } + + let recipient = if let Some(Some(recipient)) = tx.get_recipients().first() { + recipient.address.clone() + } else { + warn!("Invalid tx: First output not recognized"); + return Err(OpError::InvalidInput); + }; + + let (fulfillment_fee, peg_wallet_address) = + if let Some(Some(recipient)) = tx.get_recipients().get(1) { + (recipient.amount, recipient.address.clone()) + } else { + warn!("Invalid tx: Second output not recognized"); + return Err(OpError::InvalidInput); + }; + + let parsed_data = Self::parse_data(&tx.data())?; + + let txid = tx.txid(); + let vtxindex = tx.vtxindex(); + let block_height = block_header.block_height; + let burn_header_hash = block_header.block_hash; + + Ok(Self { + amount: parsed_data.amount, + signature: parsed_data.signature, + recipient, + peg_wallet_address, + fulfillment_fee, + memo: parsed_data.memo, + txid, + vtxindex, + block_height, + burn_header_hash, + }) + } + + fn parse_data(data: &[u8]) -> Result { + /* + Wire format: + + 0 2 3 11 76 80 + |------|--|---------|-----------------|----| + magic op amount signature memo + + Note that `data` is missing the first 3 bytes -- the magic and op must + be stripped before this method is called. At the time of writing, + this is done in `burnchains::bitcoin::blocks::BitcoinBlockParser::parse_data`. + */ + + if data.len() < 73 { + // too short + warn!( + "PegOutRequestOp payload is malformed ({} bytes, expected {})", + data.len(), + 73 + ); + return Err(ParseError::MalformedPayload); + } + + let amount = u64::from_be_bytes(data[0..8].try_into().unwrap()); + let signature = MessageSignature::from_bytes(&data[8..73]).unwrap(); + let memo = data.get(73..).unwrap_or(&[]).to_vec(); + + Ok(ParsedData { + amount, + signature, + memo, + }) + } + + /// Recover the stacks address which was used by the sBTC holder to sign + /// the amount and recipient fields of this peg out request. + pub fn stx_address(&self, version: u8) -> Result { + let script_pubkey = self.recipient.to_bitcoin_tx_out(0).script_pubkey; + + let mut msg = self.amount.to_be_bytes().to_vec(); + msg.extend_from_slice(script_pubkey.as_bytes()); + + let msg_hash = Sha256Sum::from_data(&msg); + let pub_key = StacksPublicKey::recover_to_pubkey(msg_hash.as_bytes(), &self.signature) + .map_err(RecoverError::PubKeyRecoveryFailed)?; + + let hash_bits = + public_keys_to_address_hash(&AddressHashMode::SerializeP2PKH, 1, &vec![pub_key]); + Ok(StacksAddress::new(version, hash_bits)) + } + + pub fn check(&self) -> Result<(), OpError> { + if self.amount == 0 { + warn!("PEG_OUT_REQUEST Invalid: Requested BTC amount must be positive"); + return Err(OpError::AmountMustBePositive); + } + + if self.fulfillment_fee == 0 { + warn!("PEG_OUT_REQUEST Invalid: Fulfillment fee must be positive"); + return Err(OpError::AmountMustBePositive); + } + + Ok(()) + } +} + +struct ParsedData { + amount: u64, + signature: MessageSignature, + memo: Vec, +} + +#[derive(Debug, PartialEq)] +enum ParseError { + MalformedPayload, + SliceConversion, +} + +#[derive(Debug, PartialEq)] +pub enum RecoverError { + PubKeyRecoveryFailed(&'static str), +} + +impl From for OpError { + fn from(_: ParseError) -> Self { + Self::ParseError + } +} + +impl From for ParseError { + fn from(_: std::array::TryFromSliceError) -> Self { + Self::SliceConversion + } +} + +#[cfg(test)] +mod tests { + use stacks_common::deps_common::bitcoin::blockdata::transaction::Transaction; + use stacks_common::deps_common::bitcoin::network::serialize::deserialize; + use stacks_common::types::chainstate::BurnchainHeaderHash; + use stacks_common::types::chainstate::StacksPrivateKey; + use stacks_common::types::PrivateKey; + use stacks_common::types::StacksEpochId; + use stacks_common::util::hash::{hex_bytes, to_hex}; + + use super::*; + use crate::burnchains::bitcoin::blocks::BitcoinBlockParser; + use crate::burnchains::bitcoin::BitcoinNetworkType; + use crate::burnchains::Txid; + use crate::burnchains::BLOCKSTACK_MAGIC_MAINNET; + use crate::chainstate::burn::operations::test; + use crate::chainstate::stacks::address::PoxAddress; + use crate::chainstate::stacks::address::PoxAddressType32; + use crate::chainstate::stacks::C32_ADDRESS_VERSION_TESTNET_SINGLESIG; + + #[test] + fn test_parse_peg_out_request_should_succeed_given_a_conforming_transaction() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegOutRequest; + + let dust_amount = 1; + let recipient_address_bytes = test::random_bytes(&mut rng); + let output2 = test::Output::new(dust_amount, recipient_address_bytes); + + let peg_wallet_address = test::random_bytes(&mut rng); + let fulfillment_fee = 3; + let output3 = test::Output::new(fulfillment_fee, peg_wallet_address); + + let mut data = vec![]; + let amount: u64 = 10; + let signature: [u8; 65] = test::random_bytes(&mut rng); + data.extend_from_slice(&amount.to_be_bytes()); + data.extend_from_slice(&signature); + + let tx = test::burnchain_transaction(data, [output2, output3], opcode); + let header = test::burnchain_block_header(); + + let op = + PegOutRequestOp::from_tx(&header, &tx).expect("Failed to construct peg-out operation"); + + assert_eq!(op.recipient.bytes(), recipient_address_bytes); + assert_eq!(op.signature.as_bytes(), &signature); + assert_eq!(op.amount, amount); + } + + #[test] + fn test_parse_peg_out_request_should_succeed_given_a_transaction_with_extra_memo_bytes() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegOutRequest; + + let dust_amount = 1; + let recipient_address_bytes = test::random_bytes(&mut rng); + let output2 = test::Output::new(dust_amount, recipient_address_bytes); + + let peg_wallet_address = test::random_bytes(&mut rng); + let fulfillment_fee = 3; + let output3 = test::Output::new(fulfillment_fee, peg_wallet_address); + + let mut data = vec![]; + let amount: u64 = 10; + let signature: [u8; 65] = test::random_bytes(&mut rng); + data.extend_from_slice(&amount.to_be_bytes()); + data.extend_from_slice(&signature); + let memo_bytes: [u8; 4] = test::random_bytes(&mut rng); + data.extend_from_slice(&memo_bytes); + + let tx = test::burnchain_transaction(data, [output2, output3], opcode); + let header = test::burnchain_block_header(); + + let op = + PegOutRequestOp::from_tx(&header, &tx).expect("Failed to construct peg-out operation"); + + assert_eq!(op.recipient.bytes(), recipient_address_bytes); + assert_eq!(op.signature.as_bytes(), &signature); + assert_eq!(&op.memo, &memo_bytes); + assert_eq!(op.amount, amount); + assert_eq!(op.peg_wallet_address.bytes(), peg_wallet_address); + assert_eq!(op.fulfillment_fee, fulfillment_fee); + } + + #[test] + fn test_parse_peg_out_request_should_return_error_given_wrong_opcode() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::LeaderKeyRegister; + + let dust_amount = 1; + let recipient_address_bytes = test::random_bytes(&mut rng); + let output2 = test::Output::new(dust_amount, recipient_address_bytes); + + let peg_wallet_address = test::random_bytes(&mut rng); + let fulfillment_fee = 3; + let output3 = test::Output::new(fulfillment_fee, peg_wallet_address); + + let mut data = vec![]; + let amount: u64 = 10; + let signature: [u8; 65] = test::random_bytes(&mut rng); + data.extend_from_slice(&amount.to_be_bytes()); + data.extend_from_slice(&signature); + + let tx = test::burnchain_transaction(data, [output2, output3], opcode); + let header = test::burnchain_block_header(); + + let op = PegOutRequestOp::from_tx(&header, &tx); + + match op { + Err(OpError::InvalidInput) => (), + result => panic!("Expected OpError::InvalidInput, got {:?}", result), + } + } + + #[test] + fn test_parse_peg_out_request_should_return_error_given_no_outputs() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegOutRequest; + + let mut data = vec![]; + let amount: u64 = 10; + let signature: [u8; 65] = test::random_bytes(&mut rng); + data.extend_from_slice(&amount.to_be_bytes()); + data.extend_from_slice(&signature); + + let tx = test::burnchain_transaction(data, None, opcode); + let header = test::burnchain_block_header(); + + let op = PegOutRequestOp::from_tx(&header, &tx); + + match op { + Err(OpError::InvalidInput) => (), + result => panic!("Expected OpError::InvalidInput, got {:?}", result), + } + } + + #[test] + fn test_parse_peg_out_request_should_return_error_given_no_third_output() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegOutRequest; + + let dust_amount = 1; + let recipient_address_bytes = test::random_bytes(&mut rng); + let output2 = test::Output::new(dust_amount, recipient_address_bytes); + + let mut data = vec![]; + let amount: u64 = 10; + let signature: [u8; 65] = test::random_bytes(&mut rng); + data.extend_from_slice(&amount.to_be_bytes()); + data.extend_from_slice(&signature); + + let tx = test::burnchain_transaction(data, Some(output2), opcode); + let header = test::burnchain_block_header(); + + let op = PegOutRequestOp::from_tx(&header, &tx); + + match op { + Err(OpError::InvalidInput) => (), + result => panic!("Expected OpError::InvalidInput, got {:?}", result), + } + } + + #[test] + fn test_parse_peg_out_request_should_return_error_given_no_signature() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegOutRequest; + + let dust_amount = 1; + let recipient_address_bytes = test::random_bytes(&mut rng); + let output2 = test::Output::new(dust_amount, recipient_address_bytes); + + let peg_wallet_address = test::random_bytes(&mut rng); + let fulfillment_fee = 3; + let output3 = test::Output::new(fulfillment_fee, peg_wallet_address); + + let mut data = vec![]; + let amount: u64 = 10; + let signature: [u8; 0] = test::random_bytes(&mut rng); + data.extend_from_slice(&amount.to_be_bytes()); + data.extend_from_slice(&signature); + + let tx = test::burnchain_transaction(data, [output2, output3], opcode); + let header = test::burnchain_block_header(); + + let op = PegOutRequestOp::from_tx(&header, &tx); + + match op { + Err(OpError::ParseError) => (), + result => panic!("Expected OpError::ParseError, got {:?}", result), + } + } + + #[test] + fn test_parse_peg_out_request_should_return_error_on_zero_amount_and_ok_on_any_other_values() { + let mut rng = test::seeded_rng(); + + let dust_amount = 1; + let recipient_address_bytes = test::random_bytes(&mut rng); + let output2 = test::Output::new(dust_amount, recipient_address_bytes); + + let peg_wallet_address = test::random_bytes(&mut rng); + + let mut create_op = move |amount: u64, fulfillment_fee: u64| { + let opcode = Opcodes::PegOutRequest; + + let mut data = vec![]; + let signature: [u8; 65] = test::random_bytes(&mut rng); + data.extend_from_slice(&amount.to_be_bytes()); + data.extend_from_slice(&signature); + + let output3 = test::Output::new(fulfillment_fee, peg_wallet_address.clone()); + + let tx = test::burnchain_transaction(data, [output2.clone(), output3.clone()], opcode); + let header = test::burnchain_block_header(); + + PegOutRequestOp::from_tx(&header, &tx) + .expect("Failed to construct peg-out request operation") + }; + + match create_op(0, 1).check() { + Err(OpError::AmountMustBePositive) => (), + result => panic!( + "Expected OpError::PegInAmountMustBePositive, got {:?}", + result + ), + }; + + match create_op(1, 0).check() { + Err(OpError::AmountMustBePositive) => (), + result => panic!( + "Expected OpError::PegInAmountMustBePositive, got {:?}", + result + ), + }; + + create_op(1, 1) + .check() + .expect("Any strictly positive amounts should be ok"); + + create_op(u64::MAX, 1) + .check() + .expect("Any strictly positive amounts should be ok"); + } + + #[test] + fn test_stx_address_should_recover_the_same_address_used_to_sign_the_request() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegOutRequest; + + let private_key = StacksPrivateKey::from_hex( + "42faca653724860da7a41bfcef7e6ba78db55146f6900de8cb2a9f760ffac70c01", + ) + .unwrap(); + + let stx_address = StacksAddress::from_public_keys( + C32_ADDRESS_VERSION_TESTNET_SINGLESIG, + &AddressHashMode::SerializeP2PKH, + 1, + &vec![StacksPublicKey::from_private(&private_key)], + ) + .unwrap(); + + let dust_amount = 1; + let recipient_address_bytes = test::random_bytes(&mut rng); + let output2 = test::Output::new(dust_amount, recipient_address_bytes); + + let peg_wallet_address = test::random_bytes(&mut rng); + let fulfillment_fee = 3; + let output3 = test::Output::new(fulfillment_fee, peg_wallet_address); + + let mut data = vec![]; + let amount: u64 = 10; + + let mut script_pubkey = vec![81, 32]; // OP_1 OP_PUSHBYTES_32 + script_pubkey.extend_from_slice(&recipient_address_bytes); + + let mut msg = amount.to_be_bytes().to_vec(); + msg.extend_from_slice(&script_pubkey); + + let msg_hash = Sha256Sum::from_data(&msg); + + let signature = private_key.sign(msg_hash.as_bytes()).unwrap(); + data.extend_from_slice(&amount.to_be_bytes()); + data.extend_from_slice(signature.as_bytes()); + + let tx = test::burnchain_transaction(data, [output2, output3], opcode); + let header = test::burnchain_block_header(); + + let op = + PegOutRequestOp::from_tx(&header, &tx).expect("Failed to construct peg-out operation"); + + assert_eq!( + op.stx_address(C32_ADDRESS_VERSION_TESTNET_SINGLESIG) + .unwrap(), + stx_address + ); + } + + #[test] + fn test_stx_address_should_fail_to_recover_stx_address_if_signature_is_noise() { + let mut rng = test::seeded_rng(); + let opcode = Opcodes::PegOutRequest; + + let private_key = StacksPrivateKey::from_hex( + "42faca653724860da7a41bfcef7e6ba78db55146f6900de8cb2a9f760ffac70c01", + ) + .unwrap(); + + let stx_address = StacksAddress::from_public_keys( + C32_ADDRESS_VERSION_TESTNET_SINGLESIG, + &AddressHashMode::SerializeP2PKH, + 1, + &vec![StacksPublicKey::from_private(&private_key)], + ) + .unwrap(); + + let dust_amount = 1; + let recipient_address_bytes = test::random_bytes(&mut rng); + let output2 = test::Output::new(dust_amount, recipient_address_bytes); + + let peg_wallet_address = test::random_bytes(&mut rng); + let fulfillment_fee = 3; + let output3 = test::Output::new(fulfillment_fee, peg_wallet_address); + + let mut data = vec![]; + let amount: u64 = 10; + + let mut script_pubkey = vec![81, 32]; // OP_1 OP_PUSHBYTES_32 + script_pubkey.extend_from_slice(&recipient_address_bytes); + + let mut msg = amount.to_be_bytes().to_vec(); + msg.extend_from_slice(&script_pubkey); + + let msg_hash = Sha256Sum::from_data(&msg); + + let signature = MessageSignature(test::random_bytes(&mut rng)); + data.extend_from_slice(&amount.to_be_bytes()); + data.extend_from_slice(signature.as_bytes()); + + let tx = test::burnchain_transaction(data, [output2, output3], opcode); + let header = test::burnchain_block_header(); + + let op = + PegOutRequestOp::from_tx(&header, &tx).expect("Failed to construct peg-out operation"); + + assert_eq!( + op.stx_address(C32_ADDRESS_VERSION_TESTNET_SINGLESIG) + .unwrap_err(), + RecoverError::PubKeyRecoveryFailed( + "Invalid signature: failed to decode recoverable signature" + ), + ); + } + + #[test] + fn test_stx_address_with_hard_coded_fixtures() { + let vtxindex = 1; + let _block_height = 694; + let burn_header_hash = BurnchainHeaderHash::from_hex( + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .unwrap(); + + let fixtures = [ + OpFixture { + txstr: "02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff00ffffffff0300000000000000004f6a4c4c69643e000000000000053900dc18d08e2ee9f476a89c4c195edd402610176bb6264ec56f3f9e42e7386c543846e09282b6f03495c663c8509df7c97ffbcd2adc537bbabe23abd828a52bc8cd390500000000000022512000000000000000000000000000000000000000000000000000000000000000002a00000000000000225120000000000000000000000000000000000000000000000000000000000000000000000000", + signer: StacksAddress::from_string("ST3W2ATS1H9RF29DMYW5QP7NYJ643WNP2YFT4Z45C").unwrap(), + result: Ok(PegOutRequestOp { + amount: 1337, + recipient: PoxAddress::Addr32(false, PoxAddressType32::P2TR, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + signature: MessageSignature::from_hex("00dc18d08e2ee9f476a89c4c195edd402610176bb6264ec56f3f9e42e7386c543846e09282b6f03495c663c8509df7c97ffbcd2adc537bbabe23abd828a52bc8cd").unwrap(), + peg_wallet_address: PoxAddress::Addr32(false, PoxAddressType32::P2TR, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + fulfillment_fee: 42, memo: vec![], txid: Txid::from_hex("44a2aea3936f7764b4c089d3245b001069e0961e501fcb0024277ea9dedb2fea").unwrap(), + vtxindex: 1, + block_height: 0, + burn_header_hash: BurnchainHeaderHash::from_hex("0000000000000000000000000000000000000000000000000000000000000000").unwrap() }), + }, + OpFixture { + txstr: "02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff00ffffffff030000000000000000536a4c5069643e000000000000053900dc18d08e2ee9f476a89c4c195edd402610176bb6264ec56f3f9e42e7386c543846e09282b6f03495c663c8509df7c97ffbcd2adc537bbabe23abd828a52bc8cddeadbeef390500000000000022512000000000000000000000000000000000000000000000000000000000000000002a00000000000000225120000000000000000000000000000000000000000000000000000000000000000000000000", + signer: StacksAddress::from_string("ST3W2ATS1H9RF29DMYW5QP7NYJ643WNP2YFT4Z45C").unwrap(), + result: Ok(PegOutRequestOp { + amount: 1337, + recipient: PoxAddress::Addr32(false, PoxAddressType32::P2TR, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + signature: MessageSignature::from_hex("00dc18d08e2ee9f476a89c4c195edd402610176bb6264ec56f3f9e42e7386c543846e09282b6f03495c663c8509df7c97ffbcd2adc537bbabe23abd828a52bc8cd").unwrap(), + peg_wallet_address: PoxAddress::Addr32(false, PoxAddressType32::P2TR, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + fulfillment_fee: 42, memo: vec![222, 173, 190, 239], txid: Txid::from_hex("7431035f255c4ce215b66883d67e593f392b0b2026c24186e650019872b6f095").unwrap(), + vtxindex: 1, + block_height: 0, + burn_header_hash: BurnchainHeaderHash::from_hex("0000000000000000000000000000000000000000000000000000000000000000").unwrap() }), + }, + ]; + + let parser = BitcoinBlockParser::new(BitcoinNetworkType::Testnet, BLOCKSTACK_MAGIC_MAINNET); + + for fixture in fixtures { + let tx = make_tx(&fixture.txstr).unwrap(); + let burnchain_tx = BurnchainTransaction::Bitcoin( + parser + .parse_tx(&tx, vtxindex as usize, StacksEpochId::Epoch21) + .unwrap(), + ); + + let header = match fixture.result { + Ok(ref op) => BurnchainBlockHeader { + block_height: op.block_height, + block_hash: op.burn_header_hash.clone(), + parent_block_hash: op.burn_header_hash.clone(), + num_txs: 1, + timestamp: 0, + }, + Err(_) => BurnchainBlockHeader { + block_height: 0, + block_hash: BurnchainHeaderHash::zero(), + parent_block_hash: BurnchainHeaderHash::zero(), + num_txs: 0, + timestamp: 0, + }, + }; + + let result = PegOutRequestOp::from_tx(&header, &burnchain_tx); + + match (result, fixture.result) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected); + assert_eq!( + actual + .stx_address(C32_ADDRESS_VERSION_TESTNET_SINGLESIG) + .unwrap(), + fixture.signer + ); + } + _ => panic!("Unsupported test scenario"), + } + } + } + + pub struct OpFixture { + txstr: &'static str, + signer: StacksAddress, + result: Result, + } + + fn make_tx(hex_str: &str) -> Result { + let tx_bin = hex_bytes(hex_str).map_err(|_e| "failed to decode hex string")?; + let tx = deserialize(&tx_bin.to_vec()).map_err(|_e| "failed to deserialize")?; + Ok(tx) + } +} diff --git a/src/chainstate/burn/operations/test/mod.rs b/src/chainstate/burn/operations/test/mod.rs new file mode 100644 index 0000000000..6356443032 --- /dev/null +++ b/src/chainstate/burn/operations/test/mod.rs @@ -0,0 +1,91 @@ +use rand::rngs::StdRng; +use rand::SeedableRng; +use stacks_common::util::hash::Hash160; + +use crate::burnchains::BurnchainBlockHeader; +use crate::burnchains::BurnchainTransaction; +use crate::burnchains::{ + bitcoin::{ + address::{ + BitcoinAddress, LegacyBitcoinAddress, LegacyBitcoinAddressType, SegwitBitcoinAddress, + }, + BitcoinInputType, BitcoinNetworkType, BitcoinTransaction, BitcoinTxInputStructured, + BitcoinTxOutput, + }, + Txid, +}; +use crate::chainstate::burn::Opcodes; + +mod serialization; + +pub(crate) fn seeded_rng() -> StdRng { + SeedableRng::from_seed([0; 32]) +} + +pub(crate) fn random_bytes(rng: &mut Rng) -> [u8; N] { + [rng.gen(); N] +} + +pub(crate) fn burnchain_block_header() -> BurnchainBlockHeader { + BurnchainBlockHeader { + block_height: 0, + block_hash: [0; 32].into(), + parent_block_hash: [0; 32].into(), + num_txs: 0, + timestamp: 0, + } +} + +pub(crate) fn burnchain_transaction( + data: Vec, + outputs: impl IntoIterator, + opcode: Opcodes, +) -> BurnchainTransaction { + BurnchainTransaction::Bitcoin(bitcoin_transaction(data, outputs, opcode)) +} + +fn bitcoin_transaction( + data: Vec, + outputs: impl IntoIterator, + opcode: Opcodes, +) -> BitcoinTransaction { + BitcoinTransaction { + txid: Txid([0; 32]), + vtxindex: 0, + opcode: opcode as u8, + data, + data_amt: 0, + inputs: vec![BitcoinTxInputStructured { + keys: vec![], + num_required: 0, + in_type: BitcoinInputType::Standard, + tx_ref: (Txid([0; 32]), 2), + } + .into()], + outputs: outputs + .into_iter() + .map(|output2data| output2data.as_bitcoin_tx_output()) + .collect(), + } +} + +#[derive(Debug, Clone)] +pub(crate) struct Output { + amount: u64, + address: [u8; 32], +} + +impl Output { + pub(crate) fn new(amount: u64, peg_wallet_address: [u8; 32]) -> Self { + Self { + amount, + address: peg_wallet_address, + } + } + pub(crate) fn as_bitcoin_tx_output(&self) -> BitcoinTxOutput { + BitcoinTxOutput { + units: self.amount, + address: BitcoinAddress::Segwit(SegwitBitcoinAddress::P2TR(true, self.address)), + } + } +} diff --git a/src/chainstate/burn/operations/test/serialization.rs b/src/chainstate/burn/operations/test/serialization.rs new file mode 100644 index 0000000000..82306a7abd --- /dev/null +++ b/src/chainstate/burn/operations/test/serialization.rs @@ -0,0 +1,652 @@ +use crate::burnchains::Txid; +use crate::chainstate::burn::operations::{ + BlockstackOperationType, DelegateStxOp, PegInOp, PegOutFulfillOp, PegOutRequestOp, PreStxOp, + StackStxOp, TransferStxOp, +}; +use crate::chainstate::stacks::address::{PoxAddress, PoxAddressType32}; +use crate::net::BurnchainOps; +use clarity::vm::types::PrincipalData; +use serde_json::Value; +use stacks_common::address::C32_ADDRESS_VERSION_MAINNET_SINGLESIG; +use stacks_common::types::chainstate::StacksBlockId; +use stacks_common::types::chainstate::{ + BlockHeaderHash, BurnchainHeaderHash, ConsensusHash, StacksAddress, VRFSeed, +}; +use stacks_common::types::Address; +use stacks_common::util::hash::Hash160; +use stacks_common::util::secp256k1::MessageSignature; + +#[test] +fn test_serialization_transfer_stx_op() { + let sender_addr = "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2"; + let sender = StacksAddress::from_string(sender_addr).unwrap(); + let recipient_addr = "SP24ZBZ8ZE6F48JE9G3F3HRTG9FK7E2H6K2QZ3Q1K"; + let recipient = StacksAddress::from_string(recipient_addr).unwrap(); + let op = TransferStxOp { + sender, + recipient, + transfered_ustx: 10, + memo: vec![0x00, 0x01, 0x02, 0x03, 0x04, 0x05], + txid: Txid([10u8; 32]), + vtxindex: 10, + block_height: 10, + burn_header_hash: BurnchainHeaderHash([0x10; 32]), + }; + let serialized_json = BlockstackOperationType::transfer_stx_to_json(&op); + let constructed_json = serde_json::json!({ + "transfer_stx": { + "burn_block_height": 10, + "burn_header_hash": "1010101010101010101010101010101010101010101010101010101010101010", + "memo": "0x000102030405", + "recipient": { + "address": "SP24ZBZ8ZE6F48JE9G3F3HRTG9FK7E2H6K2QZ3Q1K", + "address_hash_bytes": "0x89f5fd1f719e4449c980de38e3504be6770a2698", + "address_version": 22, + }, + "sender": { + "address": "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2", + "address_hash_bytes": "0xaf3f91f38aa21ade7e9f95efdbc4201eeb4cf0f8", + "address_version": 26, + }, + "transfered_ustx": 10, + "burn_txid": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a", + "vtxindex": 10, + } + }); + + assert_json_diff::assert_json_eq!(serialized_json, constructed_json); +} + +#[test] +fn test_serialization_stack_stx_op() { + let sender_addr = "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2"; + let sender = StacksAddress::from_string(sender_addr).unwrap(); + let reward_addr = PoxAddress::Standard( + StacksAddress { + version: C32_ADDRESS_VERSION_MAINNET_SINGLESIG, + bytes: Hash160([0x01; 20]), + }, + None, + ); + + let op = StackStxOp { + sender, + reward_addr, + stacked_ustx: 10, + txid: Txid([10u8; 32]), + vtxindex: 10, + block_height: 10, + burn_header_hash: BurnchainHeaderHash([0x10; 32]), + num_cycles: 10, + }; + let serialized_json = BlockstackOperationType::stack_stx_to_json(&op); + let constructed_json = serde_json::json!({ + "stack_stx": { + "burn_block_height": 10, + "burn_header_hash": "1010101010101010101010101010101010101010101010101010101010101010", + "num_cycles": 10, + "reward_addr": "16Jswqk47s9PUcyCc88MMVwzgvHPvtEpf", + "sender": { + "address": "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2", + "address_hash_bytes": "0xaf3f91f38aa21ade7e9f95efdbc4201eeb4cf0f8", + "address_version": 26, + }, + "stacked_ustx": 10, + "burn_txid": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a", + "vtxindex": 10, + } + }); + + assert_json_diff::assert_json_eq!(serialized_json, constructed_json); +} + +#[test] +fn test_serialization_pre_stx_op() { + let output_addr = "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2"; + let output = StacksAddress::from_string(output_addr).unwrap(); + + let op = PreStxOp { + output, + txid: Txid([10u8; 32]), + vtxindex: 10, + block_height: 10, + burn_header_hash: BurnchainHeaderHash([0x10; 32]), + }; + let serialized_json = BlockstackOperationType::pre_stx_to_json(&op); + let constructed_json = serde_json::json!({ + "pre_stx": { + "burn_block_height": 10, + "burn_header_hash": "1010101010101010101010101010101010101010101010101010101010101010", + "output": { + "address": "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2", + "address_hash_bytes": "0xaf3f91f38aa21ade7e9f95efdbc4201eeb4cf0f8", + "address_version": 26, + }, + "burn_txid": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a", + "vtxindex": 10, + } + }); + + assert_json_diff::assert_json_eq!(serialized_json, constructed_json); +} + +#[test] +fn test_serialization_delegate_stx_op() { + let sender_addr = "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2"; + let sender = StacksAddress::from_string(sender_addr).unwrap(); + let delegate_to_addr = "SP24ZBZ8ZE6F48JE9G3F3HRTG9FK7E2H6K2QZ3Q1K"; + let delegate_to = StacksAddress::from_string(delegate_to_addr).unwrap(); + let pox_addr = PoxAddress::Standard( + StacksAddress { + version: C32_ADDRESS_VERSION_MAINNET_SINGLESIG, + bytes: Hash160([0x01; 20]), + }, + None, + ); + let op = DelegateStxOp { + sender, + delegate_to, + reward_addr: Some((10, pox_addr)), + delegated_ustx: 10, + until_burn_height: None, + txid: Txid([10u8; 32]), + vtxindex: 10, + block_height: 10, + burn_header_hash: BurnchainHeaderHash([0x10; 32]), + }; + let serialized_json = BlockstackOperationType::delegate_stx_to_json(&op); + let constructed_json = serde_json::json!({ + "delegate_stx": { + "burn_block_height": 10, + "burn_header_hash": "1010101010101010101010101010101010101010101010101010101010101010", + "delegate_to": { + "address": "SP24ZBZ8ZE6F48JE9G3F3HRTG9FK7E2H6K2QZ3Q1K", + "address_hash_bytes": "0x89f5fd1f719e4449c980de38e3504be6770a2698", + "address_version": 22, + }, + "delegated_ustx": 10, + "sender": { + "address": "ST2QKZ4FKHAH1NQKYKYAYZPY440FEPK7GZ1R5HBP2", + "address_hash_bytes": "0xaf3f91f38aa21ade7e9f95efdbc4201eeb4cf0f8", + "address_version": 26, + }, + "reward_addr": [10, "16Jswqk47s9PUcyCc88MMVwzgvHPvtEpf"], + "burn_txid": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a", + "until_burn_height": null, + "vtxindex": 10, + } + }); + + assert_json_diff::assert_json_eq!(serialized_json, constructed_json); +} + +#[test] +/// Test the serialization and deserialization of PegIn operations in `BurnchainOps` +/// using JSON string fixtures +fn serialization_peg_in_in_ops() { + let test_cases = [ + ( + r#" + { + "peg_in": [ + { + "amount": 1337, + "block_height": 218, + "burn_header_hash": "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + "memo": "0001020304", + "peg_wallet_address": "1111111111111111111114oLvT2", + "recipient": "S0000000000000000000002AA028H.awesome_contract", + "txid": "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + "vtxindex": 2 + } + ] + } + "#, + PegInOp { + recipient: PrincipalData::parse("S0000000000000000000002AA028H.awesome_contract") + .unwrap(), + peg_wallet_address: PoxAddress::Standard(StacksAddress::burn_address(true), None), + amount: 1337, + memo: vec![0, 1, 2, 3, 4], + txid: Txid::from_hex( + "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + ) + .unwrap(), + vtxindex: 2, + block_height: 218, + burn_header_hash: BurnchainHeaderHash::from_hex( + "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + ) + .unwrap(), + }, + ), + ( + r#" + { + "peg_in": [ + { + "amount": 1337, + "block_height": 218, + "burn_header_hash": "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + "memo": "", + "peg_wallet_address": "tb1pqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqkgkkf5", + "recipient": "S0000000000000000000002AA028H.awesome_contract", + "txid": "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + "vtxindex": 2 + } + ] + } + "#, + PegInOp { + recipient: PrincipalData::parse("S0000000000000000000002AA028H.awesome_contract") + .unwrap(), + peg_wallet_address: PoxAddress::Addr32(false, PoxAddressType32::P2TR, [0; 32]), + amount: 1337, + memo: vec![], + txid: Txid::from_hex( + "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + ) + .unwrap(), + vtxindex: 2, + block_height: 218, + burn_header_hash: BurnchainHeaderHash::from_hex( + "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + ) + .unwrap(), + }, + ), + ( + r#" + { + "peg_in": [ + { + "amount": 1337, + "block_height": 218, + "burn_header_hash": "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + "memo": "", + "peg_wallet_address": "tb1qqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvps3f3cyq", + "recipient": "S0000000000000000000002AA028H", + "txid": "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + "vtxindex": 2 + } + ] + } + "#, + PegInOp { + recipient: PrincipalData::parse("S0000000000000000000002AA028H").unwrap(), + peg_wallet_address: PoxAddress::Addr32(false, PoxAddressType32::P2WSH, [3; 32]), + amount: 1337, + memo: vec![], + txid: Txid::from_hex( + "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + ) + .unwrap(), + vtxindex: 2, + block_height: 218, + burn_header_hash: BurnchainHeaderHash::from_hex( + "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + ) + .unwrap(), + }, + ), + ]; + + for (expected_json, op) in test_cases { + // Test that op serializes to a JSON value equal to expected_json + assert_json_diff::assert_json_eq!( + serde_json::from_str::(expected_json).unwrap(), + BurnchainOps::PegIn(vec![op.clone()]) + ); + + // Test that expected JSON deserializes into a BurnchainOps that is equal to op + assert_eq!( + serde_json::from_str::(expected_json).unwrap(), + BurnchainOps::PegIn(vec![op]) + ); + } +} + +#[test] +/// Test the serialization of PegIn operations via +/// `blockstack_op_to_json()` using JSON string fixtures +fn serialization_peg_in() { + let test_cases = [ + ( + r#" + { + "peg_in": + { + "amount": 1337, + "block_height": 218, + "burn_header_hash": "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + "memo": "0001020304", + "peg_wallet_address": "1111111111111111111114oLvT2", + "recipient": "S0000000000000000000002AA028H.awesome_contract", + "txid": "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + "vtxindex": 2 + } + } + "#, + PegInOp { + recipient: PrincipalData::parse("S0000000000000000000002AA028H.awesome_contract") + .unwrap(), + peg_wallet_address: PoxAddress::standard_burn_address(true), + amount: 1337, + memo: vec![0, 1, 2, 3, 4], + txid: Txid::from_hex( + "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + ) + .unwrap(), + vtxindex: 2, + block_height: 218, + burn_header_hash: BurnchainHeaderHash::from_hex( + "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + ) + .unwrap(), + }, + ), + ( + r#" + { + "peg_in": + { + "amount": 1337, + "block_height": 218, + "burn_header_hash": "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + "memo": "", + "peg_wallet_address": "tb1pqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqkgkkf5", + "recipient": "S0000000000000000000002AA028H.awesome_contract", + "txid": "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + "vtxindex": 2 + } + } + "#, + PegInOp { + recipient: PrincipalData::parse("S0000000000000000000002AA028H.awesome_contract") + .unwrap(), + peg_wallet_address: PoxAddress::Addr32(false, PoxAddressType32::P2TR, [0; 32]), + amount: 1337, + memo: vec![], + txid: Txid::from_hex( + "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + ) + .unwrap(), + vtxindex: 2, + block_height: 218, + burn_header_hash: BurnchainHeaderHash::from_hex( + "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + ) + .unwrap(), + }, + ), + ( + r#" + { + "peg_in": + { + "amount": 1337, + "block_height": 218, + "burn_header_hash": "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + "memo": "", + "peg_wallet_address": "tb1qqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvps3f3cyq", + "recipient": "S0000000000000000000002AA028H", + "txid": "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + "vtxindex": 2 + } + } + "#, + PegInOp { + recipient: PrincipalData::parse("S0000000000000000000002AA028H").unwrap(), + peg_wallet_address: PoxAddress::Addr32(false, PoxAddressType32::P2WSH, [3; 32]), + amount: 1337, + memo: vec![], + txid: Txid::from_hex( + "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + ) + .unwrap(), + vtxindex: 2, + block_height: 218, + burn_header_hash: BurnchainHeaderHash::from_hex( + "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + ) + .unwrap(), + }, + ), + ]; + + for (expected_json, op) in test_cases { + // Test that op serializes to a JSON value equal to expected_json + assert_json_diff::assert_json_eq!( + serde_json::from_str::(expected_json).unwrap(), + BlockstackOperationType::PegIn(op).blockstack_op_to_json() + ); + } +} + +#[test] +/// Test the serialization and deserialization of PegOutRequest operations in `BurnchainOps` +/// using JSON string fixtures +fn serialization_peg_out_request_in_ops() { + let test_cases = [( + r#" + { + "peg_out_request": [{ + "amount": 1337, + "recipient": "1BixGeiRyKT7NTkJAHpWuP197KXUNqhCU9", + "block_height": 218, + "burn_header_hash": "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + "peg_wallet_address": "tb1qqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvps3f3cyq", + "txid": "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + "vtxindex": 2, + "signature": "0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d", + "fulfillment_fee": 0, + "memo": "00010203" + }] + } + "#, + PegOutRequestOp { + amount: 1337, + recipient: PoxAddress::Standard( + StacksAddress::from_string("SP1TT0WQYZMEBX1XJ8QF4BH4A93TWZK7X9R76Z3SZ").unwrap(), + None, + ), + signature: MessageSignature([13; 65]), + peg_wallet_address: PoxAddress::Addr32(false, PoxAddressType32::P2WSH, [3; 32]), + fulfillment_fee: 0, + memo: vec![0, 1, 2, 3], + txid: Txid::from_hex( + "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + ) + .unwrap(), + vtxindex: 2, + block_height: 218, + burn_header_hash: BurnchainHeaderHash::from_hex( + "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + ) + .unwrap(), + }, + )]; + + for (expected_json, op) in test_cases { + // Test that op serializes to a JSON value equal to expected_json + assert_json_diff::assert_json_eq!( + serde_json::from_str::(expected_json).unwrap(), + BurnchainOps::PegOutRequest(vec![op.clone()]) + ); + + // Test that expected JSON deserializes into a BurnchainOps that is equal to op + assert_eq!( + serde_json::from_str::(expected_json).unwrap(), + BurnchainOps::PegOutRequest(vec![op]) + ); + } +} + +#[test] +/// Test the serialization of PegOutRequest operations via +/// `blockstack_op_to_json()` using JSON string fixtures +fn serialization_peg_out_request() { + let test_cases = [( + r#" + { + "peg_out_request": + { + "amount": 1337, + "recipient": "1BixGeiRyKT7NTkJAHpWuP197KXUNqhCU9", + "block_height": 218, + "burn_header_hash": "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + "peg_wallet_address": "tb1qqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvps3f3cyq", + "txid": "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + "vtxindex": 2, + "signature": "0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d", + "fulfillment_fee": 0, + "memo": "00010203" + } + } + "#, + PegOutRequestOp { + amount: 1337, + recipient: PoxAddress::Standard( + StacksAddress::from_string("SP1TT0WQYZMEBX1XJ8QF4BH4A93TWZK7X9R76Z3SZ").unwrap(), + None, + ), + signature: MessageSignature([13; 65]), + peg_wallet_address: PoxAddress::Addr32(false, PoxAddressType32::P2WSH, [3; 32]), + fulfillment_fee: 0, + memo: vec![0, 1, 2, 3], + txid: Txid::from_hex( + "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + ) + .unwrap(), + vtxindex: 2, + block_height: 218, + burn_header_hash: BurnchainHeaderHash::from_hex( + "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + ) + .unwrap(), + }, + )]; + + for (expected_json, op) in test_cases { + // Test that op serializes to a JSON value equal to expected_json + assert_json_diff::assert_json_eq!( + serde_json::from_str::(expected_json).unwrap(), + BlockstackOperationType::PegOutRequest(op).blockstack_op_to_json() + ); + } +} + +#[test] +/// Test the serialization and deserialization of PegOutFulfill operations in `BurnchainOps` +/// using JSON string fixtures +fn serialization_peg_out_fulfill_in_ops() { + let test_cases = [( + r#" + { + "peg_out_fulfill": [{ + "chain_tip": "0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e", + "amount": 1337, + "recipient": "1BixGeiRyKT7NTkJAHpWuP197KXUNqhCU9", + "request_ref": "e81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157772", + "burn_header_hash": "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + "txid": "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + "block_height": 218, + "vtxindex": 2, + "memo": "00010203" + }] + } + "#, + PegOutFulfillOp { + chain_tip: StacksBlockId([14; 32]), + amount: 1337, + recipient: PoxAddress::Standard( + StacksAddress::from_string("SP1TT0WQYZMEBX1XJ8QF4BH4A93TWZK7X9R76Z3SZ").unwrap(), + None, + ), + request_ref: Txid::from_hex( + "e81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157772", + ) + .unwrap(), + memo: vec![0, 1, 2, 3], + txid: Txid::from_hex( + "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + ) + .unwrap(), + vtxindex: 2, + block_height: 218, + burn_header_hash: BurnchainHeaderHash::from_hex( + "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + ) + .unwrap(), + }, + )]; + + for (expected_json, op) in test_cases { + // Test that op serializes to a JSON value equal to expected_json + assert_json_diff::assert_json_eq!( + serde_json::from_str::(expected_json).unwrap(), + BurnchainOps::PegOutFulfill(vec![op.clone()]) + ); + + // Test that expected JSON deserializes into a BurnchainOps that is equal to op + assert_eq!( + serde_json::from_str::(expected_json).unwrap(), + BurnchainOps::PegOutFulfill(vec![op]) + ); + } +} + +#[test] +/// Test the serialization of PegOutFulfill operations via +/// `blockstack_op_to_json()` using JSON string fixtures +fn serialization_peg_out_fulfill() { + let test_cases = [( + r#" + { + "peg_out_fulfill": + { + "chain_tip": "0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e", + "amount": 1337, + "recipient": "1BixGeiRyKT7NTkJAHpWuP197KXUNqhCU9", + "request_ref": "e81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157772", + "burn_header_hash": "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + "txid": "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + "block_height": 218, + "vtxindex": 2, + "memo": "00010203" + } + } + "#, + PegOutFulfillOp { + chain_tip: StacksBlockId([14; 32]), + amount: 1337, + recipient: PoxAddress::Standard( + StacksAddress::from_string("SP1TT0WQYZMEBX1XJ8QF4BH4A93TWZK7X9R76Z3SZ").unwrap(), + None, + ), + request_ref: Txid::from_hex( + "e81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157772", + ) + .unwrap(), + memo: vec![0, 1, 2, 3], + txid: Txid::from_hex( + "d81bec73a0ea0bdcf9bc011f567944eb1eae5889bf002bf7ae641d7096157771", + ) + .unwrap(), + vtxindex: 2, + block_height: 218, + burn_header_hash: BurnchainHeaderHash::from_hex( + "3292a7d2a7e941499b5c0dcff2a5656c159010718450948a60c2be9e1c221dc4", + ) + .unwrap(), + }, + )]; + + for (expected_json, op) in test_cases { + // Test that op serializes to a JSON value equal to expected_json + assert_json_diff::assert_json_eq!( + serde_json::from_str::(expected_json).unwrap(), + BlockstackOperationType::PegOutFulfill(op).blockstack_op_to_json() + ); + } +} diff --git a/src/chainstate/coordinator/tests.rs b/src/chainstate/coordinator/tests.rs index 4b59d9b347..7a7ebef262 100644 --- a/src/chainstate/coordinator/tests.rs +++ b/src/chainstate/coordinator/tests.rs @@ -38,7 +38,7 @@ use crate::chainstate::burn::operations::leader_block_commit::*; use crate::chainstate::burn::operations::*; use crate::chainstate::burn::*; use crate::chainstate::coordinator::{Error as CoordError, *}; -use crate::chainstate::stacks::address::PoxAddress; +use crate::chainstate::stacks::address::{PoxAddress, PoxAddressType20, PoxAddressType32}; use crate::chainstate::stacks::boot::POX_1_NAME; use crate::chainstate::stacks::boot::POX_2_NAME; use crate::chainstate::stacks::boot::{PoxStartCycleInfo, POX_3_NAME}; @@ -61,6 +61,7 @@ use clarity::vm::{ use stacks_common::address; use stacks_common::consts::CHAIN_ID_TESTNET; use stacks_common::util::hash::{to_hex, Hash160}; +use stacks_common::util::secp256k1::MessageSignature; use stacks_common::util::vrf::*; use crate::chainstate::stacks::boot::COSTS_2_NAME; @@ -3278,6 +3279,276 @@ fn test_stx_transfer_btc_ops() { } } +#[test] +fn test_sbtc_ops() { + let path = "/tmp/stacks-blockchain-sbtc-ops"; + let _r = std::fs::remove_dir_all(path); + + let pox_v1_unlock_ht = 12; + let pox_v2_unlock_ht = u32::MAX; + let sunset_ht = 8000; + let pox_consts = Some(PoxConstants::new( + 100, + 3, + 3, + 25, + 5, + 7010, + sunset_ht, + pox_v1_unlock_ht, + pox_v2_unlock_ht, + u32::MAX, + )); + let burnchain_conf = get_burnchain(path, pox_consts.clone()); + + let vrf_keys: Vec<_> = (0..50).map(|_| VRFPrivateKey::new()).collect(); + let committers: Vec<_> = (0..50).map(|_| StacksPrivateKey::new()).collect(); + + let stacker = p2pkh_from(&StacksPrivateKey::new()); + let recipient = p2pkh_from(&StacksPrivateKey::new()); + let balance = 6_000_000_000 * (core::MICROSTACKS_PER_STACKS as u64); + let transfer_amt = 1_000_000_000 * (core::MICROSTACKS_PER_STACKS as u128); + let initial_balances = vec![(stacker.clone().into(), balance)]; + + setup_states( + &[path], + &vrf_keys, + &committers, + pox_consts.clone(), + Some(initial_balances), + StacksEpochId::Epoch21, + ); + + let mut coord = make_coordinator(path, Some(burnchain_conf.clone())); + + coord.handle_new_burnchain_block().unwrap(); + + let sort_db = get_sortition_db(path, pox_consts.clone()); + + let tip = SortitionDB::get_canonical_burn_chain_tip(sort_db.conn()).unwrap(); + assert_eq!(tip.block_height, 1); + assert_eq!(tip.sortition, false); + let (_, ops) = sort_db + .get_sortition_result(&tip.sortition_id) + .unwrap() + .unwrap(); + + // we should have all the VRF registrations accepted + assert_eq!(ops.accepted_ops.len(), vrf_keys.len()); + assert_eq!(ops.consumed_leader_keys.len(), 0); + + // process sequential blocks, and their sortitions... + let mut stacks_blocks: Vec<(SortitionId, StacksBlock)> = vec![]; + let mut burnchain_block_hashes = vec![]; + + let first_peg_in_memo = vec![1, 3, 3, 7]; + let second_peg_in_memo = vec![4, 2]; + + let first_peg_out_request_memo = vec![1, 3, 3, 8]; + let second_peg_out_request_memo = vec![4, 3]; + + let peg_out_fulfill_memo = vec![1, 3, 3, 8]; + + for ix in 0..vrf_keys.len() { + let vrf_key = &vrf_keys[ix]; + let miner = &committers[ix]; + + let mut burnchain = get_burnchain_db(path, pox_consts.clone()); + let mut chainstate = get_chainstate(path); + + let parent = if ix == 0 { + BlockHeaderHash([0; 32]) + } else { + stacks_blocks[ix - 1].1.header.block_hash() + }; + + let burnchain_tip = burnchain.get_canonical_chain_tip().unwrap(); + let next_mock_header = BurnchainBlockHeader { + block_height: burnchain_tip.block_height + 1, + block_hash: BurnchainHeaderHash([0; 32]), + parent_block_hash: burnchain_tip.block_hash, + num_txs: 0, + timestamp: 1, + }; + + let b = get_burnchain(path, pox_consts.clone()); + let (good_op, block) = if ix == 0 { + make_genesis_block_with_recipients( + &sort_db, + &mut chainstate, + &parent, + miner, + 10000, + vrf_key, + ix as u32, + None, + ) + } else { + make_stacks_block_with_recipients( + &sort_db, + &mut chainstate, + &b, + &parent, + burnchain_tip.block_height, + miner, + 1000, + vrf_key, + ix as u32, + None, + ) + }; + + let expected_winner = good_op.txid(); + let mut ops = vec![good_op]; + let peg_wallet_address = PoxAddress::Addr32(false, PoxAddressType32::P2TR, [0; 32]); + let recipient_btc_address = PoxAddress::Standard(stacker.into(), None); + let canonical_chain_tip_snapshot = + SortitionDB::get_canonical_burn_chain_tip(sort_db.conn()).unwrap(); + + let chain_tip = StacksBlockId::new( + &canonical_chain_tip_snapshot.consensus_hash, + &canonical_chain_tip_snapshot.winning_stacks_block_hash, + ); + + match ix { + 0 => { + ops.push(BlockstackOperationType::PegIn(PegInOp { + recipient: stacker.into(), + peg_wallet_address, + amount: 1337, + memo: first_peg_in_memo.clone(), + txid: next_txid(), + vtxindex: 5, + block_height: 0, + burn_header_hash: BurnchainHeaderHash([0; 32]), + })); + } + 1 => { + // Shouldn't be accepted -- amount must be positive + ops.push(BlockstackOperationType::PegIn(PegInOp { + recipient: stacker.into(), + peg_wallet_address, + amount: 0, + memo: second_peg_in_memo.clone(), + txid: next_txid(), + vtxindex: 5, + block_height: 0, + burn_header_hash: BurnchainHeaderHash([0; 32]), + })); + } + 2 => { + // Shouldn't be accepted -- amount must be positive + ops.push(BlockstackOperationType::PegOutRequest(PegOutRequestOp { + recipient: recipient_btc_address, + signature: MessageSignature([0; 65]), + amount: 0, + peg_wallet_address, + fulfillment_fee: 3, + memo: first_peg_out_request_memo.clone(), + txid: next_txid(), + vtxindex: 5, + block_height: 0, + burn_header_hash: BurnchainHeaderHash([0; 32]), + })); + } + 3 => { + // Add a valid peg-out request op + ops.push(BlockstackOperationType::PegOutRequest(PegOutRequestOp { + recipient: recipient_btc_address, + signature: MessageSignature([0; 65]), + amount: 5, + peg_wallet_address, + fulfillment_fee: 3, + txid: Txid([0x13; 32]), + memo: second_peg_out_request_memo.clone(), + vtxindex: 8, + block_height: 0, + burn_header_hash: BurnchainHeaderHash([0; 32]), + })); + } + 4 => { + // Fulfill the peg-out request + ops.push(BlockstackOperationType::PegOutFulfill(PegOutFulfillOp { + recipient: recipient_btc_address, + amount: 3, + chain_tip, + memo: peg_out_fulfill_memo.clone(), + request_ref: Txid([0x13; 32]), + txid: next_txid(), + vtxindex: 6, + block_height: 0, + burn_header_hash: BurnchainHeaderHash([0; 32]), + })); + } + _ => {} + }; + + let burnchain_tip = burnchain.get_canonical_chain_tip().unwrap(); + produce_burn_block( + &b, + &mut burnchain, + &burnchain_tip.block_hash, + ops, + vec![].iter_mut(), + ); + + burnchain_block_hashes.push(burnchain_tip.block_hash); + // handle the sortition + coord.handle_new_burnchain_block().unwrap(); + + let tip = SortitionDB::get_canonical_burn_chain_tip(sort_db.conn()).unwrap(); + assert_eq!(&tip.winning_block_txid, &expected_winner); + + // load the block into staging + let block_hash = block.header.block_hash(); + + assert_eq!(&tip.winning_stacks_block_hash, &block_hash); + stacks_blocks.push((tip.sortition_id.clone(), block.clone())); + + preprocess_block(&mut chainstate, &sort_db, &tip, block); + + // handle the stacks block + coord.handle_new_stacks_block().unwrap(); + } + + let peg_in_ops: Vec<_> = burnchain_block_hashes + .iter() + .flat_map(|block_hash| { + SortitionDB::get_peg_in_ops(&sort_db.conn(), block_hash) + .expect("Failed to get peg in ops") + }) + .collect(); + + let peg_out_request_ops: Vec<_> = burnchain_block_hashes + .iter() + .flat_map(|block_hash| { + SortitionDB::get_peg_out_request_ops(&sort_db.conn(), block_hash) + .expect("Failed to get peg out request ops") + }) + .collect(); + + let peg_out_fulfill_ops: Vec<_> = burnchain_block_hashes + .iter() + .flat_map(|block_hash| { + SortitionDB::get_peg_out_fulfill_ops(&sort_db.conn(), block_hash) + .expect("Failed to get peg out fulfillment ops") + }) + .collect(); + + assert_eq!(peg_in_ops.len(), 1); + assert_eq!(peg_in_ops[0].memo, first_peg_in_memo); + + assert_eq!(peg_out_request_ops.len(), 1); + assert_eq!(peg_out_request_ops[0].memo, second_peg_out_request_memo); + + assert_eq!(peg_out_fulfill_ops.len(), 1); + assert_eq!(peg_out_fulfill_ops[0].memo, peg_out_fulfill_memo); + assert_eq!( + peg_out_fulfill_ops[0].request_ref, + peg_out_request_ops[0].txid + ); +} + // This helper function retrieves the delegation info from the delegate address // from the pox-2 contract. // Given an address, it retrieves the fields `amount-ustx` and `pox-addr` from the map diff --git a/src/chainstate/stacks/address.rs b/src/chainstate/stacks/address.rs index 9f06783cb2..a953b065b7 100644 --- a/src/chainstate/stacks/address.rs +++ b/src/chainstate/stacks/address.rs @@ -32,8 +32,10 @@ use crate::chainstate::stacks::{ C32_ADDRESS_VERSION_TESTNET_MULTISIG, C32_ADDRESS_VERSION_TESTNET_SINGLESIG, }; use crate::net::Error as net_error; +use clarity::address::b58::{check_encode_slice, from_check}; use clarity::vm::types::{PrincipalData, SequenceData, StandardPrincipalData}; use clarity::vm::types::{TupleData, Value}; +use serde::{Deserialize, Deserializer, Serializer}; use stacks_common::address::b58; use stacks_common::address::c32::c32_address; use stacks_common::address::c32::c32_address_decode; @@ -92,6 +94,21 @@ pub enum PoxAddress { Addr32(bool, PoxAddressType32, [u8; 32]), } +/// Serializes a PoxAddress as a B58 check encoded address or a bech32 address +pub fn pox_addr_b58_serialize( + input: &PoxAddress, + ser: S, +) -> Result { + ser.serialize_str(&input.clone().to_b58()) +} + +/// Deserializes a PoxAddress from a B58 check encoded address or a bech32 address +pub fn pox_addr_b58_deser<'de, D: Deserializer<'de>>(deser: D) -> Result { + let string_repr = String::deserialize(deser)?; + PoxAddress::from_b58(&string_repr) + .ok_or_else(|| serde::de::Error::custom("Failed to decode PoxAddress from string")) +} + impl std::fmt::Display for PoxAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.to_db_string()) @@ -407,6 +424,15 @@ impl PoxAddress { } } + // Convert from a B58 encoded bitcoin address + pub fn from_b58(input: &str) -> Option { + let btc_addr = BitcoinAddress::from_string(input)?; + PoxAddress::try_from_bitcoin_output(&BitcoinTxOutput { + address: btc_addr, + units: 0, + }) + } + /// Convert this PoxAddress into a Bitcoin tx output pub fn to_bitcoin_tx_out(&self, value: u64) -> TxOut { match *self { @@ -1188,6 +1214,63 @@ mod test { ); } + #[test] + fn test_pox_addr_from_b58() { + // representative test PoxAddresses + let pox_addrs: Vec = vec![ + PoxAddress::Standard( + StacksAddress { + version: C32_ADDRESS_VERSION_MAINNET_SINGLESIG, + bytes: Hash160([0x01; 20]), + }, + Some(AddressHashMode::SerializeP2PKH), + ), + PoxAddress::Addr20(true, PoxAddressType20::P2WPKH, [0x01; 20]), + PoxAddress::Addr20(false, PoxAddressType20::P2WPKH, [0x01; 20]), + PoxAddress::Addr32(true, PoxAddressType32::P2WSH, [0x01; 32]), + PoxAddress::Addr32(false, PoxAddressType32::P2WSH, [0x01; 32]), + PoxAddress::Addr32(true, PoxAddressType32::P2TR, [0x01; 32]), + PoxAddress::Addr32(false, PoxAddressType32::P2TR, [0x01; 32]), + PoxAddress::Standard( + StacksAddress { + version: C32_ADDRESS_VERSION_MAINNET_MULTISIG, + bytes: Hash160([0x01; 20]), + }, + Some(AddressHashMode::SerializeP2SH), + ), + PoxAddress::Standard( + StacksAddress { + version: C32_ADDRESS_VERSION_MAINNET_SINGLESIG, + bytes: Hash160([0x01; 20]), + }, + Some(AddressHashMode::SerializeP2SH), + ), + PoxAddress::Standard( + StacksAddress { + version: C32_ADDRESS_VERSION_MAINNET_MULTISIG, + bytes: Hash160([0x01; 20]), + }, + Some(AddressHashMode::SerializeP2WSH), + ), + PoxAddress::Standard( + StacksAddress { + version: C32_ADDRESS_VERSION_MAINNET_MULTISIG, + bytes: Hash160([0x01; 20]), + }, + Some(AddressHashMode::SerializeP2WPKH), + ), + ]; + for addr in pox_addrs.iter() { + let addr_str = addr.clone().to_b58(); + let addr_parsed = PoxAddress::from_b58(&addr_str).unwrap(); + let mut addr_checked = addr.clone(); + if let PoxAddress::Standard(_, ref mut hash_mode) = addr_checked { + hash_mode.take(); + } + assert_eq!(&addr_parsed, &addr_checked); + } + } + #[test] fn test_try_from_bitcoin_output() { assert_eq!( diff --git a/src/net/http.rs b/src/net/http.rs index a0a74fefdb..4277ae0401 100644 --- a/src/net/http.rs +++ b/src/net/http.rs @@ -38,7 +38,7 @@ use time; use url::{form_urlencoded, Url}; use crate::burnchains::{Address, Txid}; -use crate::chainstate::burn::ConsensusHash; +use crate::chainstate::burn::{ConsensusHash, Opcodes}; use crate::chainstate::stacks::{ StacksBlock, StacksMicroblock, StacksPublicKey, StacksTransaction, }; @@ -158,6 +158,8 @@ lazy_static! { Regex::new(r#"^/v2/attachments/([0-9a-f]{40})$"#).unwrap(); static ref PATH_POST_MEMPOOL_QUERY: Regex = Regex::new(r#"^/v2/mempool/query$"#).unwrap(); + static ref PATH_GET_BURN_OPS: Regex = + Regex::new(r#"^/v2/burn_ops/(?P[0-9]{1,20})/(?P[a-z_]{1,20})$"#).unwrap(); static ref PATH_OPTIONS_WILDCARD: Regex = Regex::new("^/v2/.{0,4096}$").unwrap(); } @@ -1602,6 +1604,11 @@ impl HttpRequestType { &PATH_POST_MEMPOOL_QUERY, &HttpRequestType::parse_post_mempool_query, ), + ( + "GET", + &PATH_GET_BURN_OPS, + &HttpRequestType::parse_get_burn_ops, + ), ]; // use url::Url to parse path and query string @@ -1987,6 +1994,25 @@ impl HttpRequestType { )) } + fn parse_get_burn_ops( + _protocol: &mut StacksHttp, + preamble: &HttpRequestPreamble, + captures: &Captures, + _query: Option<&str>, + _fd: &mut R, + ) -> Result { + let height = u64::from_str(&captures["height"]) + .map_err(|_| net_error::DeserializeError("Failed to parse u64 height".into()))?; + + let opcode = Opcodes::from_http_str(&captures["op"]).ok_or_else(|| { + net_error::DeserializeError(format!("Unsupported burn operation: {}", &captures["op"])) + })?; + + let md = HttpRequestMetadata::from_preamble(preamble); + + Ok(HttpRequestType::GetBurnOps { md, height, opcode }) + } + fn parse_get_contract_abi( _protocol: &mut StacksHttp, preamble: &HttpRequestPreamble, @@ -2705,6 +2731,7 @@ impl HttpRequestType { HttpRequestType::MemPoolQuery(ref md, ..) => md, HttpRequestType::FeeRateEstimate(ref md, _, _) => md, HttpRequestType::ClientError(ref md, ..) => md, + HttpRequestType::GetBurnOps { ref md, .. } => md, } } @@ -2735,6 +2762,7 @@ impl HttpRequestType { HttpRequestType::GetAttachment(ref mut md, ..) => md, HttpRequestType::MemPoolQuery(ref mut md, ..) => md, HttpRequestType::FeeRateEstimate(ref mut md, _, _) => md, + HttpRequestType::GetBurnOps { ref mut md, .. } => md, HttpRequestType::ClientError(ref mut md, ..) => md, } } @@ -2909,6 +2937,11 @@ impl HttpRequestType { ClientError::NotFound(path) => path.to_string(), _ => "error path unknown".into(), }, + HttpRequestType::GetBurnOps { + height, ref opcode, .. + } => { + format!("/v2/burn_ops/{}/{}", height, opcode.to_http_str()) + } } } @@ -2944,6 +2977,7 @@ impl HttpRequestType { HttpRequestType::GetIsTraitImplemented(..) => "/v2/traits/:principal/:contract_name", HttpRequestType::MemPoolQuery(..) => "/v2/mempool/query", HttpRequestType::FeeRateEstimate(_, _, _) => "/v2/fees/transaction", + HttpRequestType::GetBurnOps { .. } => "/v2/burn_ops/:height/:opname", HttpRequestType::OptionsPreflight(..) | HttpRequestType::ClientError(..) => "/", } } @@ -4021,6 +4055,7 @@ impl HttpResponseType { HttpResponseType::MemPoolTxs(ref md, ..) => md, HttpResponseType::OptionsPreflight(ref md) => md, HttpResponseType::TransactionFeeEstimation(ref md, _) => md, + HttpResponseType::GetBurnchainOps(ref md, _) => md, // errors HttpResponseType::BadRequestJSON(ref md, _) => md, HttpResponseType::BadRequest(ref md, _) => md, @@ -4273,6 +4308,10 @@ impl HttpResponseType { HttpResponsePreamble::ok_JSON_from_md(fd, md)?; HttpResponseType::send_json(protocol, md, fd, unconfirmed_status)?; } + HttpResponseType::GetBurnchainOps(ref md, ref ops) => { + HttpResponsePreamble::ok_JSON_from_md(fd, md)?; + HttpResponseType::send_json(protocol, md, fd, ops)?; + } HttpResponseType::MemPoolTxStream(ref md) => { // only send the preamble. The caller will need to figure out how to send along // the tx data itself. @@ -4438,6 +4477,7 @@ impl MessageSequence for StacksHttpMessage { HttpRequestType::OptionsPreflight(..) => "HTTP(OptionsPreflight)", HttpRequestType::ClientError(..) => "HTTP(ClientError)", HttpRequestType::FeeRateEstimate(_, _, _) => "HTTP(FeeRateEstimate)", + HttpRequestType::GetBurnOps { .. } => "HTTP(GetBurnOps)", }, StacksHttpMessage::Response(ref res) => match res { HttpResponseType::TokenTransferCost(_, _) => "HTTP(TokenTransferCost)", @@ -4479,6 +4519,7 @@ impl MessageSequence for StacksHttpMessage { HttpResponseType::TransactionFeeEstimation(_, _) => { "HTTP(TransactionFeeEstimation)" } + HttpResponseType::GetBurnchainOps(_, _) => "HTTP(GetBurnchainOps)", }, } } diff --git a/src/net/mod.rs b/src/net/mod.rs index fe337f1c31..a1ecc22895 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -46,7 +46,10 @@ use url; use crate::burnchains::affirmation::AffirmationMap; use crate::burnchains::Error as burnchain_error; use crate::burnchains::Txid; -use crate::chainstate::burn::ConsensusHash; +use crate::chainstate::burn::operations::PegInOp; +use crate::chainstate::burn::operations::PegOutFulfillOp; +use crate::chainstate::burn::operations::PegOutRequestOp; +use crate::chainstate::burn::{ConsensusHash, Opcodes}; use crate::chainstate::coordinator::Error as coordinator_error; use crate::chainstate::stacks::db::blocks::MemPoolRejection; use crate::chainstate::stacks::index::Error as marf_error; @@ -1527,6 +1530,11 @@ pub enum HttpRequestType { TipRequest, ), MemPoolQuery(HttpRequestMetadata, MemPoolSyncData, Option), + GetBurnOps { + md: HttpRequestMetadata, + height: u64, + opcode: Opcodes, + }, /// catch-all for any errors we should surface from parsing ClientError(HttpRequestMetadata, ClientError), } @@ -1653,6 +1661,7 @@ pub enum HttpResponseType { NotFound(HttpResponseMetadata, String), ServerError(HttpResponseMetadata, String), ServiceUnavailable(HttpResponseMetadata, String), + GetBurnchainOps(HttpResponseMetadata, BurnchainOps), Error(HttpResponseMetadata, u16, String), } @@ -1688,6 +1697,18 @@ pub enum StacksMessageID { Reserved = 255, } +/// This enum wraps Vecs of a single kind of `BlockstackOperationType`. +/// This allows `handle_get_burn_ops` to use an enum for the different operation +/// types without having to buffer and re-structure a `Vec` +/// from a, e.g., `Vec` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum BurnchainOps { + PegIn(Vec), + PegOutRequest(Vec), + PegOutFulfill(Vec), +} + /// Message type for all P2P Stacks network messages #[derive(Debug, Clone, PartialEq)] pub struct StacksMessage { @@ -2185,7 +2206,10 @@ pub mod test { BlockstackOperationType::TransferStx(_) | BlockstackOperationType::DelegateStx(_) | BlockstackOperationType::PreStx(_) - | BlockstackOperationType::StackStx(_) => Ok(()), + | BlockstackOperationType::StackStx(_) + | BlockstackOperationType::PegIn(_) + | BlockstackOperationType::PegOutRequest(_) + | BlockstackOperationType::PegOutFulfill(_) => Ok(()), } } diff --git a/src/net/rpc.rs b/src/net/rpc.rs index 24a2136625..7b72b369ae 100644 --- a/src/net/rpc.rs +++ b/src/net/rpc.rs @@ -37,6 +37,7 @@ use crate::burnchains::BurnchainView; use crate::burnchains::*; use crate::chainstate::burn::db::sortdb::SortitionDB; use crate::chainstate::burn::ConsensusHash; +use crate::chainstate::burn::Opcodes; use crate::chainstate::stacks::db::blocks::CheckError; use crate::chainstate::stacks::db::{ blocks::MINIMUM_TX_FEE_RATE_PER_BYTE, StacksChainState, StreamCursor, @@ -60,6 +61,7 @@ use crate::net::p2p::PeerMap; use crate::net::p2p::PeerNetwork; use crate::net::relay::Relayer; use crate::net::BlocksDatum; +use crate::net::BurnchainOps; use crate::net::Error as net_error; use crate::net::HttpRequestMetadata; use crate::net::HttpRequestType; @@ -762,6 +764,86 @@ impl ConversationHttp { response.send(http, fd) } + fn response_get_burn_ops( + req: &HttpRequestType, + sortdb: &SortitionDB, + burn_block_height: u64, + op_type: &Opcodes, + ) -> Result { + let response_metadata = HttpResponseMetadata::from_http_request_type(req, None); + let handle = sortdb.index_handle_at_tip(); + let burn_header_hash = match handle.get_block_snapshot_by_height(burn_block_height) { + Ok(Some(snapshot)) => snapshot.burn_header_hash, + _ => { + return Ok(HttpResponseType::NotFound( + response_metadata, + format!("Could not find burn block at height {}", burn_block_height), + )); + } + }; + + let response = match op_type { + Opcodes::PegIn => { + SortitionDB::get_peg_in_ops(sortdb.conn(), &burn_header_hash).map(|ops| { + HttpResponseType::GetBurnchainOps( + response_metadata.clone(), + BurnchainOps::PegIn(ops), + ) + }) + } + Opcodes::PegOutRequest => { + SortitionDB::get_peg_out_request_ops(sortdb.conn(), &burn_header_hash).map(|ops| { + HttpResponseType::GetBurnchainOps( + response_metadata.clone(), + BurnchainOps::PegOutRequest(ops), + ) + }) + } + Opcodes::PegOutFulfill => { + SortitionDB::get_peg_out_fulfill_ops(sortdb.conn(), &burn_header_hash).map(|ops| { + HttpResponseType::GetBurnchainOps( + response_metadata.clone(), + BurnchainOps::PegOutFulfill(ops), + ) + }) + } + _ => { + return Ok(HttpResponseType::NotFound( + response_metadata, + format!( + "Burnchain operation {:?} is not supported by this endpoint", + op_type + ), + )); + } + }; + + response.or_else(|e| { + Ok(HttpResponseType::NotFound( + response_metadata, + format!( + "Failure fetching {:?} operations from the sortition db: {}", + op_type, e + ), + )) + }) + } + + /// Handle a GET for the burnchain operations at a particular + /// burn block height + fn handle_get_burn_ops( + http: &mut StacksHttp, + fd: &mut W, + req: &HttpRequestType, + sortdb: &SortitionDB, + burn_block_height: u64, + op_type: &Opcodes, + ) -> Result<(), net_error> { + let response = Self::response_get_burn_ops(req, sortdb, burn_block_height, op_type)?; + + response.send(http, fd) + } + /// Handle a GET pox info. /// The response will be synchronously written to the given fd (so use a fd that can buffer!) fn handle_getpoxinfo( @@ -2964,6 +3046,19 @@ impl ConversationHttp { .map(|_| ())?; None } + HttpRequestType::GetBurnOps { + height, ref opcode, .. + } => { + Self::handle_get_burn_ops( + &mut self.connection.protocol, + &mut reply, + &req, + sortdb, + height, + opcode, + )?; + None + } }; match stream_opt { diff --git a/testnet/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/testnet/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index 8b23d48c1f..fb05f58760 100644 --- a/testnet/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/testnet/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -39,8 +39,8 @@ use stacks::burnchains::{ use stacks::burnchains::{Burnchain, BurnchainParameters}; use stacks::chainstate::burn::db::sortdb::SortitionDB; use stacks::chainstate::burn::operations::{ - BlockstackOperationType, DelegateStxOp, LeaderBlockCommitOp, LeaderKeyRegisterOp, PreStxOp, - TransferStxOp, UserBurnSupportOp, + BlockstackOperationType, DelegateStxOp, LeaderBlockCommitOp, LeaderKeyRegisterOp, PegInOp, + PegOutFulfillOp, PegOutRequestOp, PreStxOp, TransferStxOp, UserBurnSupportOp, }; use stacks::chainstate::coordinator::comm::CoordinatorChannels; #[cfg(test)] @@ -50,12 +50,14 @@ use stacks::core::{StacksEpoch, StacksEpochId}; use stacks::util::hash::{hex_bytes, Hash160}; use stacks::util::secp256k1::Secp256k1PublicKey; use stacks::util::sleep_ms; +use stacks::vm::types::PrincipalData; use stacks_common::deps_common::bitcoin::blockdata::opcodes; use stacks_common::deps_common::bitcoin::blockdata::script::{Builder, Script}; use stacks_common::deps_common::bitcoin::blockdata::transaction::{ OutPoint, Transaction, TxIn, TxOut, }; use stacks_common::deps_common::bitcoin::network::encodable::ConsensusEncodable; +use stacks_common::types::chainstate::StacksAddress; #[cfg(test)] use stacks_common::deps_common::bitcoin::network::serialize::deserialize as btc_deserialize; @@ -889,7 +891,9 @@ impl BitcoinRegtestController { | BlockstackOperationType::LeaderKeyRegister(_) | BlockstackOperationType::StackStx(_) | BlockstackOperationType::DelegateStx(_) - | BlockstackOperationType::UserBurnSupport(_) => { + | BlockstackOperationType::UserBurnSupport(_) + | BlockstackOperationType::PegOutRequest(_) + | BlockstackOperationType::PegIn(_) => { unimplemented!(); } BlockstackOperationType::PreStx(payload) => { @@ -898,6 +902,9 @@ impl BitcoinRegtestController { BlockstackOperationType::TransferStx(payload) => { self.build_transfer_stacks_tx(epoch_id, payload, op_signer, utxo) } + BlockstackOperationType::PegOutFulfill(payload) => { + self.build_peg_out_fulfill_tx(epoch_id, payload, op_signer, utxo) + } }?; let ser_transaction = SerializedTx::new(transaction.clone()); @@ -1139,6 +1146,227 @@ impl BitcoinRegtestController { Some(tx) } + #[cfg(not(test))] + fn build_peg_in_tx( + &mut self, + _epoch_id: StacksEpochId, + _payload: PegInOp, + _signer: &mut BurnchainOpSigner, + ) -> Option { + unimplemented!() + } + + #[cfg(test)] + fn build_peg_in_tx( + &mut self, + epoch_id: StacksEpochId, + payload: PegInOp, + signer: &mut BurnchainOpSigner, + ) -> Option { + let public_key = signer.get_public_key(); + let max_tx_size = 230; + + let output_amt = DUST_UTXO_LIMIT + + max_tx_size * self.config.burnchain.satoshis_per_byte + + payload.amount; + let (mut tx, mut utxos) = + self.prepare_tx(epoch_id, &public_key, output_amt, None, None, 0)?; + + let op_bytes = { + let mut bytes = self.config.burnchain.magic_bytes.as_bytes().to_vec(); + bytes.push(Opcodes::PegIn as u8); + let (recipient_address, contract_name): (StacksAddress, String) = + match payload.recipient { + PrincipalData::Standard(standard_principal) => { + (standard_principal.into(), String::new()) + } + PrincipalData::Contract(contract_identifier) => ( + contract_identifier.issuer.into(), + contract_identifier.name.into(), + ), + }; + bytes.push(recipient_address.version); + bytes.extend_from_slice(recipient_address.bytes.as_bytes()); + bytes.extend_from_slice(contract_name.as_bytes()); + bytes + }; + + let peg_in_address_output = TxOut { + value: 0, + script_pubkey: Builder::new() + .push_opcode(opcodes::All::OP_RETURN) + .push_slice(&op_bytes) + .into_script(), + }; + + tx.output = vec![peg_in_address_output]; + tx.output + .push(payload.peg_wallet_address.to_bitcoin_tx_out(payload.amount)); + + self.finalize_tx( + epoch_id, + &mut tx, + payload.amount, + 0, + max_tx_size, + self.config.burnchain.satoshis_per_byte, + &mut utxos, + signer, + )?; + + increment_btc_ops_sent_counter(); + + info!("Miner node: submitting peg-in op - {}", public_key.to_hex()); + + Some(tx) + } + + #[cfg(not(test))] + fn build_peg_out_request_tx( + &mut self, + _epoch_id: StacksEpochId, + _payload: PegOutRequestOp, + _signer: &mut BurnchainOpSigner, + ) -> Option { + unimplemented!() + } + + #[cfg(test)] + fn build_peg_out_request_tx( + &mut self, + epoch_id: StacksEpochId, + payload: PegOutRequestOp, + signer: &mut BurnchainOpSigner, + ) -> Option { + let public_key = signer.get_public_key(); + let max_tx_size = 230; + let dust_amount = 10000; + + let output_amt = DUST_UTXO_LIMIT + + dust_amount + + max_tx_size * self.config.burnchain.satoshis_per_byte + + payload.fulfillment_fee; + let (mut tx, mut utxos) = + self.prepare_tx(epoch_id, &public_key, output_amt, None, None, 0)?; + + let op_bytes = { + let mut bytes = self.config.burnchain.magic_bytes.as_bytes().to_vec(); + bytes.push(Opcodes::PegOutRequest as u8); + bytes.extend_from_slice(&payload.amount.to_be_bytes()); + bytes.extend_from_slice(payload.signature.as_bytes()); + bytes + }; + + let amount_and_signature_output = TxOut { + value: 0, + script_pubkey: Builder::new() + .push_opcode(opcodes::All::OP_RETURN) + .push_slice(&op_bytes) + .into_script(), + }; + + tx.output = vec![amount_and_signature_output]; + tx.output + .push(payload.recipient.to_bitcoin_tx_out(dust_amount)); + tx.output.push( + payload + .peg_wallet_address + .to_bitcoin_tx_out(payload.fulfillment_fee), + ); + + self.finalize_tx( + epoch_id, + &mut tx, + payload.fulfillment_fee, + 0, + max_tx_size, + self.config.burnchain.satoshis_per_byte, + &mut utxos, + signer, + )?; + + increment_btc_ops_sent_counter(); + + info!( + "Miner node: submitting peg-out request op - {}", + public_key.to_hex() + ); + + Some(tx) + } + + #[cfg(not(test))] + fn build_peg_out_fulfill_tx( + &mut self, + _epoch_id: StacksEpochId, + _payload: PegOutFulfillOp, + _signer: &mut BurnchainOpSigner, + _utxo_to_use: Option, + ) -> Option { + unimplemented!() + } + + #[cfg(test)] + fn build_peg_out_fulfill_tx( + &mut self, + epoch_id: StacksEpochId, + payload: PegOutFulfillOp, + signer: &mut BurnchainOpSigner, + utxo_to_use: Option, + ) -> Option { + let public_key = signer.get_public_key(); + let max_tx_size = 230; + + let output_amt = DUST_UTXO_LIMIT + + max_tx_size * self.config.burnchain.satoshis_per_byte + + payload.amount; + let (mut tx, mut utxos) = + self.prepare_tx(epoch_id, &public_key, output_amt, None, None, 0)?; + + if let Some(utxo) = utxo_to_use { + utxos.utxos.insert(0, utxo); + } + + let op_bytes = { + let mut bytes = self.config.burnchain.magic_bytes.as_bytes().to_vec(); + bytes.push(Opcodes::PegOutFulfill as u8); + bytes.extend_from_slice(payload.chain_tip.as_bytes()); + bytes + }; + + let block_header_output = TxOut { + value: 0, + script_pubkey: Builder::new() + .push_opcode(opcodes::All::OP_RETURN) + .push_slice(&op_bytes) + .into_script(), + }; + + tx.output = vec![block_header_output]; + tx.output + .push(payload.recipient.to_bitcoin_tx_out(payload.amount)); + + self.finalize_tx( + epoch_id, + &mut tx, + payload.amount, + 0, + max_tx_size, + self.config.burnchain.satoshis_per_byte, + &mut utxos, + signer, + )?; + + increment_btc_ops_sent_counter(); + + info!( + "Miner node: submitting peg-out fulfill op - {}", + public_key.to_hex() + ); + + Some(tx) + } + fn send_block_commit_operation( &mut self, epoch_id: StacksEpochId, @@ -1811,6 +2039,15 @@ impl BitcoinRegtestController { BlockstackOperationType::TransferStx(payload) => { self.build_transfer_stacks_tx(epoch_id, payload, op_signer, None) } + BlockstackOperationType::PegIn(payload) => { + self.build_peg_in_tx(epoch_id, payload, op_signer) + } + BlockstackOperationType::PegOutRequest(payload) => { + self.build_peg_out_request_tx(epoch_id, payload, op_signer) + } + BlockstackOperationType::PegOutFulfill(payload) => { + self.build_peg_out_fulfill_tx(epoch_id, payload, op_signer, None) + } BlockstackOperationType::StackStx(_payload) => unimplemented!(), BlockstackOperationType::DelegateStx(payload) => { self.build_delegate_stacks_tx(epoch_id, payload, op_signer, None) diff --git a/testnet/stacks-node/src/burnchains/mocknet_controller.rs b/testnet/stacks-node/src/burnchains/mocknet_controller.rs index 8b83d28357..997ca8d65b 100644 --- a/testnet/stacks-node/src/burnchains/mocknet_controller.rs +++ b/testnet/stacks-node/src/burnchains/mocknet_controller.rs @@ -9,7 +9,8 @@ use stacks::chainstate::burn::db::sortdb::{SortitionDB, SortitionHandleTx}; use stacks::chainstate::burn::operations::DelegateStxOp; use stacks::chainstate::burn::operations::{ leader_block_commit::BURN_BLOCK_MINED_AT_MODULUS, BlockstackOperationType, LeaderBlockCommitOp, - LeaderKeyRegisterOp, PreStxOp, StackStxOp, TransferStxOp, UserBurnSupportOp, + LeaderKeyRegisterOp, PegInOp, PegOutFulfillOp, PegOutRequestOp, PreStxOp, StackStxOp, + TransferStxOp, UserBurnSupportOp, }; use stacks::chainstate::burn::BlockSnapshot; use stacks::core::{ @@ -264,6 +265,27 @@ impl BurnchainController for MocknetController { ..payload }) } + BlockstackOperationType::PegIn(payload) => { + BlockstackOperationType::PegIn(PegInOp { + block_height: next_block_header.block_height, + burn_header_hash: next_block_header.block_hash, + ..payload + }) + } + BlockstackOperationType::PegOutRequest(payload) => { + BlockstackOperationType::PegOutRequest(PegOutRequestOp { + block_height: next_block_header.block_height, + burn_header_hash: next_block_header.block_hash, + ..payload + }) + } + BlockstackOperationType::PegOutFulfill(payload) => { + BlockstackOperationType::PegOutFulfill(PegOutFulfillOp { + block_height: next_block_header.block_height, + burn_header_hash: next_block_header.block_hash, + ..payload + }) + } }; ops.push(op); } diff --git a/testnet/stacks-node/src/tests/neon_integrations.rs b/testnet/stacks-node/src/tests/neon_integrations.rs index a197523af6..2de66bb2d5 100644 --- a/testnet/stacks-node/src/tests/neon_integrations.rs +++ b/testnet/stacks-node/src/tests/neon_integrations.rs @@ -13,13 +13,16 @@ use std::{env, thread}; use rusqlite::types::ToSql; +use stacks::address::C32_ADDRESS_VERSION_TESTNET_SINGLESIG; use stacks::burnchains::bitcoin::address::{BitcoinAddress, LegacyBitcoinAddressType}; use stacks::burnchains::bitcoin::BitcoinNetworkType; use stacks::burnchains::Txid; use stacks::chainstate::burn::operations::{ - BlockstackOperationType, DelegateStxOp, PreStxOp, TransferStxOp, + BlockstackOperationType, DelegateStxOp, PegInOp, PegOutFulfillOp, PegOutRequestOp, PreStxOp, + TransferStxOp, }; use stacks::chainstate::coordinator::comm::CoordinatorChannels; +use stacks::chainstate::stacks::address; use stacks::clarity_cli::vm_execute as execute; use stacks::codec::StacksMessageCodec; use stacks::core; @@ -29,6 +32,7 @@ use stacks::core::{ PEER_VERSION_EPOCH_2_0, PEER_VERSION_EPOCH_2_05, PEER_VERSION_EPOCH_2_1, }; use stacks::net::atlas::{AtlasConfig, AtlasDB, MAX_ATTACHMENT_INV_PAGES_PER_REQUEST}; +use stacks::net::BurnchainOps; use stacks::net::{ AccountEntryResponse, ContractSrcResponse, GetAttachmentResponse, GetAttachmentsInvResponse, PostTransactionRequestBody, RPCPeerInfoData, StacksBlockAcceptedData, @@ -43,6 +47,8 @@ use stacks::util::secp256k1::Secp256k1PublicKey; use stacks::util::{get_epoch_time_ms, get_epoch_time_secs, sleep_ms}; use stacks::util_lib::boot::boot_code_id; use stacks::vm::types::PrincipalData; +use stacks::vm::types::QualifiedContractIdentifier; +use stacks::vm::types::StandardPrincipalData; use stacks::vm::ClarityVersion; use stacks::vm::Value; use stacks::{ @@ -735,6 +741,18 @@ fn get_tip_anchored_block(conf: &Config) -> (ConsensusHash, StacksBlock) { (stacks_tip_consensus_hash, block) } +fn get_peg_in_ops(conf: &Config, height: u64) -> BurnchainOps { + let http_origin = format!("http://{}", &conf.node.rpc_bind); + let path = format!("{}/v2/burn_ops/{}/peg_in", &http_origin, height); + let client = reqwest::blocking::Client::new(); + + let response: serde_json::Value = client.get(&path).send().unwrap().json().unwrap(); + + eprintln!("{}", response); + + serde_json::from_value(response).unwrap() +} + fn find_microblock_privkey( conf: &Config, pubkey_hash: &Hash160, @@ -10730,3 +10748,435 @@ fn microblock_miner_multiple_attempts() { channel.stop_chains_coordinator(); } + +#[test] +#[ignore] +fn test_submit_and_observe_sbtc_ops() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let recipient_stx_addr = + StacksAddress::new(C32_ADDRESS_VERSION_TESTNET_SINGLESIG, Hash160([0; 20])); + let receiver_contract_name = ContractName::from("awesome_contract"); + let receiver_contract_principal: PrincipalData = + QualifiedContractIdentifier::new(recipient_stx_addr.into(), receiver_contract_name).into(); + let receiver_standard_principal: PrincipalData = + StandardPrincipalData::from(recipient_stx_addr).into(); + + let peg_wallet_sk = StacksPrivateKey::from_hex(SK_1).unwrap(); + let peg_wallet_address = address::PoxAddress::Standard(to_addr(&peg_wallet_sk), None); + + let recipient_btc_addr = address::PoxAddress::Standard(recipient_stx_addr, None); + + let (mut conf, _) = neon_integration_test_conf(); + + let epoch_2_05 = 210; + let epoch_2_1 = 215; + + let mut epochs = core::STACKS_EPOCHS_REGTEST.to_vec(); + epochs[1].end_height = epoch_2_05; + epochs[2].start_height = epoch_2_05; + epochs[2].end_height = epoch_2_1; + epochs[3].start_height = epoch_2_1; + + conf.node.mine_microblocks = false; + conf.burnchain.max_rbf = 1000000; + conf.miner.first_attempt_time_ms = 5_000; + conf.miner.subsequent_attempt_time_ms = 10_000; + conf.miner.segwit = false; + conf.node.wait_time_for_blocks = 0; + + conf.burnchain.epochs = Some(epochs); + + let mut btcd_controller = BitcoinCoreController::new(conf.clone()); + let mut run_loop = neon::RunLoop::new(conf.clone()); + + btcd_controller + .start_bitcoind() + .ok() + .expect("Failed starting bitcoind"); + + let mut btc_regtest_controller = BitcoinRegtestController::new(conf.clone(), None); + btc_regtest_controller.bootstrap_chain(216); + + let blocks_processed = run_loop.get_blocks_processed_arc(); + let run_loop_coordinator_channel = run_loop.get_coordinator_channel().unwrap(); + + thread::spawn(move || run_loop.start(None, 0)); + + // give the run loop some time to start up! + wait_for_runloop(&blocks_processed); + + // first block wakes up the run loop + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + + // first block will hold our VRF registration + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + + // second block will be the first mined Stacks block + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + + // Let's send some sBTC ops. + let peg_in_op_standard = PegInOp { + recipient: receiver_standard_principal, + peg_wallet_address: peg_wallet_address.clone(), + amount: 133700, + memo: Vec::new(), + // filled in later + txid: Txid([0u8; 32]), + vtxindex: 0, + block_height: 0, + burn_header_hash: BurnchainHeaderHash([0u8; 32]), + }; + + let peg_in_op_contract = PegInOp { + recipient: receiver_contract_principal, + peg_wallet_address: peg_wallet_address.clone(), + amount: 133700, + memo: Vec::new(), + // filled in later + txid: Txid([1u8; 32]), + vtxindex: 0, + block_height: 0, + burn_header_hash: BurnchainHeaderHash([0u8; 32]), + }; + + let peg_out_request_op = PegOutRequestOp { + recipient: recipient_btc_addr.clone(), + signature: MessageSignature([0; 65]), + amount: 133700, + peg_wallet_address, + fulfillment_fee: 1_000_000, + memo: Vec::new(), + // filled in later + txid: Txid([2u8; 32]), + vtxindex: 0, + block_height: 0, + burn_header_hash: BurnchainHeaderHash([0u8; 32]), + }; + + let peg_out_fulfill_op = PegOutFulfillOp { + chain_tip: StacksBlockId([0; 32]), + recipient: recipient_btc_addr, + amount: 133700, + request_ref: Txid([2u8; 32]), + memo: Vec::new(), + // filled in later + txid: Txid([3u8; 32]), + vtxindex: 0, + block_height: 0, + burn_header_hash: BurnchainHeaderHash([0u8; 32]), + }; + + let mut miner_signer = Keychain::default(conf.node.seed.clone()).generate_op_signer(); + assert!( + btc_regtest_controller + .submit_operation( + StacksEpochId::Epoch21, + BlockstackOperationType::PegIn(peg_in_op_standard.clone()), + &mut miner_signer, + 1 + ) + .is_some(), + "Peg-in operation should submit successfully" + ); + + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + + let parsed_peg_in_op_standard = { + let sortdb = btc_regtest_controller.sortdb_mut(); + let tip = SortitionDB::get_canonical_burn_chain_tip(&sortdb.conn()).unwrap(); + let mut ops = SortitionDB::get_peg_in_ops(&sortdb.conn(), &tip.burn_header_hash) + .expect("Failed to get peg in ops"); + assert_eq!(ops.len(), 1); + + ops.pop().unwrap() + }; + + let mut miner_signer = Keychain::default(conf.node.seed.clone()).generate_op_signer(); + assert!( + btc_regtest_controller + .submit_operation( + StacksEpochId::Epoch21, + BlockstackOperationType::PegIn(peg_in_op_contract.clone()), + &mut miner_signer, + 1 + ) + .is_some(), + "Peg-in operation should submit successfully" + ); + + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + + let parsed_peg_in_op_contract = { + let sortdb = btc_regtest_controller.sortdb_mut(); + let tip = SortitionDB::get_canonical_burn_chain_tip(&sortdb.conn()).unwrap(); + let mut ops = SortitionDB::get_peg_in_ops(&sortdb.conn(), &tip.burn_header_hash) + .expect("Failed to get peg in ops"); + assert_eq!(ops.len(), 1); + + ops.pop().unwrap() + }; + + let mut miner_signer = Keychain::default(conf.node.seed.clone()).generate_op_signer(); + + let peg_out_request_txid = btc_regtest_controller + .submit_operation( + StacksEpochId::Epoch21, + BlockstackOperationType::PegOutRequest(peg_out_request_op.clone()), + &mut miner_signer, + 1, + ) + .expect("Peg-out request operation should submit successfully"); + + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + + let parsed_peg_out_request_op = { + let sortdb = btc_regtest_controller.sortdb_mut(); + let tip = SortitionDB::get_canonical_burn_chain_tip(&sortdb.conn()).unwrap(); + let mut ops = SortitionDB::get_peg_out_request_ops(&sortdb.conn(), &tip.burn_header_hash) + .expect("Failed to get peg out request ops"); + assert_eq!(ops.len(), 1); + + ops.pop().unwrap() + }; + + let peg_out_request_tx = btc_regtest_controller.get_raw_transaction(&peg_out_request_txid); + + // synthesize the UTXO for this txout, which will be consumed by the peg-out fulfillment tx + let peg_out_request_utxo = UTXO { + txid: peg_out_request_tx.txid(), + vout: 2, + script_pub_key: peg_out_request_tx.output[2].script_pubkey.clone(), + amount: peg_out_request_tx.output[2].value, + confirmations: 0, + }; + + let mut peg_wallet_signer = BurnchainOpSigner::new(peg_wallet_sk.clone(), false); + + assert!( + btc_regtest_controller + .submit_manual( + StacksEpochId::Epoch21, + BlockstackOperationType::PegOutFulfill(peg_out_fulfill_op.clone()), + &mut peg_wallet_signer, + Some(peg_out_request_utxo), + ) + .is_some(), + "Peg-out fulfill operation should submit successfully" + ); + + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + + let parsed_peg_out_fulfill_op = { + let sortdb = btc_regtest_controller.sortdb_mut(); + let tip = SortitionDB::get_canonical_burn_chain_tip(&sortdb.conn()).unwrap(); + let mut ops = SortitionDB::get_peg_out_fulfill_ops(&sortdb.conn(), &tip.burn_header_hash) + .expect("Failed to get peg out fulfill ops"); + assert_eq!(ops.len(), 1); + + ops.pop().unwrap() + }; + + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + + assert_eq!( + parsed_peg_in_op_standard.recipient, + peg_in_op_standard.recipient + ); + assert_eq!(parsed_peg_in_op_standard.amount, peg_in_op_standard.amount); + assert_eq!( + parsed_peg_in_op_standard.peg_wallet_address, + peg_in_op_standard.peg_wallet_address + ); + + assert_eq!( + parsed_peg_in_op_contract.recipient, + peg_in_op_contract.recipient + ); + assert_eq!(parsed_peg_in_op_contract.amount, peg_in_op_contract.amount); + assert_eq!( + parsed_peg_in_op_contract.peg_wallet_address, + peg_in_op_contract.peg_wallet_address + ); + + // now test that the responses from the RPC endpoint match the data + // from the DB + + let query_height_op_contract = parsed_peg_in_op_contract.block_height; + let parsed_resp = get_peg_in_ops(&conf, query_height_op_contract); + + let parsed_peg_in_op_contract = match parsed_resp { + BurnchainOps::PegIn(mut vec) => { + assert_eq!(vec.len(), 1); + vec.pop().unwrap() + } + _ => panic!("Unexpected op"), + }; + + let query_height_op_standard = parsed_peg_in_op_standard.block_height; + let parsed_resp = get_peg_in_ops(&conf, query_height_op_standard); + + let parsed_peg_in_op_standard = match parsed_resp { + BurnchainOps::PegIn(mut vec) => { + assert_eq!(vec.len(), 1); + vec.pop().unwrap() + } + _ => panic!("Unexpected op"), + }; + + assert_eq!( + parsed_peg_in_op_standard.recipient, + peg_in_op_standard.recipient + ); + assert_eq!(parsed_peg_in_op_standard.amount, peg_in_op_standard.amount); + assert_eq!( + parsed_peg_in_op_standard.peg_wallet_address, + peg_in_op_standard.peg_wallet_address + ); + + assert_eq!( + parsed_peg_in_op_contract.recipient, + peg_in_op_contract.recipient + ); + assert_eq!(parsed_peg_in_op_contract.amount, peg_in_op_contract.amount); + assert_eq!( + parsed_peg_in_op_contract.peg_wallet_address, + peg_in_op_contract.peg_wallet_address + ); + + assert_eq!( + parsed_peg_out_request_op.recipient, + peg_out_request_op.recipient + ); + assert_eq!(parsed_peg_out_request_op.amount, peg_out_request_op.amount); + assert_eq!( + parsed_peg_out_request_op.signature, + peg_out_request_op.signature + ); + assert_eq!( + parsed_peg_out_request_op.peg_wallet_address, + peg_out_request_op.peg_wallet_address + ); + assert_eq!( + parsed_peg_out_request_op.fulfillment_fee, + peg_out_request_op.fulfillment_fee + ); + + assert_eq!( + parsed_peg_out_fulfill_op.recipient, + peg_out_fulfill_op.recipient + ); + assert_eq!(parsed_peg_out_fulfill_op.amount, peg_out_fulfill_op.amount); + assert_eq!( + parsed_peg_out_fulfill_op.chain_tip, + peg_out_fulfill_op.chain_tip + ); + + let http_origin = format!("http://{}", &conf.node.rpc_bind); + let get_path = + |op, block_height| format!("{}/v2/burn_ops/{}/{}", &http_origin, block_height, op); + let client = reqwest::blocking::Client::new(); + + // Test peg in + let response: serde_json::Value = client + .get(&get_path("peg_in", parsed_peg_in_op_standard.block_height)) + .send() + .unwrap() + .json() + .unwrap(); + eprintln!("{}", response); + + let parsed_resp: BurnchainOps = serde_json::from_value(response).unwrap(); + + let parsed_peg_in_op = match parsed_resp { + BurnchainOps::PegIn(mut vec) => { + assert_eq!(vec.len(), 1); + vec.pop().unwrap() + } + _ => panic!("Op not peg_in"), + }; + + // Test peg out request + let response: serde_json::Value = client + .get(&get_path( + "peg_out_request", + parsed_peg_out_request_op.block_height, + )) + .send() + .unwrap() + .json() + .unwrap(); + eprintln!("{}", response); + + let parsed_resp: BurnchainOps = serde_json::from_value(response).unwrap(); + + let parsed_peg_out_request_op = match parsed_resp { + BurnchainOps::PegOutRequest(mut vec) => { + assert_eq!(vec.len(), 1); + vec.pop().unwrap() + } + _ => panic!("Op not peg_out_request"), + }; + + // Test peg out fulfill + let response: serde_json::Value = client + .get(&get_path( + "peg_out_fulfill", + parsed_peg_out_fulfill_op.block_height, + )) + .send() + .unwrap() + .json() + .unwrap(); + eprintln!("{}", response); + + let parsed_resp: BurnchainOps = serde_json::from_value(response).unwrap(); + + let parsed_peg_out_fulfill_op = match parsed_resp { + BurnchainOps::PegOutFulfill(mut vec) => { + assert_eq!(vec.len(), 1); + vec.pop().unwrap() + } + _ => panic!("Op not peg_out_fulfill"), + }; + + assert_eq!(parsed_peg_in_op.recipient, peg_in_op_standard.recipient); + assert_eq!(parsed_peg_in_op.amount, peg_in_op_standard.amount); + assert_eq!( + parsed_peg_in_op.peg_wallet_address, + peg_in_op_standard.peg_wallet_address + ); + + assert_eq!( + parsed_peg_out_request_op.recipient, + peg_out_request_op.recipient + ); + assert_eq!(parsed_peg_out_request_op.amount, peg_out_request_op.amount); + assert_eq!( + parsed_peg_out_request_op.signature, + peg_out_request_op.signature + ); + assert_eq!( + parsed_peg_out_request_op.peg_wallet_address, + peg_out_request_op.peg_wallet_address + ); + assert_eq!( + parsed_peg_out_request_op.fulfillment_fee, + peg_out_request_op.fulfillment_fee + ); + + assert_eq!( + parsed_peg_out_fulfill_op.recipient, + peg_out_fulfill_op.recipient + ); + assert_eq!(parsed_peg_out_fulfill_op.amount, peg_out_fulfill_op.amount); + assert_eq!( + parsed_peg_out_fulfill_op.chain_tip, + peg_out_fulfill_op.chain_tip + ); + + run_loop_coordinator_channel.stop_chains_coordinator(); +}