icepick workflow sol generate-nonce-account
This commit is contained in:
parent
92fa056195
commit
be5f9a9fa0
File diff suppressed because it is too large
Load Diff
|
@ -29,6 +29,11 @@ pub enum Request {
|
|||
#[serde(flatten)]
|
||||
values: serde_json::Value,
|
||||
},
|
||||
|
||||
Cat {
|
||||
#[serde(flatten)]
|
||||
values: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
|
@ -58,6 +63,12 @@ impl Module for Internal {
|
|||
description: "Save data from a JSON file.".to_string(),
|
||||
arguments: vec![filename.clone()],
|
||||
},
|
||||
icepick_module::help::Operation {
|
||||
name: "cat".to_string(),
|
||||
description: "Return all inputs. Usable in workflows to sum up all desired outputs"
|
||||
.to_string(),
|
||||
arguments: vec![],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -100,6 +111,11 @@ impl Module for Internal {
|
|||
"blob": {},
|
||||
}))
|
||||
}
|
||||
Request::Cat { values } => {
|
||||
Ok(serde_json::json!({
|
||||
"blob": values,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,12 +5,16 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
base64 = "0.22.1"
|
||||
bincode = "1.3.3"
|
||||
bs58 = "0.5.1"
|
||||
ed25519-dalek = "=1.0.1"
|
||||
icepick-module = { version = "0.1.0", path = "../../icepick-module" }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
solana-rpc-client = { version = "2.1.1", default-features = false }
|
||||
solana-sdk = { version = "2.1.1" }
|
||||
solana-transaction-status = "2.1.1"
|
||||
solana-transaction-status-client-types = "2.1.1"
|
||||
spl-associated-token-account = "6.0.0"
|
||||
spl-token = "7.0.0"
|
||||
spl-token-2022 = "6.0.0"
|
||||
|
|
|
@ -63,12 +63,36 @@ use icepick_module::{
|
|||
Module,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solana_sdk::signer::Signer;
|
||||
use std::str::FromStr;
|
||||
use solana_sdk::{
|
||||
pubkey::Pubkey,
|
||||
signer::{keypair::Keypair, Signer},
|
||||
system_instruction,
|
||||
};
|
||||
use std::{collections::HashSet, str::FromStr};
|
||||
|
||||
// How does this not exist in solana_sdk.
|
||||
const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
|
||||
|
||||
fn get_account(
|
||||
account_index: impl Into<Option<u8>>,
|
||||
account_keys: &[String],
|
||||
instruction_keys: &[u8],
|
||||
) -> Pubkey {
|
||||
let instruction_index: usize = account_index
|
||||
.into()
|
||||
.expect("account index did not exist")
|
||||
.into();
|
||||
let account_index: usize = instruction_keys
|
||||
.get(instruction_index)
|
||||
.copied()
|
||||
.unwrap_or_else(|| panic!("instruction account {instruction_index} did not exist"))
|
||||
.into();
|
||||
let account_string = account_keys
|
||||
.get(account_index)
|
||||
.unwrap_or_else(|| panic!("account at index {account_index} did not exist"));
|
||||
Pubkey::from_str(account_string).expect("could not parse account from string")
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {}
|
||||
|
||||
|
@ -118,11 +142,31 @@ pub struct GenerateWallet {
|
|||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GetWalletAddress {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct AwaitFunds {
|
||||
address: String,
|
||||
lamports: String,
|
||||
cluster: Option<Cluster>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GetTokenInfo {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct CreateNonceAccountAndSigningKey {
|
||||
authorization_address: String,
|
||||
from_account: Option<String>,
|
||||
from_address: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct FindNonceAccounts {
|
||||
authorization_address: String,
|
||||
cluster: Option<Cluster>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Transfer {
|
||||
amount: String,
|
||||
|
@ -158,6 +202,8 @@ pub struct TransferToken {
|
|||
pub struct Sign {
|
||||
blockhash: String,
|
||||
transaction: solana_sdk::transaction::Transaction,
|
||||
#[serde(default)]
|
||||
signing_keys: Vec<[u8; Keypair::SECRET_KEY_LENGTH]>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
|
@ -182,7 +228,10 @@ pub enum Operation {
|
|||
GetBlockhash(GetBlockhash),
|
||||
GenerateWallet(GenerateWallet),
|
||||
GetWalletAddress(GetWalletAddress),
|
||||
AwaitFunds(AwaitFunds),
|
||||
GetTokenInfo(GetTokenInfo),
|
||||
CreateNonceAccountAndSigningKey(CreateNonceAccountAndSigningKey),
|
||||
FindNonceAccounts(FindNonceAccounts),
|
||||
Transfer(Transfer),
|
||||
CreateTokenAccount(CreateTokenAccount),
|
||||
TransferToken(TransferToken),
|
||||
|
@ -193,14 +242,13 @@ pub enum Operation {
|
|||
pub struct Solana;
|
||||
|
||||
impl Solana {
|
||||
fn keypair_from_bytes(given_bytes: [u8; 32]) -> solana_sdk::signer::keypair::Keypair {
|
||||
fn keypair_from_bytes(given_bytes: [u8; 32]) -> Keypair {
|
||||
use ed25519_dalek::{PublicKey, SecretKey};
|
||||
let secret_key = SecretKey::from_bytes(&given_bytes).expect("key should be 32 bytes");
|
||||
let mut bytes = [0u8; 64];
|
||||
bytes[..32].clone_from_slice(&given_bytes);
|
||||
bytes[32..].clone_from_slice(PublicKey::from(&secret_key).as_bytes());
|
||||
solana_sdk::signer::keypair::Keypair::from_bytes(&bytes)
|
||||
.expect("solana sdk should expect 64 bytes")
|
||||
Keypair::from_bytes(&bytes).expect("solana sdk should expect 64 bytes")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -265,6 +313,19 @@ impl Module for Solana {
|
|||
description: "Get the address for a given wallet.".to_string(),
|
||||
arguments: vec![],
|
||||
},
|
||||
icepick_module::help::Operation {
|
||||
name: "await-funds".to_string(),
|
||||
description: "Await a minimum amount of funds in an account".to_string(),
|
||||
arguments: vec![Argument {
|
||||
name: "address".to_string(),
|
||||
description: "The address to monitor".to_string(),
|
||||
r#type: ArgumentType::Required,
|
||||
}, Argument {
|
||||
name: "amount".to_string(),
|
||||
description: "The amount of lamports to await".to_string(),
|
||||
r#type: ArgumentType::Required,
|
||||
}],
|
||||
},
|
||||
icepick_module::help::Operation {
|
||||
name: "get-token-info".to_string(),
|
||||
description: "Get the address for a given token.".to_string(),
|
||||
|
@ -274,6 +335,31 @@ impl Module for Solana {
|
|||
r#type: ArgumentType::Required,
|
||||
}],
|
||||
},
|
||||
icepick_module::help::Operation {
|
||||
name: "create-nonce-account-and-signing-key".to_string(),
|
||||
description: "Create a nonce account for signing durable transactions".to_string(),
|
||||
arguments: vec![
|
||||
account.clone(),
|
||||
from_address.clone(),
|
||||
Argument {
|
||||
name: "authorization_address".to_string(),
|
||||
description: "The account authorized to use and advance the nonce.".to_string(),
|
||||
r#type: ArgumentType::Required,
|
||||
},
|
||||
],
|
||||
},
|
||||
icepick_module::help::Operation {
|
||||
name: "find-nonce-accounts".to_string(),
|
||||
description: "Find all nonce accounts for an authorized address".to_string(),
|
||||
arguments: vec![
|
||||
cluster.clone(),
|
||||
Argument {
|
||||
name: "authorization_address".to_string(),
|
||||
description: "The account authorized to use and advance nonces.".to_string(),
|
||||
r#type: ArgumentType::Required,
|
||||
},
|
||||
],
|
||||
},
|
||||
icepick_module::help::Operation {
|
||||
name: "transfer".to_string(),
|
||||
description: "Transfer SOL from a Keyfork wallet to an external wallet."
|
||||
|
@ -418,6 +504,51 @@ impl Module for Solana {
|
|||
}
|
||||
}))
|
||||
}
|
||||
Operation::AwaitFunds(AwaitFunds {
|
||||
address,
|
||||
lamports,
|
||||
cluster,
|
||||
}) => {
|
||||
let cluster = cluster.unwrap_or(Cluster::MainnetBeta);
|
||||
let cluster_url = format!("https://api.{cluster}.solana.com");
|
||||
let client = solana_rpc_client::rpc_client::RpcClient::new(cluster_url);
|
||||
let account_pk = Pubkey::from_str(&address).unwrap();
|
||||
let minimum_balance = u64::from_str(&lamports).unwrap();
|
||||
|
||||
let sleep = || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(10));
|
||||
};
|
||||
|
||||
let account_balance = loop {
|
||||
let account = match client.get_account(&account_pk) {
|
||||
Ok(account) => account,
|
||||
Err(_) => {
|
||||
eprintln!("Waiting for account to be created and funded: {account_pk}");
|
||||
sleep();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let account_size = account.data.len();
|
||||
let rent = client
|
||||
.get_minimum_balance_for_rent_exemption(account_size)
|
||||
.unwrap();
|
||||
let balance = account.lamports;
|
||||
if balance
|
||||
.checked_sub(rent)
|
||||
.is_some_and(|bal| bal > minimum_balance)
|
||||
{
|
||||
break balance;
|
||||
}
|
||||
eprintln!("Waiting for {minimum_balance} + rent ({rent}) in {account_pk}");
|
||||
sleep();
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"blob": {
|
||||
"lamports": account_balance,
|
||||
},
|
||||
}))
|
||||
}
|
||||
Operation::GetTokenInfo(GetTokenInfo { token }) => {
|
||||
let values = match token.as_str() {
|
||||
// Only exists on devnet
|
||||
|
@ -443,6 +574,176 @@ impl Module for Solana {
|
|||
}),
|
||||
})
|
||||
}
|
||||
Operation::CreateNonceAccountAndSigningKey(CreateNonceAccountAndSigningKey {
|
||||
authorization_address,
|
||||
from_account,
|
||||
from_address,
|
||||
}) => {
|
||||
// NOTE: Since this transaction is meant to be run on an online system with a
|
||||
// freshly generated mnemonic, only designed to live to make the nonce account, we
|
||||
// are going to assume we're not using a separate fee payer. It's a stretch having
|
||||
// a `--from-account` option, really, but it is probably to be expected given the
|
||||
// `from-address` variable. In truth, we will likely have the account randomly
|
||||
// generated using `generate-wallet | get-wallet-address`.
|
||||
|
||||
// NOTE: new() calls generate() which requires CryptoRng. By default,
|
||||
// this uses OsRng, which sources from getrandom() if available, which pulls from
|
||||
// /dev/urandom, or sources from `/dev/urandom` directly.
|
||||
let keypair = Keypair::new();
|
||||
|
||||
let from_pk = Pubkey::from_str(&from_address).unwrap();
|
||||
let authorization_pk = Pubkey::from_str(&authorization_address).unwrap();
|
||||
|
||||
let instructions = system_instruction::create_nonce_account(
|
||||
&from_pk,
|
||||
&keypair.pubkey(),
|
||||
&authorization_pk,
|
||||
// just above the approximate rent necessary for a nonce account
|
||||
1500000,
|
||||
);
|
||||
|
||||
let message = solana_sdk::message::Message::new(&instructions, None);
|
||||
let transaction = solana_sdk::transaction::Transaction::new_unsigned(message);
|
||||
let from_account = from_account
|
||||
.and_then(|a| u32::from_str(&a).ok())
|
||||
.unwrap_or(0);
|
||||
let requested_accounts = vec![from_account | 1 << 31];
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"blob": {
|
||||
"nonce_pubkey": keypair.pubkey().to_string(),
|
||||
"nonce_privkey": [keypair.secret().to_bytes()],
|
||||
"transaction": transaction,
|
||||
},
|
||||
"derivation_accounts": requested_accounts,
|
||||
}))
|
||||
}
|
||||
Operation::FindNonceAccounts(FindNonceAccounts {
|
||||
authorization_address,
|
||||
cluster,
|
||||
}) => {
|
||||
use solana_sdk::{
|
||||
instruction::CompiledInstruction, system_instruction::SystemInstruction,
|
||||
};
|
||||
use solana_transaction_status_client_types::{
|
||||
EncodedConfirmedTransactionWithStatusMeta, EncodedTransaction,
|
||||
EncodedTransactionWithStatusMeta, UiMessage, UiRawMessage, UiTransaction,
|
||||
};
|
||||
|
||||
let cluster = cluster.unwrap_or(Cluster::MainnetBeta);
|
||||
let cluster_url = format!("https://api.{cluster}.solana.com");
|
||||
let client = solana_rpc_client::rpc_client::RpcClient::new(cluster_url);
|
||||
|
||||
let authorized_pk = Pubkey::from_str(&authorization_address).unwrap();
|
||||
|
||||
let mut nonced_accounts: HashSet<Pubkey> = HashSet::new();
|
||||
|
||||
let transaction_statuses =
|
||||
client.get_signatures_for_address(&authorized_pk).unwrap();
|
||||
|
||||
for status in transaction_statuses
|
||||
/*.iter().rev()*/
|
||||
{
|
||||
let signature = solana_sdk::signature::Signature::from_str(&status.signature)
|
||||
.expect("cluster provided invalid signature");
|
||||
let transaction = client
|
||||
.get_transaction_with_config(&signature, Default::default())
|
||||
.unwrap();
|
||||
let EncodedConfirmedTransactionWithStatusMeta {
|
||||
slot: _,
|
||||
block_time: _,
|
||||
transaction:
|
||||
EncodedTransactionWithStatusMeta {
|
||||
meta: _,
|
||||
version: _,
|
||||
transaction:
|
||||
EncodedTransaction::Json(UiTransaction {
|
||||
signatures: _,
|
||||
message:
|
||||
UiMessage::Raw(UiRawMessage {
|
||||
header: _,
|
||||
account_keys,
|
||||
recent_blockhash: _,
|
||||
address_table_lookups: _,
|
||||
instructions,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}: EncodedConfirmedTransactionWithStatusMeta = transaction
|
||||
else {
|
||||
eprintln!("Unable to destructure transaction");
|
||||
continue;
|
||||
};
|
||||
// search for program based on the following:
|
||||
// * program is SystemProgram
|
||||
// * instruction is
|
||||
for ui_instruction in &instructions {
|
||||
let instruction = CompiledInstruction {
|
||||
program_id_index: ui_instruction.program_id_index,
|
||||
accounts: ui_instruction.accounts.clone(),
|
||||
data: bs58::decode(ui_instruction.data.as_bytes())
|
||||
.into_vec()
|
||||
.unwrap(),
|
||||
};
|
||||
let program_pk = account_keys
|
||||
.get(instruction.program_id_index as usize)
|
||||
.map(|k| &**k)
|
||||
.map(Pubkey::from_str)
|
||||
.transpose()
|
||||
.ok()
|
||||
.flatten()
|
||||
.expect("could not get program key from transaction");
|
||||
if solana_sdk::system_program::check_id(&program_pk) {
|
||||
let parsed_instruction: SystemInstruction =
|
||||
bincode::deserialize(&instruction.data).unwrap();
|
||||
match parsed_instruction {
|
||||
SystemInstruction::InitializeNonceAccount(pubkey) => {
|
||||
// [Nonce, RecentBlockhashes, Rent]
|
||||
// Argument is new authority
|
||||
let nonce_account =
|
||||
get_account(0, &account_keys, &instruction.accounts);
|
||||
if authorized_pk == pubkey {
|
||||
nonced_accounts.insert(nonce_account);
|
||||
}
|
||||
}
|
||||
SystemInstruction::AuthorizeNonceAccount(pubkey) => {
|
||||
// [Nonce, Authority]
|
||||
// Argument is new authority
|
||||
let nonce_account =
|
||||
get_account(0, &account_keys, &instruction.accounts);
|
||||
let authorizing_pk =
|
||||
get_account(1, &account_keys, &instruction.accounts);
|
||||
if authorized_pk == pubkey {
|
||||
// we are given it
|
||||
nonced_accounts.insert(nonce_account);
|
||||
} else if authorizing_pk == pubkey {
|
||||
// we are giving it
|
||||
nonced_accounts.remove(&nonce_account);
|
||||
}
|
||||
}
|
||||
SystemInstruction::WithdrawNonceAccount(_lamports) => {
|
||||
// [Nonce, Recipient, RecentBlockhashes, Rent, Authority]
|
||||
// Because the nonce account will be deleted due to nonpayment
|
||||
// of rent, we do not re-insert into created accounts.
|
||||
let nonce_account =
|
||||
get_account(0, &account_keys, &instruction.accounts);
|
||||
nonced_accounts.remove(&nonce_account);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let nonced_accounts = nonced_accounts
|
||||
.iter()
|
||||
.map(|account| account.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
Ok(serde_json::json!({
|
||||
"blob": {
|
||||
"nonced_accounts": nonced_accounts,
|
||||
}
|
||||
}))
|
||||
}
|
||||
Operation::Transfer(Transfer {
|
||||
amount,
|
||||
from_account,
|
||||
|
@ -458,7 +759,6 @@ impl Module for Solana {
|
|||
let amount = f64::from_str(&amount).expect("float amount");
|
||||
let amount: u64 = (amount * LAMPORTS_PER_SOL as f64) as u64;
|
||||
|
||||
use solana_sdk::pubkey::Pubkey;
|
||||
let to_pk = Pubkey::from_str(&to_address).unwrap();
|
||||
let from_pk = Pubkey::from_str(&from_address).unwrap();
|
||||
let payer_account_and_pk = {
|
||||
|
@ -479,8 +779,7 @@ impl Module for Solana {
|
|||
_ => panic!("Invalid combination of fee_payer and fee_payer_address"),
|
||||
}
|
||||
};
|
||||
let instruction =
|
||||
solana_sdk::system_instruction::transfer(&from_pk, &to_pk, amount);
|
||||
let instruction = system_instruction::transfer(&from_pk, &to_pk, amount);
|
||||
let message = solana_sdk::message::Message::new(
|
||||
&[instruction],
|
||||
payer_account_and_pk.map(|v| v.1).as_ref(),
|
||||
|
@ -508,9 +807,9 @@ impl Module for Solana {
|
|||
token_address,
|
||||
}) => {
|
||||
// TODO: allow changing derivation account of funder_address
|
||||
use sata::instruction::create_associated_token_account;
|
||||
use solana_sdk::pubkey::Pubkey;
|
||||
use spl_associated_token_account as sata;
|
||||
|
||||
use sata::instruction::create_associated_token_account;
|
||||
use spl_token::ID as TOKEN_ID;
|
||||
let funder_address = funder_address.unwrap_or_else(|| wallet_address.clone());
|
||||
let funder_pubkey = Pubkey::from_str(&funder_address).unwrap();
|
||||
|
@ -550,7 +849,6 @@ impl Module for Solana {
|
|||
let decimals = u8::from_str(&decimals).expect("decimals");
|
||||
let amount: u64 = (amount * 10u64.pow(decimals as u32) as f64) as u64;
|
||||
|
||||
use solana_sdk::pubkey::Pubkey;
|
||||
use spl_associated_token_account::get_associated_token_address;
|
||||
let to_pk = Pubkey::from_str(&to_address).unwrap();
|
||||
let from_pk = Pubkey::from_str(&from_address).unwrap();
|
||||
|
@ -629,11 +927,13 @@ impl Module for Solana {
|
|||
Operation::Sign(Sign {
|
||||
blockhash,
|
||||
mut transaction,
|
||||
signing_keys,
|
||||
}) => {
|
||||
let keys = request
|
||||
.derived_keys
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.chain(&signing_keys)
|
||||
.map(|k| Self::keypair_from_bytes(*k))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
|
|
|
@ -12,6 +12,8 @@ use super::{derive_keys, get_command, Commands, ModuleConfig, Operation};
|
|||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Workflow {
|
||||
pub name: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub inputs: Vec<String>,
|
||||
|
||||
#[serde(rename = "step")]
|
||||
|
|
55
icepick.toml
55
icepick.toml
|
@ -107,3 +107,58 @@ outputs = { transaction = "transaction" }
|
|||
type = "sol-broadcast"
|
||||
inputs = { cluster = "cluster", transaction = "transaction" }
|
||||
outputs = { status = "status", url = "url" }
|
||||
|
||||
[[module.workflow]]
|
||||
name = "generate-nonce-account"
|
||||
inputs = ["cluster", "authorization_address"]
|
||||
|
||||
[[module.workflow.step]]
|
||||
type = "sol-generate-wallet"
|
||||
|
||||
[[module.workflow.step]]
|
||||
type = "sol-get-wallet-address"
|
||||
outputs = { pubkey = "wallet_pubkey" }
|
||||
|
||||
[[module.workflow.step]]
|
||||
type = "sol-await-funds"
|
||||
inputs = { address = "wallet_pubkey", cluster = "cluster" }
|
||||
# enough to cover two signatures and the 1_500_000 approx. rent fee
|
||||
values = { lamports = "1510000" }
|
||||
|
||||
[[module.workflow.step]]
|
||||
type = "sol-get-blockhash"
|
||||
inputs = { cluster = "cluster" }
|
||||
outputs = { blockhash = "blockhash" }
|
||||
|
||||
[[module.workflow.step]]
|
||||
type = "sol-create-nonce-account-and-signing-key"
|
||||
|
||||
[module.workflow.step.inputs]
|
||||
from_address = "wallet_pubkey"
|
||||
authorization_address = "authorization_address"
|
||||
|
||||
[module.workflow.step.outputs]
|
||||
transaction = "unsigned_transaction"
|
||||
nonce_pubkey = "nonce_pubkey"
|
||||
nonce_privkey = "private_keys"
|
||||
|
||||
[[module.workflow.step]]
|
||||
type = "sol-sign"
|
||||
|
||||
[module.workflow.step.inputs]
|
||||
blockhash = "blockhash"
|
||||
signing_keys = "private_keys"
|
||||
transaction = "unsigned_transaction"
|
||||
|
||||
[module.workflow.step.outputs]
|
||||
transaction = "signed_transaction"
|
||||
|
||||
[[module.workflow.step]]
|
||||
type = "sol-broadcast"
|
||||
inputs = { cluster = "cluster", transaction = "signed_transaction" }
|
||||
outputs = { status = "status", url = "url" }
|
||||
|
||||
[[module.workflow.step]]
|
||||
type = "internal-cat"
|
||||
inputs = { status = "status", url = "url", nonce_account = "nonce_pubkey" }
|
||||
outputs = { status = "status", url = "url", nonce_account = "nonce_account" }
|
||||
|
|
Loading…
Reference in New Issue