From de63e817f08b0afacd6c78ab0f4990f00a097878 Mon Sep 17 00:00:00 2001 From: Isaac Doidge Date: Wed, 4 Nov 2026 02:02:45 +1000 Subject: [PATCH 1/8] test: run the node-backed tests and the automation example on nodes without CoinStore Nodes whose genesis enables the fungible-asset migration keep SUPRA in primary fungible stores: accounts have no CoinStore, transfers emit 0x1::fungible_asset::Deposit and Withdraw, and account_coin_transactions lists no SUPRA movement. The coin-transaction and events-by-type tests now pick their expectations by whether the account holds the CoinStore. The automation registration in the tests and in examples/automation.py used a gas price cap of 100, below the node's minimum gas price, which the node refuses at validation. The cap now comes from estimate_gas_price()'s min_configured_gas_price. The cancel and stop tests expect the V2 events the framework emits. The example no longer suggests passing a task type and priority together, and treats task index 0 as valid. Co-Authored-By: Claude Opus 5.5 --- examples/automation.py | 14 +++--- tests/unit/test_supra_client.py | 77 +++++++++++++++++++++++++++------ 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/examples/automation.py b/examples/automation.py index 5bfe740..1927f07 100644 --- a/examples/automation.py +++ b/examples/automation.py @@ -40,17 +40,17 @@ async def main(): # registration parameters. To use version 2, pass `automation_task_type` (USER or # SYSTEM; SYSTEM needs an account the registry has authorised for system tasks) # and/or `automation_priority`. Without a priority, the registry uses the task's - # index as its priority: + # index as its priority. # - # from supra_sdk.transactions import AutomationTaskType - # - # automation_task_type=AutomationTaskType.USER, - # automation_priority=10, + # The gas price cap must be at least the node's minimum gas price. + gas_price_cap = (await supra_client.estimate_gas_price())[ + "min_configured_gas_price" + ] task_registration_tx_hash = await supra_client.register_automation_task( sender, automated_function, 9, - 100, + gas_price_cap, 1 * (10**8), int(time.time()) + 7200, [], @@ -66,7 +66,7 @@ async def main(): if event["type"] == "0x1::automation_registry::AutomationTaskMetaData": task_index = event["data"]["task_index"] - if not task_index: + if task_index is None: raise Exception("`task_index` not found in automation registration transaction") print(f"Task registered successfully, task_id: {task_index}") diff --git a/tests/unit/test_supra_client.py b/tests/unit/test_supra_client.py index f0ecb5c..9b1794f 100644 --- a/tests/unit/test_supra_client.py +++ b/tests/unit/test_supra_client.py @@ -32,6 +32,29 @@ ) +SUPRA_COIN_STORE = "0x1::coin::CoinStore<0x1::supra_coin::SupraCoin>" +FA_DEPOSIT_EVENT = "0x1::fungible_asset::Deposit" +COIN_DEPOSIT_EVENT = "0x1::coin::DepositEvent" + + +async def has_supra_coin_store( + supra_client: SupraClient, address: AccountAddress +) -> bool: + """Whether the account holds SUPRA in a `CoinStore`. + + Nodes whose genesis predates the fungible-asset migration keep SUPRA in a + `CoinStore`; newer ones keep it in a primary fungible store, so the + resource is absent. + """ + try: + await supra_client.account_resource(address, SUPRA_COIN_STORE) + except ApiError as error: + if error.status_code == 404: + return False + raise + return True + + @requires_node class TestSupraClientEndpointsIntegration(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): @@ -192,6 +215,11 @@ async def test_account_coin_transactions(self): 0, ) await self.supra_client.faucet(self.test_account.address()) + # Without a SUPRA `CoinStore`, SUPRA moves between primary fungible stores and + # `account_coin_transactions` lists none of it. + uses_coin_store = await has_supra_coin_store( + self.supra_client, self.test_account.address() + ) self.assertEqual( len( ( @@ -200,9 +228,9 @@ async def test_account_coin_transactions(self): ) )[0] ), - 1, + 1 if uses_coin_store else 0, ) - await self.supra_client.transfer_supra_coin( + tx_hash = await self.supra_client.transfer_supra_coin( self.test_account, AccountAddress.from_str("0x1"), 100 ) self.assertEqual( @@ -213,7 +241,7 @@ async def test_account_coin_transactions(self): ) )[0] ), - 2, + 2 if uses_coin_store else 0, ) self.assertEqual( len( @@ -223,8 +251,15 @@ async def test_account_coin_transactions(self): ) )[0] ), - 1, - ) + 1 if uses_coin_store else 0, + ) + if not uses_coin_store: + # The deposit event names the receiving store, not the account. + deposits = await self._events_of_type(tx_hash, FA_DEPOSIT_EVENT) + self.assertEqual( + [event["data"]["amount"] for event in deposits], + ["100"], + ) async def test_transaction_by_hash(self): await self.supra_client.faucet(self.test_account.address()) @@ -295,11 +330,15 @@ async def test_events_by_type(self): tx_hash = await self.supra_client.transfer_supra_coin( self.test_account, AccountAddress.from_str("0x1"), 1 ) - # # Due to bug in rpc, this case will fail - # self.assertGreater( - # len((await self.supra_client.events_by_type("0x1::coin::DepositEvent"))[0]), - # 1, - # ) + # With a SUPRA `CoinStore` the transfer emits `0x1::coin::DepositEvent`; + # without one it emits `0x1::fungible_asset::Deposit`. + deposit_event_type = ( + COIN_DEPOSIT_EVENT + if await has_supra_coin_store( + self.supra_client, self.test_account.address() + ) + else FA_DEPOSIT_EVENT + ) block_height = (await self.supra_client.transaction_by_hash(tx_hash))[ "block_header" ]["height"] @@ -307,7 +346,7 @@ async def test_events_by_type(self): len( ( await self.supra_client.events_by_type( - "0x1::coin::DepositEvent", + deposit_event_type, EventsPagination( start_height=block_height, end_height=block_height + 1, @@ -352,6 +391,16 @@ async def test_txs_by_block(self): ]["hash"] self.assertTrue(tx_hash in (await self.supra_client.txs_by_block(block_hash))) + async def _events_of_type( + self, tx_hash: str, event_type: str + ) -> list[dict[str, Any]]: + tx_data = await self.supra_client.transaction_by_hash(tx_hash) + return [ + event + for event in tx_data["output"]["Move"]["events"] + if event["type"] == event_type + ] + async def create_test_signed_transaction_payload(self) -> SignedTransaction: transaction_arguments = [ TransactionArgument(AccountAddress.from_str("0x1"), Serializer.struct), @@ -408,7 +457,7 @@ async def test_cancel_automation_task(self): ) self.assertIsNotNone( await self._get_event_data_from_tx( - task_cancel_tx_hash, "0x1::automation_registry::TaskCancelled" + task_cancel_tx_hash, "0x1::automation_registry::TaskCancelledV2" ) ) @@ -427,7 +476,7 @@ async def test_stop_automation_task(self): ) self.assertIsNotNone( await self._get_event_data_from_tx( - task_stop_tx_hash, "0x1::automation_registry::TasksStopped" + task_stop_tx_hash, "0x1::automation_registry::TasksStoppedV2" ) ) @@ -445,7 +494,7 @@ async def register_test_automation_task(self) -> str: self.sender, automated_function, 9, - 100, + (await self.supra_client.estimate_gas_price())["min_configured_gas_price"], 1 * (10**8), int(time.time()) + 7200, [], From 9d8d1b84fa4a9068764f7c76143fe8ad517539b0 Mon Sep 17 00:00:00 2001 From: Isaac Doidge Date: Wed, 4 Nov 2026 02:20:08 +1000 Subject: [PATCH 2/8] test: pick the expected deposit event from the receiver and tighten the automation example The type of a SUPRA deposit event follows the receiver's layout (CoinStore or primary fungible store), not the sender's. The coin-transaction and events-by-type tests now choose it from the receiver. Where the receiver uses a primary fungible store, the test checks that the Deposit event's store is the receiver's primary store (from the primary_store_address view) and that its amount is the transferred amount, and that an empty coin-transaction listing carries no cursor. The event extraction helper is shared by the tests. examples/automation.py takes its gas price cap from the recent median price, bounded by the node's minimum and maximum, because a task is skipped in blocks priced above its cap. The comment on task type and priority states which combinations the registry accepts. Co-Authored-By: Claude Opus 5.5 --- examples/automation.py | 21 ++++--- tests/unit/test_supra_client.py | 105 +++++++++++++++++++------------- 2 files changed, 74 insertions(+), 52 deletions(-) diff --git a/examples/automation.py b/examples/automation.py index 1927f07..ac4518b 100644 --- a/examples/automation.py +++ b/examples/automation.py @@ -36,16 +36,19 @@ async def main(): TransactionArgument(1, Serializer.u64), ], ) - # Without a task type or a priority the task is registered with version 1 of the - # registration parameters. To use version 2, pass `automation_task_type` (USER or - # SYSTEM; SYSTEM needs an account the registry has authorised for system tasks) - # and/or `automation_priority`. Without a priority, the registry uses the task's - # index as its priority. + # Without a task type the task is registered with version 1 of the registration + # parameters. `automation_task_type` selects version 2. `automation_priority` is + # accepted only with `AutomationTaskType.SYSTEM`, from an account the registry has + # authorised for system tasks; a user task's priority is its task index. # - # The gas price cap must be at least the node's minimum gas price. - gas_price_cap = (await supra_client.estimate_gas_price())[ - "min_configured_gas_price" - ] + # The node refuses a gas price cap below its minimum gas price. A task runs only in + # blocks whose gas price is at or below its cap and is skipped in blocks priced above + # it, so the cap here is the recent median price, within the node's bounds. + gas_prices = await supra_client.estimate_gas_price() + gas_price_cap = min( + max(gas_prices["min_configured_gas_price"], gas_prices["median_gas_price"]), + gas_prices["max_gas_price"], + ) task_registration_tx_hash = await supra_client.register_automation_task( sender, automated_function, diff --git a/tests/unit/test_supra_client.py b/tests/unit/test_supra_client.py index 9b1794f..0963b28 100644 --- a/tests/unit/test_supra_client.py +++ b/tests/unit/test_supra_client.py @@ -35,6 +35,8 @@ SUPRA_COIN_STORE = "0x1::coin::CoinStore<0x1::supra_coin::SupraCoin>" FA_DEPOSIT_EVENT = "0x1::fungible_asset::Deposit" COIN_DEPOSIT_EVENT = "0x1::coin::DepositEvent" +# Address of the SUPRA fungible asset metadata object. +SUPRA_COIN_METADATA = "0xa" async def has_supra_coin_store( @@ -42,9 +44,9 @@ async def has_supra_coin_store( ) -> bool: """Whether the account holds SUPRA in a `CoinStore`. - Nodes whose genesis predates the fungible-asset migration keep SUPRA in a - `CoinStore`; newer ones keep it in a primary fungible store, so the - resource is absent. + With the framework's coin-to-fungible-asset features 64 and 65 on at genesis, SUPRA + lives in primary fungible stores and the resource is absent; without them, accounts + keep `CoinStore`. """ try: await supra_client.account_resource(address, SUPRA_COIN_STORE) @@ -55,6 +57,27 @@ async def has_supra_coin_store( return True +async def deposit_event_type( + supra_client: SupraClient, receiver: AccountAddress +) -> str: + """The event type a SUPRA deposit into `receiver` emits, which follows the receiver's layout.""" + if await has_supra_coin_store(supra_client, receiver): + return COIN_DEPOSIT_EVENT + return FA_DEPOSIT_EVENT + + +async def events_of_type( + supra_client: SupraClient, tx_hash: str, event_type: str +) -> list[dict[str, Any]]: + """The Move events of the given type that a transaction emitted, in order.""" + tx_data = await supra_client.transaction_by_hash(tx_hash) + return [ + event + for event in tx_data["output"]["Move"]["events"] + if event["type"] == event_type + ] + + @requires_node class TestSupraClientEndpointsIntegration(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): @@ -243,23 +266,35 @@ async def test_account_coin_transactions(self): ), 2 if uses_coin_store else 0, ) - self.assertEqual( - len( - ( - await self.supra_client.account_coin_transactions( - self.test_account.address(), PaginationWithOrder(count=1) - ) - )[0] - ), - 1 if uses_coin_store else 0, - ) - if not uses_coin_store: - # The deposit event names the receiving store, not the account. - deposits = await self._events_of_type(tx_hash, FA_DEPOSIT_EVENT) - self.assertEqual( - [event["data"]["amount"] for event in deposits], - ["100"], + page, cursor = await self.supra_client.account_coin_transactions( + self.test_account.address(), PaginationWithOrder(count=1) + ) + if uses_coin_store: + self.assertEqual(len(page), 1) + else: + # An empty listing carries no cursor. + self.assertEqual((page, cursor), ([], "")) + + # The deposit event type follows the receiver's layout, not the sender's. + receiver = AccountAddress.from_str("0x1") + deposit_type = await deposit_event_type(self.supra_client, receiver) + deposits = await events_of_type(self.supra_client, tx_hash, deposit_type) + if deposit_type == FA_DEPOSIT_EVENT: + # The event names the receiver's primary store, not the receiver. + (receiver_store,) = await self.supra_client.view( + "0x1::primary_fungible_store::primary_store_address", + ["0x1::fungible_asset::Metadata"], + [str(receiver), SUPRA_COIN_METADATA], ) + matching = [ + event + for event in deposits + if AccountAddress.from_str(event["data"]["store"]) + == AccountAddress.from_str(receiver_store) + ] + self.assertEqual([event["data"]["amount"] for event in matching], ["100"]) + else: + self.assertEqual([event["data"]["amount"] for event in deposits], ["100"]) async def test_transaction_by_hash(self): await self.supra_client.faucet(self.test_account.address()) @@ -330,14 +365,9 @@ async def test_events_by_type(self): tx_hash = await self.supra_client.transfer_supra_coin( self.test_account, AccountAddress.from_str("0x1"), 1 ) - # With a SUPRA `CoinStore` the transfer emits `0x1::coin::DepositEvent`; - # without one it emits `0x1::fungible_asset::Deposit`. - deposit_event_type = ( - COIN_DEPOSIT_EVENT - if await has_supra_coin_store( - self.supra_client, self.test_account.address() - ) - else FA_DEPOSIT_EVENT + # The deposit event type follows the receiver's layout. + event_type = await deposit_event_type( + self.supra_client, AccountAddress.from_str("0x1") ) block_height = (await self.supra_client.transaction_by_hash(tx_hash))[ "block_header" @@ -346,7 +376,7 @@ async def test_events_by_type(self): len( ( await self.supra_client.events_by_type( - deposit_event_type, + event_type, EventsPagination( start_height=block_height, end_height=block_height + 1, @@ -391,16 +421,6 @@ async def test_txs_by_block(self): ]["hash"] self.assertTrue(tx_hash in (await self.supra_client.txs_by_block(block_hash))) - async def _events_of_type( - self, tx_hash: str, event_type: str - ) -> list[dict[str, Any]]: - tx_data = await self.supra_client.transaction_by_hash(tx_hash) - return [ - event - for event in tx_data["output"]["Move"]["events"] - if event["type"] == event_type - ] - async def create_test_signed_transaction_payload(self) -> SignedTransaction: transaction_arguments = [ TransactionArgument(AccountAddress.from_str("0x1"), Serializer.struct), @@ -490,6 +510,8 @@ async def register_test_automation_task(self) -> str: TransactionArgument(1, Serializer.u64), ], ) + # The node refuses a cap below its minimum gas price. These tests do not wait for + # the task to execute, so the minimum is enough. tx_hash = await self.supra_client.register_automation_task( self.sender, automated_function, @@ -504,11 +526,8 @@ async def register_test_automation_task(self) -> str: async def _get_event_data_from_tx( self, tx_hash: str, event_type: str ) -> dict[str, Any] | None: - tx_data = await self.supra_client.transaction_by_hash(tx_hash) - for event in tx_data["output"]["Move"]["events"]: - if event["type"] == event_type: - return event - return None + events = await events_of_type(self.supra_client, tx_hash, event_type) + return events[0] if events else None async def asyncTearDown(self): await self.supra_client.close() From 04a179642174f8418391b7e25ac2a06bb18a4d90 Mon Sep 17 00:00:00 2001 From: Isaac Doidge Date: Wed, 4 Nov 2026 02:42:42 +1000 Subject: [PATCH 3/8] fix(examples): run every example as shipped, against Devnet by default The examples read the RPC node from SUPRA_RPC_NODE_URL (default Devnet, https://rpc-devnet.supra.com) and the faucet node from SUPRA_FAUCET_URL (default the RPC node). `common.fund_accounts` funds the accounts an example generates: by transfers from the account of SUPRA_EXAMPLES_PRIVATE_KEY when it is set, otherwise from one account funded by a single faucet request. No example except `faucet.py` calls the faucet more than once, and `faucet.py` makes one request. `fund_accounts` refuses Mainnet. The Move packages that `your_coin.py`, `multisig.py` and `hello_blockchain.py` publish are committed in compiled form in `examples/move/packages.json`, compiled for a placeholder address that `common.compiled_package` replaces with the publisher's. The examples no longer run `supra move tool compile` on a sibling aptos-core checkout. `hello_blockchain.py` publishes its package when no address is given. `aiofiles` is dropped: the examples read files with `pathlib`. The unreleased `examples` extra and the dev dependency are removed. Other changes: - `rotate_key.py` no longer prints part of each private key. - `simulate_transfer_coin.py` sets max gas, since the estimate's simulation fails while the receiver has no coin store. - `automation.py` registers a task of one hour, within the 7200-second cap of a localnet, sends the registration and the stop again while the registry reports a cycle transition, and uses the estimated max gas. - `main` functions are annotated, so that mypy checks their bodies, and mypy now checks `examples/` (`make typecheck` and CI). Co-Authored-By: Claude Opus 5.5 --- examples/automation.py | 69 ++++++++---- examples/common.py | 160 ++++++++++++++++++++++++++-- examples/faucet.py | 35 +++--- examples/fee_payer_transfer_coin.py | 12 +-- examples/hello_blockchain.py | 34 +++--- examples/move/packages.json | 36 +++++++ examples/multisig.py | 96 +++++++---------- examples/rotate_key.py | 18 ++-- examples/simple_nft.py | 49 ++++++--- examples/simple_supra_token.py | 7 +- examples/simulate_transfer_coin.py | 26 +++-- examples/supra_token.py | 8 +- examples/transfer_coin.py | 10 +- examples/transfer_two_by_two.py | 24 ++--- examples/your_coin.py | 46 ++------ pyproject.toml | 6 +- uv.lock | 20 +--- 17 files changed, 413 insertions(+), 243 deletions(-) create mode 100644 examples/move/packages.json diff --git a/examples/automation.py b/examples/automation.py index ac4518b..82fabaa 100644 --- a/examples/automation.py +++ b/examples/automation.py @@ -4,29 +4,47 @@ import asyncio import json import time +from collections.abc import Awaitable, Callable -from examples.common import RPC_NODE_URL +from examples.common import RPC_NODE_URL, fund_accounts from supra_sdk.account import Account from supra_sdk.account_address import AccountAddress from supra_sdk.bcs import Serializer -from supra_sdk.clients.rest import SupraClient, SupraClientConfig +from supra_sdk.clients.rest import SupraClient +from supra_sdk.clients.rest.supra_client import TransactionFailedError +from supra_sdk.clients.rest.transaction_defaults import MaxGasEstimationError from supra_sdk.transactions import EntryFunction, TransactionArgument +TASK_DURATION_SECONDS = 3600 -async def main(): - supra_client_config = SupraClientConfig( - expiration_ttl=300, - gas_unit_price=100_000, - max_gas_amount=100_000, - ) - supra_client = SupraClient(RPC_NODE_URL, supra_client_config) +# The registry refuses registrations and stops while it moves from one automation cycle +# to the next. The example sends the transaction again after a pause, this many times. +CYCLE_TRANSITION_ATTEMPTS = 10 +CYCLE_TRANSITION_PAUSE_SECONDS = 3.0 + + +async def outside_cycle_transition(action: Callable[[], Awaitable[str]]) -> str: + """Runs `action` again while the registry reports a cycle transition in progress.""" + for attempt in range(1, CYCLE_TRANSITION_ATTEMPTS + 1): + try: + return await action() + except (TransactionFailedError, MaxGasEstimationError) as error: + in_transition = "ECYCLE_TRANSITION_IN_PROGRESS" in str(error.vm_status) + if not in_transition or attempt == CYCLE_TRANSITION_ATTEMPTS: + raise + print("Automation cycle transition in progress, trying again") + await asyncio.sleep(CYCLE_TRANSITION_PAUSE_SECONDS) + raise AssertionError("unreachable") + + +async def main() -> None: + supra_client = SupraClient(RPC_NODE_URL) sender = Account.generate() print(f"Automation task creator account address: {sender.address()}") - await supra_client.faucet(sender.address()) - print(f"Faucet on {sender.address()} done") + await fund_accounts(supra_client, [sender.address()]) - print("Registering automation tasK") + print("Registering automation task") automated_function = EntryFunction.natural( "0x1::supra_account", "transfer", @@ -49,14 +67,21 @@ async def main(): max(gas_prices["min_configured_gas_price"], gas_prices["median_gas_price"]), gas_prices["max_gas_price"], ) - task_registration_tx_hash = await supra_client.register_automation_task( - sender, - automated_function, - 9, - gas_price_cap, - 1 * (10**8), - int(time.time()) + 7200, - [], + # The registry refuses a task whose duration from the block time of its + # registration exceeds `task_duration_cap_in_secs` of + # `0x1::automation_registry::ActiveAutomationRegistryConfigV2` (7200 seconds on a + # localnet), and a task that expires before the end of the next automation cycle. + expiry_time = int(time.time()) + TASK_DURATION_SECONDS + task_registration_tx_hash = await outside_cycle_transition( + lambda: supra_client.register_automation_task( + sender, + automated_function, + 9, + gas_price_cap, + 1 * (10**8), + expiry_time, + [], + ) ) # The registration already succeeded (the helper raised otherwise); fetch its events to read the task index. @@ -74,7 +99,9 @@ async def main(): print(f"Task registered successfully, task_id: {task_index}") print(f"Stopping task, task_index: {task_index}") - await supra_client.stop_automation_tasks(sender, [int(task_index)]) + await outside_cycle_transition( + lambda: supra_client.stop_automation_tasks(sender, [int(task_index)]) + ) print(f"Task stopped successfully, task_index: {task_index}") await supra_client.close() diff --git a/examples/common.py b/examples/common.py index d60e514..15cadc1 100644 --- a/examples/common.py +++ b/examples/common.py @@ -2,11 +2,159 @@ # Parts of the project are originally copyright © Aptos Foundation # SPDX-License-Identifier: Apache-2.0 +"""Settings and helpers shared by the examples. + +The examples read the network and the funding account from the environment: + +- `SUPRA_RPC_NODE_URL`: the base URL of the RPC node (without `/rpc/v3`). Default + `https://rpc-devnet.supra.com` (Devnet). +- `SUPRA_FAUCET_URL`: the base URL of the node that serves the faucet. Default + `SUPRA_RPC_NODE_URL`. +- `SUPRA_EXAMPLES_PRIVATE_KEY`: the Ed25519 private key (32 bytes as hex, with or + without `0x`) of a funded account. When it is set, the examples fund the accounts + they generate by transfers from this account and do not call the faucet. When it is + not set, an example funds one generated account from the faucet and funds the other + accounts it generates by transfers from that account, so it calls the faucet once. + +An empty variable counts as unset. The examples do not send transactions to Mainnet. +""" + +import asyncio +import json import os +import time +from dataclasses import dataclass +from pathlib import Path + +from supra_sdk.account import Account +from supra_sdk.account_address import AccountAddress +from supra_sdk.clients.rest import SupraClient +from supra_sdk.clients.rest.supra_client import FaucetRequestNotAcceptedError + +DEVNET_RPC_URL = "https://rpc-devnet.supra.com" + +RPC_NODE_URL = os.getenv("SUPRA_RPC_NODE_URL") or DEVNET_RPC_URL +FAUCET_URL = os.getenv("SUPRA_FAUCET_URL") or RPC_NODE_URL + +PRIVATE_KEY_VARIABLE = "SUPRA_EXAMPLES_PRIVATE_KEY" + +MAINNET_CHAIN_ID = 8 + +QUANTS_PER_SUPRA = 10**8 + +# SUPRA given to each account that `fund_accounts` funds, unless the example asks for +# another amount. It pays for the gas of a few transactions at the minimum gas price. +DEFAULT_FUNDING_QUANTS = 10 * QUANTS_PER_SUPRA + +# How long to retry a faucet request that the faucet does not accept ("try later"), +# and how long to wait for the faucet's funds to show in the balance. +FAUCET_TIMEOUT_SECONDS = 180.0 +FAUCET_RETRY_INTERVAL_SECONDS = 2.0 + +PACKAGES_PATH = Path(__file__).parent / "move" / "packages.json" + +# The account that funds the accounts of this example, once it is known. +_funding_account: Account | None = None + + +async def fund_accounts( + supra_client: SupraClient, + addresses: list[AccountAddress], + quants: int = DEFAULT_FUNDING_QUANTS, +) -> None: + """Sends `quants` of SUPRA to each of `addresses` from the funding account. + + The funding account is the account of `SUPRA_EXAMPLES_PRIVATE_KEY` when it is set. + Otherwise it is an account generated on the first call and funded once from the + faucet at `SUPRA_FAUCET_URL`; later calls in the same process reuse it. + + Raises: + RuntimeError: If the node is a Mainnet node, or the account of + `SUPRA_EXAMPLES_PRIVATE_KEY` holds no SUPRA. + + """ + if await supra_client.chain_id() == MAINNET_CHAIN_ID: + raise RuntimeError("The examples do not send transactions to Mainnet") + funder = await _funder(supra_client) + for address in addresses: + await supra_client.transfer_supra_coin(funder, address, quants) + print(f"Funded {address} with {quants} quants from {funder.address()}") + + +async def _funder(supra_client: SupraClient) -> Account: + global _funding_account + if _funding_account is not None: + return _funding_account + + private_key = os.getenv(PRIVATE_KEY_VARIABLE) or "" + if private_key: + funder = Account.load_key(private_key.strip()) + if await supra_client.account_supra_balance(funder.address()) == 0: + raise RuntimeError( + f"The account of {PRIVATE_KEY_VARIABLE} ({funder.address()}) holds no SUPRA" + ) + else: + funder = Account.generate() + await _fund_from_faucet(supra_client, funder.address()) + print(f"Funding account: {funder.address()}") + _funding_account = funder + return funder + + +async def _fund_from_faucet(supra_client: SupraClient, address: AccountAddress) -> None: + """Funds `address` from the faucet and waits until its balance shows the funds.""" + faucet_client = ( + supra_client if FAUCET_URL == RPC_NODE_URL else SupraClient(FAUCET_URL) + ) + deadline = time.monotonic() + FAUCET_TIMEOUT_SECONDS + try: + while True: + try: + await faucet_client.faucet(address) + break + except FaucetRequestNotAcceptedError: + if time.monotonic() > deadline: + raise + await asyncio.sleep(FAUCET_RETRY_INTERVAL_SECONDS) + finally: + if faucet_client is not supra_client: + await faucet_client.close() + + # The faucet may be served by another node than `supra_client`'s. + while await supra_client.account_supra_balance(address) == 0: + if time.monotonic() > deadline: + raise RuntimeError(f"{address} holds no SUPRA after the faucet request") + await asyncio.sleep(FAUCET_RETRY_INTERVAL_SECONDS) + + +@dataclass(frozen=True) +class CompiledPackage: + """A Move package of `move/packages.json`, compiled for one address.""" + + metadata: bytes + modules: list[bytes] + scripts: list[bytes] + + +def compiled_package(name: str, address: AccountAddress) -> CompiledPackage: + """Returns the package `name` of `move/packages.json` compiled for `address`. + + The packages in the file were compiled for the address it names. Every occurrence of + that address in the bytecode (the address of the package's modules, and constants + equal to it) is replaced by `address`. The metadata contains no address. + """ + data = json.loads(PACKAGES_PATH.read_text()) + placeholder = AccountAddress.from_str(data["address"]).address + package = data["packages"][name] + + def relocate(code_hex: str) -> bytes: + code = bytes.fromhex(code_hex) + if placeholder not in code: + raise ValueError(f"The bytecode of {name} does not contain its address") + return code.replace(placeholder, address.address) -SUPRA_CORE_PATH = os.getenv("SUPRA_CORE_PATH", os.path.abspath("../aptos-core")) -FAUCET_URL = os.getenv( - "SUPRA_FAUCET_URL", - "http://localhost:27001/", -) -RPC_NODE_URL = os.getenv("SUPRA_RPC_NODE_URL", "http://localhost:27001/") + return CompiledPackage( + metadata=bytes.fromhex(package["metadata"]), + modules=[relocate(module) for module in package["modules"]], + scripts=[relocate(script) for script in package["scripts"]], + ) diff --git a/examples/faucet.py b/examples/faucet.py index 9123247..9b44075 100644 --- a/examples/faucet.py +++ b/examples/faucet.py @@ -1,6 +1,13 @@ # Copyright © Supra # SPDX-License-Identifier: Apache-2.0 +"""Funds a new account from the faucet at `SUPRA_FAUCET_URL` with one faucet request. + +`SupraClient.faucet` waits for the faucet transaction by default. This example sends +the request without waiting and then waits for the transaction with +`SupraClient.wait_for_faucet`. +""" + import asyncio from examples.common import FAUCET_URL @@ -8,27 +15,21 @@ from supra_sdk.clients.rest import SupraClient -async def main(): +async def main() -> None: supra_client = SupraClient(FAUCET_URL) alice = Account.generate() - bob = Account.generate() + print(f"Alice account address: {alice.address()}") + + tx_hash = await supra_client.faucet(alice.address(), wait_for_faucet=False) + assert tx_hash, "The faucet did not accept the request, try again" + print(f"Faucet request accepted, tx_hash: {tx_hash}") + + faucet_transaction = await supra_client.wait_for_faucet(tx_hash) + print(f"Faucet transaction status: {faucet_transaction['status']}") + assert faucet_transaction["status"] == "Success", "The faucet transaction failed" - # Send faucet request + wait for faucet tx execution in single go. - tx_hash = await supra_client.faucet(alice.address()) - assert tx_hash, "faucet request resolution missed, try again" alice_balance = await supra_client.account_supra_balance(alice.address()) - print( - f"Faucet on {alice.address()} successfully done, tx_hash: {tx_hash}, faucet_amount: {alice_balance}" - ) - - # First send faucet request and then manually wait for faucet tx execution. - tx_hash = await supra_client.faucet(bob.address(), wait_for_faucet=False) - assert tx_hash, "faucet request resolution missed, try again" - await supra_client.wait_for_faucet(tx_hash) - bob_balance = await supra_client.account_supra_balance(bob.address()) - print( - f"Faucet on {bob.address()} successfully done, tx_hash: {tx_hash}, faucet_amount: {bob_balance}" - ) + print(f"Alice's balance: {alice_balance}") await supra_client.close() diff --git a/examples/fee_payer_transfer_coin.py b/examples/fee_payer_transfer_coin.py index 915683a..c1aa90d 100644 --- a/examples/fee_payer_transfer_coin.py +++ b/examples/fee_payer_transfer_coin.py @@ -4,7 +4,7 @@ import asyncio -from examples.common import RPC_NODE_URL +from examples.common import RPC_NODE_URL, fund_accounts from supra_sdk.account import Account from supra_sdk.bcs import Serializer from supra_sdk.clients.rest import SupraClient @@ -16,7 +16,7 @@ from supra_sdk.type_tag import StructTag, TypeTag -async def main(): +async def main() -> None: supra_client = SupraClient(RPC_NODE_URL) alice = Account.generate() bob = Account.generate() @@ -26,9 +26,9 @@ async def main(): print(f"Bob account address: {bob.address()}") print(f"Sponsor account address: {sponsor.address()}") - await supra_client.faucet(sponsor.address()) - await supra_client.faucet(alice.address()) - await supra_client.faucet(bob.address()) + await fund_accounts( + supra_client, [sponsor.address(), alice.address(), bob.address()] + ) print("Accounts data before fee payer based transfer transaction") print( @@ -65,7 +65,7 @@ async def main(): tx_hash = await supra_client.submit_transaction(signed_transaction) print(f"Transaction successfully executed, tx_hash: {tx_hash}") - print("Accounts data before fee payer based transfer transaction") + print("Accounts data after fee payer based transfer transaction") print( f"Alice sequence number: {await supra_client.account_sequence_number(alice.address())}" ) diff --git a/examples/hello_blockchain.py b/examples/hello_blockchain.py index 1937e9e..303948d 100644 --- a/examples/hello_blockchain.py +++ b/examples/hello_blockchain.py @@ -2,19 +2,21 @@ # Parts of the project are originally copyright © Aptos Foundation # SPDX-License-Identifier: Apache-2.0 -"""This example depends on the hello_blockchain.move module having already been published to the destination blockchain. +"""Sets and reads a message with the `message` module of the `hello_blockchain` package. -Steps: - * `cd ~/aptos-core/aptos-move/move-examples/hello_blockchain` - * `supra move tool publish --named-addresses hello_blockchain=${your_address_from_supra_init}` - * `python3 -m examples.hello_blockchain ${your_address_from_supra_init}` +`python -m examples.hello_blockchain` publishes the compiled package of +`examples/move/packages.json` under Alice's address and uses it. +`python -m examples.hello_blockchain
` uses the package already published at +`
` instead, for example one published with: + + supra move tool publish --named-addresses hello_blockchain=
""" import asyncio import sys from typing import Any -from examples.common import RPC_NODE_URL +from examples.common import RPC_NODE_URL, compiled_package, fund_accounts from supra_sdk.account import Account from supra_sdk.account_address import AccountAddress from supra_sdk.bcs import Serializer @@ -58,7 +60,7 @@ async def set_message( return await self.submit_transaction(signed_transaction) -async def main(contract_address: AccountAddress): +async def main(contract_address: AccountAddress | None) -> None: alice = Account.generate() bob = Account.generate() @@ -66,8 +68,14 @@ async def main(contract_address: AccountAddress): print(f"Bob account address: {bob.address()}") supra_client = HelloBlockchainClient(RPC_NODE_URL) - await supra_client.faucet(alice.address()) - await supra_client.faucet(bob.address()) + await fund_accounts(supra_client, [alice.address(), bob.address()]) + + if contract_address is None: + print("\n=== Publishing the hello_blockchain package ===") + package = compiled_package("hello_blockchain", alice.address()) + await supra_client.publish_package(alice, package.metadata, package.modules) + contract_address = alice.address() + print(f"Published under {contract_address}") print("\n=== Testing Alice ===") message = await supra_client.get_message(contract_address, alice.address()) @@ -89,7 +97,7 @@ async def main(contract_address: AccountAddress): if __name__ == "__main__": - assert len(sys.argv) == 2, "Expecting the contract address" - contract_address_str = sys.argv[1] - - asyncio.run(main(AccountAddress.from_str(contract_address_str))) + assert len(sys.argv) <= 2, "Expecting at most the contract address" + asyncio.run( + main(AccountAddress.from_str(sys.argv[1]) if len(sys.argv) == 2 else None) + ) diff --git a/examples/move/packages.json b/examples/move/packages.json new file mode 100644 index 0000000..4f70c4a --- /dev/null +++ b/examples/move/packages.json @@ -0,0 +1,36 @@ +{ + "address": "0x9c1f5e3a7b2d4c6e8f0a1b3c5d7e9f2a4b6c8d0e1f3a5b7c9d2e4f6a8b0c1d3e", + "packages": { + "hello_blockchain": { + "metadata": "084578616d706c657301000000000000000040394242393236323139343532384445344343393345464239323230444538314643363845373435453631463130363432363241444345333743323741464636428f011f8b08000000000002ff3dccc10ac2300c06e07b9ea2f46ee70b78d417f038c6886d74a55d5b1aa682f8ee2682925cf2e74bc6863ee18d2628b89239187b7ce2da32b1853b758eb568b877521660c4103a31134fb050ce75bee4ea935f307eddac2650a312a8f8a8ecbcb58ea72edf1fb527412f232798953b37485f7fcb81d5eefeb3356ff800c97afdf5a100000001076d657373616765e7041f8b08000000000002ff9555db6adb40107df7578c132812883a8552ca3a0ebd609a97b40fce5b088b2c8d2c6169d7dd8b1d53f4ef5dad2e5d599693ee8bf1ecec9973e6a682c73a474831cf395de73cda46699831420a9432dc20fc9980395a2248151382427031efdb64b66138302a91b18d63d43b11d24484051eb8d81aa43d32359f5887d98c4cc99d40c9b588d09acc7b1d2978a869dcf33c46016928618bc78654751a9a04ea7884acec6f601dca06fcd6a077e0d676fd64c33f9f09f53d0d99d15d858a05df05e69a0b74428651c4355304c23836a832e86e12c10b7a91517514a7afb06e68cfe03145133a93c078ab147626a6a16e7d22cea482e5cf5ff461b95a7dfdb124a03f7d8405dccc5b9dfb0c0fb5cc9d5ee759048966b041d572f02a159d16ff949251fb5b67e6eaa4124e3aa444a1a61ebe6452c9db9edb9d45f703b06d4308e38a262679b1e770f6fd7907b6e6c6f14037395f87f959acf70d6f37558d32931571b4faa4abaf2d57dda6c158cbf893bab8afc8cd51b51d402b4226d93530214d0e294fbc778d8b232d4bc09b8e24c9c1f37d2798ed70be47aa7807198c523b9989a07751fadddf12309778f2b0d26500dbb4d1b4465ff44b420bad2eb29f0f40dda13070c3186d45fb4fed7c9a2d5164caeb4fe650f0bf8974a804033797c9f0d61dcb88ef8e6369ecd31c9763b40e84956ed75e3f2994aa4d9f71ff72f3f2c17f1eeb6866b06914327aa1b9fd37f6effff4ed6071b7314924305448dba4275cd05a51bf11cef00dbad9d32af9ecadafeeabef4f00dfba0fd055b515063ba697fad325e6c362f106dc7e3d9d3dd4d91beee5a49cfc059c069e131f07000000000400000000000000000000000000000000000000000000000000000000000000010b4170746f735374646c696200000000000000000000000000000000000000000000000000000000000000010a4d6f76655374646c696200000000000000000000000000000000000000000000000000000000000000010e53757072614672616d65776f726b00000000000000000000000000000000000000000000000000000000000000010b53757072615374646c696200", + "modules": [ + "a11ceb0b060000000c01000a020a0c03161a04300205321c074e970108e5014006a5020a10af02670a9603110ca703590d8004020000010101020103010400050600000608000407070000080001000009020300010d040400030e060000020f0803010604070105010802020c08020001030305080207080101060c010800010900076d657373616765056572726f72056576656e74067369676e657206737472696e670d4d6573736167654368616e67650d4d657373616765486f6c64657206537472696e670b6765745f6d6573736167650b7365745f6d657373616765076163636f756e740c66726f6d5f6d6573736167650a746f5f6d657373616765096e6f745f666f756e640a616464726573735f6f6604656d69749c1f5e3a7b2d4c6e8f0a1b3c5d7e9f2a4b6c8d0e1f3a5b7c9d2e4f6a8b0c1d3e000000000000000000000000000000000000000000000000000000000000000103080000000000000000126170746f733a3a6d657461646174615f7631530100000000000000000b454e4f5f4d4553534147451b5468657265206973206e6f206d6573736167652070726573656e74010d4d6573736167654368616e6765010400010b6765745f6d6573736167650101000002030a050b08020c08020102010008020001000101030c0a0029010404050707001102270b002b01100014020101040101051d0e0011030c020a02290120040c0e000b0112012d01051c0a022a010c040a041000140c030b020b030a01120038000b010b040f001502010000" + ], + "scripts": [] + }, + "moon_coin": { + "metadata": "084578616d706c657301000000000000000040323841333042453530464637344636363434463135324432443136343830373535414341433132313736363633414435463846373733463146393438374242328a011f8b08000000000002ff3d8c410a02310c45f73945e9de8e177025ba73e5721824b451ca7492d2302a8877b71194fccdffef91b1629cf14613302ee476ce1f9eb8d442eae14e4db3b08ddbd0cf038c98522355d2094e22bc97fce51763892a71228ed9f079ad0d8fad7f7d489bbbf472452216d343187aae3f38a8b99b7ff7ee0d1f7cde11c59900000001096d6f6f6e5f636f696ebb011f8b08000000000002ff6590c10e82301044effd8acac14062d29b879570f1ac7c02a9b09046d89a96c644c2bfdb56c5a03d4d6767df242b046ca018b42636e8c6f5c84f5e1fb52280e056b5977c62dc3f3b1a578f4b804f338b7eeb882b5263f522a416a941037c6b554768b2f77a44b89b91556be480776daebe4392ecb089350081a264af1e987f5a8a74598e80c8deadbc4b12c23ca493bf49599e7fccfdfadbcadee2d7ca0e51ce6c6642e4fe38f1364f98ca2b372701000000000400000000000000000000000000000000000000000000000000000000000000010b4170746f735374646c696200000000000000000000000000000000000000000000000000000000000000010a4d6f76655374646c696200000000000000000000000000000000000000000000000000000000000000010e53757072614672616d65776f726b00000000000000000000000000000000000000000000000000000000000000010b53757072615374646c696200", + "modules": [ + "a11ceb0b060000000a01000402040403080b04130205151007254308684006a801150abd01050cc2011200000101000200000003000100010503010100010201060c0001080005060c0a020a020201096d6f6f6e5f636f696e0c6d616e616765645f636f696e084d6f6f6e436f696e0b696e69745f6d6f64756c650b64756d6d795f6669656c640a696e697469616c697a659c1f5e3a7b2d4c6e8f0a1b3c5d7e9f2a4b6c8d0e1f3a5b7c9d2e4f6a8b0c1d3e00000000000000000000000000000000000000000000000000000000000000010a020a094d6f6f6e20436f696e0a0205044d4f4f4e00020104010000000001070b000700070131060938000200" + ], + "scripts": [] + }, + "upgrade_and_govern_genesis": { + "metadata": "1055706772616465416e64476f7665726e0100000000000000004031344435454346344444333044363333333745413635394142354541453339373846323341314333313342393930423034393231334542414232304543343433a5011f8b08000000000002ff3d8ec10ac2400c44eff98a951ef6e4b65e8b0a5ef403c4532925346929b5d96563f5f7ed5a2c0c8161de90c94cb92bcf13cad0b1bea00ad88ed8730d82139b93b18fd04724be08ddfc9ba35858ae0e5e527870852b2c4085449155596b98d74283424dffab24b2491471602196766075f73944bcc6e5cdc7c7b186a76ff19950e7f255dd3fcc35b1fbcd5b9399e3b27b9bfd0579d86b25c6000000010a706172616d6574657273da021f8b08000000000002ff8d93516f823010c7dff914b79705122791391f40cd7c20cb1e5c8cfade94529188ad2bed9665e1bb0f01a186bad8b7e67ebdbbffff7aae0bfe833f3ff25865d4725d17969c1c80b327b2c72983847f51c13023144e58e0239554e443abe6419d1281638a308b514dfa7e87c1af65417972291491f0d6a65a75c81ee770a03f250acd69dfa3910f6a321e18225e15a902455d8370969725c28f70f3be41abc57ab10cb7e1bac901331879cfc1bfa47721c72f93a04eba530c52964a54cbb5db4e0cbae1314f134645c5389a9e8c4acd45a4d933335ad2bdecb9619037b8417b26da2b82163f96a591e453530b73fb2a6b5fedc0acc809f4919c5494a5a43231a152c36cc7af00fb3cddb3e94e75c5e453a582e6e68f7287a148d01dccae5a8fb810fc1b25198f707643eb6b5f9fd3f964df2c35d466a38de11ede732e461550aedcb45cc16603ff006ab421ab9003000000000400000000000000000000000000000000000000000000000000000000000000010b4170746f735374646c696200000000000000000000000000000000000000000000000000000000000000010a4d6f76655374646c696200000000000000000000000000000000000000000000000000000000000000010e53757072614672616d65776f726b00000000000000000000000000000000000000000000000000000000000000010b53757072615374646c696200", + "modules": [ + "a11ceb0b060000000a01000202020403060a05100b071b53086e20068e01360ac401070ccb01290df40104000000010800000200010000030200000002030301060c010608000a706172616d657465727314476f7665726e616e6365506172616d65746572730e6765745f706172616d65746572730b696e69745f6d6f64756c650b706172616d657465725f310b706172616d657465725f329c1f5e3a7b2d4c6e8f0a1b3c5d7e9f2a4b6c8d0e1f3a5b7c9d2e4f6a8b0c1d3e03087b000000000000000308c80100000000000005209c1f5e3a7b2d4c6e8f0a1b3c5d7e9f2a4b6c8d0e1f3a5b7c9d2e4f6a8b0c1d3e000202040305030001000100030a07022b000c000a001000140b00100114020100000000060b000700070112002d00020000000100" + ], + "scripts": [] + }, + "upgrade_and_govern_upgrade": { + "metadata": "1055706772616465416e64476f7665726e0100000000000000004036464336354138303334353746453943343345463032363939413532363042364432323034374534334231413438363143464438423533324532344430344139a5011f8b08000000000002ff3d8ec10ac2400c44eff98a951ef6e4d65e8b0a5ef403c4532925346929b5d96563f5f7ed5a2c0c8161de90c94cb92bcf13cad0b1bea00ad88ed8730d82139b93b18fd04724be08ddfc9ba35858ae0e5e5258b8c21d2c4085449155596b98d74283424dffab24b2491471602196766075f73944bcc6e5cdc7c7b186a76ff19950e7f255dd3fcc35b1fbcd5b9399e3b27b9bfd056f2f79dfc6000000020a706172616d6574657273d4031f8b08000000000002ff9d534b4fe33010bef7570c179448854296e5e010442522a8b4b0a814244e969bb82122b5831f20b4ea7f5f2781c451ddaae09b335f66fc3d66341ac10d4f5e80b383e499e40c32fe4605232ca1501241965451210f074b9eea82822e3341528a094b718344a883c1bfc100cc914ae844c155dbeaae833c13092ff4c340e1f3b4ffe36304faf464e8a80475a52eac9a190967d28c886fe3fbc93dbe1b4fc737f12c9e7ef680088e835fe15664f0853cf97d1a364d179a41ce72851bba5efb12076fd89779c6a8a831bec5a7a0ca52115bf2444e49ba3fd7d470d01b6e40072e74b00a5bf8d28cc68a9fb99e70eef5baaeb31dba19f9a16d49a9e7459ed42266545930cf4735c0abdcad44f7eb2b495e752ea87407650741b1a00b887a4f9f7321f83bce0a3e27c506ae17ebfcfc4e276fe3a843cb1bcb865df0816f0b65960eeda173529694a534adbf6949cddea40835a94288a4a9d14662be08edc4c77872fb38fe33b9c4e387d9f5dfe964f6f495e3a3b0e703654a7cd46ec8be1bbba47af8d3f5f477b695484985daf33aa29ec31888227018d65f83ea3884b15cdd9a21bcd46a5b8eaafa8fb2b475a21d27e39e75fb7e83a0d72068d7725565edcc84adcdda7f3cad8871f20500000000087472616e7366657296021f8b08000000000002ff9552b16e84300cddf90a77a9404245309aeb2d3777ea074429188a80843ac9555575ff5e205cb893daa19eece767fbd97296013ee071d4b51b28cab20c5e74d543a53b0596a5320d31f82cd87769a15367dd9381569f89955415c124598e6489cd53b451ddd4b2ac4948550bcf440cedbea308667386c0b889a56896fa4fcd3da20796f188af8b7f9addf26f7e7597fe6decaeaef47327f736741590b2fc058ddbf7141f4e2a6be295b558c37a4478345dab88d3005b2d720459d74cc6dcc1458057349957bd6607b210cb513b65459ec2e615093cdfdc0fb1252bf6384ecad0c0dfe4aaf5108e738c1799e9aa2af4cdff5758dc08f2859728bac0fc0d87f93bb6e7f80153f895ce2b02000000000400000000000000000000000000000000000000000000000000000000000000010b4170746f735374646c696200000000000000000000000000000000000000000000000000000000000000010a4d6f76655374646c696200000000000000000000000000000000000000000000000000000000000000010e53757072614672616d65776f726b00000000000000000000000000000000000000000000000000000000000000010b53757072615374646c696200", + "modules": [ + "a11ceb0b060000000b010004020404030814051c1607327408a6014006e6014010a602340ada02070ce102530db40304000001010002080000030001000004020000000503000001080206000002030301060c03060c0303010608000107080001050a706172616d6574657273067369676e657214476f7665726e616e6365506172616d65746572730e6765745f706172616d65746572730b696e69745f6d6f64756c650e7365745f706172616d65746572730b706172616d657465725f310b706172616d657465725f320a616464726573735f6f669c1f5e3a7b2d4c6e8f0a1b3c5d7e9f2a4b6c8d0e1f3a5b7c9d2e4f6a8b0c1d3e00000000000000000000000000000000000000000000000000000000000000010308000000000000000003087b000000000000000308c80100000000000005209c1f5e3a7b2d4c6e8f0a1b3c5d7e9f2a4b6c8d0e1f3a5b7c9d2e4f6a8b0c1d3e126170746f733a3a6d657461646174615f76312001000000000000000013455f494e56414c49445f415554484f52495459000000000202060307030001000100040a07032b000c000a001000140b00100114020100000000060b000701070212002d0002020104010005140b0011030703210406050807002707032a000c030b010a030f00150b020b030f0115020000000100", + "a11ceb0b0600000008010008020804030c10041c02051e11072f4d087c400cbc011d00000101010200030206080000040001000305010200010004010100020303060c05050002030301080003060c0503087472616e7366657204636f696e0a73757072615f636f696e0a706172616d65746572730f7472616e736665725f7175616e74730e6765745f706172616d6574657273095375707261436f696e9c1f5e3a7b2d4c6e8f0a1b3c5d7e9f2a4b6c8d0e1f3a5b7c9d2e4f6a8b0c1d3e000000000000000000000000000000000000000000000000000000000000000100010400020c11010c040c030a000b010b0338000b000b020b0438000200" + ], + "scripts": [ + "a11ceb0b060000000601000403040a050e0b071933084c20066c14000000010002020100010300010003060c05050003060c03030a706172616d6574657273087472616e736665720e7365745f706172616d65746572730f7472616e736665725f7175616e74739c1f5e3a7b2d4c6e8f0a1b3c5d7e9f2a4b6c8d0e1f3a5b7c9d2e4f6a8b0c1d3e03082c010000000000000308c800000000000000000001090a000700070111000b000b010b02110102" + ] + } + } +} diff --git a/examples/multisig.py b/examples/multisig.py index 0efd794..c90a77d 100644 --- a/examples/multisig.py +++ b/examples/multisig.py @@ -2,13 +2,23 @@ # Parts of the project are originally copyright © Aptos Foundation # SPDX-License-Identifier: Apache-2.0 +"""A 2-of-3 MultiEd25519 account: a transfer, a key rotation to the multisig key, a +package publication and upgrade, and a Move script, all signed by the multisig. + +The packages are the `upgrade_and_govern` packages of `examples/move/packages.json`. +`python -m examples.multisig true` pauses between the steps. +""" + import asyncio import sys from typing import cast -import aiofiles - -from examples.common import RPC_NODE_URL, SUPRA_CORE_PATH +from examples.common import ( + QUANTS_PER_SUPRA, + RPC_NODE_URL, + compiled_package, + fund_accounts, +) from supra_sdk import ed25519 from supra_sdk.account import Account, RotationProofChallenge from supra_sdk.account_address import AccountAddress @@ -33,6 +43,13 @@ # 1,700 units) and a package publish (about 2,000 units). MAX_GAS_AMOUNT = 20_000 +# The balance of each account that sends a transaction with MAX_GAS_AMOUNT must cover +# MAX_GAS_AMOUNT at the gas unit price (20 SUPRA at the minimum price of 100,000 quants). +MULTISIG_SENDER_FUNDING_QUANTS = 30 * QUANTS_PER_SUPRA + +# The signers send no transaction; they receive the transfers of the example. +SIGNER_FUNDING_QUANTS = 1 * QUANTS_PER_SUPRA + def wait(): """Wait for user to press Enter before starting next section.""" @@ -40,7 +57,7 @@ def wait(): input("\nPress Enter to continue...") -async def main(should_wait_input: bool): +async def main(should_wait_input: bool) -> None: global should_wait should_wait = should_wait_input @@ -81,10 +98,14 @@ async def main(should_wait_input: bool): wait() print("\n=== Funding accounts ===") - await supra_client.faucet(alice.address()) - await supra_client.faucet(bob.address()) - await supra_client.faucet(chad.address()) - await supra_client.faucet(multisig_address) + await fund_accounts( + supra_client, + [alice.address(), bob.address(), chad.address()], + SIGNER_FUNDING_QUANTS, + ) + await fund_accounts( + supra_client, [multisig_address], MULTISIG_SENDER_FUNDING_QUANTS + ) alice_balance = await supra_client.account_supra_balance(alice.address()) bob_balance = await supra_client.account_supra_balance(bob.address()) @@ -162,7 +183,9 @@ async def main(should_wait_input: bool): print(f"Deedee's address: {deedee.address()}") print(f"Deedee's public key: {deedee.public_key()}") - await supra_client.faucet(deedee.address()) + await fund_accounts( + supra_client, [deedee.address()], MULTISIG_SENDER_FUNDING_QUANTS + ) deedee_balance = await supra_client.account_supra_balance(deedee.address()) print(f"Deedee's balance: {deedee_balance}") # <:!:section_7 wait() @@ -217,29 +240,9 @@ async def main(should_wait_input: bool): wait() print("\n=== Genesis publication ===") - packages_dir = f"{SUPRA_CORE_PATH}/aptos-move/move-examples/upgrade_and_govern/" - - command = ( - f"supra move tool compile " - f"--save-metadata " - f"--package-dir {packages_dir}genesis " - f"--named-addresses upgrade_and_govern={deedee.address()!s}" - ) - - print(f"Running supra CLI command: {command}\n") - process = await asyncio.create_subprocess_exec( - *command.split(), - ) - await process.wait() - assert process.returncode == 0, "supra move tool compile failed" - - build_path = f"{packages_dir}genesis/build/UpgradeAndGovern/" - - async with aiofiles.open(f"{build_path}package-metadata.bcs", "rb") as f: - package_metadata = await f.read() - - async with aiofiles.open(f"{build_path}bytecode_modules/parameters.mv", "rb") as f: - parameters_module = await f.read() + genesis_package = compiled_package("upgrade_and_govern_genesis", deedee.address()) + package_metadata = genesis_package.metadata + [parameters_module] = genesis_package.modules modules_serializer = Serializer.sequence_serializer(Serializer.to_bytes) @@ -283,27 +286,9 @@ async def main(should_wait_input: bool): wait() print("\n=== Upgrade publication ===") - command = ( - f"supra move tool compile " - f"--save-metadata " - f"--package-dir {packages_dir}upgrade " - f"--named-addresses upgrade_and_govern={deedee.address()!s}" - ) - - print(f"Running supra CLI command: {command}\n") - process = await asyncio.create_subprocess_exec(*command.split()) - await process.wait() - assert process.returncode == 0, "supra move tool compile failed" - build_path = f"{packages_dir}upgrade/build/UpgradeAndGovern/" - - async with aiofiles.open(f"{build_path}package-metadata.bcs", "rb") as f: - package_metadata = await f.read() - - async with aiofiles.open(f"{build_path}bytecode_modules/parameters.mv", "rb") as f: - parameters_module = await f.read() - - async with aiofiles.open(f"{build_path}bytecode_modules/transfer.mv", "rb") as f: - transfer_module = await f.read() + upgrade_package = compiled_package("upgrade_and_govern_upgrade", deedee.address()) + package_metadata = upgrade_package.metadata + [parameters_module, transfer_module] = upgrade_package.modules entry_function_payload = EntryFunction.natural( module="0x1::code", @@ -349,10 +334,7 @@ async def main(should_wait_input: bool): wait() print("\n=== Invoking Move script ===") - async with aiofiles.open( - f"{build_path}bytecode_scripts/set_and_transfer.mv", "rb" - ) as f: - script_code = await f.read() + [script_code] = upgrade_package.scripts script_payload = Script( code=script_code, ty_args=[], diff --git a/examples/rotate_key.py b/examples/rotate_key.py index d6486d2..3990c1e 100644 --- a/examples/rotate_key.py +++ b/examples/rotate_key.py @@ -7,7 +7,7 @@ import supra_sdk.asymmetric_crypto as asymmetric_crypto import supra_sdk.ed25519 as ed25519 -from examples.common import RPC_NODE_URL +from examples.common import RPC_NODE_URL, fund_accounts from supra_sdk.account import Account, RotationProofChallenge from supra_sdk.account_address import AccountAddress from supra_sdk.authenticator import Authenticator @@ -30,14 +30,15 @@ def format_account_info(account: Account) -> str: vals = [ str(account.address()), account.auth_key(), - account.private_key.hex(), str(account.public_key()), ] return "".join([truncate(v).ljust(WIDTH, " ") for v in vals]) async def rotate_auth_key_ed25519_payload( - supra_client: SupraClient, from_account: Account, to_private_key: ed25519.PrivateKey + supra_client: SupraClient, + from_account: Account, + to_private_key: asymmetric_crypto.PrivateKey, ) -> TransactionPayload: to_account = Account.load_key(to_private_key.hex()) rotation_proof_challenge = RotationProofChallenge( @@ -64,7 +65,7 @@ async def rotate_auth_key_ed25519_payload( async def rotate_auth_key_multi_ed25519_payload( supra_client: SupraClient, from_account: Account, - private_keys: list[ed25519.PrivateKey], + private_keys: list[asymmetric_crypto.PrivateKey], ) -> TransactionPayload: to_accounts = list( map(lambda private_key: Account.load_key(private_key.hex()), private_keys) @@ -125,7 +126,7 @@ def rotation_payload( return TransactionPayload(entry_function) -async def main(): +async def main() -> None: # Initialize the clients used to interact with the blockchain supra_client = SupraClient(RPC_NODE_URL) @@ -134,7 +135,7 @@ async def main(): bob = Account.generate() # Fund Alice's account, since we don't use Bob's - await supra_client.faucet(alice.address()) + await fund_accounts(supra_client, [alice.address()]) # Display formatted account info print( @@ -142,12 +143,9 @@ async def main(): + "Account".ljust(WIDTH, " ") + "Address".ljust(WIDTH, " ") + "Auth Key".ljust(WIDTH, " ") - + "Private Key".ljust(WIDTH, " ") + "Public Key".ljust(WIDTH, " ") ) - print( - "-------------------------------------------------------------------------------------------" - ) + print("-" * (4 * WIDTH)) print("Alice".ljust(WIDTH, " ") + format_account_info(alice)) print("Bob".ljust(WIDTH, " ") + format_account_info(bob)) diff --git a/examples/simple_nft.py b/examples/simple_nft.py index 7969e5f..d2a00e5 100644 --- a/examples/simple_nft.py +++ b/examples/simple_nft.py @@ -5,13 +5,13 @@ import asyncio import json -from examples.common import RPC_NODE_URL +from examples.common import RPC_NODE_URL, fund_accounts from supra_sdk.account import Account from supra_sdk.clients import SupraTokenClient from supra_sdk.clients.rest import SupraClient -async def main(): +async def main() -> None: supra_client = SupraClient(RPC_NODE_URL) token_client = SupraTokenClient(supra_client) @@ -25,8 +25,7 @@ async def main(): print(f"Alice account address: {alice.address()}") print(f"Bob account address: {bob.address()}") - await supra_client.faucet(alice.address()) - await supra_client.faucet(bob.address()) + await fund_accounts(supra_client, [alice.address(), bob.address()]) print("\n=== Creating Collection and Token ===") await token_client.create_collection( @@ -77,13 +76,22 @@ async def main(): property_version, ) - alice_balance = token_client.get_token_balance( - alice.address(), alice.address(), collection_name, token_name, property_version - ) - bob_balance = token_client.get_token_balance( - bob.address(), alice.address(), collection_name, token_name, property_version + alice_balance, bob_balance = await asyncio.gather( + token_client.get_token_balance( + alice.address(), + alice.address(), + collection_name, + token_name, + property_version, + ), + token_client.get_token_balance( + bob.address(), + alice.address(), + collection_name, + token_name, + property_version, + ), ) - [alice_balance, bob_balance] = await asyncio.gather(*[alice_balance, bob_balance]) print(f"Alice's token balance: {alice_balance}") print(f"Bob's token balance: {bob_balance}") @@ -91,13 +99,22 @@ async def main(): await token_client.direct_transfer_token( bob, alice, alice.address(), collection_name, token_name, 0, 1 ) - alice_balance = token_client.get_token_balance( - alice.address(), alice.address(), collection_name, token_name, property_version - ) - bob_balance = token_client.get_token_balance( - bob.address(), alice.address(), collection_name, token_name, property_version + alice_balance, bob_balance = await asyncio.gather( + token_client.get_token_balance( + alice.address(), + alice.address(), + collection_name, + token_name, + property_version, + ), + token_client.get_token_balance( + bob.address(), + alice.address(), + collection_name, + token_name, + property_version, + ), ) - [alice_balance, bob_balance] = await asyncio.gather(*[alice_balance, bob_balance]) print(f"Alice's token balance: {alice_balance}") print(f"Bob's token balance: {bob_balance}") diff --git a/examples/simple_supra_token.py b/examples/simple_supra_token.py index 565d52b..a179f06 100644 --- a/examples/simple_supra_token.py +++ b/examples/simple_supra_token.py @@ -5,7 +5,7 @@ import asyncio import json -from examples.common import RPC_NODE_URL +from examples.common import RPC_NODE_URL, fund_accounts from supra_sdk.account import Account from supra_sdk.account_address import AccountAddress from supra_sdk.clients.rest import SupraClient @@ -48,7 +48,7 @@ async def get_token_data( } -async def main(): +async def main() -> None: supra_client = SupraClient(RPC_NODE_URL) token_client = SupraTokenObjectClient(supra_client) @@ -62,8 +62,7 @@ async def main(): print(f"Alice account address: {alice.address()}") print(f"Bob account address: {bob.address()}") - await supra_client.faucet(alice.address()) - await supra_client.faucet(bob.address()) + await fund_accounts(supra_client, [alice.address(), bob.address()]) print("\n=== Creating Collection and Token ===") diff --git a/examples/simulate_transfer_coin.py b/examples/simulate_transfer_coin.py index 7119f77..2c31453 100644 --- a/examples/simulate_transfer_coin.py +++ b/examples/simulate_transfer_coin.py @@ -5,7 +5,7 @@ import asyncio import json -from examples.common import RPC_NODE_URL +from examples.common import RPC_NODE_URL, fund_accounts from supra_sdk.account import Account from supra_sdk.bcs import Serializer from supra_sdk.clients.api_client import ApiError @@ -17,8 +17,11 @@ ) from supra_sdk.type_tag import StructTag, TypeTag +# Max gas of the transfer, which uses fewer than 100 units. +MAX_GAS_AMOUNT = 2_000 -async def main(): + +async def main() -> None: supra_client = SupraClient(RPC_NODE_URL) alice = Account.generate() bob = Account.generate() @@ -26,7 +29,7 @@ async def main(): print(f"Alice account address: {alice.address()}") print(f"Bob account address: {bob.address()}") - await supra_client.faucet(alice.address()) + await fund_accounts(supra_client, [alice.address()]) payload = EntryFunction.natural( "0x1::coin", @@ -37,20 +40,27 @@ async def main(): TransactionArgument(100_000, Serializer.u64), ], ) + # The max gas amount is given: without it the SDK estimates it by simulating the + # transfer, and that simulation fails while Bob has no coin store. signed_transaction = await supra_client.create_signed_transaction( - alice, TransactionPayload(payload) + alice, TransactionPayload(payload), max_gas_amount=MAX_GAS_AMOUNT ) print("\n=== Simulate before creating Bob's Account ===") try: - await supra_client.simulate_transaction(signed_transaction) + simulation_result = await supra_client.simulate_transaction(signed_transaction) except ApiError as e: - assert "ABORTED" in str(e), "Expected CoinStore error" + print(f"Simulation refused: {e}") + else: + status = simulation_result["status"] + vm_status = simulation_result["output"]["Move"]["vm_status"] + print(f"Simulation status: {status}, vm_status: {vm_status}") + assert status != "Success", "Expected the transfer to fail without a coin store" print("\n=== Simulate after creating Bob's Account ===") - await supra_client.faucet(bob.address()) + await fund_accounts(supra_client, [bob.address()]) - # Create another transaction with broken signature for second simulation + # The same signed transaction, simulated again now that Bob's account exists. simulation_result = await supra_client.simulate_transaction(signed_transaction) vm_status = simulation_result["output"]["Move"]["vm_status"] assert vm_status == "Executed successfully", f"Expected success, got: {vm_status}" diff --git a/examples/supra_token.py b/examples/supra_token.py index b2f26c3..b22f23f 100644 --- a/examples/supra_token.py +++ b/examples/supra_token.py @@ -4,7 +4,7 @@ import asyncio -from examples.common import RPC_NODE_URL +from examples.common import RPC_NODE_URL, fund_accounts from supra_sdk.account import Account from supra_sdk.account_address import AccountAddress from supra_sdk.clients.rest import SupraClient @@ -16,7 +16,7 @@ ) -async def main(): +async def main() -> None: supra_client = SupraClient(RPC_NODE_URL) token_client = SupraTokenObjectClient(supra_client) @@ -29,8 +29,7 @@ async def main(): print(f"Alice account address: {alice.address()}") print(f"Bob account address: {bob.address()}") - await supra_client.faucet(alice.address()) - await supra_client.faucet(bob.address()) + await fund_accounts(supra_client, [alice.address(), bob.address()]) print("\n=== Creating Collection and Token ===") @@ -107,7 +106,6 @@ async def main(): print(f"Owner: {token_data.resources[Object].owner}\n") await supra_client.close() - await supra_client.close() if __name__ == "__main__": diff --git a/examples/transfer_coin.py b/examples/transfer_coin.py index 3971d3b..5e3b68f 100644 --- a/examples/transfer_coin.py +++ b/examples/transfer_coin.py @@ -4,12 +4,12 @@ import asyncio -from examples.common import RPC_NODE_URL +from examples.common import RPC_NODE_URL, fund_accounts from supra_sdk.account import Account from supra_sdk.clients.rest import SupraClient -async def main(): +async def main() -> None: supra_client = SupraClient(RPC_NODE_URL) alice = Account.generate() @@ -18,8 +18,7 @@ async def main(): print(f"Alice account address: {alice.address()}") print(f"Bob account address: {bob.address()}") - await supra_client.faucet(alice.address()) - await supra_client.faucet(bob.address()) + await fund_accounts(supra_client, [alice.address(), bob.address()]) print("\n=== Initial Balances ===") alice_balance = await supra_client.account_supra_balance(alice.address()) @@ -36,7 +35,7 @@ async def main(): print(f"Alice: {alice_balance}") print(f"Bob: {bob_balance}") - # Have Alice give Bob another 1_000 coins using BCS + # Have Alice give Bob another 1_000 coins await supra_client.transfer_supra_coin(alice, bob.address(), 1_000) print("\n=== Final Balances ===") @@ -46,7 +45,6 @@ async def main(): print(f"Bob: {bob_balance}") await supra_client.close() - await supra_client.close() if __name__ == "__main__": diff --git a/examples/transfer_two_by_two.py b/examples/transfer_two_by_two.py index 327983c..9d8ba79 100644 --- a/examples/transfer_two_by_two.py +++ b/examples/transfer_two_by_two.py @@ -3,17 +3,17 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio -import os +from pathlib import Path -import aiofiles - -from examples.common import RPC_NODE_URL +from examples.common import RPC_NODE_URL, fund_accounts from supra_sdk.account import Account from supra_sdk.clients.rest import SupraClient from supra_sdk.transactions import Script, ScriptArgument, TransactionPayload +SCRIPT_PATH = Path(__file__).parent / "two_by_two_transfer.mv" + -async def main(): +async def main() -> None: supra_client = SupraClient(RPC_NODE_URL) alice = Account.generate() @@ -26,10 +26,10 @@ async def main(): print(f"Carol account address: {carol.address()}") print(f"David account address: {david.address()}") - await supra_client.faucet(alice.address()) - await supra_client.faucet(bob.address()) - await supra_client.faucet(carol.address()) - await supra_client.faucet(david.address()) + await fund_accounts( + supra_client, + [alice.address(), bob.address(), carol.address(), david.address()], + ) alice_balance = await supra_client.account_supra_balance(alice.address()) bob_balance = await supra_client.account_supra_balance(bob.address()) @@ -42,10 +42,7 @@ async def main(): print(f"Carol: {carol_balance}") print(f"David: {david_balance}") - path = os.path.dirname(__file__) - filepath = os.path.join(path, "two_by_two_transfer.mv") - async with aiofiles.open(filepath, mode="rb") as file: - code = await file.read() + code = SCRIPT_PATH.read_bytes() script_arguments = [ ScriptArgument(ScriptArgument.U64, 100), @@ -73,7 +70,6 @@ async def main(): print(f"David: {david_balance}") await supra_client.close() - await supra_client.close() if __name__ == "__main__": diff --git a/examples/your_coin.py b/examples/your_coin.py index 81a146e..26c60a7 100644 --- a/examples/your_coin.py +++ b/examples/your_coin.py @@ -2,20 +2,15 @@ # Parts of the project are originally copyright © Aptos Foundation # SPDX-License-Identifier: Apache-2.0 -"""This example depends on the MoonCoin.move module. +"""Publishes the `moon_coin` package, a managed coin, and mints and transfers the coin. -Steps: - * `cd ~/aptos-core/aptos-move/move-examples/moon_coin` - * `supra move tool compile --save-metadata --named-addresses MoonCoin=` - * `python3 -m examples.your_coin ` +The package is the compiled `moon_coin` package of `examples/move/packages.json`, +published under Alice's address. """ import asyncio -import os -import aiofiles - -from examples.common import RPC_NODE_URL, SUPRA_CORE_PATH +from examples.common import RPC_NODE_URL, compiled_package, fund_accounts from supra_sdk.account import Account from supra_sdk.account_address import AccountAddress from supra_sdk.bcs import Serializer @@ -71,7 +66,7 @@ async def get_balance( return str(await self.account_coin_balance(account_address, coin_type)) -async def main(): +async def main() -> None: alice = Account.generate() bob = Account.generate() @@ -80,35 +75,12 @@ async def main(): supra_client = CoinClient(RPC_NODE_URL) - await supra_client.faucet(alice.address()) - await supra_client.faucet(bob.address()) - - moon_coin_path = f"{SUPRA_CORE_PATH}/aptos-move/move-examples/moon_coin/" - command = ( - f"supra move tool compile " - f"--save-metadata " - f"--package-dir {moon_coin_path} " - f"--named-addresses MoonCoin={alice.address()!s}" - ) - print(f"Running supra CLI command: {command}\n") - process = await asyncio.create_subprocess_exec(*command.split()) - await process.wait() - assert process.returncode == 0, "supra move tool compile failed" + await fund_accounts(supra_client, [alice.address(), bob.address()]) - module_path = os.path.join( - moon_coin_path, "build", "Examples", "bytecode_modules", "moon_coin.mv" - ) - async with aiofiles.open(module_path, "rb") as f: - module = await f.read() - - metadata_path = os.path.join( - moon_coin_path, "build", "Examples", "package-metadata.bcs" - ) - async with aiofiles.open(metadata_path, "rb") as f: - metadata = await f.read() + package = compiled_package("moon_coin", alice.address()) print("\nPublishing MoonCoin package.") - await supra_client.publish_package(alice, metadata, [module]) + await supra_client.publish_package(alice, package.metadata, package.modules) print("\nBob registers the newly created coin so he can receive it from Alice.") await supra_client.register_coin(alice.address(), bob) @@ -133,6 +105,8 @@ async def main(): balance = await supra_client.get_balance(alice.address(), bob.address()) print(f"Bob's updated MoonCoin balance: {balance}") + await supra_client.close() + if __name__ == "__main__": asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index bcaa8f8..a2e83f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,9 +23,6 @@ dependencies = [ "typing-extensions>=4.4.0,<5.0.0", ] -[project.optional-dependencies] -examples = ["aiofiles>=24.1.0"] - [project.urls] Homepage = "https://github.com/Entropy-Foundation/supra-python-sdk" Documentation = "https://supra-python-sdk.docs.supra.com/" @@ -81,7 +78,7 @@ docstring-code-format = true [tool.mypy] python_version = "3.12" -files = ["supra_sdk"] +files = ["supra_sdk", "examples"] warn_unused_ignores = true no_implicit_optional = true @@ -98,7 +95,6 @@ module-root = "" [dependency-groups] dev = [ - "aiofiles>=24.1.0", "behave>=1.2.6,<2.0.0", "black>=25.1.0", "coverage>=7.10.6", diff --git a/uv.lock b/uv.lock index 0bb9f25..cdb5241 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", @@ -7,15 +7,6 @@ resolution-markers = [ "python_full_version < '3.13'", ] -[[package]] -name = "aiofiles" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, -] - [[package]] name = "alabaster" version = "1.0.0" @@ -1297,14 +1288,8 @@ dependencies = [ { name = "typing-extensions" }, ] -[package.optional-dependencies] -examples = [ - { name = "aiofiles" }, -] - [package.dev-dependencies] dev = [ - { name = "aiofiles" }, { name = "behave" }, { name = "black" }, { name = "coverage" }, @@ -1320,16 +1305,13 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiofiles", marker = "extra == 'examples'", specifier = ">=24.1.0" }, { name = "httpx", extras = ["http2"], specifier = ">=0.28.1,<0.29.0" }, { name = "pynacl", specifier = ">=1.6.2,<2.0.0" }, { name = "typing-extensions", specifier = ">=4.4.0,<5.0.0" }, ] -provides-extras = ["examples"] [package.metadata.requires-dev] dev = [ - { name = "aiofiles", specifier = ">=24.1.0" }, { name = "behave", specifier = ">=1.2.6,<2.0.0" }, { name = "black", specifier = ">=25.1.0" }, { name = "coverage", specifier = ">=7.10.6" }, From 3b647430952461950e422cc9fd96ae27944340b9 Mon Sep 17 00:00:00 2001 From: Isaac Doidge Date: Wed, 4 Nov 2026 02:42:48 +1000 Subject: [PATCH 4/8] ci: run every example against Devnet daily and on demand `.github/workflows/examples.yaml` runs `make examples` against Devnet on a daily schedule and on `workflow_dispatch`, funding the examples' accounts from the repository secret SUPRA_EXAMPLES_PRIVATE_KEY. Without the secret the job runs no example and reports a notice. Runs share one concurrency group, so two runs never send from the account at once. `make examples` now runs all thirteen examples, including `faucet` and `hello_blockchain`, stops at the first failure, and refuses to run unless SUPRA_RPC_NODE_URL is set, so that the network it transacts on is explicit. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/examples.yaml | 56 +++++++++++++++++++++++++++++++++ Makefile | 29 ++++++++++------- 2 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/examples.yaml diff --git a/.github/workflows/examples.yaml b/.github/workflows/examples.yaml new file mode 100644 index 0000000..19640cd --- /dev/null +++ b/.github/workflows/examples.yaml @@ -0,0 +1,56 @@ +name: Examples on Devnet + +# Runs every example in `examples/` against Devnet with `make examples`. The examples fund the accounts they generate +# from the account of the repository secret SUPRA_EXAMPLES_PRIVATE_KEY (an Ed25519 private key, 32 bytes as hex, of an +# account funded on Devnet). Without the secret the job skips the examples and reports a notice. + +on: + schedule: + - cron: "41 4 * * *" + workflow_dispatch: + +permissions: + contents: read + +# Two runs sending transactions from the same account at once would race for its sequence numbers. +concurrency: + group: examples-devnet + cancel-in-progress: false + +jobs: + examples: + name: Examples on Devnet + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + HAS_FUNDED_KEY: ${{ secrets.SUPRA_EXAMPLES_PRIVATE_KEY != '' }} + SUPRA_RPC_NODE_URL: https://rpc-devnet.supra.com + UV_PYTHON: "3.12" + + steps: + - name: Skip without a funded key + if: env.HAS_FUNDED_KEY != 'true' + run: echo "::notice title=Examples skipped::The repository secret SUPRA_EXAMPLES_PRIVATE_KEY is not set, so no example ran." + + - name: Checkout code + if: env.HAS_FUNDED_KEY == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install uv + if: env.HAS_FUNDED_KEY == 'true' + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + python-version: "3.12" + + - name: Install dependencies + if: env.HAS_FUNDED_KEY == 'true' + run: uv sync --locked + + # The key is visible to this step only, not to the dependency installation above. + - name: Run the examples + if: env.HAS_FUNDED_KEY == 'true' + env: + SUPRA_EXAMPLES_PRIVATE_KEY: ${{ secrets.SUPRA_EXAMPLES_PRIVATE_KEY }} + run: make examples diff --git a/Makefile b/Makefile index 5e8718f..ed79dc2 100644 --- a/Makefile +++ b/Makefile @@ -39,18 +39,25 @@ audit: ci: lint fmt-check typecheck test-unit test-spec test-coverage build audit +# `make examples` runs every example, in this order, against the node at +# SUPRA_RPC_NODE_URL, and stops at the first that fails. The examples send +# transactions, so the target needs SUPRA_RPC_NODE_URL set; see examples/README.md +# for the other variables. For example: +# SUPRA_RPC_NODE_URL=http://localhost:27001 make examples +# SUPRA_RPC_NODE_URL=https://rpc-devnet.supra.com SUPRA_EXAMPLES_PRIVATE_KEY= make examples +EXAMPLES = automation faucet fee_payer_transfer_coin hello_blockchain multisig \ + rotate_key simple_nft simple_supra_token simulate_transfer_coin supra_token \ + transfer_coin transfer_two_by_two your_coin + examples: - uv run python -m examples.automation - uv run python -m examples.fee_payer_transfer_coin - uv run python -m examples.multisig - uv run python -m examples.rotate_key - uv run python -m examples.simple_nft - uv run python -m examples.simple_supra_token - uv run python -m examples.simulate_transfer_coin - uv run python -m examples.supra_token - uv run python -m examples.transfer_coin - uv run python -m examples.transfer_two_by_two - uv run python -m examples.your_coin + @if [ -z "$(SUPRA_RPC_NODE_URL)" ]; then \ + echo "Set SUPRA_RPC_NODE_URL to the node to run the examples against (see examples/README.md)"; \ + exit 1; \ + fi + @for example in $(EXAMPLES); do \ + echo "=== examples.$$example ==="; \ + uv run python -m examples.$$example || exit 1; \ + done generate-api-docs: uv run sphinx-apidoc -o docs supra_sdk From 9c0cb697c0ff34651be21df5e3056cd72bfba5b0 Mon Sep 17 00:00:00 2001 From: Isaac Doidge Date: Wed, 4 Nov 2026 02:42:50 +1000 Subject: [PATCH 5/8] docs(examples): name the networks, the environment and each example The examples README lists the networks, the environment variables and how the examples fund their accounts, how to run one example or all of them, what each example shows, and how the compiled Move packages in `move/packages.json` were built. Co-Authored-By: Claude Opus 5.5 --- examples/README.md | 114 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 102 insertions(+), 12 deletions(-) diff --git a/examples/README.md b/examples/README.md index 6c2413e..035f390 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,30 +1,120 @@ # Examples -This section provides **practical, real-world examples** demonstrating how to use the **`supra-python-sdk`** for most of the significant operations. - -Each example is **self-contained**, focusing on a single use case, making it easy to understand and adapt for your own projects. +Runnable examples of the **`supra-python-sdk`**. Each example covers one use case: coin transfers, sponsored and +multi-agent transactions, simulation, key rotation, multisig accounts, tokens, publishing Move packages, automation +tasks and the faucet. ## Requirements -Before running the examples, ensure you have the following: +- Python 3.12 or later. +- [`uv`](https://docs.astral.sh/uv/) ([installation guide](https://docs.astral.sh/uv/getting-started/installation/)). + +The examples need only the SDK's own dependencies. They do not need a Move toolchain: the Move packages they publish +are committed in compiled form (see [Move packages](#move-packages)). + +## Networks + +The examples run against Devnet by default (`https://rpc-devnet.supra.com`). Set `SUPRA_RPC_NODE_URL` to run them +against another network, for example Testnet (`https://rpc-testnet.supra.com`) or a localnet +(`http://localhost:27001`). The examples refuse to send transactions to Mainnet. + +## Environment + +| Variable | Meaning | +|---|---| +| `SUPRA_RPC_NODE_URL` | Base URL of the RPC node, without `/rpc/v3`. Default `https://rpc-devnet.supra.com` (Devnet). | +| `SUPRA_FAUCET_URL` | Base URL of the node that serves the faucet. Default `SUPRA_RPC_NODE_URL`. | +| `SUPRA_EXAMPLES_PRIVATE_KEY` | Ed25519 private key (32 bytes as hex, with or without `0x`) of a funded account. Optional. | + +An empty variable counts as unset. -- **Python 3.12+** installed on your system. -- **[`uv`](https://docs.astral.sh/uv/)** – used for packaging and dependency management. +Every example generates fresh accounts. It funds them in one of two ways: -### Installing `uv` +- With `SUPRA_EXAMPLES_PRIVATE_KEY` set, the account of that key sends SUPRA to each generated account. No faucet is + called (except by `faucet.py`, which demonstrates the faucet). The key is never printed; the examples print the + account's address. +- Without it, the example funds one generated account from the faucet and that account sends SUPRA to the other + generated accounts. Each example makes one faucet request. -Follow the official [installation guide](https://docs.astral.sh/uv/getting-started/installation/) to install `uv` on your system. +The Devnet faucet limits the requests from one IP address, so running more than one example against Devnet needs +`SUPRA_EXAMPLES_PRIVATE_KEY`. A localnet faucet has no such limit. Each generated account receives 10 SUPRA +(`multisig.py`: 30 SUPRA for each account that sends transactions, 1 SUPRA for each signer); a full run of +`make examples` takes about 315 SUPRA from the funding account, most of it left in the generated accounts. -## Running an Example +Keep private keys in environment variables or an untracked file. Do not commit them. -You can run any example using the following syntax: +## Running + +One example, from the repository root: ```bash uv run python -m examples. ``` -Running `transfer_coin.py` example: +For example: + +```bash +# Devnet, accounts funded from a funded account +SUPRA_EXAMPLES_PRIVATE_KEY= uv run python -m examples.transfer_coin + +# A localnet, accounts funded from the localnet faucet +SUPRA_RPC_NODE_URL=http://localhost:27001 uv run python -m examples.transfer_coin +``` + +Every example, in sequence, stopping at the first that fails. `make examples` needs `SUPRA_RPC_NODE_URL` set, so that +the network the examples send transactions to is chosen explicitly: ```bash -uv run python -m examples.transfer_coin +SUPRA_RPC_NODE_URL=https://rpc-devnet.supra.com SUPRA_EXAMPLES_PRIVATE_KEY= make examples +SUPRA_RPC_NODE_URL=http://localhost:27001 make examples ``` + +An example exits with a non-zero status when a step raises, which includes every transaction that does not end with +status `Success`. + +## The examples + +| Example | Shows | +|---|---| +| [`automation.py`](./automation.py) | Registering an automation task and stopping it | +| [`faucet.py`](./faucet.py) | One faucet request, sent without waiting, then waiting for the faucet transaction | +| [`fee_payer_transfer_coin.py`](./fee_payer_transfer_coin.py) | A coin transfer whose gas a sponsor pays, simulated and then submitted | +| [`hello_blockchain.py`](./hello_blockchain.py) | Publishing the `hello_blockchain` package and calling its entry function; `python -m examples.hello_blockchain
` uses a package already published at `
` | +| [`multisig.py`](./multisig.py) | A 2-of-3 MultiEd25519 account: a transfer, rotating an account's key to the multisig key, publishing and upgrading the `upgrade_and_govern` package, and a Move script; `python -m examples.multisig true` pauses between the steps | +| [`rotate_key.py`](./rotate_key.py) | Rotating an account's authentication key to another Ed25519 key, then to a MultiEd25519 key | +| [`simple_nft.py`](./simple_nft.py) | A token collection and token of `0x3::token`: create, offer, claim, and a multi-agent transfer | +| [`simple_supra_token.py`](./simple_supra_token.py) | A collection and token of the object-based token standard, and token transfers | +| [`simulate_transfer_coin.py`](./simulate_transfer_coin.py) | Simulating a transfer before and after the receiver has a coin store | +| [`supra_token.py`](./supra_token.py) | Adding, removing and updating the properties of an object-based token, and transferring it | +| [`transfer_coin.py`](./transfer_coin.py) | SUPRA transfers and balances | +| [`transfer_two_by_two.py`](./transfer_two_by_two.py) | A multi-agent Move script (`two_by_two_transfer.mv`) with two senders and two receivers | +| [`your_coin.py`](./your_coin.py) | Publishing the `moon_coin` package, a managed coin, and registering, minting and transferring the coin | + +[`common.py`](./common.py) reads the environment and funds the generated accounts. + +## Move packages + +[`move/packages.json`](./move/packages.json) holds the compiled packages that `hello_blockchain.py`, `multisig.py` and +`your_coin.py` publish: for each package, the package metadata, the module bytecode and the script bytecode, as hex. +They were compiled for the address named in the file, and `compiled_package` of `common.py` replaces that address +with the publisher's in the bytecode before publishing. The metadata contains no address. + +The sources are in [`Entropy-Foundation/aptos-core`](https://github.com/Entropy-Foundation/aptos-core): + +| Package in `packages.json` | Source directory | Named address | +|---|---|---| +| `hello_blockchain` | `aptos-move/move-examples/hello_blockchain` | `hello_blockchain` | +| `moon_coin` | `aptos-move/move-examples/moon_coin` | `MoonCoin` | +| `upgrade_and_govern_genesis` | `aptos-move/move-examples/upgrade_and_govern/genesis` | `upgrade_and_govern` | +| `upgrade_and_govern_upgrade` | `aptos-move/move-examples/upgrade_and_govern/upgrade` | `upgrade_and_govern` | + +Each was compiled with the `supra` CLI of node release v11.7.2, against the Supra framework of that release, with: + +```bash +supra move tool compile --save-metadata --package-dir \ + --named-addresses =0x9c1f5e3a7b2d4c6e8f0a1b3c5d7e9f2a4b6c8d0e1f3a5b7c9d2e4f6a8b0c1d3e +``` + +The outputs are `build//package-metadata.bcs`, `build//bytecode_modules/*.mv` and +`build//bytecode_scripts/*.mv`; `packages.json` lists the modules in the order of publication +(`parameters` before `transfer`) and, for `upgrade_and_govern_upgrade`, the script `set_and_transfer`. From 4d3429bd54ccc953a133a5501cee5153eef358e8 Mon Sep 17 00:00:00 2001 From: Isaac Doidge Date: Wed, 4 Nov 2026 03:02:05 +1000 Subject: [PATCH 6/8] fix(examples): bound the faucet retry, check the funding balance, and run every example in CI - The faucet request is sent at most five times, after pauses of 5, 10, 20 and 40 seconds, while the faucet answers "TryLater". The retry catches FaucetRequestNotAcceptedError and the AttributeError that SDK versions without "TryLater" handling raise for that answer. - Before funding, `fund_accounts` checks that the funding account holds the amount it sends plus 2,000 gas units per transfer at the node's minimum gas price, and otherwise stops with the shortfall in SUPRA. - A whitespace-only SUPRA_EXAMPLES_PRIVATE_KEY, SUPRA_RPC_NODE_URL or SUPRA_FAUCET_URL counts as unset. - `simulate_transfer_coin.py` asserts that the first simulation reports the ECOIN_STORE_NOT_PUBLISHED abort, and reads the simulation output with `.get`. - `make examples` runs every example, then fails and lists the failed ones if any failed. - The Devnet workflow installs with `uv sync --frozen` before the step that sees the key, and that step runs with UV_FROZEN=1. - uv.lock keeps lock revision 5 (written with uv 0.13.7). - The examples README names the public source tag aptosvm-v1.16_supra-v1.8.9, from which the compiled packages are byte-identical, states that the sources are Apache-2.0, and describes the faucet retry, the balance check and the sequence-number race between two runs that share a key. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/examples.yaml | 6 ++- Makefile | 18 ++++--- examples/README.md | 23 +++++--- examples/common.py | 85 +++++++++++++++++++++--------- examples/simulate_transfer_coin.py | 18 +++++-- uv.lock | 2 +- 6 files changed, 110 insertions(+), 42 deletions(-) diff --git a/.github/workflows/examples.yaml b/.github/workflows/examples.yaml index 19640cd..3de7361 100644 --- a/.github/workflows/examples.yaml +++ b/.github/workflows/examples.yaml @@ -46,11 +46,15 @@ jobs: - name: Install dependencies if: env.HAS_FUNDED_KEY == 'true' - run: uv sync --locked + run: uv sync --frozen # The key is visible to this step only, not to the dependency installation above. + # UV_FROZEN makes `uv run` use uv.lock without resolving. The environment already + # matches uv.lock after the step above, so nothing is resolved or installed while + # the key is present. - name: Run the examples if: env.HAS_FUNDED_KEY == 'true' env: SUPRA_EXAMPLES_PRIVATE_KEY: ${{ secrets.SUPRA_EXAMPLES_PRIVATE_KEY }} + UV_FROZEN: "1" run: make examples diff --git a/Makefile b/Makefile index ed79dc2..5cf9d67 100644 --- a/Makefile +++ b/Makefile @@ -40,9 +40,9 @@ audit: ci: lint fmt-check typecheck test-unit test-spec test-coverage build audit # `make examples` runs every example, in this order, against the node at -# SUPRA_RPC_NODE_URL, and stops at the first that fails. The examples send -# transactions, so the target needs SUPRA_RPC_NODE_URL set; see examples/README.md -# for the other variables. For example: +# SUPRA_RPC_NODE_URL. It runs all of them even when one fails, then fails if any +# failed and lists those. The examples send transactions, so the target needs +# SUPRA_RPC_NODE_URL set; see examples/README.md for the other variables. For example: # SUPRA_RPC_NODE_URL=http://localhost:27001 make examples # SUPRA_RPC_NODE_URL=https://rpc-devnet.supra.com SUPRA_EXAMPLES_PRIVATE_KEY= make examples EXAMPLES = automation faucet fee_payer_transfer_coin hello_blockchain multisig \ @@ -54,10 +54,16 @@ examples: echo "Set SUPRA_RPC_NODE_URL to the node to run the examples against (see examples/README.md)"; \ exit 1; \ fi - @for example in $(EXAMPLES); do \ + @failed=""; \ + for example in $(EXAMPLES); do \ echo "=== examples.$$example ==="; \ - uv run python -m examples.$$example || exit 1; \ - done + uv run python -m examples.$$example || failed="$$failed $$example"; \ + done; \ + if [ -n "$$failed" ]; then \ + echo "Failed examples:$$failed"; \ + exit 1; \ + fi; \ + echo "All examples passed" generate-api-docs: uv run sphinx-apidoc -o docs supra_sdk diff --git a/examples/README.md b/examples/README.md index 035f390..f2abb8c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,7 +26,7 @@ against another network, for example Testnet (`https://rpc-testnet.supra.com`) o | `SUPRA_FAUCET_URL` | Base URL of the node that serves the faucet. Default `SUPRA_RPC_NODE_URL`. | | `SUPRA_EXAMPLES_PRIVATE_KEY` | Ed25519 private key (32 bytes as hex, with or without `0x`) of a funded account. Optional. | -An empty variable counts as unset. +An empty or whitespace-only variable counts as unset. Every example generates fresh accounts. It funds them in one of two ways: @@ -34,13 +34,21 @@ Every example generates fresh accounts. It funds them in one of two ways: called (except by `faucet.py`, which demonstrates the faucet). The key is never printed; the examples print the account's address. - Without it, the example funds one generated account from the faucet and that account sends SUPRA to the other - generated accounts. Each example makes one faucet request. + generated accounts. Each example makes one faucet request. The faucet can answer `TryLater` when it cannot serve + the request yet; the example then sends the request again after 5, 10, 20 and 40 seconds, and fails after the fifth + request. + +Before funding, an example checks that the funding account holds the SUPRA it is about to send plus 2,000 units of gas +per transfer at the node's minimum gas price, and otherwise stops with the shortfall in SUPRA. The Devnet faucet limits the requests from one IP address, so running more than one example against Devnet needs `SUPRA_EXAMPLES_PRIVATE_KEY`. A localnet faucet has no such limit. Each generated account receives 10 SUPRA (`multisig.py`: 30 SUPRA for each account that sends transactions, 1 SUPRA for each signer); a full run of `make examples` takes about 315 SUPRA from the funding account, most of it left in the generated accounts. +Two runs that use the same `SUPRA_EXAMPLES_PRIVATE_KEY` at the same time send transactions from the same account and +race for its sequence numbers, so their funding transfers can fail. Run one at a time per key. + Keep private keys in environment variables or an untracked file. Do not commit them. ## Running @@ -61,8 +69,9 @@ SUPRA_EXAMPLES_PRIVATE_KEY= uv run python -m examples.transfer_coin SUPRA_RPC_NODE_URL=http://localhost:27001 uv run python -m examples.transfer_coin ``` -Every example, in sequence, stopping at the first that fails. `make examples` needs `SUPRA_RPC_NODE_URL` set, so that -the network the examples send transactions to is chosen explicitly: +Every example, in sequence. `make examples` runs all of them even when one fails, then exits with a non-zero status +and lists the failed examples if any failed. It needs `SUPRA_RPC_NODE_URL` set, so that the network the examples send +transactions to is chosen explicitly: ```bash SUPRA_RPC_NODE_URL=https://rpc-devnet.supra.com SUPRA_EXAMPLES_PRIVATE_KEY= make examples @@ -99,7 +108,8 @@ status `Success`. They were compiled for the address named in the file, and `compiled_package` of `common.py` replaces that address with the publisher's in the bytecode before publishing. The metadata contains no address. -The sources are in [`Entropy-Foundation/aptos-core`](https://github.com/Entropy-Foundation/aptos-core): +The sources are in [`Entropy-Foundation/aptos-core`](https://github.com/Entropy-Foundation/aptos-core) at the tag +`aptosvm-v1.16_supra-v1.8.9`, and are licensed under Apache-2.0; the compiled packages derive from them: | Package in `packages.json` | Source directory | Named address | |---|---|---| @@ -108,7 +118,8 @@ The sources are in [`Entropy-Foundation/aptos-core`](https://github.com/Entropy- | `upgrade_and_govern_genesis` | `aptos-move/move-examples/upgrade_and_govern/genesis` | `upgrade_and_govern` | | `upgrade_and_govern_upgrade` | `aptos-move/move-examples/upgrade_and_govern/upgrade` | `upgrade_and_govern` | -Each was compiled with the `supra` CLI of node release v11.7.2, against the Supra framework of that release, with: +Each was compiled with the `supra` CLI of node release v11.7.2, against the framework packages +(`aptos-move/framework/*`) of the same tag, with: ```bash supra move tool compile --save-metadata --package-dir \ diff --git a/examples/common.py b/examples/common.py index 15cadc1..8c632a7 100644 --- a/examples/common.py +++ b/examples/common.py @@ -14,9 +14,11 @@ without `0x`) of a funded account. When it is set, the examples fund the accounts they generate by transfers from this account and do not call the faucet. When it is not set, an example funds one generated account from the faucet and funds the other - accounts it generates by transfers from that account, so it calls the faucet once. + accounts it generates by transfers from that account. It sends one faucet request, + and sends it again, at most four times, while the faucet answers "TryLater". -An empty variable counts as unset. The examples do not send transactions to Mainnet. +An empty or whitespace-only variable counts as unset. The examples do not send +transactions to Mainnet. """ import asyncio @@ -33,8 +35,8 @@ DEVNET_RPC_URL = "https://rpc-devnet.supra.com" -RPC_NODE_URL = os.getenv("SUPRA_RPC_NODE_URL") or DEVNET_RPC_URL -FAUCET_URL = os.getenv("SUPRA_FAUCET_URL") or RPC_NODE_URL +RPC_NODE_URL = (os.getenv("SUPRA_RPC_NODE_URL") or "").strip() or DEVNET_RPC_URL +FAUCET_URL = (os.getenv("SUPRA_FAUCET_URL") or "").strip() or RPC_NODE_URL PRIVATE_KEY_VARIABLE = "SUPRA_EXAMPLES_PRIVATE_KEY" @@ -46,10 +48,18 @@ # another amount. It pays for the gas of a few transactions at the minimum gas price. DEFAULT_FUNDING_QUANTS = 10 * QUANTS_PER_SUPRA -# How long to retry a faucet request that the faucet does not accept ("try later"), -# and how long to wait for the faucet's funds to show in the balance. -FAUCET_TIMEOUT_SECONDS = 180.0 -FAUCET_RETRY_INTERVAL_SECONDS = 2.0 +# Gas units reserved for each funding transfer when checking the funding account's +# balance. The SDK's max gas estimate is at least 1,500 units, and the node accepts a +# transfer only if the balance covers the amount plus the max gas at the gas price. +FUNDING_TRANSFER_GAS_UNITS = 2_000 + +# Pauses before the second to fifth faucet request, sent while the faucet answers +# "TryLater". After the fifth request the example gives up. +FAUCET_RETRY_PAUSES_SECONDS = (5.0, 10.0, 20.0, 40.0) + +# How long to wait for the faucet's funds to show in the balance, and how often to look. +FAUCET_BALANCE_TIMEOUT_SECONDS = 180.0 +FAUCET_BALANCE_POLL_SECONDS = 2.0 PACKAGES_PATH = Path(__file__).parent / "move" / "packages.json" @@ -65,34 +75,58 @@ async def fund_accounts( """Sends `quants` of SUPRA to each of `addresses` from the funding account. The funding account is the account of `SUPRA_EXAMPLES_PRIVATE_KEY` when it is set. - Otherwise it is an account generated on the first call and funded once from the - faucet at `SUPRA_FAUCET_URL`; later calls in the same process reuse it. + Otherwise it is an account generated on the first call and funded from the faucet + at `SUPRA_FAUCET_URL`; later calls in the same process reuse it. + + Before sending, it checks that the funding account's balance covers the transfers: + `quants` for each address plus `FUNDING_TRANSFER_GAS_UNITS` of gas for each at the + node's minimum gas price. Raises: - RuntimeError: If the node is a Mainnet node, or the account of - `SUPRA_EXAMPLES_PRIVATE_KEY` holds no SUPRA. + RuntimeError: If the node is a Mainnet node, or the funding account's balance + does not cover the transfers. """ if await supra_client.chain_id() == MAINNET_CHAIN_ID: raise RuntimeError("The examples do not send transactions to Mainnet") funder = await _funder(supra_client) + + gas_unit_price = (await supra_client.estimate_gas_price())[ + "min_configured_gas_price" + ] + needed = len(addresses) * (quants + FUNDING_TRANSFER_GAS_UNITS * gas_unit_price) + balance = await supra_client.account_supra_balance(funder.address()) + if balance < needed: + source = f" (the account of {PRIVATE_KEY_VARIABLE})" if _private_key() else "" + raise RuntimeError( + f"The funding account {funder.address()}{source} holds " + f"{_supra(balance)} SUPRA; funding {len(addresses)} account(s) needs " + f"{_supra(needed)} SUPRA, {_supra(needed - balance)} SUPRA more" + ) + for address in addresses: await supra_client.transfer_supra_coin(funder, address, quants) print(f"Funded {address} with {quants} quants from {funder.address()}") +def _supra(quants: int) -> str: + """Formats an amount of quants as SUPRA, with all eight decimals.""" + return f"{quants // QUANTS_PER_SUPRA}.{quants % QUANTS_PER_SUPRA:08d}" + + +def _private_key() -> str: + """The value of SUPRA_EXAMPLES_PRIVATE_KEY; empty or whitespace counts as unset.""" + return (os.getenv(PRIVATE_KEY_VARIABLE) or "").strip() + + async def _funder(supra_client: SupraClient) -> Account: global _funding_account if _funding_account is not None: return _funding_account - private_key = os.getenv(PRIVATE_KEY_VARIABLE) or "" + private_key = _private_key() if private_key: - funder = Account.load_key(private_key.strip()) - if await supra_client.account_supra_balance(funder.address()) == 0: - raise RuntimeError( - f"The account of {PRIVATE_KEY_VARIABLE} ({funder.address()}) holds no SUPRA" - ) + funder = Account.load_key(private_key) else: funder = Account.generate() await _fund_from_faucet(supra_client, funder.address()) @@ -106,25 +140,28 @@ async def _fund_from_faucet(supra_client: SupraClient, address: AccountAddress) faucet_client = ( supra_client if FAUCET_URL == RPC_NODE_URL else SupraClient(FAUCET_URL) ) - deadline = time.monotonic() + FAUCET_TIMEOUT_SECONDS try: - while True: + for pause in (*FAUCET_RETRY_PAUSES_SECONDS, None): try: await faucet_client.faucet(address) break - except FaucetRequestNotAcceptedError: - if time.monotonic() > deadline: + # A "TryLater" answer raises FaucetRequestNotAcceptedError. SDK versions + # that do not handle "TryLater" raise AttributeError for it instead. + except (FaucetRequestNotAcceptedError, AttributeError): + if pause is None: raise - await asyncio.sleep(FAUCET_RETRY_INTERVAL_SECONDS) + print(f"The faucet answered TryLater; requesting again in {pause:g} s") + await asyncio.sleep(pause) finally: if faucet_client is not supra_client: await faucet_client.close() # The faucet may be served by another node than `supra_client`'s. + deadline = time.monotonic() + FAUCET_BALANCE_TIMEOUT_SECONDS while await supra_client.account_supra_balance(address) == 0: if time.monotonic() > deadline: raise RuntimeError(f"{address} holds no SUPRA after the faucet request") - await asyncio.sleep(FAUCET_RETRY_INTERVAL_SECONDS) + await asyncio.sleep(FAUCET_BALANCE_POLL_SECONDS) @dataclass(frozen=True) diff --git a/examples/simulate_transfer_coin.py b/examples/simulate_transfer_coin.py index 2c31453..9d8e314 100644 --- a/examples/simulate_transfer_coin.py +++ b/examples/simulate_transfer_coin.py @@ -20,6 +20,9 @@ # Max gas of the transfer, which uses fewer than 100 units. MAX_GAS_AMOUNT = 2_000 +# The abort of `0x1::coin` for a receiver without a coin store. +COIN_STORE_ABORT = "ECOIN_STORE_NOT_PUBLISHED" + async def main() -> None: supra_client = SupraClient(RPC_NODE_URL) @@ -47,22 +50,29 @@ async def main() -> None: ) print("\n=== Simulate before creating Bob's Account ===") + # Bob has no coin store, so `0x1::coin::transfer` aborts. The node reports the abort + # in the simulation result, or refuses the simulation with an error naming it. try: simulation_result = await supra_client.simulate_transaction(signed_transaction) except ApiError as e: print(f"Simulation refused: {e}") + assert "ABORTED" in str(e) or COIN_STORE_ABORT in str(e), ( + f"Expected the coin store abort, got: {e}" + ) else: - status = simulation_result["status"] - vm_status = simulation_result["output"]["Move"]["vm_status"] + status = simulation_result.get("status") + vm_status = simulation_result.get("output", {}).get("Move", {}).get("vm_status") print(f"Simulation status: {status}, vm_status: {vm_status}") - assert status != "Success", "Expected the transfer to fail without a coin store" + assert status == "Fail" and COIN_STORE_ABORT in str(vm_status), ( + f"Expected the coin store abort, got status {status}, vm_status {vm_status}" + ) print("\n=== Simulate after creating Bob's Account ===") await fund_accounts(supra_client, [bob.address()]) # The same signed transaction, simulated again now that Bob's account exists. simulation_result = await supra_client.simulate_transaction(signed_transaction) - vm_status = simulation_result["output"]["Move"]["vm_status"] + vm_status = simulation_result.get("output", {}).get("Move", {}).get("vm_status") assert vm_status == "Executed successfully", f"Expected success, got: {vm_status}" print(json.dumps(simulation_result, indent=4, sort_keys=True)) diff --git a/uv.lock b/uv.lock index cdb5241..268db1f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", From c8747b2d45203dfec7e99583ec37cd2262e8f0e4 Mon Sep 17 00:00:00 2001 From: Isaac Doidge Date: Wed, 4 Nov 2026 10:49:53 +1000 Subject: [PATCH 7/8] fix(examples): send the faucet request once per attempt and harden the examples workflow The faucet retry loop sends only the request (`wait_for_faucet=False`) and treats as TryLater a missing transaction hash, `FaucetRequestNotAcceptedError`, or the `AttributeError` that SDK versions without TryLater handling raise from the request. It sends at most five requests, with pauses of 5, 10, 20 and 40 seconds. After an accepted request it waits for the faucet transaction once, outside the retry, and fails if the transaction does not end in `Success`. `make examples` refuses a whitespace-only SUPRA_RPC_NODE_URL, which the examples treat as unset. The Examples workflow decides whether a funded key is present in a first step that receives the secret through `env`, strips whitespace and writes only `present=true|false`; a whitespace-only secret skips the job like a missing one. setup-uv installs uv 0.13.7, and the step that holds the key sets UV_NO_SYNC next to UV_FROZEN so nothing is resolved or installed while the key is present. The gas reserve comment states what the 2,000-unit reservation assumes, and examples/README.md states which examples retry TryLater. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/examples.yaml | 38 +++++++++++++++++++++--------- Makefile | 2 +- examples/README.md | 7 +++--- examples/common.py | 41 +++++++++++++++++++++++++-------- 4 files changed, 63 insertions(+), 25 deletions(-) diff --git a/.github/workflows/examples.yaml b/.github/workflows/examples.yaml index 3de7361..79ca979 100644 --- a/.github/workflows/examples.yaml +++ b/.github/workflows/examples.yaml @@ -2,7 +2,8 @@ name: Examples on Devnet # Runs every example in `examples/` against Devnet with `make examples`. The examples fund the accounts they generate # from the account of the repository secret SUPRA_EXAMPLES_PRIVATE_KEY (an Ed25519 private key, 32 bytes as hex, of an -# account funded on Devnet). Without the secret the job skips the examples and reports a notice. +# account funded on Devnet). Without the secret, or with a secret that is only whitespace, the job skips the examples +# and reports a notice. on: schedule: @@ -23,38 +24,53 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 env: - HAS_FUNDED_KEY: ${{ secrets.SUPRA_EXAMPLES_PRIVATE_KEY != '' }} SUPRA_RPC_NODE_URL: https://rpc-devnet.supra.com UV_PYTHON: "3.12" steps: + # The examples treat a key that is empty or only whitespace as unset, so this step decides the same way. + # The secret reaches the step through `env`; the step writes only whether a key is present. + - name: Check for a funded key + id: key + env: + KEY: ${{ secrets.SUPRA_EXAMPLES_PRIVATE_KEY }} + run: | + stripped="${KEY//[[:space:]]/}" + if [ -n "$stripped" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + - name: Skip without a funded key - if: env.HAS_FUNDED_KEY != 'true' + if: steps.key.outputs.present != 'true' run: echo "::notice title=Examples skipped::The repository secret SUPRA_EXAMPLES_PRIVATE_KEY is not set, so no example ran." - name: Checkout code - if: env.HAS_FUNDED_KEY == 'true' + if: steps.key.outputs.present == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install uv - if: env.HAS_FUNDED_KEY == 'true' + if: steps.key.outputs.present == 'true' uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: + version: "0.13.7" python-version: "3.12" - name: Install dependencies - if: env.HAS_FUNDED_KEY == 'true' + if: steps.key.outputs.present == 'true' run: uv sync --frozen - # The key is visible to this step only, not to the dependency installation above. - # UV_FROZEN makes `uv run` use uv.lock without resolving. The environment already - # matches uv.lock after the step above, so nothing is resolved or installed while - # the key is present. + # The uv release and the packages are fixed before the key is present: setup-uv installs the pinned uv, and + # the step above installs the packages from uv.lock. The key is visible to this step only. UV_FROZEN makes + # `uv run` use uv.lock without resolving, and UV_NO_SYNC makes it skip syncing the environment, so nothing is + # resolved, downloaded or installed while the key is present. - name: Run the examples - if: env.HAS_FUNDED_KEY == 'true' + if: steps.key.outputs.present == 'true' env: SUPRA_EXAMPLES_PRIVATE_KEY: ${{ secrets.SUPRA_EXAMPLES_PRIVATE_KEY }} UV_FROZEN: "1" + UV_NO_SYNC: "1" run: make examples diff --git a/Makefile b/Makefile index 5cf9d67..a75ce32 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ EXAMPLES = automation faucet fee_payer_transfer_coin hello_blockchain multisig \ transfer_coin transfer_two_by_two your_coin examples: - @if [ -z "$(SUPRA_RPC_NODE_URL)" ]; then \ + @if [ -z "$(strip $(SUPRA_RPC_NODE_URL))" ]; then \ echo "Set SUPRA_RPC_NODE_URL to the node to run the examples against (see examples/README.md)"; \ exit 1; \ fi diff --git a/examples/README.md b/examples/README.md index f2abb8c..aafe037 100644 --- a/examples/README.md +++ b/examples/README.md @@ -34,9 +34,10 @@ Every example generates fresh accounts. It funds them in one of two ways: called (except by `faucet.py`, which demonstrates the faucet). The key is never printed; the examples print the account's address. - Without it, the example funds one generated account from the faucet and that account sends SUPRA to the other - generated accounts. Each example makes one faucet request. The faucet can answer `TryLater` when it cannot serve - the request yet; the example then sends the request again after 5, 10, 20 and 40 seconds, and fails after the fifth - request. + generated accounts. The faucet can answer `TryLater` when it cannot serve the request yet. Every example except + `faucet.py` then sends the request again after 5, 10, 20 and 40 seconds, so it sends at most five requests, and fails + after the fifth `TryLater`. It waits for the faucet transaction once, after the faucet accepts a request. + `faucet.py` sends one request and does not send it again. Before funding, an example checks that the funding account holds the SUPRA it is about to send plus 2,000 units of gas per transfer at the node's minimum gas price, and otherwise stops with the shortfall in SUPRA. diff --git a/examples/common.py b/examples/common.py index 8c632a7..7dc198b 100644 --- a/examples/common.py +++ b/examples/common.py @@ -49,8 +49,12 @@ DEFAULT_FUNDING_QUANTS = 10 * QUANTS_PER_SUPRA # Gas units reserved for each funding transfer when checking the funding account's -# balance. The SDK's max gas estimate is at least 1,500 units, and the node accepts a -# transfer only if the balance covers the amount plus the max gas at the gas price. +# balance. The node accepts a transfer only if the balance covers the amount plus the +# max gas at the gas price, and the SDK sets the max gas to 1.5 times the gas the +# simulated transfer uses, with no upper bound. A transfer to a new account uses about +# 1,000 units on Devnet today, so the estimate is about 1,500 to 1,600 units; the +# reserve covers an estimate of up to 2,000. The check is made before sending, and a +# transfer can still fail if the gas it uses rises. FUNDING_TRANSFER_GAS_UNITS = 2_000 # Pauses before the second to fifth faucet request, sent while the faucet answers @@ -141,17 +145,34 @@ async def _fund_from_faucet(supra_client: SupraClient, address: AccountAddress) supra_client if FAUCET_URL == RPC_NODE_URL else SupraClient(FAUCET_URL) ) try: + faucet_tx_hash = None for pause in (*FAUCET_RETRY_PAUSES_SECONDS, None): try: - await faucet_client.faucet(address) - break - # A "TryLater" answer raises FaucetRequestNotAcceptedError. SDK versions - # that do not handle "TryLater" raise AttributeError for it instead. + faucet_tx_hash = await faucet_client.faucet( + address, wait_for_faucet=False + ) + # SDK versions that do not handle a "TryLater" body raise AttributeError + # from the request for it. Nothing else in this call is caught. except (FaucetRequestNotAcceptedError, AttributeError): - if pause is None: - raise - print(f"The faucet answered TryLater; requesting again in {pause:g} s") - await asyncio.sleep(pause) + faucet_tx_hash = None + if faucet_tx_hash: + break + # The faucet answered "TryLater" (the request returned no hash). + if pause is None: + break + print(f"The faucet answered TryLater; requesting again in {pause:g} s") + await asyncio.sleep(pause) + + if not faucet_tx_hash: + raise FaucetRequestNotAcceptedError + + # The request was accepted; wait for its transaction once, outside the retry. + faucet_data = await faucet_client.wait_for_faucet(faucet_tx_hash) + if faucet_data.get("status") != "Success": + raise RuntimeError( + f"The faucet transaction {faucet_tx_hash} ended with status " + f"{faucet_data.get('status')}" + ) finally: if faucet_client is not supra_client: await faucet_client.close() From 1fe46bf022d60615e3205d7990a18b49619b53c7 Mon Sep 17 00:00:00 2001 From: Isaac Doidge Date: Wed, 4 Nov 2026 10:51:33 +1000 Subject: [PATCH 8/8] fix(examples): run fungible_asset.py like the other examples fungible_asset.py funds Alice with `fund_accounts`, so it uses the Devnet default, the optional funded key, the bounded TryLater retry and the Mainnet refusal. Bob is not funded; the fungible asset transfer creates his primary store. `make examples` runs it, and examples/README.md describes it and counts its 10 SUPRA. Co-Authored-By: Claude Sonnet 5.5 --- Makefile | 2 +- examples/README.md | 3 ++- examples/fungible_asset.py | 7 ++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index a75ce32..99a5ab7 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ ci: lint fmt-check typecheck test-unit test-spec test-coverage build audit # SUPRA_RPC_NODE_URL set; see examples/README.md for the other variables. For example: # SUPRA_RPC_NODE_URL=http://localhost:27001 make examples # SUPRA_RPC_NODE_URL=https://rpc-devnet.supra.com SUPRA_EXAMPLES_PRIVATE_KEY= make examples -EXAMPLES = automation faucet fee_payer_transfer_coin hello_blockchain multisig \ +EXAMPLES = automation faucet fee_payer_transfer_coin fungible_asset hello_blockchain multisig \ rotate_key simple_nft simple_supra_token simulate_transfer_coin supra_token \ transfer_coin transfer_two_by_two your_coin diff --git a/examples/README.md b/examples/README.md index aafe037..bf2cdc7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -45,7 +45,7 @@ per transfer at the node's minimum gas price, and otherwise stops with the short The Devnet faucet limits the requests from one IP address, so running more than one example against Devnet needs `SUPRA_EXAMPLES_PRIVATE_KEY`. A localnet faucet has no such limit. Each generated account receives 10 SUPRA (`multisig.py`: 30 SUPRA for each account that sends transactions, 1 SUPRA for each signer); a full run of -`make examples` takes about 315 SUPRA from the funding account, most of it left in the generated accounts. +`make examples` takes about 325 SUPRA from the funding account, most of it left in the generated accounts. Two runs that use the same `SUPRA_EXAMPLES_PRIVATE_KEY` at the same time send transactions from the same account and race for its sequence numbers, so their funding transfers can fail. Run one at a time per key. @@ -89,6 +89,7 @@ status `Success`. | [`automation.py`](./automation.py) | Registering an automation task and stopping it | | [`faucet.py`](./faucet.py) | One faucet request, sent without waiting, then waiting for the faucet transaction | | [`fee_payer_transfer_coin.py`](./fee_payer_transfer_coin.py) | A coin transfer whose gas a sponsor pays, simulated and then submitted | +| [`fungible_asset.py`](./fungible_asset.py) | Reading a SUPRA balance as a fungible asset, migrating a `CoinStore` balance when the fungible balance is 0, a fungible asset transfer, and walking the receiver's fungible store transactions | | [`hello_blockchain.py`](./hello_blockchain.py) | Publishing the `hello_blockchain` package and calling its entry function; `python -m examples.hello_blockchain
` uses a package already published at `
` | | [`multisig.py`](./multisig.py) | A 2-of-3 MultiEd25519 account: a transfer, rotating an account's key to the multisig key, publishing and upgrading the `upgrade_and_govern` package, and a Move script; `python -m examples.multisig true` pauses between the steps | | [`rotate_key.py`](./rotate_key.py) | Rotating an account's authentication key to another Ed25519 key, then to a MultiEd25519 key | diff --git a/examples/fungible_asset.py b/examples/fungible_asset.py index 3d8bf64..74c1f5f 100644 --- a/examples/fungible_asset.py +++ b/examples/fungible_asset.py @@ -10,19 +10,20 @@ import asyncio -from examples.common import RPC_NODE_URL +from examples.common import RPC_NODE_URL, fund_accounts from supra_sdk.account import Account from supra_sdk.clients.rest import SupraClient from supra_sdk.clients.rest.rest_types import PaginationWithOrder -async def main(): +async def main() -> None: supra_client = SupraClient(RPC_NODE_URL) alice = Account.generate() bob = Account.generate() - await supra_client.faucet(alice.address()) + # Bob only receives the fungible asset transfer, which creates his primary store. + await fund_accounts(supra_client, [alice.address()]) # `account_supra_balance` sums the CoinStore and the fungible store; `account_fa_balance` reads the latter. print(