diff --git a/cmd/soroban-cli/src/rpc/mod.rs b/cmd/soroban-cli/src/rpc/mod.rs index e817b2a38b..a1a63d6251 100644 --- a/cmd/soroban-cli/src/rpc/mod.rs +++ b/cmd/soroban-cli/src/rpc/mod.rs @@ -7,8 +7,9 @@ use serde_aux::prelude::{deserialize_default_from_null, deserialize_number_from_ use soroban_env_host::xdr::{ self, AccountEntry, AccountId, ContractDataEntry, DiagnosticEvent, Error as XdrError, LedgerEntryData, LedgerFootprint, LedgerKey, LedgerKeyAccount, PublicKey, ReadXdr, - SorobanAuthorizationEntry, SorobanResources, Transaction, TransactionEnvelope, TransactionMeta, - TransactionMetaV3, TransactionResult, TransactionV1Envelope, Uint256, VecM, WriteXdr, + SequenceNumber, SorobanAuthorizationEntry, SorobanResources, Transaction, TransactionEnvelope, + TransactionMeta, TransactionMetaV3, TransactionResult, TransactionV1Envelope, Uint256, VecM, + WriteXdr, }; use soroban_env_host::xdr::{DepthLimitedRead, SorobanAuthorizedFunction}; use soroban_sdk::token; @@ -24,7 +25,7 @@ use tokio::time::sleep; use crate::utils::{self, contract_spec}; mod transaction; -use transaction::{assemble, sign_soroban_authorizations}; +use transaction::{assemble, build_restore_txn, sign_soroban_authorizations}; const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION"); @@ -231,12 +232,12 @@ pub struct SimulateTransactionResponse { rename = "latestLedger", deserialize_with = "deserialize_number_from_string" )] - pub latest_ledger: u64, + pub latest_ledger: u32, #[serde(skip_serializing_if = "Option::is_none", default)] pub error: Option, } -#[derive(serde::Deserialize, serde::Serialize, Debug)] +#[derive(serde::Deserialize, serde::Serialize, Debug, Default)] pub struct RestorePreamble { #[serde(rename = "transactionData")] pub transaction_data: String, @@ -626,7 +627,7 @@ soroban config identity fund {address} --helper-url "# pub async fn prepare_transaction( &self, tx: &Transaction, - ) -> Result<(Transaction, Vec), Error> { + ) -> Result<(Transaction, Option, Vec), Error> { tracing::trace!(?tx); let sim_response = self .simulate_transaction(&TransactionEnvelope::Tx(TransactionV1Envelope { @@ -634,14 +635,18 @@ soroban config identity fund {address} --helper-url "# signatures: VecM::default(), })) .await?; - let events = sim_response .events .iter() .map(DiagnosticEvent::from_xdr_base64) .collect::, _>>()?; - - Ok((assemble(tx, &sim_response)?, events)) + Ok(( + assemble(tx, &sim_response)?, + sim_response + .restore_preamble + .filter(|p| !p.transaction_data.is_empty()), + events, + )) } pub async fn prepare_and_send_transaction( @@ -654,7 +659,19 @@ soroban config identity fund {address} --helper-url "# log_resources: Option, ) -> Result<(TransactionResult, TransactionMeta, Vec), Error> { let GetLatestLedgerResponse { sequence, .. } = self.get_latest_ledger().await?; - let (unsigned_tx, events) = self.prepare_transaction(tx_without_preflight).await?; + let (mut unsigned_tx, restore_preamble, events) = + self.prepare_transaction(tx_without_preflight).await?; + if let Some(restore) = restore_preamble { + // Build and submit the restore transaction + self.send_transaction(&utils::sign_transaction( + source_key, + &build_restore_txn(&unsigned_tx, &restore)?, + network_passphrase, + )?) + .await?; + // Increment the original txn's seq_num so it doesn't conflict + unsigned_tx.seq_num = SequenceNumber(unsigned_tx.seq_num.0 + 1); + } let (part_signed_tx, signed_auth_entries) = sign_soroban_authorizations( &unsigned_tx, source_key, @@ -671,7 +688,8 @@ soroban config identity fund {address} --helper-url "# (part_signed_tx, events) } else { // re-simulate to calculate the new fees - self.prepare_transaction(&part_signed_tx).await? + let (tx, _, events) = self.prepare_transaction(&part_signed_tx).await?; + (tx, events) }; // Try logging stuff if requested diff --git a/cmd/soroban-cli/src/rpc/transaction.rs b/cmd/soroban-cli/src/rpc/transaction.rs index 551013ba65..520fa3601b 100644 --- a/cmd/soroban-cli/src/rpc/transaction.rs +++ b/cmd/soroban-cli/src/rpc/transaction.rs @@ -1,13 +1,14 @@ use ed25519_dalek::Signer; use sha2::{Digest, Sha256}; use soroban_env_host::xdr::{ - AccountId, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, OperationBody, PublicKey, - ReadXdr, ScAddress, ScMap, ScSymbol, ScVal, SorobanAddressCredentials, - SorobanAuthorizationEntry, SorobanCredentials, SorobanTransactionData, Transaction, - TransactionExt, Uint256, VecM, WriteXdr, + AccountId, ExtensionPoint, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, Memo, + Operation, OperationBody, Preconditions, PublicKey, ReadXdr, RestoreFootprintOp, ScAddress, + ScMap, ScSymbol, ScVal, SorobanAddressCredentials, SorobanAuthorizationEntry, + SorobanCredentials, SorobanTransactionData, Transaction, TransactionExt, Uint256, VecM, + WriteXdr, }; -use crate::rpc::{Error, SimulateTransactionResponse}; +use crate::rpc::{Error, RestorePreamble, SimulateTransactionResponse}; // Apply the result of a simulateTransaction onto a transaction envelope, preparing it for // submission to the network. @@ -213,6 +214,35 @@ pub fn sign_soroban_authorization_entry( Ok(auth) } +pub fn build_restore_txn( + parent: &Transaction, + restore: &RestorePreamble, +) -> Result { + let transaction_data = + SorobanTransactionData::from_xdr_base64(restore.transaction_data.clone())?; + let fee = u32::try_from(restore.min_resource_fee) + .map_err(|_| Error::LargeFee(restore.min_resource_fee))?; + Ok(Transaction { + source_account: parent.source_account.clone(), + fee: parent + .fee + .checked_add(fee) + .ok_or(Error::LargeFee(restore.min_resource_fee))?, + seq_num: parent.seq_num.clone(), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::RestoreFootprint(RestoreFootprintOp { + ext: ExtensionPoint::V0, + }), + }] + .try_into() + .unwrap(), + ext: TransactionExt::V1(transaction_data), + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/cmd/soroban-rpc/internal/test/cli_test.go b/cmd/soroban-rpc/internal/test/cli_test.go index 88bb75a5a6..38d3a32a3b 100644 --- a/cmd/soroban-rpc/internal/test/cli_test.go +++ b/cmd/soroban-rpc/internal/test/cli_test.go @@ -1,11 +1,13 @@ package test import ( + "context" "crypto/sha256" "fmt" "os" "strings" "testing" + "time" "github.com/creachadair/jrpc2" "github.com/creachadair/jrpc2/jhttp" @@ -13,6 +15,8 @@ import ( "github.com/stellar/go/keypair" "github.com/stellar/go/txnbuild" "github.com/stellar/go/xdr" + "github.com/stellar/soroban-tools/cmd/soroban-rpc/internal/methods" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gotest.tools/v3/icmd" ) @@ -62,6 +66,152 @@ func TestCLIContractDeployAndInvoke(t *testing.T) { require.Contains(t, output, `["Hello","world"]`) } +func TestCLISimulateTransactionBumpAndRestoreFootprint(t *testing.T) { + test := NewCLITest(t) + ch := jhttp.NewChannel(test.sorobanRPCURL(), nil) + client := jrpc2.NewClient(ch, nil) + + sourceAccount := keypair.Root(StandaloneNetworkPassphrase) + address := sourceAccount.Address() + account := txnbuild.NewSimpleAccount(address, 0) + + helloWorldContract := getHelloWorldContract(t) + + params := preflightTransactionParams(t, client, txnbuild.TransactionParams{ + SourceAccount: &account, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{ + createInstallContractCodeOperation(account.AccountID, helloWorldContract), + }, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{ + TimeBounds: txnbuild.NewInfiniteTimeout(), + }, + }) + tx, err := txnbuild.NewTransaction(params) + assert.NoError(t, err) + sendSuccessfulTransaction(t, client, sourceAccount, tx) + + params = preflightTransactionParams(t, client, txnbuild.TransactionParams{ + SourceAccount: &account, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{ + createCreateContractOperation(t, address, helloWorldContract, StandaloneNetworkPassphrase), + }, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{ + TimeBounds: txnbuild.NewInfiniteTimeout(), + }, + }) + tx, err = txnbuild.NewTransaction(params) + assert.NoError(t, err) + sendSuccessfulTransaction(t, client, sourceAccount, tx) + + contractID := getContractID(t, address, testSalt, StandaloneNetworkPassphrase) + runSuccessfulCLICmd(t, fmt.Sprintf("contract invoke --id %s -- inc", contractID)) + + // get the counter ledger entry expiration + contractIDHash := xdr.Hash(contractID) + counterSym := xdr.ScSymbol("COUNTER") + key := xdr.LedgerKey{ + Type: xdr.LedgerEntryTypeContractData, + ContractData: &xdr.LedgerKeyContractData{ + Contract: xdr.ScAddress{ + Type: xdr.ScAddressTypeScAddressTypeContract, + ContractId: &contractIDHash, + }, + Key: xdr.ScVal{ + Type: xdr.ScValTypeScvSymbol, + Sym: &counterSym, + }, + Durability: xdr.ContractDataDurabilityPersistent, + }, + } + + binKey, err := key.MarshalBinary() + assert.NoError(t, err) + + expirationKey := xdr.LedgerKey{ + Type: xdr.LedgerEntryTypeExpiration, + Expiration: &xdr.LedgerKeyExpiration{ + KeyHash: sha256.Sum256(binKey), + }, + } + + keyB64, err := xdr.MarshalBase64(expirationKey) + require.NoError(t, err) + getLedgerEntryrequest := methods.GetLedgerEntryRequest{ + Key: keyB64, + } + var getLedgerEntryResult methods.GetLedgerEntryResponse + err = client.CallResult(context.Background(), "getLedgerEntry", getLedgerEntryrequest, &getLedgerEntryResult) + assert.NoError(t, err) + var entry xdr.LedgerEntryData + assert.NoError(t, xdr.SafeUnmarshalBase64(getLedgerEntryResult.XDR, &entry)) + + assert.Equal(t, xdr.LedgerEntryTypeExpiration, entry.Type) + initialExpirationSeq := entry.Expiration.ExpirationLedgerSeq + + // bump the initial expiration + params = preflightTransactionParams(t, client, txnbuild.TransactionParams{ + SourceAccount: &account, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{ + &txnbuild.BumpFootprintExpiration{ + LedgersToExpire: 20, + Ext: xdr.TransactionExt{ + V: 1, + SorobanData: &xdr.SorobanTransactionData{ + Resources: xdr.SorobanResources{ + Footprint: xdr.LedgerFootprint{ + ReadOnly: []xdr.LedgerKey{key}, + }, + }, + }, + }, + }, + }, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{ + TimeBounds: txnbuild.NewInfiniteTimeout(), + }, + }) + tx, err = txnbuild.NewTransaction(params) + assert.NoError(t, err) + sendSuccessfulTransaction(t, client, sourceAccount, tx) + + err = client.CallResult(context.Background(), "getLedgerEntry", getLedgerEntryrequest, &getLedgerEntryResult) + assert.NoError(t, err) + assert.NoError(t, xdr.SafeUnmarshalBase64(getLedgerEntryResult.XDR, &entry)) + assert.Equal(t, xdr.LedgerEntryTypeExpiration, entry.Type) + newExpirationSeq := entry.Expiration.ExpirationLedgerSeq + assert.Greater(t, newExpirationSeq, initialExpirationSeq) + + // Wait until it expires + waitForExpiration := func() { + expired := false + for i := 0; i < 50; i++ { + err = client.CallResult(context.Background(), "getLedgerEntry", getLedgerEntryrequest, &getLedgerEntryResult) + assert.NoError(t, err) + assert.NoError(t, xdr.SafeUnmarshalBase64(getLedgerEntryResult.XDR, &entry)) + assert.Equal(t, xdr.LedgerEntryTypeExpiration, entry.Type) + // See https://soroban.stellar.org/docs/fundamentals-and-concepts/state-expiration#expiration-ledger + currentLedger := getLedgerEntryResult.LatestLedger + 1 + if xdr.Uint32(currentLedger) > entry.Expiration.ExpirationLedgerSeq { + expired = true + t.Logf("ledger entry expired") + break + } + t.Log("waiting for ledger entry to expire at ledger", entry.Expiration.ExpirationLedgerSeq) + time.Sleep(time.Second) + } + require.True(t, expired) + } + waitForExpiration() + output := runSuccessfulCLICmd(t, fmt.Sprintf("contract invoke --id %s -- inc", contractID)) + require.Equal(t, "2", output) +} + func runSuccessfulCLICmd(t *testing.T, cmd string) string { res := runCLICommand(t, cmd) require.NoError(t, res.Error, fmt.Sprintf("stderr:\n%s\nstdout:\n%s\n", res.Stderr(), res.Stdout()))