Parse token-2022 account extensions (mostly) (#24621)
* Parse most account/mint extensions * Add comments
This commit is contained in:
parent
431c8412ef
commit
e7074bc61b
|
@ -11,6 +11,7 @@ pub mod parse_nonce;
|
||||||
pub mod parse_stake;
|
pub mod parse_stake;
|
||||||
pub mod parse_sysvar;
|
pub mod parse_sysvar;
|
||||||
pub mod parse_token;
|
pub mod parse_token;
|
||||||
|
pub mod parse_token_extension;
|
||||||
pub mod parse_vote;
|
pub mod parse_vote;
|
||||||
pub mod validator_info;
|
pub mod validator_info;
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
use {
|
use {
|
||||||
crate::{
|
crate::{
|
||||||
parse_account_data::{ParsableAccount, ParseAccountError},
|
parse_account_data::{ParsableAccount, ParseAccountError},
|
||||||
|
parse_token_extension::{parse_extension, UiExtension},
|
||||||
StringAmount, StringDecimals,
|
StringAmount, StringDecimals,
|
||||||
},
|
},
|
||||||
solana_sdk::pubkey::Pubkey,
|
solana_sdk::pubkey::Pubkey,
|
||||||
|
@ -68,6 +69,11 @@ pub fn parse_token(
|
||||||
"no mint_decimals provided to parse spl-token account".to_string(),
|
"no mint_decimals provided to parse spl-token account".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let extension_types = account.get_extension_types().unwrap_or_default();
|
||||||
|
let ui_extensions = extension_types
|
||||||
|
.iter()
|
||||||
|
.map(|extension_type| parse_extension::<Account>(extension_type, &account))
|
||||||
|
.collect();
|
||||||
return Ok(TokenAccountType::Account(UiTokenAccount {
|
return Ok(TokenAccountType::Account(UiTokenAccount {
|
||||||
mint: account.base.mint.to_string(),
|
mint: account.base.mint.to_string(),
|
||||||
owner: account.base.owner.to_string(),
|
owner: account.base.owner.to_string(),
|
||||||
|
@ -94,9 +100,15 @@ pub fn parse_token(
|
||||||
COption::Some(pubkey) => Some(pubkey.to_string()),
|
COption::Some(pubkey) => Some(pubkey.to_string()),
|
||||||
COption::None => None,
|
COption::None => None,
|
||||||
},
|
},
|
||||||
|
extensions: ui_extensions,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if let Ok(mint) = StateWithExtensions::<Mint>::unpack(data) {
|
if let Ok(mint) = StateWithExtensions::<Mint>::unpack(data) {
|
||||||
|
let extension_types = mint.get_extension_types().unwrap_or_default();
|
||||||
|
let ui_extensions = extension_types
|
||||||
|
.iter()
|
||||||
|
.map(|extension_type| parse_extension::<Mint>(extension_type, &mint))
|
||||||
|
.collect();
|
||||||
return Ok(TokenAccountType::Mint(UiMint {
|
return Ok(TokenAccountType::Mint(UiMint {
|
||||||
mint_authority: match mint.base.mint_authority {
|
mint_authority: match mint.base.mint_authority {
|
||||||
COption::Some(pubkey) => Some(pubkey.to_string()),
|
COption::Some(pubkey) => Some(pubkey.to_string()),
|
||||||
|
@ -109,6 +121,7 @@ pub fn parse_token(
|
||||||
COption::Some(pubkey) => Some(pubkey.to_string()),
|
COption::Some(pubkey) => Some(pubkey.to_string()),
|
||||||
COption::None => None,
|
COption::None => None,
|
||||||
},
|
},
|
||||||
|
extensions: ui_extensions,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if data.len() == Multisig::get_packed_len() {
|
if data.len() == Multisig::get_packed_len() {
|
||||||
|
@ -162,6 +175,8 @@ pub struct UiTokenAccount {
|
||||||
pub delegated_amount: Option<UiTokenAmount>,
|
pub delegated_amount: Option<UiTokenAmount>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub close_authority: Option<String>,
|
pub close_authority: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
pub extensions: Vec<UiExtension>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
@ -253,6 +268,8 @@ pub struct UiMint {
|
||||||
pub decimals: u8,
|
pub decimals: u8,
|
||||||
pub is_initialized: bool,
|
pub is_initialized: bool,
|
||||||
pub freeze_authority: Option<String>,
|
pub freeze_authority: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
pub extensions: Vec<UiExtension>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
@ -274,7 +291,17 @@ pub fn get_token_account_mint(data: &[u8]) -> Option<Pubkey> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use {
|
||||||
|
super::*,
|
||||||
|
crate::parse_token_extension::{UiMemoTransfer, UiMintCloseAuthority},
|
||||||
|
spl_token_2022::{
|
||||||
|
extension::{
|
||||||
|
immutable_owner::ImmutableOwner, memo_transfer::MemoTransfer,
|
||||||
|
mint_close_authority::MintCloseAuthority, ExtensionType, StateWithExtensionsMut,
|
||||||
|
},
|
||||||
|
pod::OptionalNonZeroPubkey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_token() {
|
fn test_parse_token() {
|
||||||
|
@ -308,6 +335,7 @@ mod test {
|
||||||
rent_exempt_reserve: None,
|
rent_exempt_reserve: None,
|
||||||
delegated_amount: None,
|
delegated_amount: None,
|
||||||
close_authority: Some(owner_pubkey.to_string()),
|
close_authority: Some(owner_pubkey.to_string()),
|
||||||
|
extensions: vec![],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -328,6 +356,7 @@ mod test {
|
||||||
decimals: 3,
|
decimals: 3,
|
||||||
is_initialized: true,
|
is_initialized: true,
|
||||||
freeze_authority: Some(owner_pubkey.to_string()),
|
freeze_authority: Some(owner_pubkey.to_string()),
|
||||||
|
extensions: vec![],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -465,4 +494,152 @@ mod test {
|
||||||
);
|
);
|
||||||
assert_eq!(token_amount.ui_amount, None);
|
assert_eq!(token_amount.ui_amount, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_token_account_with_extensions() {
|
||||||
|
let mint_pubkey = SplTokenPubkey::new(&[2; 32]);
|
||||||
|
let owner_pubkey = SplTokenPubkey::new(&[3; 32]);
|
||||||
|
|
||||||
|
let account_base = Account {
|
||||||
|
mint: mint_pubkey,
|
||||||
|
owner: owner_pubkey,
|
||||||
|
amount: 42,
|
||||||
|
state: AccountState::Initialized,
|
||||||
|
is_native: COption::None,
|
||||||
|
close_authority: COption::Some(owner_pubkey),
|
||||||
|
delegate: COption::None,
|
||||||
|
delegated_amount: 0,
|
||||||
|
};
|
||||||
|
let account_size = ExtensionType::get_account_len::<Mint>(&[
|
||||||
|
ExtensionType::ImmutableOwner,
|
||||||
|
ExtensionType::MemoTransfer,
|
||||||
|
]);
|
||||||
|
let mut account_data = vec![0; account_size];
|
||||||
|
let mut account_state =
|
||||||
|
StateWithExtensionsMut::<Account>::unpack_uninitialized(&mut account_data).unwrap();
|
||||||
|
|
||||||
|
account_state.base = account_base;
|
||||||
|
account_state.pack_base();
|
||||||
|
account_state.init_account_type().unwrap();
|
||||||
|
|
||||||
|
assert!(parse_token(&account_data, None).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
parse_token(&account_data, Some(2)).unwrap(),
|
||||||
|
TokenAccountType::Account(UiTokenAccount {
|
||||||
|
mint: mint_pubkey.to_string(),
|
||||||
|
owner: owner_pubkey.to_string(),
|
||||||
|
token_amount: UiTokenAmount {
|
||||||
|
ui_amount: Some(0.42),
|
||||||
|
decimals: 2,
|
||||||
|
amount: "42".to_string(),
|
||||||
|
ui_amount_string: "0.42".to_string()
|
||||||
|
},
|
||||||
|
delegate: None,
|
||||||
|
state: UiAccountState::Initialized,
|
||||||
|
is_native: false,
|
||||||
|
rent_exempt_reserve: None,
|
||||||
|
delegated_amount: None,
|
||||||
|
close_authority: Some(owner_pubkey.to_string()),
|
||||||
|
extensions: vec![],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut account_data = vec![0; account_size];
|
||||||
|
let mut account_state =
|
||||||
|
StateWithExtensionsMut::<Account>::unpack_uninitialized(&mut account_data).unwrap();
|
||||||
|
|
||||||
|
account_state.base = account_base;
|
||||||
|
account_state.pack_base();
|
||||||
|
account_state.init_account_type().unwrap();
|
||||||
|
|
||||||
|
account_state.init_extension::<ImmutableOwner>().unwrap();
|
||||||
|
let mut memo_transfer = account_state.init_extension::<MemoTransfer>().unwrap();
|
||||||
|
memo_transfer.require_incoming_transfer_memos = true.into();
|
||||||
|
|
||||||
|
assert!(parse_token(&account_data, None).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
parse_token(&account_data, Some(2)).unwrap(),
|
||||||
|
TokenAccountType::Account(UiTokenAccount {
|
||||||
|
mint: mint_pubkey.to_string(),
|
||||||
|
owner: owner_pubkey.to_string(),
|
||||||
|
token_amount: UiTokenAmount {
|
||||||
|
ui_amount: Some(0.42),
|
||||||
|
decimals: 2,
|
||||||
|
amount: "42".to_string(),
|
||||||
|
ui_amount_string: "0.42".to_string()
|
||||||
|
},
|
||||||
|
delegate: None,
|
||||||
|
state: UiAccountState::Initialized,
|
||||||
|
is_native: false,
|
||||||
|
rent_exempt_reserve: None,
|
||||||
|
delegated_amount: None,
|
||||||
|
close_authority: Some(owner_pubkey.to_string()),
|
||||||
|
extensions: vec![
|
||||||
|
UiExtension::ImmutableOwner,
|
||||||
|
UiExtension::MemoTransfer(UiMemoTransfer {
|
||||||
|
require_incoming_transfer_memos: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_token_mint_with_extensions() {
|
||||||
|
let owner_pubkey = SplTokenPubkey::new(&[3; 32]);
|
||||||
|
let mint_size =
|
||||||
|
ExtensionType::get_account_len::<Mint>(&[ExtensionType::MintCloseAuthority]);
|
||||||
|
let mint_base = Mint {
|
||||||
|
mint_authority: COption::Some(owner_pubkey),
|
||||||
|
supply: 42,
|
||||||
|
decimals: 3,
|
||||||
|
is_initialized: true,
|
||||||
|
freeze_authority: COption::Some(owner_pubkey),
|
||||||
|
};
|
||||||
|
let mut mint_data = vec![0; mint_size];
|
||||||
|
let mut mint_state =
|
||||||
|
StateWithExtensionsMut::<Mint>::unpack_uninitialized(&mut mint_data).unwrap();
|
||||||
|
|
||||||
|
mint_state.base = mint_base;
|
||||||
|
mint_state.pack_base();
|
||||||
|
mint_state.init_account_type().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_token(&mint_data, None).unwrap(),
|
||||||
|
TokenAccountType::Mint(UiMint {
|
||||||
|
mint_authority: Some(owner_pubkey.to_string()),
|
||||||
|
supply: 42.to_string(),
|
||||||
|
decimals: 3,
|
||||||
|
is_initialized: true,
|
||||||
|
freeze_authority: Some(owner_pubkey.to_string()),
|
||||||
|
extensions: vec![],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut mint_data = vec![0; mint_size];
|
||||||
|
let mut mint_state =
|
||||||
|
StateWithExtensionsMut::<Mint>::unpack_uninitialized(&mut mint_data).unwrap();
|
||||||
|
|
||||||
|
let mut mint_close_authority = mint_state.init_extension::<MintCloseAuthority>().unwrap();
|
||||||
|
mint_close_authority.close_authority =
|
||||||
|
OptionalNonZeroPubkey::try_from(Some(owner_pubkey)).unwrap();
|
||||||
|
|
||||||
|
mint_state.base = mint_base;
|
||||||
|
mint_state.pack_base();
|
||||||
|
mint_state.init_account_type().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_token(&mint_data, None).unwrap(),
|
||||||
|
TokenAccountType::Mint(UiMint {
|
||||||
|
mint_authority: Some(owner_pubkey.to_string()),
|
||||||
|
supply: 42.to_string(),
|
||||||
|
decimals: 3,
|
||||||
|
is_initialized: true,
|
||||||
|
freeze_authority: Some(owner_pubkey.to_string()),
|
||||||
|
extensions: vec![UiExtension::MintCloseAuthority(UiMintCloseAuthority {
|
||||||
|
close_authority: Some(owner_pubkey.to_string()),
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,161 @@
|
||||||
|
use {
|
||||||
|
crate::parse_token::UiAccountState,
|
||||||
|
spl_token_2022::{
|
||||||
|
extension::{self, BaseState, ExtensionType, StateWithExtensions},
|
||||||
|
solana_program::pubkey::Pubkey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "extension", content = "state")]
|
||||||
|
pub enum UiExtension {
|
||||||
|
Uninitialized,
|
||||||
|
TransferFeeConfig(UiTransferFeeConfig),
|
||||||
|
TransferFeeAmount(UiTransferFeeAmount),
|
||||||
|
MintCloseAuthority(UiMintCloseAuthority),
|
||||||
|
ConfidentialTransferMint, // Implementation of extension state to come
|
||||||
|
ConfidentialTransferAccount, // Implementation of extension state to come
|
||||||
|
DefaultAccountState(UiDefaultAccountState),
|
||||||
|
ImmutableOwner,
|
||||||
|
MemoTransfer(UiMemoTransfer),
|
||||||
|
UnparseableExtension,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_extension<S: BaseState>(
|
||||||
|
extension_type: &ExtensionType,
|
||||||
|
account: &StateWithExtensions<S>,
|
||||||
|
) -> UiExtension {
|
||||||
|
match &extension_type {
|
||||||
|
ExtensionType::Uninitialized => UiExtension::Uninitialized,
|
||||||
|
ExtensionType::TransferFeeConfig => account
|
||||||
|
.get_extension::<extension::transfer_fee::TransferFeeConfig>()
|
||||||
|
.map(|&extension| UiExtension::TransferFeeConfig(extension.into()))
|
||||||
|
.unwrap_or(UiExtension::UnparseableExtension),
|
||||||
|
ExtensionType::TransferFeeAmount => account
|
||||||
|
.get_extension::<extension::transfer_fee::TransferFeeAmount>()
|
||||||
|
.map(|&extension| UiExtension::TransferFeeAmount(extension.into()))
|
||||||
|
.unwrap_or(UiExtension::UnparseableExtension),
|
||||||
|
ExtensionType::MintCloseAuthority => account
|
||||||
|
.get_extension::<extension::mint_close_authority::MintCloseAuthority>()
|
||||||
|
.map(|&extension| UiExtension::MintCloseAuthority(extension.into()))
|
||||||
|
.unwrap_or(UiExtension::UnparseableExtension),
|
||||||
|
ExtensionType::ConfidentialTransferMint => UiExtension::ConfidentialTransferMint,
|
||||||
|
ExtensionType::ConfidentialTransferAccount => UiExtension::ConfidentialTransferAccount,
|
||||||
|
ExtensionType::DefaultAccountState => account
|
||||||
|
.get_extension::<extension::default_account_state::DefaultAccountState>()
|
||||||
|
.map(|&extension| UiExtension::DefaultAccountState(extension.into()))
|
||||||
|
.unwrap_or(UiExtension::UnparseableExtension),
|
||||||
|
ExtensionType::ImmutableOwner => UiExtension::ImmutableOwner,
|
||||||
|
ExtensionType::MemoTransfer => account
|
||||||
|
.get_extension::<extension::memo_transfer::MemoTransfer>()
|
||||||
|
.map(|&extension| UiExtension::MemoTransfer(extension.into()))
|
||||||
|
.unwrap_or(UiExtension::UnparseableExtension),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UiTransferFee {
|
||||||
|
pub epoch: u64,
|
||||||
|
pub maximum_fee: u64,
|
||||||
|
pub transfer_fee_basis_points: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<extension::transfer_fee::TransferFee> for UiTransferFee {
|
||||||
|
fn from(transfer_fee: extension::transfer_fee::TransferFee) -> Self {
|
||||||
|
Self {
|
||||||
|
epoch: u64::from(transfer_fee.epoch),
|
||||||
|
maximum_fee: u64::from(transfer_fee.maximum_fee),
|
||||||
|
transfer_fee_basis_points: u16::from(transfer_fee.transfer_fee_basis_points),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UiTransferFeeConfig {
|
||||||
|
pub transfer_fee_config_authority: Option<String>,
|
||||||
|
pub withdraw_withheld_authority: Option<String>,
|
||||||
|
pub withheld_amount: u64,
|
||||||
|
pub older_transfer_fee: UiTransferFee,
|
||||||
|
pub newer_transfer_fee: UiTransferFee,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<extension::transfer_fee::TransferFeeConfig> for UiTransferFeeConfig {
|
||||||
|
fn from(transfer_fee_config: extension::transfer_fee::TransferFeeConfig) -> Self {
|
||||||
|
let transfer_fee_config_authority: Option<Pubkey> =
|
||||||
|
transfer_fee_config.transfer_fee_config_authority.into();
|
||||||
|
let withdraw_withheld_authority: Option<Pubkey> =
|
||||||
|
transfer_fee_config.withdraw_withheld_authority.into();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
transfer_fee_config_authority: transfer_fee_config_authority
|
||||||
|
.map(|pubkey| pubkey.to_string()),
|
||||||
|
withdraw_withheld_authority: withdraw_withheld_authority
|
||||||
|
.map(|pubkey| pubkey.to_string()),
|
||||||
|
withheld_amount: u64::from(transfer_fee_config.withheld_amount),
|
||||||
|
older_transfer_fee: transfer_fee_config.older_transfer_fee.into(),
|
||||||
|
newer_transfer_fee: transfer_fee_config.newer_transfer_fee.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UiTransferFeeAmount {
|
||||||
|
pub withheld_amount: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<extension::transfer_fee::TransferFeeAmount> for UiTransferFeeAmount {
|
||||||
|
fn from(transfer_fee_amount: extension::transfer_fee::TransferFeeAmount) -> Self {
|
||||||
|
Self {
|
||||||
|
withheld_amount: u64::from(transfer_fee_amount.withheld_amount),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UiMintCloseAuthority {
|
||||||
|
pub close_authority: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<extension::mint_close_authority::MintCloseAuthority> for UiMintCloseAuthority {
|
||||||
|
fn from(mint_close_authority: extension::mint_close_authority::MintCloseAuthority) -> Self {
|
||||||
|
let authority: Option<Pubkey> = mint_close_authority.close_authority.into();
|
||||||
|
Self {
|
||||||
|
close_authority: authority.map(|pubkey| pubkey.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UiDefaultAccountState {
|
||||||
|
pub account_state: UiAccountState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<extension::default_account_state::DefaultAccountState> for UiDefaultAccountState {
|
||||||
|
fn from(default_account_state: extension::default_account_state::DefaultAccountState) -> Self {
|
||||||
|
let account_state =
|
||||||
|
spl_token_2022::state::AccountState::try_from(default_account_state.state)
|
||||||
|
.unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
account_state: account_state.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UiMemoTransfer {
|
||||||
|
pub require_incoming_transfer_memos: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<extension::memo_transfer::MemoTransfer> for UiMemoTransfer {
|
||||||
|
fn from(memo_transfer: extension::memo_transfer::MemoTransfer) -> Self {
|
||||||
|
Self {
|
||||||
|
require_incoming_transfer_memos: memo_transfer.require_incoming_transfer_memos.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue