diff --git a/runtime-sdk-macros/src/module_derive/method_handler.rs b/runtime-sdk-macros/src/module_derive/method_handler.rs index 99f4019db2..c8082e4c92 100644 --- a/runtime-sdk-macros/src/module_derive/method_handler.rs +++ b/runtime-sdk-macros/src/module_derive/method_handler.rs @@ -100,7 +100,29 @@ impl super::Deriver for DeriveMethodHandler { }; let dispatch_call_impl = { - let (handler_names, handler_idents) = filter_by_kind(handlers, HandlerKind::Call); + let (handler_names, handler_fns): (Vec<_>, Vec<_>) = handlers + .iter() + .filter_map(|h| h.handler.as_ref()) + .filter(|h| h.attrs.kind == HandlerKind::Call) + .map(|h| { + (h.attrs.rpc_name.clone(), { + let ident = &h.ident; + + if h.attrs.is_internal { + quote! { + |ctx, body| { + if !ctx.is_internal() { + return Err(sdk::modules::core::Error::Forbidden.into()); + } + Self::#ident(ctx, body) + } + } + } else { + quote! { Self::#ident } + } + }) + }) + .unzip(); if handler_names.is_empty() { quote! {} @@ -113,7 +135,7 @@ impl super::Deriver for DeriveMethodHandler { ) -> DispatchResult { match method { #( - #handler_names => module::dispatch_call(ctx, body, Self::#handler_idents), + #handler_names => module::dispatch_call(ctx, body, #handler_fns), )* _ => DispatchResult::Unhandled(body), } @@ -347,6 +369,8 @@ struct MethodHandlerAttr { allow_private_km: bool, /// Whether this handler is tagged as allowing interactive calls. Only applies to call handlers. allow_interactive: bool, + /// Whether this handler is tagged as internal. + is_internal: bool, } impl syn::parse::Parse for MethodHandlerAttr { fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result { @@ -365,6 +389,7 @@ impl syn::parse::Parse for MethodHandlerAttr { let mut is_expensive = false; let mut allow_private_km = false; let mut allow_interactive = false; + let mut is_internal = false; while input.peek(syn::token::Comma) { let _: syn::token::Comma = input.parse()?; let tag: syn::Ident = input.parse()?; @@ -393,10 +418,18 @@ impl syn::parse::Parse for MethodHandlerAttr { )); } allow_interactive = true; + } else if tag == "internal" { + if kind != HandlerKind::Call { + return Err(syn::Error::new( + tag.span(), + "`internal` tag is only allowed on `call` handlers", + )); + } + is_internal = true; } else { return Err(syn::Error::new( tag.span(), - "invalid handler tag; supported: `expensive`, `allow_private_km`, `allow_interactive`", + "invalid handler tag; supported: `expensive`, `allow_private_km`, `allow_interactive`, `internal`", )); } } @@ -410,6 +443,7 @@ impl syn::parse::Parse for MethodHandlerAttr { is_expensive, allow_private_km, allow_interactive, + is_internal, }) } } diff --git a/runtime-sdk-macros/src/module_derive/mod.rs b/runtime-sdk-macros/src/module_derive/mod.rs index e7c2b5d73b..9721d507ac 100644 --- a/runtime-sdk-macros/src/module_derive/mod.rs +++ b/runtime-sdk-macros/src/module_derive/mod.rs @@ -159,6 +159,8 @@ mod tests { fn my_call(foo2: Bar2) -> Baz2 {} #[handler(call = "my_module.MyOtherCall")] fn my_other_call(foo3: Bar3) -> Baz3 {} + #[handler(call = "my_module.MyInternalCall", internal)] + fn my_internal_call(foo4: Bar4) -> Baz4 {} } ); @@ -184,6 +186,7 @@ mod tests { Self::prefetch_for_my_call(&mut add_prefix, body, auth_info), ), "my_module.MyOtherCall" => module::DispatchResult::Handled(Ok(())), + "my_module.MyInternalCall" => module::DispatchResult::Handled(Ok(())), _ => module::DispatchResult::Unhandled(body), } } @@ -197,6 +200,12 @@ mod tests { "my_module.MyOtherCall" => { module::dispatch_call(ctx, body, Self::my_other_call) } + "my_module.MyInternalCall" => module::dispatch_call(ctx, body, |ctx, body| { + if !ctx.is_internal() { + return Err(sdk::modules::core::Error::Forbidden.into()); + } + Self::my_internal_call(ctx, body) + }), _ => DispatchResult::Unhandled(body), } } @@ -223,6 +232,10 @@ mod tests { kind: core_types::MethodHandlerKind::Call, name: "my_module.MyOtherCall".to_string(), }, + core_types::MethodHandlerInfo { + kind: core_types::MethodHandlerKind::Call, + name: "my_module.MyInternalCall".to_string(), + }, ] } } @@ -237,6 +250,8 @@ mod tests { fn my_call(foo2: Bar2) -> Baz2 {} #[handler(call = "my_module.MyOtherCall")] fn my_other_call(foo3: Bar3) -> Baz3 {} + #[handler(call = "my_module.MyInternalCall", internal)] + fn my_internal_call(foo4: Bar4) -> Baz4 {} } }; ) @@ -537,6 +552,18 @@ mod tests { super::derive_module(input); } + #[test] + #[should_panic(expected = "only allowed on `call` handlers")] + fn generate_method_handler_malformed_internal_noncall() { + let input: syn::ItemImpl = syn::parse_quote!( + impl MyModule { + #[handler(query = "foo", internal)] + fn my_method_call() -> () {} + } + ); + super::derive_module(input); + } + #[test] #[should_panic] fn generate_method_handler_malformed_multiple_metas() { diff --git a/runtime-sdk/src/modules/consensus/mod.rs b/runtime-sdk/src/modules/consensus/mod.rs index 9159529101..7fe1064a25 100644 --- a/runtime-sdk/src/modules/consensus/mod.rs +++ b/runtime-sdk/src/modules/consensus/mod.rs @@ -45,6 +45,12 @@ const MODULE_NAME: &str = "consensus"; pub struct Parameters { pub consensus_denomination: token::Denomination, pub consensus_scaling_factor: u64, + + /// Minimum amount that is allowed to be delegated. This should be greater than or equal to what + /// is configured in the consensus layer as the consensus layer will do its own checks. + /// + /// The amount is in consensus units. + pub min_delegate_amount: u128, } impl Default for Parameters { @@ -52,6 +58,7 @@ impl Default for Parameters { Self { consensus_denomination: token::Denomination::from_str("TEST").unwrap(), consensus_scaling_factor: 1, + min_delegate_amount: 0, } } } @@ -119,6 +126,10 @@ pub enum Error { #[sdk_error(code = 5)] AmountNotRepresentable, + #[error("amount is lower than the minimum delegation amount")] + #[sdk_error(code = 6)] + UnderMinDelegationAmount, + #[error("history: {0}")] #[sdk_error(transparent)] History(#[from] history::Error), @@ -278,6 +289,10 @@ impl API for Module { Self::ensure_consensus_denomination(ctx, amount.denomination())?; let amount = Self::amount_to_consensus(ctx, amount.amount())?; + if amount < Self::params().min_delegate_amount { + return Err(Error::UnderMinDelegationAmount); + } + ctx.emit_message( Message::Staking(Versioned::new( 0, diff --git a/runtime-sdk/src/modules/consensus/test.rs b/runtime-sdk/src/modules/consensus/test.rs index 8e953b4429..2c520ad79d 100644 --- a/runtime-sdk/src/modules/consensus/test.rs +++ b/runtime-sdk/src/modules/consensus/test.rs @@ -19,7 +19,7 @@ use crate::{ }, }; -use super::{Genesis, Parameters, API as _}; +use super::{Error, Genesis, Parameters, API as _}; #[test] fn test_api_transfer_invalid_denomination() { @@ -276,6 +276,43 @@ fn test_api_escrow() { }); } +#[test] +fn test_api_escrow_min_delegate_amount() { + let mut mock = mock::Mock::default(); + let mut ctx = mock.create_ctx(); + + Consensus::set_params(Parameters { + min_delegate_amount: 10, + ..Default::default() + }); + + ctx.with_tx(mock::transaction().into(), |mut tx_ctx, _call| { + let hook_name = "test_event_handler"; + let amount = BaseUnits::new(5, Denomination::from_str("TEST").unwrap()); + let result = Consensus::escrow( + &mut tx_ctx, + keys::alice::address(), + &amount, + MessageEventHookInvocation::new(hook_name.to_string(), 0), + ); + + assert!(matches!(result, Err(Error::UnderMinDelegationAmount))); + }); + + ctx.with_tx(mock::transaction().into(), |mut tx_ctx, _call| { + let hook_name = "test_event_handler"; + let amount = BaseUnits::new(15, Denomination::from_str("TEST").unwrap()); + let result = Consensus::escrow( + &mut tx_ctx, + keys::alice::address(), + &amount, + MessageEventHookInvocation::new(hook_name.to_string(), 0), + ); + + assert!(result.is_ok()); + }); +} + #[test] fn test_api_escrow_scaling() { let mut mock = mock::Mock::default(); @@ -430,6 +467,7 @@ fn test_query_parameters() { let params = Parameters { consensus_denomination: Denomination::NATIVE, consensus_scaling_factor: 1_000, + min_delegate_amount: 10, }; Consensus::set_params(params.clone()); @@ -445,6 +483,7 @@ fn test_init_bad_scaling_factor_1() { consensus_denomination: Denomination::NATIVE, // Zero scaling factor is invalid. consensus_scaling_factor: 0, + min_delegate_amount: 0, }, }); } @@ -457,6 +496,7 @@ fn test_init_bad_scaling_factor_2() { consensus_denomination: Denomination::NATIVE, // Scaling factor that is not a power of 10 is invalid. consensus_scaling_factor: 1230, + min_delegate_amount: 0, }, }); } diff --git a/runtime-sdk/src/modules/consensus_accounts/mod.rs b/runtime-sdk/src/modules/consensus_accounts/mod.rs index 1d034431ff..faf5015b73 100644 --- a/runtime-sdk/src/modules/consensus_accounts/mod.rs +++ b/runtime-sdk/src/modules/consensus_accounts/mod.rs @@ -75,6 +75,11 @@ pub struct GasCosts { pub tx_withdraw: u64, pub tx_delegate: u64, pub tx_undelegate: u64, + + /// Cost of storing a delegation/undelegation receipt. + pub store_receipt: u64, + /// Cost of taking a delegation/undelegation receipt. + pub take_receipt: u64, } /// Parameters for the consensus module. @@ -198,6 +203,7 @@ pub trait API { nonce: u64, to: Address, amount: token::BaseUnits, + receipt: bool, ) -> Result<(), Error>; /// Start the undelegation process of the given number of shares from consensus staking account @@ -213,6 +219,7 @@ pub trait API { nonce: u64, to: Address, shares: u128, + receipt: bool, ) -> Result<(), Error>; } @@ -309,6 +316,7 @@ impl API nonce: u64, to: Address, amount: token::BaseUnits, + receipt: bool, ) -> Result<(), Error> { Consensus::escrow( ctx, @@ -321,6 +329,7 @@ impl API nonce, to, amount: amount.clone(), + receipt, }, ), )?; @@ -343,6 +352,7 @@ impl API nonce: u64, to: Address, shares: u128, + receipt: bool, ) -> Result<(), Error> { // Subtract shares from delegation, making sure there are enough there. state::sub_delegation(to, from, shares)?; @@ -358,6 +368,7 @@ impl API nonce, to, shares, + receipt, }, ), )?; @@ -450,34 +461,77 @@ impl fn tx_delegate(ctx: &mut C, body: types::Delegate) -> Result<(), Error> { let params = Self::params(); ::Core::use_tx_gas(ctx, params.gas_costs.tx_delegate)?; + let store_receipt = body.receipt > 0; + if store_receipt { + ::Core::use_tx_gas(ctx, params.gas_costs.store_receipt)?; + } // Check whether delegate is allowed. if params.disable_delegate { return Err(Error::Forbidden); } + // Make sure receipts can only be requested internally (e.g. via subcalls). + if store_receipt && !ctx.is_internal() { + return Err(Error::InvalidArgument); + } // Signer. let signer = &ctx.tx_auth_info().signer_info[0]; let from = signer.address_spec.address(); - let nonce = signer.nonce; - Self::delegate(ctx, from, nonce, body.to, body.amount) + let nonce = if store_receipt { + body.receipt // Use receipt identifier as the nonce. + } else { + signer.nonce // Use signer nonce as the nonce. + }; + Self::delegate(ctx, from, nonce, body.to, body.amount, store_receipt) } #[handler(call = "consensus.Undelegate")] fn tx_undelegate(ctx: &mut C, body: types::Undelegate) -> Result<(), Error> { let params = Self::params(); ::Core::use_tx_gas(ctx, params.gas_costs.tx_undelegate)?; + let store_receipt = body.receipt > 0; + if store_receipt { + ::Core::use_tx_gas(ctx, params.gas_costs.store_receipt)?; + } // Check whether undelegate is allowed. if params.disable_undelegate { return Err(Error::Forbidden); } + // Make sure receipts can only be requested internally (e.g. via subcalls). + if store_receipt && !ctx.is_internal() { + return Err(Error::InvalidArgument); + } // Signer. let signer = &ctx.tx_auth_info().signer_info[0]; let to = signer.address_spec.address(); - let nonce = signer.nonce; - Self::undelegate(ctx, body.from, nonce, to, body.shares) + let nonce = if store_receipt { + body.receipt // Use receipt identifer as the nonce. + } else { + signer.nonce // Use signer nonce as the nonce. + }; + Self::undelegate(ctx, body.from, nonce, to, body.shares, store_receipt) + } + + #[handler(call = "consensus.TakeReceipt", internal)] + fn internal_take_receipt( + ctx: &mut C, + body: types::TakeReceipt, + ) -> Result, Error> { + let params = Self::params(); + ::Core::use_tx_gas(ctx, params.gas_costs.take_receipt)?; + + if !body.kind.is_valid() { + return Err(Error::InvalidArgument); + } + + Ok(state::take_receipt( + ctx.tx_caller_address(), + body.kind, + body.id, + )) } #[handler(query = "consensus.Balance")] @@ -615,6 +669,19 @@ impl ) .expect("should have enough balance"); + // Store receipt if requested. + if context.receipt { + state::set_receipt( + context.from, + types::ReceiptKind::Delegate, + context.nonce, + types::Receipt { + error: Some(me.clone().into()), + ..Default::default() + }, + ); + } + // Emit delegation failed event. ctx.emit_event(Event::Delegate { from: context.from, @@ -639,6 +706,19 @@ impl state::add_delegation(context.from, context.to, shares).unwrap(); + // Store receipt if requested. + if context.receipt { + state::set_receipt( + context.from, + types::ReceiptKind::Delegate, + context.nonce, + types::Receipt { + shares, + ..Default::default() + }, + ); + } + // Emit delegation successful event. ctx.emit_event(Event::Delegate { from: context.from, @@ -659,6 +739,19 @@ impl // Undelegation failed, add shares back. state::add_delegation(context.to, context.from, context.shares).unwrap(); + // Store receipt if requested. + if context.receipt { + state::set_receipt( + context.to, + types::ReceiptKind::UndelegateStart, + context.nonce, + types::Receipt { + error: Some(me.clone().into()), + ..Default::default() + }, + ); + } + // Emit undelegation failed event. ctx.emit_event(Event::UndelegateStart { from: context.from, @@ -679,14 +772,35 @@ impl let result: ReclaimEscrowResult = cbor::from_value(result).unwrap(); let debonding_shares = result.debonding_shares.try_into().unwrap(); - state::add_undelegation( + let receipt = if context.receipt { + context.nonce + } else { + 0 // No receipt needed for UndelegateEnd. + }; + + let done_receipt = state::add_undelegation( context.from, context.to, result.debond_end_time, debonding_shares, + receipt, ) .unwrap(); + // Store receipt if requested. + if context.receipt { + state::set_receipt( + context.to, + types::ReceiptKind::UndelegateStart, + context.nonce, + types::Receipt { + epoch: result.debond_end_time, + receipt: done_receipt, + ..Default::default() + }, + ); + } + // Emit undelegation started event. ctx.emit_event(Event::UndelegateStart { from: context.from, @@ -781,12 +895,25 @@ impl modul .expect("shares * total_amount should not overflow") .checked_div(total_shares) .expect("total_shares should not be zero"); - let amount = Consensus::amount_from_consensus(ctx, amount).unwrap(); - let amount = token::BaseUnits::new(amount, denomination.clone()); + let raw_amount = Consensus::amount_from_consensus(ctx, amount).unwrap(); + let amount = token::BaseUnits::new(raw_amount, denomination.clone()); // Mint the given number of tokens. Accounts::mint(ctx, ud.to, &amount).unwrap(); + // Store receipt if requested. + if udi.receipt > 0 { + state::set_receipt( + ud.to, + types::ReceiptKind::UndelegateDone, + udi.receipt, + types::Receipt { + amount: raw_amount, + ..Default::default() + }, + ); + } + // Emit undelegation done event. ctx.emit_event(Event::UndelegateDone { from: ud.from, @@ -827,7 +954,7 @@ impl modul if let Some(total_supply) = ts.get(&den) { if total_supply > &rt_ga_balance { return Err(CoreError::InvariantViolation( - "total supply is greater than runtime's general account balance".to_string(), + format!("total supply ({total_supply}) is greater than runtime's general account balance ({rt_ga_balance})"), )); } } diff --git a/runtime-sdk/src/modules/consensus_accounts/state.rs b/runtime-sdk/src/modules/consensus_accounts/state.rs index 2c18ba8f79..d3feed5f46 100644 --- a/runtime-sdk/src/modules/consensus_accounts/state.rs +++ b/runtime-sdk/src/modules/consensus_accounts/state.rs @@ -19,6 +19,8 @@ pub const DELEGATIONS: &[u8] = &[0x01]; pub const UNDELEGATIONS: &[u8] = &[0x02]; /// An undelegation queue. pub const UNDELEGATION_QUEUE: &[u8] = &[0x03]; +/// Receipts. +pub const RECEIPTS: &[u8] = &[0x04]; /// Add delegation for a given (from, to) pair. /// @@ -140,13 +142,17 @@ pub fn get_delegations_by_destination() -> Result, Error /// Record new undelegation and add to undelegation queue. /// /// In case an undelegation for the given (from, to, epoch) tuple already exists, the undelegation -/// entry is merged by adding shares. +/// entry is merged by adding shares. When a non-zero receipt identifier is passed, the identifier +/// is set in case the existing entry has no such identifier yet. +/// +/// It returns the receipt identifier of the undelegation done receipt. pub fn add_undelegation( from: Address, to: Address, epoch: EpochTime, shares: u128, -) -> Result<(), Error> { + receipt: u64, +) -> Result { CurrentStore::with(|mut root_store| { let store = storage::PrefixStore::new(&mut root_store, &MODULE_NAME); let undelegations = storage::PrefixStore::new(store, &UNDELEGATIONS); @@ -154,6 +160,11 @@ pub fn add_undelegation( let mut entry = storage::TypedStore::new(storage::PrefixStore::new(account, &from)); let mut di: types::DelegationInfo = entry.get(epoch.to_storage_key()).unwrap_or_default(); + if receipt > 0 && di.receipt == 0 { + di.receipt = receipt; + } + let done_receipt = di.receipt; + di.shares = di .shares .checked_add(shares) @@ -169,7 +180,7 @@ pub fn add_undelegation( &[0xF6], /* CBOR NULL */ ); - Ok(()) + Ok(done_receipt) }) } @@ -279,6 +290,35 @@ pub fn get_queued_undelegations(epoch: EpochTime) -> Result, E }) } +/// Store the given receipt. +pub fn set_receipt(owner: Address, kind: types::ReceiptKind, id: u64, receipt: types::Receipt) { + CurrentStore::with(|store| { + let store = storage::PrefixStore::new(store, &MODULE_NAME); + let receipts = storage::PrefixStore::new(store, &RECEIPTS); + let of_owner = storage::PrefixStore::new(receipts, &owner); + let kind = [kind as u8]; + let mut of_kind = storage::TypedStore::new(storage::PrefixStore::new(of_owner, &kind)); + + of_kind.insert(id.to_be_bytes(), receipt); + }); +} + +/// Remove the given receipt from storage if it exists and return it, otherwise return `None`. +pub fn take_receipt(owner: Address, kind: types::ReceiptKind, id: u64) -> Option { + CurrentStore::with(|store| { + let store = storage::PrefixStore::new(store, &MODULE_NAME); + let receipts = storage::PrefixStore::new(store, &RECEIPTS); + let of_owner = storage::PrefixStore::new(receipts, &owner); + let kind = [kind as u8]; + let mut of_kind = storage::TypedStore::new(storage::PrefixStore::new(of_owner, &kind)); + + let receipt = of_kind.get(id.to_be_bytes()); + of_kind.remove(id.to_be_bytes()); + + receipt + }) +} + /// A trait that exists solely to convert `beacon::EpochTime` to bytes for use as a storage key. trait ToStorageKey { fn to_storage_key(&self) -> [u8; 8]; @@ -344,9 +384,9 @@ mod test { fn test_undelegation() { let _mock = mock::Mock::default(); - add_undelegation(keys::alice::address(), keys::bob::address(), 42, 500).unwrap(); - add_undelegation(keys::alice::address(), keys::bob::address(), 42, 500).unwrap(); - add_undelegation(keys::alice::address(), keys::bob::address(), 84, 200).unwrap(); + add_undelegation(keys::alice::address(), keys::bob::address(), 42, 500, 12).unwrap(); + add_undelegation(keys::alice::address(), keys::bob::address(), 42, 500, 24).unwrap(); + add_undelegation(keys::alice::address(), keys::bob::address(), 84, 200, 36).unwrap(); let qd = get_queued_undelegations(10).unwrap(); assert!(qd.is_empty()); @@ -374,6 +414,7 @@ mod test { let di = take_undelegation(&qd[0]).unwrap(); assert_eq!(di.shares, 1000); + assert_eq!(di.receipt, 12, "receipt id should not be overwritten"); let qd = get_queued_undelegations(42).unwrap(); assert!(qd.is_empty()); @@ -381,4 +422,43 @@ mod test { let udis = get_undelegations(keys::bob::address()).unwrap(); assert_eq!(udis.len(), 1); } + + #[test] + fn test_receipts() { + let _mock = mock::Mock::default(); + + let receipt = types::Receipt { + shares: 123, + ..Default::default() + }; + set_receipt( + keys::alice::address(), + types::ReceiptKind::Delegate, + 42, + receipt.clone(), + ); + + let dec_receipt = take_receipt(keys::alice::address(), types::ReceiptKind::Delegate, 10); + assert!(dec_receipt.is_none(), "missing receipt should return None"); + + let dec_receipt = take_receipt( + keys::alice::address(), + types::ReceiptKind::UndelegateStart, + 42, + ); + assert!(dec_receipt.is_none(), "missing receipt should return None"); + + let dec_receipt = take_receipt( + keys::alice::address(), + types::ReceiptKind::UndelegateDone, + 42, + ); + assert!(dec_receipt.is_none(), "missing receipt should return None"); + + let dec_receipt = take_receipt(keys::alice::address(), types::ReceiptKind::Delegate, 42); + assert_eq!(dec_receipt, Some(receipt), "receipt should be correct"); + + let dec_receipt = take_receipt(keys::alice::address(), types::ReceiptKind::Delegate, 42); + assert!(dec_receipt.is_none(), "receipt should have been removed"); + } } diff --git a/runtime-sdk/src/modules/consensus_accounts/test.rs b/runtime-sdk/src/modules/consensus_accounts/test.rs index e9d13a325d..f11fb15286 100644 --- a/runtime-sdk/src/modules/consensus_accounts/test.rs +++ b/runtime-sdk/src/modules/consensus_accounts/test.rs @@ -721,6 +721,7 @@ fn perform_delegation(ctx: &mut C, success: bool) -> u64 { body: cbor::to_value(Delegate { to: keys::bob::address(), amount: BaseUnits::new(1_000, denom.clone()), + receipt: 0, }), ..Default::default() }, @@ -893,6 +894,7 @@ fn test_api_delegate_insufficient_balance() { body: cbor::to_value(Delegate { to: keys::bob::address(), amount: BaseUnits::new(5_000, denom.clone()), + receipt: 0, }), ..Default::default() }, @@ -955,6 +957,49 @@ fn test_api_delegate_fail() { assert_eq!(tags[1].key, b"consensus_accounts\x00\x00\x00\x03"); // consensus_accounts.Delegate (code = 3) event } +#[test] +fn test_api_delegate_receipt_not_internal() { + let denom: Denomination = Denomination::from_str("TEST").unwrap(); + let mut mock = mock::Mock::default(); + let mut ctx = mock.create_ctx(); + init_accounts(&mut ctx); + + let tx = transaction::Transaction { + version: 1, + call: transaction::Call { + format: transaction::CallFormat::Plain, + method: "consensus.Delegate".to_owned(), + body: cbor::to_value(Delegate { + to: keys::bob::address(), + amount: BaseUnits::new(5_000, denom.clone()), + receipt: 42, // Receipts should only be allowed internally. + }), + ..Default::default() + }, + auth_info: transaction::AuthInfo { + signer_info: vec![transaction::SignerInfo::new_sigspec( + keys::alice::sigspec(), + 123, + )], + fee: transaction::Fee { + amount: Default::default(), + gas: 1000, + consensus_messages: 1, + }, + ..Default::default() + }, + }; + + ctx.with_tx(tx.into(), |mut tx_ctx, call| { + let result = Module::::tx_delegate( + &mut tx_ctx, + cbor::from_value(call.body).unwrap(), + ) + .unwrap_err(); + assert!(matches!(result, Error::InvalidArgument)); + }); +} + fn perform_undelegation( ctx: &mut C, success: Option, @@ -969,6 +1014,7 @@ fn perform_undelegation( body: cbor::to_value(Undelegate { from: keys::bob::address(), shares: 400, + receipt: 0, }), ..Default::default() }, @@ -1248,6 +1294,7 @@ fn test_api_undelegate_insufficient_balance() { body: cbor::to_value(Undelegate { from: keys::bob::address(), shares: 400, + receipt: 0, }), ..Default::default() }, @@ -1306,6 +1353,48 @@ fn test_api_undelegate_fail() { assert!(event.error.is_some()); } +#[test] +fn test_api_undelegate_receipt_not_internal() { + let mut mock = mock::Mock::default(); + let mut ctx = mock.create_ctx(); + init_accounts(&mut ctx); + + let tx = transaction::Transaction { + version: 1, + call: transaction::Call { + format: transaction::CallFormat::Plain, + method: "consensus.Undelegate".to_owned(), + body: cbor::to_value(Undelegate { + from: keys::bob::address(), + shares: 400, + receipt: 42, // Receipts should only be allowed internally. + }), + ..Default::default() + }, + auth_info: transaction::AuthInfo { + signer_info: vec![transaction::SignerInfo::new_sigspec( + keys::alice::sigspec(), + 123, + )], + fee: transaction::Fee { + amount: Default::default(), + gas: 1000, + consensus_messages: 1, + }, + ..Default::default() + }, + }; + + ctx.with_tx(tx.into(), |mut tx_ctx, call| { + let result = Module::::tx_undelegate( + &mut tx_ctx, + cbor::from_value(call.body).unwrap(), + ) + .unwrap_err(); + assert!(matches!(result, Error::InvalidArgument)); + }); +} + #[test] fn test_api_undelegate_suspension() { let denom: Denomination = Denomination::from_str("TEST").unwrap(); diff --git a/runtime-sdk/src/modules/consensus_accounts/types.rs b/runtime-sdk/src/modules/consensus_accounts/types.rs index 32a53b390a..98d861fc6b 100644 --- a/runtime-sdk/src/modules/consensus_accounts/types.rs +++ b/runtime-sdk/src/modules/consensus_accounts/types.rs @@ -29,6 +29,8 @@ pub struct Withdraw { pub struct Delegate { pub to: Address, pub amount: token::BaseUnits, + #[cbor(optional)] + pub receipt: u64, } /// Undelegate into runtime call. @@ -36,6 +38,59 @@ pub struct Delegate { pub struct Undelegate { pub from: Address, pub shares: u128, + #[cbor(optional)] + pub receipt: u64, +} + +/// Kind of receipt. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[repr(u8)] +pub enum ReceiptKind { + #[default] + Invalid = 0, + Delegate = 1, + UndelegateStart = 2, + UndelegateDone = 3, +} + +impl ReceiptKind { + /// Whether the receipt kind is valid. + pub fn is_valid(&self) -> bool { + !matches!(self, Self::Invalid) + } +} + +/// Take receipt internal runtime call. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct TakeReceipt { + pub kind: ReceiptKind, + pub id: u64, +} + +/// A receipt. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +#[cfg_attr(test, derive(PartialEq, Eq))] +pub struct Receipt { + /// Shares received (for delegations). + #[cbor(optional)] + pub shares: u128, + + /// Undelegate end epoch. + #[cbor(optional)] + pub epoch: EpochTime, + + /// Undelegate end receipt. + #[cbor(optional)] + pub receipt: u64, + + /// Amount of tokens received. + #[cbor(optional)] + pub amount: u128, + + /// Consensus layer error. + #[cbor(optional)] + pub error: Option, } /// Balance query. @@ -79,6 +134,10 @@ pub struct AccountBalance { pub struct DelegationInfo { /// The amount of owned shares. pub shares: u128, + + /// Receipt identifier for this undelegation. + #[cbor(optional)] + pub receipt: u64, } /// Extended information about a delegation. @@ -130,6 +189,8 @@ pub struct ConsensusDelegateContext { pub nonce: u64, pub to: Address, pub amount: token::BaseUnits, + #[cbor(optional)] + pub receipt: bool, } /// Context for consensus undelegate message handler. @@ -139,6 +200,8 @@ pub struct ConsensusUndelegateContext { pub nonce: u64, pub to: Address, pub shares: u128, + #[cbor(optional)] + pub receipt: bool, } /// Error details from the consensus layer. diff --git a/tests/e2e/contracts/delegation/Makefile b/tests/e2e/contracts/delegation/Makefile new file mode 100644 index 0000000000..1742f63b15 --- /dev/null +++ b/tests/e2e/contracts/delegation/Makefile @@ -0,0 +1,6 @@ +contract = delegation.sol +abi = delegation.abi +hex = delegation.hex + +include ../contracts.mk + diff --git a/tests/e2e/contracts/delegation/delegation.abi b/tests/e2e/contracts/delegation/delegation.abi new file mode 100644 index 0000000000..7143b904b8 --- /dev/null +++ b/tests/e2e/contracts/delegation/delegation.abi @@ -0,0 +1 @@ +[{"inputs":[{"internalType":"uint64","name":"code","type":"uint64"},{"internalType":"bytes","name":"module","type":"bytes"}],"name":"SubcallFailed","type":"error"},{"inputs":[{"internalType":"bytes","name":"to","type":"bytes"}],"name":"delegate","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"uint8","name":"receiptId","type":"uint8"}],"name":"delegateDone","outputs":[{"internalType":"uint128","name":"","type":"uint128"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes","name":"from","type":"bytes"},{"internalType":"uint128","name":"shares","type":"uint128"}],"name":"undelegate","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint8","name":"receiptId","type":"uint8"}],"name":"undelegateDone","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint8","name":"receiptId","type":"uint8"}],"name":"undelegateStart","outputs":[],"stateMutability":"nonpayable","type":"function"}] \ No newline at end of file diff --git a/tests/e2e/contracts/delegation/delegation.go b/tests/e2e/contracts/delegation/delegation.go new file mode 100644 index 0000000000..30c2a35f58 --- /dev/null +++ b/tests/e2e/contracts/delegation/delegation.go @@ -0,0 +1,36 @@ +package subcall + +import ( + _ "embed" + "encoding/hex" + "fmt" + "strings" + + ethABI "github.com/ethereum/go-ethereum/accounts/abi" +) + +// CompiledHex is the compiled subcall contract in hex encoding. +// +//go:embed delegation.hex +var CompiledHex string + +// Compiled is the compiled subcall contract. +var Compiled = func() []byte { + contract, err := hex.DecodeString(strings.TrimSpace(CompiledHex)) + if err != nil { + panic(fmt.Errorf("failed to decode contract: %w", err)) + } + return contract +}() + +//go:embed delegation.abi +var abiJSON string + +// ABI is the ABI of the subcall contract. +var ABI = func() ethABI.ABI { + abi, err := ethABI.JSON(strings.NewReader(abiJSON)) + if err != nil { + panic(err) + } + return abi +}() diff --git a/tests/e2e/contracts/delegation/delegation.hex b/tests/e2e/contracts/delegation/delegation.hex new file mode 100644 index 0000000000..21617e9dcc --- /dev/null +++ b/tests/e2e/contracts/delegation/delegation.hex @@ -0,0 +1 @@ +608060405234801561000f575f80fd5b50611d768061001d5f395ff3fe608060405260043610610049575f3560e01c80630ccfac9e1461004d5780631188461c1461007e57806338154f40146100b557806376a1b8b8146100d6578063e82d8bc6146100f5575b5f80fd5b61006061005b3660046116c3565b610114565b60405167ffffffffffffffff90911681526020015b60405180910390f35b348015610089575f80fd5b5061009d610098366004611702565b6103b3565b6040516001600160801b039091168152602001610075565b3480156100c0575f80fd5b506100d46100cf366004611702565b6106e1565b005b3480156100e1575f80fd5b506100606100f0366004611729565b610c20565b348015610100575f80fd5b506100d461010f366004611702565b61102e565b5f8054349060ff1682806101278361179b565b82546101009290920a60ff8181021990931691831602179091555f5416905060178111156101705760405162461bcd60e51b8152600401610167906117b9565b60405180910390fd5b5f80610103600160981b016001600160a01b031660405180604001604052806012815260200171636f6e73656e7375732e44656c656761746560701b815250888887876040516020016101c694939291906117f0565b60408051601f19818403018152908290526101e492916020016118d7565b60408051601f19818403018152908290526101fe91611904565b5f604051808303815f865af19150503d805f8114610237576040519150601f19603f3d011682016040523d82523d5f602084013e61023c565b606091505b50915091508161028e5760405162461bcd60e51b815260206004820152601760248201527f64656c65676174652073756263616c6c206661696c65640000000000000000006044820152606401610167565b5f80828060200190518101906102a49190611933565b915091508167ffffffffffffffff165f146102d657818160405163575a7c4d60e01b81526004016101679291906119f5565b6040518060600160405280336001600160a01b031681526020018a8a8080601f0160208091040260200160405190810160405280939291908181526020018383808284375f9201829052509385525050506001600160801b03891660209283015260ff8816815260018083526040909120835181546001600160a01b0319166001600160a01b0390911617815591830151908201906103759082611aa4565b5060409190910151600290910180546001600160801b0319166001600160801b0390921691909117905550505060ff91909116925050505b92915050565b5f60178260ff1611156103d85760405162461bcd60e51b8152600401610167906117b9565b60ff82165f90815260016020818152604080842081516060810190925280546001600160a01b03168252928301805491939284019161041690611a1f565b80601f016020809104026020016040519081016040528092919081815260200182805461044290611a1f565b801561048d5780601f106104645761010080835404028352916020019161048d565b820191905f5260205f20905b81548152906001019060200180831161047057829003601f168201915b5050509183525050600291909101546001600160801b031660209091015280519091506001600160a01b03166104d55760405162461bcd60e51b815260040161016790611b60565b5f6104e1600185611470565b90505f815f815181106104f6576104f6611b89565b6020910101516001600160f81b03191660a160f81b14801561053d57508160018151811061052657610526611b89565b6020910101516001600160f81b031916603360f91b145b801561056e57508160028151811061055757610557611b89565b6020910101516001600160f81b031916607360f81b145b15610698575f8260088151811061058757610587611b89565b60209101015160f81c601f1690505f5b8160ff1681101561061d575f846105af836009611b9d565b815181106105bf576105bf611b89565b016020015160f81c905060016105d88360ff8616611bb0565b6105e29190611bb0565b6105ed906008611bc3565b6106079060ff83166001600160801b039091161b85611bee565b935050808061061590611c15565b915050610597565b5083516001600160a01b03165f90815260026020908152604091829020908601519151849261064b91611904565b90815260405190819003602001902080545f906106729084906001600160801b0316611bee565b92506101000a8154816001600160801b0302191690836001600160801b03160217905550505b60ff85165f908152600160208190526040822080546001600160a01b031916815591906106c79083018261162c565b5060020180546001600160801b0319169055949350505050565b60178160ff1611156107055760405162461bcd60e51b8152600401610167906117b9565b60ff81165f9081526003602052604080822081516080810190925280548290829061072f90611a1f565b80601f016020809104026020016040519081016040528092919081815260200182805461075b90611a1f565b80156107a65780601f1061077d576101008083540402835291602001916107a6565b820191905f5260205f20905b81548152906001019060200180831161078957829003601f168201915b505050918352505060018201546001600160a01b039081166020808401919091526002909301546001600160801b0381166040840152600160801b900460ff1660609092019190915290820151919250166108135760405162461bcd60e51b815260040161016790611b60565b5f61081f600284611470565b9050805f8151811061083357610833611b89565b6020910101516001600160f81b031916605160f91b14801561087a57508060018151811061086357610863611b89565b6020910101516001600160f81b031916606560f81b145b80156108ab57508060028151811061089457610894611b89565b6020910101516001600160f81b031916606560f81b145b80156108dc5750806003815181106108c5576108c5611b89565b6020910101516001600160f81b031916600760fc1b145b15610b6b575f80600790505f838260ff16815181106108fd576108fd611b89565b60209101015160f81c601f1690506017811161092b5760ff81169250816109238161179b565b925050610a14565b8060ff166018036109cc5783610942836001611c2d565b60ff168151811061095557610955611b89565b016020015160f81c925061096a600283611c2d565b915060188367ffffffffffffffff1610156109c75760405162461bcd60e51b815260206004820152601a60248201527f6d616c666f726d65642065706f636820696e20726563656970740000000000006044820152606401610167565b610a14565b60405162461bcd60e51b815260206004820152601860248201527f756e737570706f727465642065706f6368206c656e67746800000000000000006044820152606401610167565b838260ff1681518110610a2957610a29611b89565b6020910101516001600160f81b031916606760f81b148015610a7c575083610a52836001611c2d565b60ff1681518110610a6557610a65611b89565b6020910101516001600160f81b031916603960f91b145b610abc5760405162461bcd60e51b81526020600482015260116024820152701b585b199bdc9b5959081c9958d95a5c1d607a1b6044820152606401610167565b5f84610ac9846008611c2d565b60ff1681518110610adc57610adc611b89565b6020910181015160ff89165f908152600383526040808220600201805460ff60801b191660f89490941c601f16600160801b81029490941790558981015183835260049094528120805492945091610b3e9084906001600160801b0316611bee565b92506101000a8154816001600160801b0302191690836001600160801b0316021790555050505050505050565b604080830151335f90815260026020528290208451925191929091610b909190611904565b90815260405190819003602001902080545f90610bb79084906001600160801b0316611bee565b82546001600160801b039182166101009390930a92830291909202199091161790555060ff83165f90815260036020526040812090610bf6828261162c565b506001810180546001600160a01b031916905560020180546001600160881b03191690555b505050565b5f80826001600160801b031611610c795760405162461bcd60e51b815260206004820152601b60248201527f6d75737420756e64656c656761746520736f6d652073686172657300000000006044820152606401610167565b335f908152600260205260409081902090516001600160801b0384169190610ca49087908790611c46565b908152604051908190036020019020546001600160801b03161015610d155760405162461bcd60e51b815260206004820152602160248201527f6d757374206861766520656e6f7567682064656c6567617465642073686172656044820152607360f81b6064820152608401610167565b5f805460ff169080610d268361179b565b82546101009290920a60ff8181021990931691831602179091555f541690506017811115610d665760405162461bcd60e51b8152600401610167906117b9565b5f80610103600160981b016001600160a01b031660405180604001604052806014815260200173636f6e73656e7375732e556e64656c656761746560601b81525088888887604051602001610dbe9493929190611c55565b60408051601f1981840301815290829052610ddc92916020016118d7565b60408051601f1981840301815290829052610df691611904565b5f604051808303815f865af19150503d805f8114610e2f576040519150601f19603f3d011682016040523d82523d5f602084013e610e34565b606091505b509150915081610e865760405162461bcd60e51b815260206004820152601960248201527f756e64656c65676174652073756263616c6c206661696c6564000000000000006044820152606401610167565b5f8082806020019051810190610e9c9190611933565b915091508167ffffffffffffffff165f14610ece57818160405163575a7c4d60e01b81526004016101679291906119f5565b335f90815260026020526040908190209051889190610ef0908c908c90611c46565b90815260405190819003602001902080545f90610f179084906001600160801b0316611cef565b92506101000a8154816001600160801b0302191690836001600160801b0316021790555060405180608001604052808a8a8080601f0160208091040260200160405190810160405280939291908181526020018383808284375f9201829052509385525050336020808501919091526001600160801b038c16604080860191909152606090940183905260ff8a16835260039052502081518190610fbb9082611aa4565b5060208201516001820180546001600160a01b0319166001600160a01b039092169190911790556040820151600290910180546060909301516001600160801b039092166001600160881b031990931692909217600160801b60ff92831602179091559490941698975050505050505050565b60178160ff1611156110525760405162461bcd60e51b8152600401610167906117b9565b60ff81165f9081526003602052604080822081516080810190925280548290829061107c90611a1f565b80601f01602080910402602001604051908101604052809291908181526020018280546110a890611a1f565b80156110f35780601f106110ca576101008083540402835291602001916110f3565b820191905f5260205f20905b8154815290600101906020018083116110d657829003601f168201915b505050918352505060018201546001600160a01b039081166020808401919091526002909301546001600160801b0381166040840152600160801b900460ff1660609092019190915290820151919250166111605760405162461bcd60e51b815260040161016790611b60565b5f816060015160ff16116111b65760405162461bcd60e51b815260206004820152601f60248201527f6d7573742063616c6c20756e64656c65676174655374617274206669727374006044820152606401610167565b606081015160ff165f9081526004602090815260408083208151808301909252546001600160801b038082168352600160801b9091041691810182905291036113c5575f61120960038460600151611470565b90505f815f8151811061121e5761121e611b89565b6020910101516001600160f81b03191660a160f81b14801561126557508160018151811061124e5761124e611b89565b6020910101516001600160f81b031916603360f91b145b801561129657508160028151811061127f5761127f611b89565b6020910101516001600160f81b031916606160f81b145b15611384575f826008815181106112af576112af611b89565b60209101015160f81c601f1690505f5b8160ff16811015611345575f846112d7836009611b9d565b815181106112e7576112e7611b89565b016020015160f81c905060016113008360ff8616611bb0565b61130a9190611bb0565b611315906008611bc3565b61132f9060ff83166001600160801b039091161b85611bee565b935050808061133d90611c15565b9150506112bf565b5050606084015160ff165f90815260046020908152604090912080546001600160801b03808516600160801b81029190921617909155908401526113c2565b60405162461bcd60e51b81526020600482015260136024820152721d5b99195b1959d85d1a5bdb8819985a5b1959606a1b6044820152606401610167565b50505b8051602082015160408401515f92916113dd91611bc3565b6113e79190611d0f565b60208401516040519192506001600160a01b0316906001600160801b03831680156108fc02915f818181858888f19350505050158015611429573d5f803e3d5ffd5b5060ff84165f90815260036020526040812090611446828261162c565b506001810180546001600160a01b031916905560020180546001600160881b031916905550505050565b60605f80610103600160981b016001600160a01b03166040518060400160405280601581526020017418dbdb9cd95b9cdd5ccb95185ad9549958d95a5c1d605a1b815250858760405160200161151292919061513160f11b8152611a5960f21b600282015260f892831b6001600160f81b03199081166004830152601960fa1b6005830152631ada5b9960e21b60068301529190921b16600a820152600b0190565b60408051601f198184030181529082905261153092916020016118d7565b60408051601f198184030181529082905261154a91611904565b5f604051808303815f865af19150503d805f8114611583576040519150601f19603f3d011682016040523d82523d5f602084013e611588565b606091505b5091509150816115da5760405162461bcd60e51b815260206004820152601b60248201527f74616b6520726563656970742073756263616c6c206661696c656400000000006044820152606401610167565b5f80828060200190518101906115f09190611933565b915091508167ffffffffffffffff165f1461162257818160405163575a7c4d60e01b81526004016101679291906119f5565b9695505050505050565b50805461163890611a1f565b5f825580601f10611647575050565b601f0160209004905f5260205f20908101906116639190611666565b50565b5b8082111561167a575f8155600101611667565b5090565b5f8083601f84011261168e575f80fd5b50813567ffffffffffffffff8111156116a5575f80fd5b6020830191508360208285010111156116bc575f80fd5b9250929050565b5f80602083850312156116d4575f80fd5b823567ffffffffffffffff8111156116ea575f80fd5b6116f68582860161167e565b90969095509350505050565b5f60208284031215611712575f80fd5b813560ff81168114611722575f80fd5b9392505050565b5f805f6040848603121561173b575f80fd5b833567ffffffffffffffff811115611751575f80fd5b61175d8682870161167e565b90945092505060208401356001600160801b038116811461177c575f80fd5b809150509250925092565b634e487b7160e01b5f52601160045260245ffd5b5f60ff821660ff81036117b0576117b0611787565b60010192915050565b6020808252601b908201527f72656365697074206964656e746966696572206f766572666c6f770000000000604082015260600190565b6151b160f11b815261746f60f01b6002820152605560f81b600482015283856005830137603360f91b6005919094019081019390935265185b5bdd5b9d60d21b600684015261082560f41b600c84015260809190911b6001600160801b031916600e83015261406760f01b601e830152661c9958d95a5c1d60ca1b602083015260f81b6001600160f81b0319166027820152602801919050565b5f5b838110156118a457818101518382015260200161188c565b50505f910152565b5f81518084526118c381602086016020860161188a565b601f01601f19169290920160200192915050565b604081525f6118e960408301856118ac565b82810360208401526118fb81856118ac565b95945050505050565b5f825161191581846020870161188a565b9190910192915050565b634e487b7160e01b5f52604160045260245ffd5b5f8060408385031215611944575f80fd5b825167ffffffffffffffff808216821461195c575f80fd5b602085015191935080821115611970575f80fd5b818501915085601f830112611983575f80fd5b8151818111156119955761199561191f565b604051601f8201601f19908116603f011681019083821181831017156119bd576119bd61191f565b816040528281528860208487010111156119d5575f80fd5b6119e683602083016020880161188a565b80955050505050509250929050565b67ffffffffffffffff83168152604060208201525f611a1760408301846118ac565b949350505050565b600181811c90821680611a3357607f821691505b602082108103611a5157634e487b7160e01b5f52602260045260245ffd5b50919050565b601f821115610c1b575f81815260208120601f850160051c81016020861015611a7d5750805b601f850160051c820191505b81811015611a9c57828155600101611a89565b505050505050565b815167ffffffffffffffff811115611abe57611abe61191f565b611ad281611acc8454611a1f565b84611a57565b602080601f831160018114611b05575f8415611aee5750858301515b5f19600386901b1c1916600185901b178555611a9c565b5f85815260208120601f198616915b82811015611b3357888601518255948401946001909101908401611b14565b5085821015611b5057878501515f19600388901b60f8161c191681555b5050505050600190811b01905550565b6020808252600f908201526e1d5b9adb9bdddb881c9958d95a5c1d608a1b604082015260600190565b634e487b7160e01b5f52603260045260245ffd5b808201808211156103ad576103ad611787565b818103818111156103ad576103ad611787565b6001600160801b03818116838216028082169190828114611be657611be6611787565b505092915050565b6001600160801b03818116838216019080821115611c0e57611c0e611787565b5092915050565b5f60018201611c2657611c26611787565b5060010190565b60ff81811683821601908111156103ad576103ad611787565b818382375f9101908152919050565b6128d960f21b81526366726f6d60e01b6002820152605560f81b600682015283856007830137603360f91b600791909401908101939093526573686172657360d01b6008840152600560fc1b600e84015260809190911b6001600160801b031916600f830152606760f81b601f830152661c9958d95a5c1d60ca1b602083015260f81b6001600160f81b0319166027820152602801919050565b6001600160801b03828116828216039080821115611c0e57611c0e611787565b5f6001600160801b0380841680611d3457634e487b7160e01b5f52601260045260245ffd5b9216919091049291505056fea2646970667358221220367068be4e2fb70d67c3b383805ad40aa3d5fc25718e2588d3ae6f20e15fc13b64736f6c63430008150033 \ No newline at end of file diff --git a/tests/e2e/contracts/delegation/delegation.sol b/tests/e2e/contracts/delegation/delegation.sol new file mode 100644 index 0000000000..060da5d843 --- /dev/null +++ b/tests/e2e/contracts/delegation/delegation.sol @@ -0,0 +1,252 @@ +pragma solidity ^0.8.0; + +contract Test { + string private constant CONSENSUS_DELEGATE = "consensus.Delegate"; + string private constant CONSENSUS_UNDELEGATE = "consensus.Undelegate"; + string private constant CONSENSUS_TAKE_RECEIPT = "consensus.TakeReceipt"; + + uint8 private constant RECEIPT_KIND_DELEGATE = 1; + uint8 private constant RECEIPT_KIND_UNDELEGATE_START = 2; + uint8 private constant RECEIPT_KIND_UNDELEGATE_DONE = 3; + + address private constant SUBCALL = 0x0100000000000000000000000000000000000103; + + // NOTE: All receipt identifiers are uint8 and <= 23 to simplify CBOR encoding/decoding. + uint8 lastReceiptId; + + // receiptId => PendingDelegation + mapping(uint8 => PendingDelegation) pendingDelegations; + // (from, to) => shares + mapping(address => mapping(bytes => uint128)) delegations; + + // receiptId => PendingUndelegation + mapping(uint8 => PendingUndelegation) pendingUndelegations; + // endReceiptId => UndelegationPool + mapping(uint8 => UndelegationPool) undelegationPools; + + struct PendingDelegation { + address from; + bytes to; + uint128 amount; + } + + struct PendingUndelegation { + bytes from; + address payable to; + uint128 shares; + uint8 endReceiptId; + } + + struct UndelegationPool { + uint128 totalShares; + uint128 totalAmount; + } + + error SubcallFailed(uint64 code, bytes module); + + function delegate(bytes calldata to) public payable returns (uint64) { + // Whatever is sent to the contract is delegated. + uint128 amount = uint128(msg.value); + + lastReceiptId++; + uint8 receiptId = lastReceiptId; + require(receiptId <= 23, "receipt identifier overflow"); // Because our CBOR encoder is lazy. + + // Delegate to target address. + (bool success, bytes memory data) = SUBCALL.call(abi.encode( + CONSENSUS_DELEGATE, + abi.encodePacked( + hex"a362", + "to", + hex"55", + to, + hex"66", + "amount", + hex"8250", + amount, + hex"4067", + "receipt", + receiptId // Only works for values <= 23. + ) + )); + require(success, "delegate subcall failed"); + (uint64 status, bytes memory result) = abi.decode(data, (uint64, bytes)); + if (status != 0) { + revert SubcallFailed(status, result); + } + + pendingDelegations[receiptId] = PendingDelegation(msg.sender, to, amount); + + return receiptId; + } + + function takeReceipt(uint8 kind, uint8 receiptId) internal returns (bytes memory) { + (bool success, bytes memory data) = SUBCALL.call(abi.encode( + CONSENSUS_TAKE_RECEIPT, + abi.encodePacked( + hex"a262", + "id", + receiptId, // Only works for values <= 23. + hex"64", + "kind", + kind // Only works for values <= 23. + ) + )); + require(success, "take receipt subcall failed"); + (uint64 status, bytes memory result) = abi.decode(data, (uint64, bytes)); + if (status != 0) { + revert SubcallFailed(status, result); + } + + return result; + } + + function delegateDone(uint8 receiptId) public returns (uint128) { + require(receiptId <= 23, "receipt identifier overflow"); // Because our CBOR encoder is lazy. + + PendingDelegation memory pending = pendingDelegations[receiptId]; + require(pending.from != address(0), "unknown receipt"); + + bytes memory result = takeReceipt(RECEIPT_KIND_DELEGATE, receiptId); + + // This is a very lazy CBOR decoder. It assumes that if there is only a shares field then + // the delegation operation succeeded and if not, there was some sort of error which is + // not decoded. + uint128 shares = 0; + if (result[0] == 0xA1 && result[1] == 0x66 && result[2] == "s") { + // Delegation succeeded, decode number of shares. + uint8 sharesLen = uint8(result[8]) & 0x1f; // Assume shares field is never greater than 16 bytes. + for (uint offset = 0; offset < sharesLen; offset++) { + uint8 v = uint8(result[9 + offset]); + shares += uint128(v) << 8*uint128(sharesLen - offset - 1); + } + + // Add to given number of shares. + delegations[pending.from][pending.to] += shares; + } else { + // Should refund the owner in case of errors. This just keeps the funds. + } + + // Remove pending delegation. + delete(pendingDelegations[receiptId]); + + return shares; + } + + function undelegate(bytes calldata from, uint128 shares) public returns (uint64) { + require(shares > 0, "must undelegate some shares"); + require(delegations[msg.sender][from] >= shares, "must have enough delegated shares"); + + lastReceiptId++; + uint8 receiptId = lastReceiptId; + require(receiptId <= 23, "receipt identifier overflow"); // Because our CBOR encoder is lazy. + + // Start undelegation from source address. + (bool success, bytes memory data) = SUBCALL.call(abi.encode( + CONSENSUS_UNDELEGATE, + abi.encodePacked( + hex"a364", + "from", + hex"55", + from, + hex"66", + "shares", + hex"50", + shares, + hex"67", + "receipt", + receiptId // Only works for values <= 23. + ) + )); + require(success, "undelegate subcall failed"); + (uint64 status, bytes memory result) = abi.decode(data, (uint64, bytes)); + if (status != 0) { + revert SubcallFailed(status, result); + } + + delegations[msg.sender][from] -= shares; + pendingUndelegations[receiptId] = PendingUndelegation(from, payable(msg.sender), shares, 0); + + return receiptId; + } + + function undelegateStart(uint8 receiptId) public { + require(receiptId <= 23, "receipt identifier overflow"); // Because our CBOR encoder is lazy. + + PendingUndelegation memory pending = pendingUndelegations[receiptId]; + require(pending.to != address(0), "unknown receipt"); + + bytes memory result = takeReceipt(RECEIPT_KIND_UNDELEGATE_START, receiptId); + + // This is a very lazy CBOR decoder. It assumes that if there are only an epoch and receipt fields + // then the undelegation operation succeeded and if not, there was some sort of error which is not + // decoded. + if (result[0] == 0xA2 && result[1] == 0x65 && result[2] == "e" && result[3] == "p") { + // Undelegation started, decode end epoch (only supported up to epoch 255). + uint64 epoch = 0; + uint8 fieldOffset = 7; + uint8 epochLow = uint8(result[fieldOffset]) & 0x1f; + if (epochLow <= 23) { + epoch = uint64(epochLow); + fieldOffset++; + } else if (epochLow == 24) { + epoch = uint64(uint8(result[fieldOffset + 1])); + fieldOffset += 2; + require(epoch >= 24, "malformed epoch in receipt"); + } else { + // A real implementation would support decoding bigger epoch numbers. + revert("unsupported epoch length"); + } + + // Decode end receipt identifier. + require(result[fieldOffset] == 0x67 && result[fieldOffset + 1] == "r", "malformed receipt"); + uint8 endReceipt = uint8(result[fieldOffset + 8]) & 0x1f; // Assume end receipt is never greater than 23. + + pendingUndelegations[receiptId].endReceiptId = endReceipt; + undelegationPools[endReceipt].totalShares += pending.shares; + } else { + // Undelegation failed to start, return the shares. + delegations[msg.sender][pending.from] += pending.shares; + delete(pendingUndelegations[receiptId]); + } + } + + function undelegateDone(uint8 receiptId) public { + require(receiptId <= 23, "receipt identifier overflow"); // Because our CBOR encoder is lazy. + + PendingUndelegation memory pending = pendingUndelegations[receiptId]; + require(pending.to != address(0), "unknown receipt"); + require(pending.endReceiptId > 0, "must call undelegateStart first"); + + UndelegationPool memory pool = undelegationPools[pending.endReceiptId]; + if (pool.totalAmount == 0) { + // Did not fetch the end receipt yet, do it now. + bytes memory result = takeReceipt(RECEIPT_KIND_UNDELEGATE_DONE, pending.endReceiptId); + + // This is a very lazy CBOR decoder. It assumes that if there is only an amount field then + // the undelegation operation succeeded and if not, there was some sort of error which is + // not decoded. + uint128 amount = 0; + if (result[0] == 0xA1 && result[1] == 0x66 && result[2] == "a") { + // Undelegation succeeded, decode token amount. + uint8 amountLen = uint8(result[8]) & 0x1f; // Assume amount field is never greater than 16 bytes. + for (uint offset = 0; offset < amountLen; offset++) { + uint8 v = uint8(result[9 + offset]); + amount += uint128(v) << 8*uint128(amountLen - offset - 1); + } + + undelegationPools[pending.endReceiptId].totalAmount = amount; + pool.totalAmount = amount; + } else { + // Should never fail. + revert("undelegation failed"); + } + } + + // Compute how much we get from the pool and transfer the amount. + uint128 transferAmount = (pending.shares * pool.totalAmount) / pool.totalShares; + pending.to.transfer(transferAmount); + + delete(pendingUndelegations[receiptId]); + } +} diff --git a/tests/e2e/evmtest.go b/tests/e2e/evmtest.go index 6e9adb60e7..a262b0e338 100644 --- a/tests/e2e/evmtest.go +++ b/tests/e2e/evmtest.go @@ -32,6 +32,7 @@ import ( "github.com/oasisprotocol/oasis-sdk/client-sdk/go/testing" "github.com/oasisprotocol/oasis-sdk/client-sdk/go/types" + contractDelegation "github.com/oasisprotocol/oasis-sdk/tests/e2e/contracts/delegation" contractRng "github.com/oasisprotocol/oasis-sdk/tests/e2e/contracts/rng" contractSubcall "github.com/oasisprotocol/oasis-sdk/tests/e2e/contracts/subcall" "github.com/oasisprotocol/oasis-sdk/tests/e2e/txgen" @@ -1323,6 +1324,168 @@ func SubcallDelegationTest(sc *RuntimeScenario, log *logging.Logger, conn *grpc. return nil } +func DelegationReceiptsTest(_ *RuntimeScenario, log *logging.Logger, conn *grpc.ClientConn, rtc client.RuntimeClient) error { + ctx := context.Background() + ev := evm.NewV1(rtc) + consAccounts := consensusAccounts.NewV1(rtc) + gasPrice := uint64(2) + + // Deploy the contract. + value := big.NewInt(0).Bytes() // Don't send any tokens. + contractAddr, err := evmCreate(ctx, rtc, ev, testing.Dave.Signer, value, contractDelegation.Compiled, gasPrice, nonc10l) + if err != nil { + return fmt.Errorf("failed to deploy contract: %w", err) + } + + // Start watching consensus and runtime events. + cons := consensus.NewConsensusClient(conn) + stakingClient := cons.Staking() + ch, sub, err := stakingClient.WatchEvents(ctx) + if err != nil { + return err + } + defer sub.Close() + acCh, err := rtc.WatchEvents(ctx, []client.EventDecoder{consAccounts}, false) + if err != nil { + return err + } + + // Fetch initial Dave's balance. + initialBalance, err := ev.Balance(ctx, client.RoundLatest, testing.Dave.EthAddress.Bytes()) + if err != nil { + return fmt.Errorf("failed to fetch initial balance: %w", err) + } + + // Call the method. + consensusAmount := quantity.NewFromUint64(10) // Consensus amount is scaled. + rawAddress, _ := testing.Alice.Address.MarshalBinary() + data, err := contractDelegation.ABI.Pack("delegate", rawAddress) + if err != nil { + return fmt.Errorf("failed to pack arguments: %w", err) + } + + value = big.NewInt(10_000).Bytes() // Any amount sent to `delegate` is delegated. + result, err := evmCall(ctx, rtc, ev, testing.Dave.Signer, contractAddr, value, data, gasPrice, nonc10l) + if err != nil { + return fmt.Errorf("failed to call contract: %w", err) + } + + // Decode the result receipt id. + results, err := contractDelegation.ABI.Unpack("delegate", result) + if err != nil { + return fmt.Errorf("failed to unpack result: %w", err) + } + receiptID := results[0].(uint64) + + // Verify that delegation succeeded. + sdkAmount := types.NewBaseUnits(*quantity.NewFromUint64(10_000), types.NativeDenomination) + runtimeAddr := staking.NewRuntimeAddress(runtimeID) + contractSdkAddress := types.NewAddressFromEth(contractAddr) + if err = ensureStakingEvent(log, ch, makeAddEscrowCheck(runtimeAddr, staking.Address(testing.Alice.Address), consensusAmount)); err != nil { + return fmt.Errorf("ensuring runtime->alice add escrow consensus event: %w", err) + } + if err = ensureRuntimeEvent(log, acCh, makeDelegateCheck(contractSdkAddress, receiptID, testing.Alice.Address, sdkAmount)); err != nil { + return fmt.Errorf("ensuring contract->alice delegate runtime event: %w", err) + } + + // Call the delegate done. + data, err = contractDelegation.ABI.Pack("delegateDone", uint8(receiptID)) // uint8 to simplify CBOR encoding. + if err != nil { + return fmt.Errorf("failed to pack arguments: %w", err) + } + + value = big.NewInt(0).Bytes() // Don't send any tokens. + result, err = evmCall(ctx, rtc, ev, testing.Dave.Signer, contractAddr, value, data, gasPrice, nonc10l) + if err != nil { + return fmt.Errorf("failed to call contract: %w", err) + } + + // Decode the number of received shares. + results, err = contractDelegation.ABI.Unpack("delegateDone", result) + if err != nil { + return fmt.Errorf("failed to unpack result: %w", err) + } + shares := results[0].(*big.Int).Uint64() // We know the actual value is less than uint128. + + if expectedShares := uint64(10); shares != expectedShares { + return fmt.Errorf("received unexpected number of shares (expected: %d got: %d)", expectedShares, shares) + } + + // Now trigger undelegation for half the shares. + consensusShares := quantity.NewFromUint64(5) + consensusAmount = quantity.NewFromUint64(5) // Expected amount of tokens to receive. + sdkAmount = types.NewBaseUnits(*quantity.NewFromUint64(5_000), types.NativeDenomination) + data, err = contractDelegation.ABI.Pack("undelegate", rawAddress, big.NewInt(5)) + if err != nil { + return fmt.Errorf("failed to pack arguments: %w", err) + } + + value = big.NewInt(0).Bytes() // Don't send any tokens. + result, err = evmCall(ctx, rtc, ev, testing.Dave.Signer, contractAddr, value, data, gasPrice, nonc10l) + if err != nil { + return fmt.Errorf("failed to call contract: %w", err) + } + + // Decode the result receipt id. + results, err = contractDelegation.ABI.Unpack("undelegate", result) + if err != nil { + return fmt.Errorf("failed to unpack result: %w", err) + } + receiptID = results[0].(uint64) + + // Verify that undelegation started. + if err = ensureRuntimeEvent(log, acCh, makeUndelegateStartCheck(testing.Alice.Address, receiptID, contractSdkAddress, consensusShares)); err != nil { + return fmt.Errorf("ensuring alice->contract undelegate start runtime event: %w", err) + } + + // Call the undelegate start method. + data, err = contractDelegation.ABI.Pack("undelegateStart", uint8(receiptID)) // uint8 to simplify CBOR encoding. + if err != nil { + return fmt.Errorf("failed to pack arguments: %w", err) + } + + value = big.NewInt(0).Bytes() // Don't send any tokens. + _, err = evmCall(ctx, rtc, ev, testing.Dave.Signer, contractAddr, value, data, gasPrice, nonc10l) + if err != nil { + return fmt.Errorf("failed to call contract: %w", err) + } + + // Verify that undelegation completed. + if err = ensureStakingEvent(log, ch, makeReclaimEscrowCheck(testing.Alice.Address.ConsensusAddress(), runtimeAddr, consensusAmount)); err != nil { + return fmt.Errorf("ensuring alice->runtime reclaim escrow consensus event: %w", err) + } + + if err = ensureRuntimeEvent(log, acCh, makeUndelegateDoneCheck(testing.Alice.Address, contractSdkAddress, consensusShares, sdkAmount)); err != nil { + return fmt.Errorf("ensuring alice->contract undelegate done runtime event: %w", err) + } + + // Call the undelegate done method. + data, err = contractDelegation.ABI.Pack("undelegateDone", uint8(receiptID)) // uint8 to simplify CBOR encoding. + if err != nil { + return fmt.Errorf("failed to pack arguments: %w", err) + } + + value = big.NewInt(0).Bytes() // Don't send any tokens. + _, err = evmCall(ctx, rtc, ev, testing.Dave.Signer, contractAddr, value, data, gasPrice, nonc10l) + if err != nil { + return fmt.Errorf("failed to call contract: %w", err) + } + + // Check balance. + balance, err := ev.Balance(ctx, client.RoundLatest, testing.Dave.EthAddress.Bytes()) + if err != nil { + return fmt.Errorf("failed to check balance: %w", err) + } + + // We delegated 10_000 then undelegated 5_000. All gas fees were zero. + expectedBalance := initialBalance.ToBigInt().Uint64() - 5_000 + if balance.ToBigInt().Uint64() != expectedBalance { + return fmt.Errorf("unexpected dave balance (expected: %d got: %s)", expectedBalance, balance) + } + + return nil +} + // EVMParametersTest tests parameters methods. func EVMParametersTest(sc *RuntimeScenario, log *logging.Logger, conn *grpc.ClientConn, rtc client.RuntimeClient) error { ctx := context.Background() diff --git a/tests/e2e/scenarios.go b/tests/e2e/scenarios.go index a9b2db0ed5..114076a9a3 100644 --- a/tests/e2e/scenarios.go +++ b/tests/e2e/scenarios.go @@ -51,6 +51,7 @@ var ( SimpleEVMSuicideTest, SimpleEVMCallSuicideTest, SubcallDelegationTest, + DelegationReceiptsTest, EVMParametersTest, }, WithCustomFixture(EVMRuntimeFixture)) diff --git a/tests/runtimes/benchmarking/src/lib.rs b/tests/runtimes/benchmarking/src/lib.rs index b4633f144e..14d49a02fd 100644 --- a/tests/runtimes/benchmarking/src/lib.rs +++ b/tests/runtimes/benchmarking/src/lib.rs @@ -85,6 +85,7 @@ impl sdk::Runtime for Runtime { consensus_denomination: Denomination::NATIVE, // Scale to 18 decimal places as this is what is expected in the EVM ecosystem. consensus_scaling_factor: 1_000_000_000, + min_delegate_amount: 0, }, }, Default::default(), diff --git a/tests/runtimes/simple-consensus/src/lib.rs b/tests/runtimes/simple-consensus/src/lib.rs index 2880303ef4..592d3aed6a 100644 --- a/tests/runtimes/simple-consensus/src/lib.rs +++ b/tests/runtimes/simple-consensus/src/lib.rs @@ -52,6 +52,7 @@ impl sdk::Runtime for Runtime { consensus_denomination: "TEST".parse().unwrap(), // Test scaling consensus base units when transferring them into the runtime. consensus_scaling_factor: 1000, + min_delegate_amount: 2, }, }, modules::consensus_accounts::Genesis { diff --git a/tests/runtimes/simple-evm/src/lib.rs b/tests/runtimes/simple-evm/src/lib.rs index 4288edf80e..f8f96934f2 100644 --- a/tests/runtimes/simple-evm/src/lib.rs +++ b/tests/runtimes/simple-evm/src/lib.rs @@ -83,6 +83,7 @@ impl sdk::Runtime for Runtime { consensus_denomination: Denomination::NATIVE, // Test scaling consensus base units when transferring them into the runtime. consensus_scaling_factor: 1000, + min_delegate_amount: 2, }, }, modules::consensus_accounts::Genesis {