diff --git a/.changelog/unreleased/SDK/2321-ibc_shielded_transfer.md b/.changelog/unreleased/SDK/2321-ibc_shielded_transfer.md new file mode 100644 index 00000000000..a90b2c40753 --- /dev/null +++ b/.changelog/unreleased/SDK/2321-ibc_shielded_transfer.md @@ -0,0 +1,2 @@ +- ibc-transfer can set a spending key to the source + ([\#2321](https://github.com/anoma/namada/issues/2321)) \ No newline at end of file diff --git a/.changelog/unreleased/features/2321-ibc_shielded_transfer.md b/.changelog/unreleased/features/2321-ibc_shielded_transfer.md new file mode 100644 index 00000000000..1d6de75a7ab --- /dev/null +++ b/.changelog/unreleased/features/2321-ibc_shielded_transfer.md @@ -0,0 +1,2 @@ +- IBC transfer from a spending key + ([\#2321](https://github.com/anoma/namada/issues/2321)) \ No newline at end of file diff --git a/apps/src/lib/cli.rs b/apps/src/lib/cli.rs index 6c0fe2ea10d..fca330ec480 100644 --- a/apps/src/lib/cli.rs +++ b/apps/src/lib/cli.rs @@ -3897,7 +3897,7 @@ pub mod args { let chain_ctx = ctx.borrow_mut_chain_or_exit(); TxIbcTransfer:: { tx, - source: chain_ctx.get(&self.source), + source: chain_ctx.get_cached(&self.source), receiver: self.receiver, token: chain_ctx.get(&self.token), amount: self.amount, @@ -3914,7 +3914,7 @@ pub mod args { impl Args for TxIbcTransfer { fn parse(matches: &ArgMatches) -> Self { let tx = Tx::parse(matches); - let source = SOURCE.parse(matches); + let source = TRANSFER_SOURCE.parse(matches); let receiver = RECEIVER.parse(matches); let token = TOKEN.parse(matches); let amount = InputAmount::Unvalidated(AMOUNT.parse(matches)); diff --git a/apps/src/lib/client/tx.rs b/apps/src/lib/client/tx.rs index 3a53b35c1fa..f57cddd4eb5 100644 --- a/apps/src/lib/client/tx.rs +++ b/apps/src/lib/client/tx.rs @@ -961,8 +961,13 @@ pub async fn submit_ibc_transfer( where ::Error: std::fmt::Display, { - submit_reveal_aux(namada, args.tx.clone(), &args.source).await?; - let (mut tx, signing_data) = args.build(namada).await?; + submit_reveal_aux( + namada, + args.tx.clone(), + &args.source.effective_address(), + ) + .await?; + let (mut tx, signing_data, _) = args.build(namada).await?; if args.tx.dump_tx { tx::dump_tx(namada.io(), &args.tx, tx); @@ -971,6 +976,8 @@ where namada.submit(tx, &args.tx).await?; } + // NOTE that the tx could fail when its submission epoch doesn't match + // construction epoch Ok(()) } diff --git a/core/src/ledger/ibc/mod.rs b/core/src/ledger/ibc/mod.rs index 37b530fe9aa..b695aef259c 100644 --- a/core/src/ledger/ibc/mod.rs +++ b/core/src/ledger/ibc/mod.rs @@ -8,6 +8,7 @@ use std::fmt::Debug; use std::rc::Rc; use std::str::FromStr; +use borsh::BorshDeserialize; pub use context::common::IbcCommonContext; use context::router::IbcRouter; pub use context::storage::{IbcStorageContext, ProofSpec}; @@ -37,16 +38,16 @@ use crate::ibc::core::router::types::module::ModuleId; use crate::ibc::primitives::proto::Any; use crate::types::address::{Address, MASP}; use crate::types::ibc::{ - get_shielded_transfer, is_ibc_denom, EVENT_TYPE_DENOM_TRACE, - EVENT_TYPE_PACKET, + get_shielded_transfer, is_ibc_denom, MsgShieldedTransfer, + EVENT_TYPE_DENOM_TRACE, EVENT_TYPE_PACKET, }; use crate::types::masp::PaymentAddress; #[allow(missing_docs)] #[derive(Error, Debug)] pub enum Error { - #[error("Decoding IBC data error: {0}")] - DecodingData(prost::DecodeError), + #[error("Decoding IBC data error")] + DecodingData, #[error("Decoding message error: {0}")] DecodingMessage(RouterError), #[error("IBC context error: {0}")] @@ -99,28 +100,37 @@ where /// Execute according to the message in an IBC transaction or VP pub fn execute(&mut self, tx_data: &[u8]) -> Result<(), Error> { - let any_msg = Any::decode(tx_data).map_err(Error::DecodingData)?; - match MsgTransfer::try_from(any_msg.clone()) { - Ok(msg) => { + let message = decode_message(tx_data)?; + match &message { + IbcMessage::Transfer(msg) => { let mut token_transfer_ctx = TokenTransferContext::new(self.ctx.inner.clone()); send_transfer_execute( &mut self.ctx, &mut token_transfer_ctx, - msg, + msg.clone(), ) .map_err(Error::TokenTransfer) } - Err(_) => { - let envelope = MsgEnvelope::try_from(any_msg) - .map_err(Error::DecodingMessage)?; + IbcMessage::ShieldedTransfer(msg) => { + let mut token_transfer_ctx = + TokenTransferContext::new(self.ctx.inner.clone()); + send_transfer_execute( + &mut self.ctx, + &mut token_transfer_ctx, + msg.message.clone(), + ) + .map_err(Error::TokenTransfer)?; + self.handle_masp_tx(message) + } + IbcMessage::Envelope(envelope) => { execute(&mut self.ctx, &mut self.router, envelope.clone()) .map_err(|e| Error::Context(Box::new(e)))?; - // For receiving the token to a shielded address - self.handle_masp_tx(&envelope)?; // the current ibc-rs execution doesn't store the denom for the // token hash when transfer with MsgRecvPacket - self.store_denom(&envelope) + self.store_denom(envelope)?; + // For receiving the token to a shielded address + self.handle_masp_tx(message) } } } @@ -218,17 +228,25 @@ where /// Validate according to the message in IBC VP pub fn validate(&self, tx_data: &[u8]) -> Result<(), Error> { - let any_msg = Any::decode(tx_data).map_err(Error::DecodingData)?; - match MsgTransfer::try_from(any_msg.clone()) { - Ok(msg) => { + let message = decode_message(tx_data)?; + match message { + IbcMessage::Transfer(msg) => { let token_transfer_ctx = TokenTransferContext::new(self.ctx.inner.clone()); send_transfer_validate(&self.ctx, &token_transfer_ctx, msg) .map_err(Error::TokenTransfer) } - Err(_) => { - let envelope = MsgEnvelope::try_from(any_msg) - .map_err(Error::DecodingMessage)?; + IbcMessage::ShieldedTransfer(msg) => { + let token_transfer_ctx = + TokenTransferContext::new(self.ctx.inner.clone()); + send_transfer_validate( + &self.ctx, + &token_transfer_ctx, + msg.message, + ) + .map_err(Error::TokenTransfer) + } + IbcMessage::Envelope(envelope) => { validate(&self.ctx, &self.router, envelope) .map_err(|e| Error::Context(Box::new(e))) } @@ -236,9 +254,9 @@ where } /// Handle the MASP transaction if needed - fn handle_masp_tx(&mut self, envelope: &MsgEnvelope) -> Result<(), Error> { - let shielded_transfer = match envelope { - MsgEnvelope::Packet(PacketMsg::Recv(_)) => { + fn handle_masp_tx(&mut self, message: IbcMessage) -> Result<(), Error> { + let shielded_transfer = match message { + IbcMessage::Envelope(MsgEnvelope::Packet(PacketMsg::Recv(_))) => { let event = self .ctx .inner @@ -257,6 +275,7 @@ where None => return Ok(()), } } + IbcMessage::ShieldedTransfer(msg) => Some(msg.shielded_transfer), _ => return Ok(()), }; if let Some(shielded_transfer) = shielded_transfer { @@ -272,6 +291,31 @@ where } } +enum IbcMessage { + Envelope(MsgEnvelope), + Transfer(MsgTransfer), + ShieldedTransfer(MsgShieldedTransfer), +} + +fn decode_message(tx_data: &[u8]) -> Result { + // ibc-rs message + if let Ok(any_msg) = Any::decode(tx_data) { + if let Ok(transfer_msg) = MsgTransfer::try_from(any_msg.clone()) { + return Ok(IbcMessage::Transfer(transfer_msg)); + } + if let Ok(envelope) = MsgEnvelope::try_from(any_msg) { + return Ok(IbcMessage::Envelope(envelope)); + } + } + + // Message with Transfer for the shielded transfer + if let Ok(msg) = MsgShieldedTransfer::try_from_slice(tx_data) { + return Ok(IbcMessage::ShieldedTransfer(msg)); + } + + Err(Error::DecodingData) +} + /// Get the IbcToken from the source/destination ports and channels pub fn received_ibc_token( ibc_denom: &PrefixedDenom, diff --git a/core/src/ledger/vp_env.rs b/core/src/ledger/vp_env.rs index 664e0307860..728e66d9a65 100644 --- a/core/src/ledger/vp_env.rs +++ b/core/src/ledger/vp_env.rs @@ -8,7 +8,9 @@ use super::storage_api::{self, OptionExt, ResultExt, StorageRead}; use crate::proto::Tx; use crate::types::address::Address; use crate::types::hash::Hash; -use crate::types::ibc::{get_shielded_transfer, IbcEvent, EVENT_TYPE_PACKET}; +use crate::types::ibc::{ + get_shielded_transfer, IbcEvent, MsgShieldedTransfer, EVENT_TYPE_PACKET, +}; use crate::types::storage::{ BlockHash, BlockHeight, Epoch, Header, Key, TxIndex, }; @@ -112,9 +114,8 @@ where tx_data: &Tx, ) -> Result<(Transfer, Transaction), storage_api::Error> { let signed = tx_data; - if let Ok(transfer) = - Transfer::try_from_slice(&signed.data().unwrap()[..]) - { + let data = signed.data().ok_or_err_msg("No transaction data")?; + if let Ok(transfer) = Transfer::try_from_slice(&data) { let shielded_hash = transfer .shielded .ok_or_err_msg("unable to find shielded hash")?; @@ -125,6 +126,13 @@ where return Ok((transfer, masp_tx)); } + if let Ok(message) = MsgShieldedTransfer::try_from_slice(&data) { + return Ok(( + message.shielded_transfer.transfer, + message.shielded_transfer.masp_tx, + )); + } + // Shielded transfer over IBC let events = self.get_ibc_events(EVENT_TYPE_PACKET.to_string())?; // The receiving event should be only one in the single IBC transaction diff --git a/core/src/types/ibc.rs b/core/src/types/ibc.rs index b620654dcfd..cfb2357fefd 100644 --- a/core/src/types/ibc.rs +++ b/core/src/types/ibc.rs @@ -11,13 +11,16 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use super::address::HASH_LEN; +use crate::ibc::apps::transfer::types::msgs::transfer::MsgTransfer; use crate::ibc::apps::transfer::types::{Memo, PrefixedDenom, TracePath}; use crate::ibc::core::handler::types::events::{ Error as IbcEventError, IbcEvent as RawIbcEvent, }; +use crate::ibc::primitives::proto::Protobuf; pub use crate::ledger::ibc::storage::is_ibc_key; use crate::tendermint::abci::Event as AbciEvent; use crate::types::masp::PaymentAddress; +use crate::types::token::Transfer; /// The event type defined in ibc-rs for receiving a token pub const EVENT_TYPE_PACKET: &str = "fungible_token_packet"; @@ -107,11 +110,47 @@ impl std::fmt::Display for IbcEvent { } } +/// IBC transfer message to send from a shielded address +#[derive(Debug, Clone)] +pub struct MsgShieldedTransfer { + /// IBC transfer message + pub message: MsgTransfer, + /// MASP tx with token transfer + pub shielded_transfer: IbcShieldedTransfer, +} + +impl BorshSerialize for MsgShieldedTransfer { + fn serialize( + &self, + writer: &mut W, + ) -> std::io::Result<()> { + let encoded_msg = self.message.clone().encode_vec(); + let members = (encoded_msg, self.shielded_transfer.clone()); + BorshSerialize::serialize(&members, writer) + } +} + +impl BorshDeserialize for MsgShieldedTransfer { + fn deserialize_reader( + reader: &mut R, + ) -> std::io::Result { + use std::io::{Error, ErrorKind}; + let (msg, shielded_transfer): (Vec, IbcShieldedTransfer) = + BorshDeserialize::deserialize_reader(reader)?; + let message = MsgTransfer::decode_vec(&msg) + .map_err(|err| Error::new(ErrorKind::InvalidData, err))?; + Ok(Self { + message, + shielded_transfer, + }) + } +} + /// IBC shielded transfer #[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] pub struct IbcShieldedTransfer { /// The IBC event type - pub transfer: crate::types::token::Transfer, + pub transfer: Transfer, /// The attributes of the IBC event pub masp_tx: masp_primitives::transaction::Transaction, } diff --git a/sdk/src/args.rs b/sdk/src/args.rs index 1a7621060f7..c12ecdf5373 100644 --- a/sdk/src/args.rs +++ b/sdk/src/args.rs @@ -295,7 +295,7 @@ pub struct TxIbcTransfer { /// Common tx arguments pub tx: Tx, /// Transfer source address - pub source: C::Address, + pub source: C::TransferSource, /// Transfer target address pub receiver: String, /// Transferred token address @@ -330,7 +330,7 @@ impl TxBuilder for TxIbcTransfer { impl TxIbcTransfer { /// Transfer source address - pub fn source(self, source: C::Address) -> Self { + pub fn source(self, source: C::TransferSource) -> Self { Self { source, ..self } } @@ -397,7 +397,8 @@ impl TxIbcTransfer { pub async fn build( &self, context: &impl Namada, - ) -> crate::error::Result<(crate::proto::Tx, SigningTxData)> { + ) -> crate::error::Result<(crate::proto::Tx, SigningTxData, Option)> + { tx::build_ibc_transfer(context, self).await } } diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index 873e36c768d..65b4a45cf4e 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -244,7 +244,7 @@ pub trait Namada: Sized + MaybeSync + MaybeSend { /// Make a TxIbcTransfer builder from the given minimum set of arguments fn new_ibc_transfer( &self, - source: Address, + source: TransferSource, receiver: String, token: Address, amount: InputAmount, diff --git a/sdk/src/tx.rs b/sdk/src/tx.rs index 66a7c39dc1e..de0474615b9 100644 --- a/sdk/src/tx.rs +++ b/sdk/src/tx.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use borsh::BorshSerialize; +use borsh_ext::BorshSerializeExt; use masp_primitives::asset_type::AssetType; use masp_primitives::transaction::builder; use masp_primitives::transaction::builder::Builder; @@ -34,7 +35,7 @@ use namada_core::ledger::pgf::cli::steward::Commission; use namada_core::types::address::{Address, InternalAddress, MASP}; use namada_core::types::dec::Dec; use namada_core::types::hash::Hash; -use namada_core::types::ibc::IbcShieldedTransfer; +use namada_core::types::ibc::{IbcShieldedTransfer, MsgShieldedTransfer}; use namada_core::types::key::*; use namada_core::types::masp::{TransferSource, TransferTarget}; use namada_core::types::storage::Epoch; @@ -1987,19 +1988,18 @@ pub async fn build_pgf_stewards_proposal( pub async fn build_ibc_transfer( context: &impl Namada, args: &args::TxIbcTransfer, -) -> Result<(Tx, SigningTxData)> { - let default_signer = Some(args.source.clone()); +) -> Result<(Tx, SigningTxData, Option)> { + let source = args.source.effective_address(); let signing_data = signing::aux_signing_data( context, &args.tx, - Some(args.source.clone()), - default_signer, + Some(source.clone()), + Some(source.clone()), ) .await?; // Check that the source address exists on chain let source = - source_exists_or_err(args.source.clone(), args.tx.force, context) - .await?; + source_exists_or_err(source.clone(), args.tx.force, context).await?; // We cannot check the receiver // validate the amount given @@ -2037,6 +2037,18 @@ pub async fn build_ibc_transfer( .await .map_err(|e| Error::from(QueryError::Wasm(e.to_string())))?; + // For transfer from a spending key + let shielded_parts = construct_shielded_parts( + context, + &args.source, + // The token will be escrowed to IBC address + &TransferTarget::Address(Address::Internal(InternalAddress::Ibc)), + &args.token, + validated_amount, + ) + .await?; + let shielded_tx_epoch = shielded_parts.as_ref().map(|trans| trans.0.epoch); + let ibc_denom = rpc::query_ibc_denom(context, &args.token.to_string(), Some(&source)) .await; @@ -2079,7 +2091,7 @@ pub async fn build_ibc_transfer( IbcTimestamp::none() }; - let msg = MsgTransfer { + let message = MsgTransfer { port_id_on_a: args.port_id.clone(), chan_id_on_a: args.channel_id.clone(), packet_data, @@ -2087,13 +2099,50 @@ pub async fn build_ibc_transfer( timeout_timestamp_on_b: timeout_timestamp, }; - let any_msg = msg.to_any(); - let mut data = vec![]; - prost::Message::encode(&any_msg, &mut data) - .map_err(TxError::EncodeFailure)?; - let chain_id = args.tx.chain_id.clone().unwrap(); let mut tx = Tx::new(chain_id, args.tx.expiration); + + let data = match shielded_parts { + Some((shielded_transfer, asset_types)) => { + let masp_tx_hash = + tx.add_masp_tx_section(shielded_transfer.masp_tx.clone()).1; + let transfer = token::Transfer { + source: source.clone(), + // The token will be escrowed to IBC address + target: Address::Internal(InternalAddress::Ibc), + token: args.token.clone(), + amount: validated_amount, + // The address could be a payment address, but the address isn't + // that of this chain. + key: None, + // Link the Transfer to the MASP Transaction by hash code + shielded: Some(masp_tx_hash), + }; + tx.add_masp_builder(MaspBuilder { + asset_types, + metadata: shielded_transfer.metadata, + builder: shielded_transfer.builder, + target: masp_tx_hash, + }); + let shielded_transfer = IbcShieldedTransfer { + transfer, + masp_tx: shielded_transfer.masp_tx, + }; + MsgShieldedTransfer { + message, + shielded_transfer, + } + .serialize_to_vec() + } + None => { + let any_msg = message.to_any(); + let mut data = vec![]; + prost::Message::encode(&any_msg, &mut data) + .map_err(TxError::EncodeFailure)?; + data + } + }; + tx.add_code_from_hash( tx_code_hash, Some(args.tx_code_path.to_string_lossy().into_owned()), @@ -2109,7 +2158,7 @@ pub async fn build_ibc_transfer( ) .await?; - Ok((tx, signing_data)) + Ok((tx, signing_data, shielded_tx_epoch)) } /// Abstraction for helping build transactions @@ -2300,42 +2349,15 @@ pub async fn build_transfer( _ => None, }; - // Construct the shielded part of the transaction, if any - let stx_result = - ShieldedContext::::gen_shielded_transfer( - context, - &args.source, - &args.target, - &args.token, - validated_amount, - ) - .await; - - let shielded_parts = match stx_result { - Ok(stx) => Ok(stx), - Err(Build(builder::Error::InsufficientFunds(_))) => { - Err(TxError::NegativeBalanceAfterTransfer( - Box::new(source.clone()), - validated_amount.amount().to_string_native(), - Box::new(args.token.clone()), - )) - } - Err(err) => Err(TxError::MaspError(err.to_string())), - }?; - - let shielded_tx_epoch = shielded_parts.clone().map(|trans| trans.epoch); - - let asset_types = match shielded_parts.clone() { - None => None, - Some(transfer) => { - // Get the decoded asset types used in the transaction to give - // offline wallet users more information - let asset_types = used_asset_types(context, &transfer.builder) - .await - .unwrap_or_default(); - Some(asset_types) - } - }; + let shielded_parts = construct_shielded_parts( + context, + &args.source, + &args.target, + &args.token, + validated_amount, + ) + .await?; + let shielded_tx_epoch = shielded_parts.as_ref().map(|trans| trans.0.epoch); // Construct the corresponding transparent Transfer object let transfer = token::Transfer { @@ -2350,12 +2372,15 @@ pub async fn build_transfer( let add_shielded = |tx: &mut Tx, transfer: &mut token::Transfer| { // Add the MASP Transaction and its Builder to facilitate validation - if let Some(ShieldedTransfer { - builder, - masp_tx, - metadata, - epoch: _, - }) = shielded_parts + if let Some(( + ShieldedTransfer { + builder, + masp_tx, + metadata, + epoch: _, + }, + asset_types, + )) = shielded_parts { // Add a MASP Transaction section to the Tx and get the tx hash let masp_tx_hash = tx.add_masp_tx_section(masp_tx).1; @@ -2364,8 +2389,7 @@ pub async fn build_transfer( tracing::debug!("Transfer data {:?}", transfer); tx.add_masp_builder(MaspBuilder { - // Is safe - asset_types: asset_types.unwrap(), + asset_types, // Store how the Info objects map to Descriptors/Outputs metadata, // Store the data that was used to construct the Transaction @@ -2389,6 +2413,43 @@ pub async fn build_transfer( Ok((tx, signing_data, shielded_tx_epoch)) } +// Construct the shielded part of the transaction, if any +async fn construct_shielded_parts( + context: &N, + source: &TransferSource, + target: &TransferTarget, + token: &Address, + amount: token::DenominatedAmount, +) -> Result)>> { + let stx_result = + ShieldedContext::::gen_shielded_transfer( + context, source, target, token, amount, + ) + .await; + + let shielded_parts = match stx_result { + Ok(Some(stx)) => stx, + Ok(None) => return Ok(None), + Err(Build(builder::Error::InsufficientFunds(_))) => { + return Err(TxError::NegativeBalanceAfterTransfer( + Box::new(source.effective_address()), + amount.amount().to_string_native(), + Box::new(token.clone()), + ) + .into()); + } + Err(err) => return Err(TxError::MaspError(err.to_string()).into()), + }; + + // Get the decoded asset types used in the transaction to give offline + // wallet users more information + let asset_types = used_asset_types(context, &shielded_parts.builder) + .await + .unwrap_or_default(); + + Ok(Some((shielded_parts, asset_types))) +} + /// Submit a transaction to initialize an account pub async fn build_init_account( context: &impl Namada, diff --git a/tests/src/e2e/ibc_tests.rs b/tests/src/e2e/ibc_tests.rs index 07e9c4b0b29..d0a3ae46388 100644 --- a/tests/src/e2e/ibc_tests.rs +++ b/tests/src/e2e/ibc_tests.rs @@ -170,7 +170,8 @@ fn run_ledger_ibc() -> Result<()> { try_invalid_transfers(&test_a, &test_b, &port_id_a, &channel_id_a)?; // Transfer 50000 received over IBC on Chain B - transfer_received_token(&port_id_b, &channel_id_b, &test_b)?; + let token = format!("{port_id_b}/{channel_id_b}/nam"); + transfer_on_chain(&test_b, BERTHA, ALBERT, token, 50000, BERTHA_KEY)?; check_balances_after_non_ibc(&port_id_b, &channel_id_b, &test_b)?; // Transfer 50000 back from the origin-specific account on Chain B to Chain @@ -862,26 +863,27 @@ fn try_invalid_transfers( Ok(()) } -fn transfer_received_token( - port_id: &PortId, - channel_id: &ChannelId, +fn transfer_on_chain( test: &Test, + sender: impl AsRef, + receiver: impl AsRef, + token: impl AsRef, + amount: u64, + signer: impl AsRef, ) -> Result<()> { let rpc = get_actor_rpc(test, Who::Validator(0)); - let ibc_denom = format!("{port_id}/{channel_id}/nam"); - let amount = Amount::native_whole(50000).to_string_native(); let tx_args = [ "transfer", "--source", - BERTHA, + sender.as_ref(), "--target", - ALBERT, + receiver.as_ref(), "--token", - &ibc_denom, + token.as_ref(), "--amount", - &amount, - "--gas-token", - NAM, + &amount.to_string(), + "--signing-keys", + signer.as_ref(), "--node", &rpc, ]; @@ -1045,11 +1047,14 @@ fn shielded_transfer( let file_path = get_shielded_transfer_path(&mut client)?; client.assert_success(); - // Send a token from Chain A to PA(B) on Chain B + // Send a token to the shielded address on Chain A + transfer_on_chain(test_a, ALBERT, AA_PAYMENT_ADDRESS, BTC, 10, ALBERT_KEY)?; + + // Send a token from SP(A) on Chain A to PA(B) on Chain B let amount = Amount::native_whole(10).to_string_native(); let height = transfer( test_a, - ALBERT, + A_SPENDING_KEY, AB_PAYMENT_ADDRESS, BTC, amount,