diff --git a/Cargo.lock b/Cargo.lock index 5991cf11..b90dd1d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2500,6 +2500,7 @@ dependencies = [ "tezos-smart-rollup", "tezos-smart-rollup-host", "tezos-smart-rollup-mock", + "tezos_crypto_rs 0.6.0", "tezos_data_encoding 0.6.0", "tokio", ] @@ -2606,6 +2607,7 @@ dependencies = [ "tezos-smart-rollup", "tezos-smart-rollup-mock", "tezos_crypto_rs 0.6.0", + "tezos_data_encoding 0.6.0", ] [[package]] diff --git a/crates/jstz_core/Cargo.toml b/crates/jstz_core/Cargo.toml index 4ce57f90..d3f3f7b2 100644 --- a/crates/jstz_core/Cargo.toml +++ b/crates/jstz_core/Cargo.toml @@ -19,11 +19,12 @@ derive_more.workspace = true erased-serde.workspace = true getrandom.workspace = true jstz_crypto = { path = "../jstz_crypto" } +nom.workspace = true serde.workspace = true +tezos_crypto_rs.workspace = true +tezos_data_encoding.workspace = true tezos-smart-rollup-host.workspace = true tezos-smart-rollup.workspace = true -tezos_data_encoding.workspace = true -nom.workspace = true [dev-dependencies] anyhow.workspace = true diff --git a/crates/jstz_core/src/kv/outbox.rs b/crates/jstz_core/src/kv/outbox.rs index d1ec6abb..a7eb60fc 100644 --- a/crates/jstz_core/src/kv/outbox.rs +++ b/crates/jstz_core/src/kv/outbox.rs @@ -5,9 +5,11 @@ use tezos_smart_rollup::{ core_unsafe::MAX_OUTPUT_SIZE, michelson::{ticket::FA2_1Ticket, MichelsonContract, MichelsonPair}, outbox::{ - AtomicBatch, OutboxMessageFull, OutboxMessageTransactionBatch, OutboxQueue, + AtomicBatch, OutboxMessageFull, OutboxMessageTransaction, + OutboxMessageTransactionBatch, OutboxQueue, }, prelude::debug_msg, + types::{Contract, Entrypoint}, }; use tezos_data_encoding::{enc::BinWriter, encoding::HasEncoding, nom::NomReader}; @@ -20,15 +22,36 @@ const PERSISTENT_OUTBOX_QUEUE_ROOT: RefPath<'static> = const JSTZ_OUTBOX_QUEUE_META: RefPath<'static> = RefPath::assert_from(b"/outbox/meta"); -type NativeWithdrawalParameters = MichelsonPair; - -type Withdrawal = OutboxMessageTransactionBatch; +type WithdrawalParameters = MichelsonPair; +type Withdrawal = OutboxMessageTransactionBatch; #[derive(Debug, HasEncoding, PartialEq)] pub enum OutboxMessage { Withdrawal(Withdrawal), } +impl OutboxMessage { + pub fn new_withdrawal_message( + receiver: &Contract, + destination: &Contract, + ticket: FA2_1Ticket, + entrypoint: &str, + ) -> Result { + let entrypoint = Entrypoint::try_from(entrypoint.to_string()) + .map_err(|_| OutboxError::InvalidEntrypoint)?; + let parameters = MichelsonPair(MichelsonContract(receiver.clone()), ticket); + let message = OutboxMessage::Withdrawal( + vec![OutboxMessageTransaction { + entrypoint, + parameters, + destination: destination.clone(), + }] + .into(), + ); + Ok(message) + } +} + impl AtomicBatch for OutboxMessage {} impl BinWriter for OutboxMessage { @@ -49,11 +72,7 @@ impl<'a> NomReader<'a> for OutboxMessage { impl From for OutboxMessageFull { fn from(message: OutboxMessage) -> Self { - match message { - OutboxMessage::Withdrawal(_) => { - OutboxMessageFull::AtomicTransactionBatch(message) - } - } + OutboxMessageFull::AtomicTransactionBatch(message) } } @@ -308,6 +327,8 @@ pub enum OutboxError { OutboxMessageSerializationError, OutboxQueueMetaNotFound, OutboxQueueMetaAlreadyExists, + InvalidTicketType, + InvalidEntrypoint, } #[cfg(test)] diff --git a/crates/jstz_mock/src/lib.rs b/crates/jstz_mock/src/lib.rs index 4b4e9fcb..6770d4ce 100644 --- a/crates/jstz_mock/src/lib.rs +++ b/crates/jstz_mock/src/lib.rs @@ -37,6 +37,10 @@ pub fn account2() -> jstz_crypto::public_key_hash::PublicKeyHash { .unwrap() } +pub fn kt1_account1() -> ContractKt1Hash { + ContractKt1Hash::try_from("KT1QgfSE4C1dX9UqrPAXjUaFQ36F9eB4nNkV").unwrap() +} + pub fn ticket_hash1() -> TicketHash { let ticket = UnitTicket::new( Contract::from_b58check("tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx").unwrap(), diff --git a/crates/jstz_proto/Cargo.toml b/crates/jstz_proto/Cargo.toml index ccc8f1db..d32e7768 100644 --- a/crates/jstz_proto/Cargo.toml +++ b/crates/jstz_proto/Cargo.toml @@ -24,6 +24,7 @@ jstz_crypto = { path = "../jstz_crypto" } serde.workspace = true serde_json.workspace = true tezos_crypto_rs.workspace = true +tezos_data_encoding.workspace = true tezos-smart-rollup.workspace = true [dev-dependencies] diff --git a/crates/jstz_proto/src/api/smart_function.rs b/crates/jstz_proto/src/api/smart_function.rs index 5d575d80..f9a56744 100644 --- a/crates/jstz_proto/src/api/smart_function.rs +++ b/crates/jstz_proto/src/api/smart_function.rs @@ -267,8 +267,11 @@ mod test { use serde_json::json; use crate::{ - context::account::{Account, Address, ParsedCode}, - executor::smart_function::{self, register_web_apis}, + context::{ + account::{Account, Address, ParsedCode}, + ticket_table::TicketTable, + }, + executor::smart_function::{self, register_web_apis, Script}, operation::RunFunction, }; @@ -326,7 +329,7 @@ mod test { } #[test] - fn call_system_script_from_smart_function_succeeds() { + fn host_script_withdraw_from_smart_function_succeeds() { let mut mock_host = JstzMockHost::default(); let host = mock_host.rt(); let mut tx = Transaction::default(); @@ -396,4 +399,125 @@ mod test { .expect_err("Expected error"); assert_eq!("EvalError: InsufficientFunds", error.to_string()); } + + #[test] + fn host_script_fa_withdraw_from_smart_function_succeeds() { + let receiver = jstz_mock::account1(); + let source = jstz_mock::account2(); + let ticketer = jstz_mock::kt1_account1(); + let ticketer_string = ticketer.clone(); + let l1_proxy_contract = ticketer.clone(); + + let ticket_id = 1234; + let ticket_content = b"random ticket content".to_vec(); + let json_ticket_content = json!(&ticket_content); + assert_eq!("[114,97,110,100,111,109,32,116,105,99,107,101,116,32,99,111,110,116,101,110,116]", format!("{}", json_ticket_content)); + let ticket = + jstz_mock::parse_ticket(ticketer, 1, (ticket_id, Some(ticket_content))); + let ticket_hash = ticket.hash().unwrap(); + let token_smart_function_intial_ticket_balance = 100; + let withdraw_amount = 90; + let mut jstz_mock_hosh = JstzMockHost::default(); + + let host = jstz_mock_hosh.rt(); + let mut tx = Transaction::default(); + + // 1. Deploy our "token contract" + tx.begin(); + let token_contract_code = format!( + r#" + export default (request) => {{ + const url = new URL(request.url) + if (url.pathname === "/withdraw") {{ + const withdrawRequest = new Request("tezos://jstz/fa-withdraw", {{ + method: "POST", + headers: {{ + "Content-type": "application/json", + }}, + body: JSON.stringify({{ + amount: {withdraw_amount}, + routing_info: {{ + receiver: {{ Tz1: "{receiver}" }}, + proxy_l1_contract: "{l1_proxy_contract}" + }}, + ticket_info: {{ + id: {ticket_id}, + content: {json_ticket_content}, + ticketer: "{ticketer_string}" + }} + }}), + }}); + return SmartFunction.call(withdrawRequest); + }} + else {{ + return Response.error(); + }} + + }} + "#, + ); + let parsed_code = ParsedCode::try_from(token_contract_code.to_string()).unwrap(); + let token_smart_function = + Script::deploy(host, &mut tx, &source, parsed_code, 0).unwrap(); + + // 2. Add its ticket blance + TicketTable::add( + host, + &mut tx, + &token_smart_function, + &ticket_hash, + token_smart_function_intial_ticket_balance, + ) + .unwrap(); + tx.commit(host).unwrap(); + + // 3. Call the smart function + tx.begin(); + let run_function = RunFunction { + uri: format!("tezos://{}/withdraw", &token_smart_function) + .try_into() + .unwrap(), + method: Method::GET, + headers: HeaderMap::new(), + body: None, + gas_limit: 1000, + }; + let fake_op_hash = Blake2b::from(b"fake_op_hash".as_ref()); + smart_function::run::execute( + host, + &mut tx, + &source, + run_function.clone(), + fake_op_hash, + ) + .expect("Fa withdraw expected"); + + tx.commit(host).unwrap(); + + let level = host.run_level(|_| {}); + let outbox = host.outbox_at(level); + + assert_eq!(1, outbox.len()); + tx.begin(); + let balance = + TicketTable::get_balance(host, &mut tx, &token_smart_function, &ticket_hash) + .unwrap(); + assert_eq!(10, balance); + + // Trying a second fa withdraw should fail with insufficient funds + tx.begin(); + let fake_op_hash2 = Blake2b::from(b"fake_op_hash2".as_ref()); + let error = smart_function::run::execute( + host, + &mut tx, + &source, + run_function, + fake_op_hash2, + ) + .expect_err("Expected error"); + assert_eq!( + "EvalError: TicketTableError: InsufficientFunds", + error.to_string() + ); + } } diff --git a/crates/jstz_proto/src/context/ticket_table.rs b/crates/jstz_proto/src/context/ticket_table.rs index 33a62b9e..dc303ce7 100644 --- a/crates/jstz_proto/src/context/ticket_table.rs +++ b/crates/jstz_proto/src/context/ticket_table.rs @@ -46,6 +46,10 @@ impl TicketTable { } } + /// Adds the given `amount` from the ticket balance of `owner` + /// for the ticket `ticket_hash` and returns the account's new balance. + /// Creates the account if it doesn't exist. Fails if the addition causes + /// an overflow. pub fn add( rt: &mut impl Runtime, tx: &mut Transaction, @@ -70,6 +74,9 @@ impl TicketTable { } } + /// Subtracts the given `amount` from the ticket balance of `owner` + /// for the ticket `ticket_hash` and returns the account's new balance. + /// Fails if the account doesn't exist or the account has insufficient funds. pub fn sub( rt: &mut impl Runtime, tx: &mut Transaction, diff --git a/crates/jstz_proto/src/error.rs b/crates/jstz_proto/src/error.rs index bc354f77..66a5d6ac 100644 --- a/crates/jstz_proto/src/error.rs +++ b/crates/jstz_proto/src/error.rs @@ -2,7 +2,10 @@ use boa_engine::{JsError, JsNativeError}; use derive_more::{Display, Error, From}; use tezos_smart_rollup::michelson::ticket::TicketHashError; -use crate::{context::ticket_table, executor::fa_deposit}; +use crate::{ + context::ticket_table, + executor::{fa_deposit, fa_withdraw}, +}; #[derive(Display, Debug, Error, From)] pub enum Error { @@ -23,14 +26,21 @@ pub enum Error { InvalidHttpRequest, InvalidHttpRequestBody, InvalidHttpRequestMethod, + InvalidHeaderValue, + InvalidUri, + InvalidTicketType, TicketTableError { source: ticket_table::TicketTableError, }, FaDepositError { source: fa_deposit::FaDepositError, }, + FaWithdrawError { + source: fa_withdraw::FaWithdrawError, + }, TicketHashError(TicketHashError), TicketAmountTooLarge, + ZeroAmountNotAllowed, } pub type Result = std::result::Result; @@ -80,12 +90,25 @@ impl From for JsError { Error::FaDepositError { source } => JsNativeError::eval() .with_message(format!("FaDepositError: {}", source)) .into(), + Error::FaWithdrawError { source } => JsNativeError::eval() + .with_message(format!("FaWithdrawError: {}", source)) + .into(), Error::TicketHashError(inner) => JsNativeError::eval() .with_message(format!("{}", inner)) .into(), Error::TicketAmountTooLarge => JsNativeError::eval() .with_message("TicketAmountTooLarge") .into(), + Error::InvalidTicketType => JsNativeError::eval() + .with_message("InvalidTicketType") + .into(), + Error::InvalidUri => JsNativeError::eval().with_message("InvalidUri").into(), + Error::InvalidHeaderValue => JsNativeError::eval() + .with_message("InvalidHeaderValue") + .into(), + Error::ZeroAmountNotAllowed => JsNativeError::eval() + .with_message("ZeroAmountNotAllowed") + .into(), } } } diff --git a/crates/jstz_proto/src/executor/fa_withdraw.rs b/crates/jstz_proto/src/executor/fa_withdraw.rs new file mode 100644 index 00000000..cd7873a6 --- /dev/null +++ b/crates/jstz_proto/src/executor/fa_withdraw.rs @@ -0,0 +1,348 @@ +use crate::context::{ + account::{Address, Amount}, + ticket_table::TicketTable, +}; + +use crate::{Error, Result}; +use derive_more::{Display, Error, From}; +use jstz_api::http::body::HttpBody; +use jstz_core::{ + host::HostRuntime, + kv::{outbox::OutboxMessage, Transaction}, +}; +use jstz_crypto::public_key_hash::PublicKeyHash; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tezos_crypto_rs::hash::ContractKt1Hash; +use tezos_smart_rollup::{ + michelson::{ + ticket::{FA2_1Ticket, TicketHash}, + MichelsonBytes, MichelsonOption, MichelsonPair, + }, + types::Contract, +}; + +const WITHDRAW_ENTRYPOINT: &str = "withdraw"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FaWithdraw { + pub amount: Amount, + pub routing_info: RoutingInfo, + pub ticket_info: TicketInfo, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoutingInfo { + pub receiver: Address, + pub proxy_l1_contract: ContractKt1Hash, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TicketInfo { + pub id: u32, + pub content: Option>, + pub ticketer: ContractKt1Hash, +} + +impl TicketInfo { + pub(super) fn to_ticket(&self, amount: Amount) -> Result { + FA2_1Ticket::new( + Contract::Originated(self.ticketer.clone()), + MichelsonPair( + self.id.into(), + MichelsonOption(self.content.clone().map(MichelsonBytes)), + ), + amount, + ) + .map_err(|_| Error::InvalidTicketType)? + .try_into() + } +} + +// Internal wrapper over FA2_1Ticket with the hash field cached. +// Computing the hash requires copying ticket content into a new +// buffer which can be costly for large contents. Exposed to super +// for use in test +pub(super) struct Ticket { + pub value: FA2_1Ticket, + pub hash: TicketHash, +} + +impl TryFrom for Ticket { + type Error = crate::Error; + + fn try_from(value: FA2_1Ticket) -> Result { + let hash = value.hash().map_err(|_| Error::InvalidTicketType)?; + Ok(Self { value, hash }) + } +} + +type OutboxMessageId = String; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FaWithdrawReceiptContent { + pub source: PublicKeyHash, + pub outbox_message_id: OutboxMessageId, +} + +impl FaWithdrawReceiptContent { + pub fn to_http_body(&self) -> HttpBody { + Some(String::as_bytes(&json!(&self).to_string()).to_vec()) + } +} + +#[derive(Display, Debug, Error, From)] +pub enum FaWithdrawError { + InvalidTicketInfo, + ProxySmartFunctionCannotBeSource, +} + +fn create_fa_withdrawal_message( + routing_info: &RoutingInfo, + ticket: FA2_1Ticket, +) -> Result { + let receiver_pkh = routing_info.receiver.to_base58(); + let destination = Contract::Originated(routing_info.proxy_l1_contract.clone()); + let message = OutboxMessage::new_withdrawal_message( + &Contract::try_from(receiver_pkh).unwrap(), + &destination, + ticket, + WITHDRAW_ENTRYPOINT, + )?; + Ok(message) +} + +// Deducts `amount` from the ticket balance of `ticket_owner` for `ticket.hash` +// and pushes a withdraw outbox message to the outbox queue, returning the outbox +// message id. +fn withdraw_from_ticket_owner( + rt: &mut impl HostRuntime, + tx: &mut Transaction, + ticket_owner: &Address, + routing_info: &RoutingInfo, + amount: Amount, + ticket: Ticket, +) -> Result { + TicketTable::sub(rt, tx, ticket_owner, &ticket.hash, amount)?; + let message = create_fa_withdrawal_message(routing_info, ticket.value)?; + tx.queue_outbox_message(rt, message)?; + // TODO: https://linear.app/tezos/issue/JSTZ-113/implement-outbox-message-id + // Implement outbox message id + Ok("".to_string()) +} + +impl FaWithdraw { + /// Execute the [FaWithdrawal] request by deducting ticket balance from `source`` and + /// pushing a withdraw message to the outbox queue. `proxy_l1_contract` is expected to + /// implement the %withdraw entrypoint. See /jstz/contracts/examples/fa_ticketer/fa_ticketer.mligo. + /// + /// Fails if: + /// * Source account has insufficient funds + /// * Outbox queue is full + /// * Amount is zero + fn fa_withdraw( + self, + rt: &mut impl HostRuntime, + tx: &mut Transaction, + source: &Address, + ) -> Result { + if self.amount == 0 { + Err(Error::ZeroAmountNotAllowed)? + } + let FaWithdraw { + amount, + routing_info, + ticket_info, + } = self; + let ticket = ticket_info.to_ticket(amount)?; + let outbox_message_id = + withdraw_from_ticket_owner(rt, tx, source, &routing_info, amount, ticket)?; + Ok(FaWithdrawReceiptContent { + source: source.clone(), + outbox_message_id, + }) + } + + /// Execute the [FaWithdraw] request atomically. See [Self::fa_withdraw]. + /// for implmentation details. + pub fn execute( + self, + rt: &mut impl HostRuntime, + tx: &mut Transaction, + source: &Address, + // TODO: https://linear.app/tezos/issue/JSTZ-114/fa-withdraw-gas-calculation + // Properly consume gas + _gas_limit: u64, + ) -> Result { + tx.begin(); + let result = self.fa_withdraw(rt, tx, source); + if result.is_ok() { + tx.commit(rt)?; + } else { + tx.rollback()?; + } + result + } +} + +#[cfg(test)] +mod test { + use tezos_data_encoding::nom::NomReader; + use tezos_smart_rollup::{ + michelson::MichelsonContract, + outbox::{OutboxMessageFull, OutboxMessageTransaction}, + types::Entrypoint, + }; + use tezos_smart_rollup_mock::MockHost; + + use crate::context::ticket_table::TicketTableError; + + use super::*; + + fn create_fa_withdrawal() -> FaWithdraw { + let ticket_info = TicketInfo { + id: 1234, + content: Some(b"random ticket content".to_vec()), + ticketer: jstz_mock::kt1_account1(), + }; + let routing_info = RoutingInfo { + receiver: jstz_mock::account2(), + proxy_l1_contract: jstz_mock::kt1_account1(), + }; + FaWithdraw { + amount: 10, + routing_info, + ticket_info, + } + } + + #[test] + fn execute_fa_withdraw_succeeds() { + let mut rt = MockHost::default(); + let mut tx = Transaction::default(); + let source = jstz_mock::account1(); + let fa_withdrawal = create_fa_withdrawal(); + let FaWithdraw { + amount, + routing_info, + ticket_info, + } = fa_withdrawal.clone(); + tx.begin(); + TicketTable::add( + &mut rt, + &mut tx, + &source, + &fa_withdrawal.ticket_info.clone().to_ticket(1).unwrap().hash, + 100, + ) + .expect("Adding ticket balance should succeed"); + tx.commit(&mut rt).unwrap(); + + tx.begin(); + let fa_withdrawal_receipt_content = fa_withdrawal + .execute(&mut rt, &mut tx, &source, 100) + .expect("Should succeed"); + tx.commit(&mut rt).unwrap(); + assert_eq!( + FaWithdrawReceiptContent { + source, + outbox_message_id: "".to_string() // outbox message not implemented yet + }, + fa_withdrawal_receipt_content, + ); + + let level = rt.run_level(|_| {}); + let outbox = rt.outbox_at(level); + + assert_eq!(1, outbox.len()); + + for message in outbox.iter() { + let (_, message) = + OutboxMessageFull::::nom_read(message).unwrap(); + let parameters = MichelsonPair( + MichelsonContract( + Contract::try_from(routing_info.clone().receiver.to_base58()) + .unwrap(), + ), + ticket_info.clone().to_ticket(amount).unwrap().value, + ); + assert_eq!( + message, + OutboxMessage::Withdrawal( + vec![OutboxMessageTransaction { + parameters, + destination: Contract::Originated( + routing_info.clone().proxy_l1_contract + ), + entrypoint: Entrypoint::try_from(WITHDRAW_ENTRYPOINT.to_string()) + .unwrap(), + }] + .into() + ) + .into() + ); + } + } + + #[test] + fn execute_fa_withdraw_fails_on_insufficient_funds() { + let mut rt = MockHost::default(); + let mut tx = Transaction::default(); + let source = jstz_mock::account1(); + let fa_withdrawal = create_fa_withdrawal(); + + tx.begin(); + TicketTable::add( + &mut rt, + &mut tx, + &source, + &fa_withdrawal.ticket_info.clone().to_ticket(1).unwrap().hash, + 5, + ) + .expect("Adding ticket balance should succeed"); + tx.commit(&mut rt).unwrap(); + + let result = fa_withdrawal.execute(&mut rt, &mut tx, &source, 100); + assert!(matches!( + result, + Err(Error::TicketTableError { + source: TicketTableError::InsufficientFunds + }) + )); + } + + #[test] + fn execute_fa_withdraw_fails_on_zero_amount() { + let mut rt = MockHost::default(); + let mut tx = Transaction::default(); + let source = jstz_mock::account1(); + let ticket_info = TicketInfo { + id: 1234, + content: Some(b"random ticket content".to_vec()), + ticketer: jstz_mock::kt1_account1(), + }; + let routing_info = RoutingInfo { + receiver: jstz_mock::account2(), + proxy_l1_contract: jstz_mock::kt1_account1(), + }; + let fa_withdrawal = FaWithdraw { + amount: 0, + routing_info, + ticket_info, + }; + + tx.begin(); + TicketTable::add( + &mut rt, + &mut tx, + &source, + &fa_withdrawal.ticket_info.clone().to_ticket(1).unwrap().hash, + 5, + ) + .expect("Adding ticket balance should succeed"); + tx.commit(&mut rt).unwrap(); + + let result = fa_withdrawal.execute(&mut rt, &mut tx, &source, 100); + assert!(matches!(result, Err(Error::ZeroAmountNotAllowed))); + } +} diff --git a/crates/jstz_proto/src/executor/mod.rs b/crates/jstz_proto/src/executor/mod.rs index eafc4ed9..635f1422 100644 --- a/crates/jstz_proto/src/executor/mod.rs +++ b/crates/jstz_proto/src/executor/mod.rs @@ -9,9 +9,9 @@ use crate::{ pub mod deposit; pub mod fa_deposit; +pub mod fa_withdraw; pub mod smart_function; pub mod withdraw; - pub const JSTZ_HOST: &str = "jstz"; fn execute_operation_inner( diff --git a/crates/jstz_proto/src/executor/smart_function.rs b/crates/jstz_proto/src/executor/smart_function.rs index 38a268f5..5c178337 100644 --- a/crates/jstz_proto/src/executor/smart_function.rs +++ b/crates/jstz_proto/src/executor/smart_function.rs @@ -521,23 +521,26 @@ pub mod run { pub mod jstz_run { use jstz_core::kv::Storage; + use serde::Deserialize; use tezos_crypto_rs::hash::ContractKt1Hash; use tezos_smart_rollup::storage::path::{OwnedPath, RefPath}; use super::*; use crate::{ - executor::{withdraw::Withdrawal, JSTZ_HOST}, - operation::{self, RunFunction}, + executor::{fa_withdraw::FaWithdraw, withdraw::Withdrawal, JSTZ_HOST}, + operation::RunFunction, receipt, }; const WITHDRAW_PATH: &str = "/withdraw"; - - fn validate_withdraw_request( - method: http::Method, - body: HttpBody, - ) -> Result { - let method = method + const FA_WITHDRAW_PATH: &str = "/fa-withdraw"; + + fn validate_withdraw_request<'de, T>(run: &'de RunFunction) -> Result + where + T: Deserialize<'de>, + { + let method = run + .method .as_str() .parse::() .map_err(|_| Error::InvalidHttpRequestMethod)?; @@ -546,18 +549,11 @@ pub mod jstz_run { return Err(Error::InvalidHttpRequestMethod); } - let body = match body { - Some(body) => body, - None => Err(Error::InvalidHttpRequestBody)?, - }; - - let withdrawal: Withdrawal = serde_json::from_str( - String::from_utf8(body) - .map_err(|_| Error::InvalidHttpRequestBody)? - .as_str(), - ) - .map_err(|_| Error::InvalidHttpRequestBody)?; - + if run.body.is_none() { + return Err(Error::InvalidHttpRequestBody); + } + let withdrawal = serde_json::from_slice(run.body.as_ref().unwrap()) + .map_err(|_| Error::InvalidHttpRequestBody)?; Ok(withdrawal) } @@ -566,11 +562,9 @@ pub mod jstz_run { tx: &mut Transaction, ticketer: &ContractKt1Hash, source: &Address, - run: operation::RunFunction, + run: RunFunction, ) -> Result { - let RunFunction { - uri, method, body, .. - } = run; + let uri = run.uri.clone(); if uri.host() != Some(JSTZ_HOST) { return Err(Error::InvalidHost); } @@ -578,7 +572,8 @@ pub mod jstz_run { WITHDRAW_PATH => { // TODO: https://linear.app/tezos/issue/JSTZ-77/check-gas-limit-when-performing-native-withdraws // Check gas limit - let withdrawal = validate_withdraw_request(method, body)?; + + let withdrawal = validate_withdraw_request::(&run)?; crate::executor::withdraw::execute_withdraw( hrt, tx, source, withdrawal, ticketer, )?; @@ -589,7 +584,18 @@ pub mod jstz_run { }; Ok(receipt) } - + FA_WITHDRAW_PATH => { + let fa_withdraw = validate_withdraw_request::(&run)?; + let fa_withdraw_receipt_content = fa_withdraw.execute( + hrt, tx, source, 1000, // fake gas limit + )?; + let receipt = receipt::RunFunction { + body: fa_withdraw_receipt_content.to_http_body(), + status_code: http::StatusCode::OK, + headers: http::HeaderMap::new(), + }; + Ok(receipt) + } _ => Err(Error::UnsupportedPath), } } @@ -598,7 +604,7 @@ pub mod jstz_run { hrt: &mut impl HostRuntime, tx: &mut Transaction, source: &Address, - run: operation::RunFunction, + run: RunFunction, ) -> Result { let ticketer_path = OwnedPath::from(&RefPath::assert_from(b"/ticketer")); let ticketer: ContractKt1Hash = @@ -616,7 +622,11 @@ pub mod jstz_run { use tezos_smart_rollup_mock::MockHost; use crate::{ - executor::smart_function::jstz_run::{execute_without_ticketer, Account}, + context::ticket_table::TicketTable, + executor::{ + fa_withdraw::{FaWithdraw, RoutingInfo, TicketInfo}, + smart_function::jstz_run::{execute_without_ticketer, Account}, + }, operation::RunFunction, Error, }; @@ -646,6 +656,34 @@ pub mod jstz_run { } } + fn fa_withdraw_request() -> RunFunction { + let ticket_info = TicketInfo { + id: 1234, + content: Some(b"random ticket content".to_vec()), + ticketer: jstz_mock::kt1_account1(), + }; + let routing_info = RoutingInfo { + receiver: jstz_mock::account2(), + proxy_l1_contract: jstz_mock::kt1_account1(), + }; + let fa_withdrawal = FaWithdraw { + amount: 10, + routing_info, + ticket_info, + }; + + RunFunction { + uri: Uri::try_from("tezos://jstz/fa-withdraw").unwrap(), + method: Method::POST, + headers: HeaderMap::from_iter([( + header::CONTENT_TYPE, + "application/json".try_into().unwrap(), + )]), + body: Some(json!(fa_withdrawal).to_string().as_bytes().to_vec()), + gas_limit: 10, + } + } + #[test] fn execute_fails_on_invalid_host() { let mut host = MockHost::default(); @@ -775,6 +813,89 @@ pub mod jstz_run { let level = rt.run_level(|_| {}); assert_eq!(1, rt.outbox_at(level).len()); } + + #[test] + fn execute_fa_withdraw_fails_on_invalid_request_method() { + let mut host = MockHost::default(); + let mut tx = Transaction::default(); + let source = jstz_mock::account1(); + let req = RunFunction { + method: Method::GET, + ..fa_withdraw_request() + }; + let ticketer = + ContractKt1Hash::from_base58_check(jstz_mock::host::NATIVE_TICKETER) + .unwrap(); + let result = execute(&mut host, &mut tx, &ticketer, &source, req); + assert!(matches!( + result, + Err(super::Error::InvalidHttpRequestMethod) + )); + } + + #[test] + fn execute_fa_withdraw_fails_on_invalid_request_body() { + let mut host = MockHost::default(); + let mut tx = Transaction::default(); + let source = jstz_mock::account1(); + let req = RunFunction { + body: Some( + json!({ + "amount": 10, + "not_receiver": jstz_mock::account2().to_base58() + }) + .to_string() + .as_bytes() + .to_vec(), + ), + ..fa_withdraw_request() + }; + let ticketer = + ContractKt1Hash::from_base58_check(jstz_mock::host::NATIVE_TICKETER) + .unwrap(); + let result = execute(&mut host, &mut tx, &ticketer, &source, req); + assert!(matches!(result, Err(Error::InvalidHttpRequestBody))); + + let req = RunFunction { + body: None, + ..withdraw_request() + }; + let result = execute(&mut host, &mut tx, &ticketer, &source, req); + assert!(matches!(result, Err(Error::InvalidHttpRequestBody))); + } + + #[test] + fn execute_fa_withdraw_succeeds() { + let mut host = MockHost::default(); + let mut tx = Transaction::default(); + let source = jstz_mock::account1(); + + let ticket = TicketInfo { + id: 1234, + content: Some(b"random ticket content".to_vec()), + ticketer: jstz_mock::kt1_account1(), + } + .to_ticket(1) + .unwrap(); + + tx.begin(); + TicketTable::add(&mut host, &mut tx, &source, &ticket.hash, 10).unwrap(); + tx.commit(&mut host).unwrap(); + + let req = fa_withdraw_request(); + let ticketer = + ContractKt1Hash::from_base58_check(jstz_mock::host::NATIVE_TICKETER) + .unwrap(); + + execute(&mut host, &mut tx, &ticketer, &source, req) + .expect("Withdraw should not fail"); + + tx.begin(); + assert_eq!(0, Account::balance(&host, &mut tx, &source).unwrap()); + + let level = host.run_level(|_| {}); + assert_eq!(1, host.outbox_at(level).len()); + } } } diff --git a/crates/jstz_proto/src/executor/withdraw.rs b/crates/jstz_proto/src/executor/withdraw.rs index 0f42f6a8..d921fb4a 100644 --- a/crates/jstz_proto/src/executor/withdraw.rs +++ b/crates/jstz_proto/src/executor/withdraw.rs @@ -5,24 +5,20 @@ use jstz_core::{ use serde::{Deserialize, Serialize}; use tezos_smart_rollup::{ - michelson::{ - ticket::FA2_1Ticket, MichelsonContract, MichelsonNat, MichelsonOption, - MichelsonPair, - }, - outbox::OutboxMessageTransaction, - types::{Contract, Entrypoint}, + michelson::{ticket::FA2_1Ticket, MichelsonOption, MichelsonPair}, + types::Contract, }; use tezos_crypto_rs::hash::ContractKt1Hash; use crate::{ context::account::{Account, Address, Amount}, - Result, + Error, Result, }; const BURN_ENTRYPOINT: &str = "burn"; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Withdrawal { pub amount: Amount, pub receiver: Address, @@ -33,25 +29,19 @@ fn create_withdrawal( receiver: &Address, ticketer: &ContractKt1Hash, ) -> Result { - let pkh = receiver.to_base58(); - let entrypoint = Entrypoint::try_from(BURN_ENTRYPOINT.to_string()).unwrap(); - let parameters = MichelsonPair( - MichelsonContract(Contract::try_from(pkh).unwrap()), - FA2_1Ticket::new( - Contract::Originated(ticketer.clone()), - MichelsonPair(MichelsonNat::from(0), MichelsonOption(None)), - amount, - ) - .unwrap(), - ); - let message = OutboxMessage::Withdrawal( - vec![OutboxMessageTransaction { - entrypoint, - parameters, - destination: Contract::Originated(ticketer.clone()), - }] - .into(), - ); + let receiver_pkh = receiver.to_base58(); + let ticket = FA2_1Ticket::new( + Contract::Originated(ticketer.clone()), + MichelsonPair(0.into(), MichelsonOption(None)), + amount, + ) + .map_err(|_| Error::InvalidTicketType)?; + let message = OutboxMessage::new_withdrawal_message( + &Contract::try_from(receiver_pkh).unwrap(), + &Contract::Originated(ticketer.clone()), + ticket, + BURN_ENTRYPOINT, + )?; Ok(message) } diff --git a/crates/jstz_proto/src/receipt.rs b/crates/jstz_proto/src/receipt.rs index 0b204117..5fa2249c 100644 --- a/crates/jstz_proto/src/receipt.rs +++ b/crates/jstz_proto/src/receipt.rs @@ -3,8 +3,12 @@ use jstz_api::http::body::HttpBody; use serde::{Deserialize, Serialize}; use crate::{ - context::account::Address, executor::fa_deposit::FaDepositReceiptContent, - operation::OperationHash, Result, + context::account::Address, + executor::{ + fa_deposit::FaDepositReceiptContent, fa_withdraw::FaWithdrawReceiptContent, + }, + operation::OperationHash, + Result, }; pub type ReceiptResult = std::result::Result; @@ -46,4 +50,5 @@ pub enum Content { RunFunction(RunFunction), Deposit, FaDeposit(FaDepositReceiptContent), + FaWithdraw(FaWithdrawReceiptContent), }