diff --git a/libraries/psibase/tester/src/DefaultTestChain.cpp b/libraries/psibase/tester/src/DefaultTestChain.cpp index 35fab72a1..cd60a6deb 100644 --- a/libraries/psibase/tester/src/DefaultTestChain.cpp +++ b/libraries/psibase/tester/src/DefaultTestChain.cpp @@ -175,7 +175,7 @@ std::vector DefaultTestChain::defaultPackages() { return {"Accounts", "AuthAny", "AuthDelegate", "AuthSig", "CommonApi", "CpuLimit", "Events", "Explorer", "Fractal", "Invite", "Nft", "Packages", "Producers", "HttpServer", - "Sites", "SetCode", "Symbol", "Tokens", "Transact"}; + "Sites", "SetCode", "StagedTx", "Symbol", "Tokens", "Transact"}; } DefaultTestChain::DefaultTestChain() : TestChain(defaultChainInstance(), true) diff --git a/services/system/AuthSig/CMakeLists.txt b/services/system/AuthSig/CMakeLists.txt index 717895d93..0c74f0bb4 100644 --- a/services/system/AuthSig/CMakeLists.txt +++ b/services/system/AuthSig/CMakeLists.txt @@ -20,4 +20,6 @@ function(add suffix) endfunction(add) +add_subdirectory(test) + conditional_add() diff --git a/services/system/AuthSig/include/services/system/AuthSig.hpp b/services/system/AuthSig/include/services/system/AuthSig.hpp index 8e41b7fa0..4ed91ee95 100644 --- a/services/system/AuthSig/include/services/system/AuthSig.hpp +++ b/services/system/AuthSig/include/services/system/AuthSig.hpp @@ -70,13 +70,27 @@ namespace SystemService /// submits a transaction. void setKey(SubjectPublicKeyInfo key); + /// Handle notification related to the acceptance of a staged transaction + /// + /// Auth-sig will execute the staged transaction if the sender of the call to `accept` + /// is the same as the sender of the staged transaction. + void stagedAccept(uint32_t staged_tx_id, psibase::AccountNumber actor); + + /// Handle notification related to the rejection of a staged transaction + /// + /// Auth-sig will reject the staged transaction if the sender of the call to `reject` is + /// the same as the sender of the staged transaction. + void stagedReject(uint32_t staged_tx_id, psibase::AccountNumber actor); + private: Tables db{psibase::getReceiver()}; }; PSIO_REFLECT(AuthSig, // method(checkAuthSys, flags, requester, sender, action, allowedActions, claims), method(canAuthUserSys, user), - method(setKey, key) + method(setKey, key), + method(stagedAccept, staged_tx_id, actor), + method(stagedReject, staged_tx_id, actor) // ) } // namespace AuthSig diff --git a/services/system/AuthSig/src/AuthSig.cpp b/services/system/AuthSig/src/AuthSig.cpp index 4b519457c..b6762c408 100644 --- a/services/system/AuthSig/src/AuthSig.cpp +++ b/services/system/AuthSig/src/AuthSig.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -81,6 +82,34 @@ namespace SystemService auto authTable = db.open(); authTable.put(AuthRecord{.account = getSender(), .pubkey = std::move(key)}); } + + void AuthSig::stagedAccept(uint32_t staged_tx_id, psibase::AccountNumber actor) + { + check(getSender() == StagedTxService::service, "can only be called by staged-tx"); + + auto staged_tx = to().get_staged_tx(staged_tx_id); + auto actions = staged_tx.action_list.actions; + + check(actions.size() > 0, "staged tx has no actions"); + if (actor == actions[0].sender) + { + auto [execute, allowedActions] = to().get_exec_info(staged_tx_id); + recurse().to().runAs(std::move(execute), allowedActions); + } + } + + void AuthSig::stagedReject(uint32_t staged_tx_id, psibase::AccountNumber actor) + { + check(getSender() == StagedTxService::service, "can only be called by staged-tx"); + + auto staged_tx = to().get_staged_tx(staged_tx_id); + auto actions = staged_tx.action_list.actions; + check(actions.size() > 0, "staged tx has no actions"); + if (actor == actions[0].sender) + { + to().remove(staged_tx.id, staged_tx.txid); + } + } } // namespace AuthSig } // namespace SystemService diff --git a/services/system/AuthSig/test/CMakeLists.txt b/services/system/AuthSig/test/CMakeLists.txt new file mode 100644 index 000000000..08d6bb158 --- /dev/null +++ b/services/system/AuthSig/test/CMakeLists.txt @@ -0,0 +1,3 @@ +add_system_test(AuthSig src/AuthSig-test.cpp) + +set(CMAKE_EXPORT_COMPILE_COMMANDS on) diff --git a/services/system/AuthSig/test/src/AuthSig-test.cpp b/services/system/AuthSig/test/src/AuthSig-test.cpp new file mode 100644 index 000000000..b7911ac84 --- /dev/null +++ b/services/system/AuthSig/test/src/AuthSig-test.cpp @@ -0,0 +1,147 @@ +#define CATCH_CONFIG_MAIN + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace SystemService; +using namespace UserService; +using namespace psibase; + +namespace +{ + auto pubFromPem = [](std::string param) { // + return AuthSig::SubjectPublicKeyInfo{AuthSig::parseSubjectPublicKeyInfo(param)}; + }; + + auto privFromPem = [](std::string param) { // + return AuthSig::PrivateKeyInfo{AuthSig::parsePrivateKeyInfo(param)}; + }; + + auto pub = pubFromPem( + "-----BEGIN PUBLIC KEY-----\n" + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWdALpn+cGuD1klsSRXTdapYlG5mu\n" + "WgoALofZYufL838GRUo43UuoGzxu/mW5T6r9Ix4/qc4gH2B+Zc6VYw/pKQ==\n" + "-----END PUBLIC KEY-----\n"); + auto priv = privFromPem( + "-----BEGIN PRIVATE KEY-----\n" + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9h35bFuOZyB8i+GT\n" + "HEfwKktshavRCyzHq3X55sdfgs6hRANCAARZ0Aumf5wa4PWSWxJFdN1qliUbma5a\n" + "CgAuh9li58vzfwZFSjjdS6gbPG7+ZblPqv0jHj+pziAfYH5lzpVjD+kp\n" + "-----END PRIVATE KEY-----\n"); + + //{"data": {"userNfts":{"edges":[{"node":{"id":2,"issuer":"alice","owner":"alice"}}]}}} + struct Node + { + uint32_t id; + AccountNumber issuer; + AccountNumber owner; + }; + PSIO_REFLECT(Node, id, issuer, owner); + + struct Edge + { + Node node; + }; + PSIO_REFLECT(Edge, node); + + struct UserNfts + { + std::vector edges; + }; + PSIO_REFLECT(UserNfts, edges); + + struct Data + { + UserNfts userNfts; + }; + PSIO_REFLECT(Data, userNfts); + + struct QueryRoot + { + Data data; + }; + PSIO_REFLECT(QueryRoot, data); + +} // namespace + +size_t getNrNfts(DefaultTestChain& chain, AccountNumber user) +{ + auto query = GraphQLBody{ + "query UserNfts { userNfts( user: \"alice\" ) { edges { node { id issuer owner } } } }"}; + + auto res = chain.post(Nft::service, "/graphql", query); + + auto body = std::string(res.body.begin(), res.body.end()); + auto response_root = psio::convert_from_json(body); + + //psibase::writeConsole(psio::format_json(response_root)); + + return response_root.data.userNfts.edges.size(); +} + +// Tx can be staged on behalf of an account using auth-sig +// Tx is executed once the staged tx sender accepts it +// Tx is deleted without execution once tx sender rejects it +SCENARIO("AuthSig") +{ + GIVEN("Regular chain") + { + DefaultTestChain t; + + KeyList keys = {{pub, priv}}; + auto alice = t.from(t.addAccount("alice"_a)).with(keys); + t.setAuth(alice.id, pub); + auto bob = t.from(t.addAccount("bob"_a)); + + auto mintAction = Action{.sender = alice.id, + .service = Nft::service, + .method = "mint"_m, + .rawData = psio::to_frac(std::tuple()) + + }; + auto proposed = std::vector{mintAction}; + + THEN("Alice has not minted any NFTs") + { + CHECK(getNrNfts(t, alice.id) == 0); + } + THEN("Alice can self-stage a tx, which auto-executes") + { + auto propose = alice.to().propose(proposed); + REQUIRE(propose.succeeded()); + REQUIRE(getNrNfts(t, alice.id) == 1); + } + THEN("Bob can stage a tx for alice, which does not auto-execute") + { + auto propose = bob.to().propose(proposed); + REQUIRE(propose.succeeded()); + REQUIRE(getNrNfts(t, alice.id) == 0); + } + WHEN("Bob stages a tx for alice") + { + auto id = bob.to().propose(proposed).returnVal(); + auto txid = bob.to().get_staged_tx(id).returnVal().txid; + + THEN("Alice can accept it, executing it") + { + auto accept = alice.to().accept(id, txid); + REQUIRE(accept.succeeded()); + REQUIRE(getNrNfts(t, alice.id) == 1); + } + THEN("Alice can reject it, deleting it") + { + auto reject = alice.to().reject(id, txid); + REQUIRE(reject.succeeded()); + REQUIRE(getNrNfts(t, alice.id) == 0); + REQUIRE(alice.to().get_staged_tx(id).failed("Unknown staged tx")); + } + } + } +} \ No newline at end of file diff --git a/services/system/CMakeLists.txt b/services/system/CMakeLists.txt index 03fec9167..9c2027a9c 100644 --- a/services/system/CMakeLists.txt +++ b/services/system/CMakeLists.txt @@ -5,6 +5,34 @@ function(add_system_service suffix name) set_target_properties(${name}${suffix} PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${ROOT_BINARY_DIR}) endfunction() +function(add_system_test_impl suffix name) + set(TARGET ${name}-test${suffix}) + add_executable(${TARGET} ${ARGN}) + target_include_directories(${TARGET} PUBLIC + ../include + ${CMAKE_CURRENT_LIST_DIR}) + target_link_libraries(${TARGET} services_system${suffix} psitestlib${suffix}) + set_target_properties(${TARGET} PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${ROOT_BINARY_DIR}) +endfunction() + +function(add_system_test_release name) + if(BUILD_RELEASE_WASM) + add_system_test_impl("" ${name} ${ARGN}) + add_wasm_test(${name}-test) + endif() +endfunction() + +function(add_system_test_debug name) + if(BUILD_DEBUG_WASM) + add_system_test_impl("-debug" ${name} ${ARGN}) + endif() +endfunction() + +function(add_system_test name) + add_system_test_release(${name} ${ARGN}) + add_system_test_debug(${name} ${ARGN}) +endfunction() + function(add suffix) add_library(services_system${suffix} INTERFACE) target_mapped_include_directories(services_system${suffix} INTERFACE diff --git a/services/system/StagedTx/service/cpp/include/services/system/StagedTx.hpp b/services/system/StagedTx/service/cpp/include/services/system/StagedTx.hpp index a5b749c1b..5ccb49a76 100644 --- a/services/system/StagedTx/service/cpp/include/services/system/StagedTx.hpp +++ b/services/system/StagedTx/service/cpp/include/services/system/StagedTx.hpp @@ -15,11 +15,12 @@ namespace SystemService { uint32_t id; psibase::Checksum256 txid; + uint32_t propose_block; psibase::TimePointUSec propose_date; psibase::AccountNumber proposer; ActionList action_list; }; - PSIO_REFLECT(StagedTx, id, txid, propose_date, proposer, action_list) + PSIO_REFLECT(StagedTx, id, txid, propose_block, propose_date, proposer, action_list) struct Response { @@ -49,10 +50,12 @@ namespace SystemService void init(); /// Proposes a new staged transaction containing the specified actions. + /// Returns the ID of the database record containing the staged transaction. + /// /// All actions must have the same sender. /// /// * `actions` - The actions to be staged - void propose(const std::vector& actions); + uint32_t propose(const std::vector& actions); /// Indicates that the caller accepts the specified staged transaction /// @@ -71,8 +74,8 @@ namespace SystemService /// Removes (deletes) a staged transaction /// - /// A staged transaction can only be removed by the proposer or the auth service of - /// the staged tx sender. + /// A staged transaction can only be removed by the proposer, the staged tx + /// first sender, or the first sender's auth service. /// /// * `id`: The ID of the database record containing the staged transaction /// * `txid`: The unique txid of the staged transaction diff --git a/services/system/StagedTx/service/src/lib.rs b/services/system/StagedTx/service/src/lib.rs index c750a4718..904de5e14 100644 --- a/services/system/StagedTx/service/src/lib.rs +++ b/services/system/StagedTx/service/src/lib.rs @@ -90,6 +90,7 @@ pub mod service { pub struct StagedTx { pub id: u32, pub txid: [u8; 32], + pub propose_block: u32, pub propose_date: TimePointUSec, pub proposer: AccountNumber, pub action_list: ActionList, @@ -148,6 +149,7 @@ pub mod service { StagedTx { id: monotonic_id, txid, + propose_block: current_block.blockNum, propose_date: current_block.time, proposer: get_sender(), action_list: ActionList { actions }, @@ -264,11 +266,13 @@ pub mod service { } /// Proposes a new staged transaction containing the specified actions. + /// Returns the ID of the database record containing the staged transaction. + /// /// All actions must have the same sender. /// /// * `actions` - The actions to be staged #[action] - fn propose(actions: Vec) { + fn propose(actions: Vec) -> u32 { let new_tx = StagedTx::new(actions); StagedTxTable::new().put(&new_tx).unwrap(); @@ -277,6 +281,8 @@ pub mod service { // A proposal is also an implicit accept accept(new_tx.id, new_tx.txid); + + new_tx.id } /// Indicates that the caller accepts the specified staged transaction @@ -309,17 +315,17 @@ pub mod service { Response::upsert(id, false); + staged_tx.emit(StagedTxEvent::REJECTED); + staged_tx .staged_tx_policy() .reject(staged_tx.id, get_sender()); - - staged_tx.emit(StagedTxEvent::REJECTED); } /// Removes (deletes) a staged transaction /// - /// A staged transaction can only be removed by the proposer or the auth service of - /// the staged tx sender. + /// A staged transaction can only be removed by the proposer, the staged tx + /// first sender, or the first sender's auth service. /// /// * `id`: The ID of the database record containing the staged transaction /// * `txid`: The unique txid of the staged transaction @@ -332,8 +338,12 @@ pub mod service { // Needed to separate the event emission from the removal logic fn remove_impl(staged_tx: &StagedTx) { + let sender = get_sender(); + let first_sender = staged_tx.first_sender(); check( - get_sender() == staged_tx.proposer || get_sender() == staged_tx.first_sender(), + sender == staged_tx.proposer + || sender == first_sender + || sender == get_auth_service(first_sender).unwrap(), "Only the proposer or the staged tx first sender can remove the staged tx", );