Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 18 additions & 34 deletions .github/workflows/examples.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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, or with a secret that is only whitespace, the job skips the examples
# and reports a notice.
# Runs the examples in `examples/` against Devnet with `make examples`. The workflow needs no repository secret. Each
# run creates a throwaway account (`examples/faucet_funder.py`), funds it with one request to the Devnet faucet, and
# runs the examples from that account: they fund the accounts they generate by transfers from it and request nothing
# from the faucet. `faucet.py` is excluded because its own request would be a second faucet request from the
# runner's IP address, and the funding step makes the same SDK faucet call. The key of the throwaway account is
# masked in the log and never written anywhere else.

on:
schedule:
Expand All @@ -13,7 +15,8 @@ on:
permissions:
contents: read

# Two runs sending transactions from the same account at once would race for its sequence numbers.
# The Devnet faucet limits requests per IP address, and runners share addresses. One run at a time keeps two runs from
# drawing on the faucet allowance of one IP address at once.
concurrency:
group: examples-devnet
cancel-in-progress: false
Expand All @@ -28,49 +31,30 @@ jobs:
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: 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: steps.key.outputs.present == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install uv
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: steps.key.outputs.present == 'true'
run: uv sync --frozen

# 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: steps.key.outputs.present == 'true'
# The uv release and the packages are fixed by the steps above. 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 once the key exists. The funder prints only the private key on stdout; `::add-mask::` hides it in
# the log before anything else can print it.
- name: Fund an account and run the examples
env:
SUPRA_EXAMPLES_PRIVATE_KEY: ${{ secrets.SUPRA_EXAMPLES_PRIVATE_KEY }}
UV_FROZEN: "1"
UV_NO_SYNC: "1"
run: make examples
run: |
set -euo pipefail
key="$(uv run python -m examples.faucet_funder)"
echo "::add-mask::$key"
SUPRA_EXAMPLES_PRIVATE_KEY="$key" make examples EXAMPLES_EXCLUDE=faucet
11 changes: 7 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,14 @@ 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. 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, except those named in EXAMPLES_EXCLUDE. 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=<key> make examples
# SUPRA_EXAMPLES_PRIVATE_KEY="$(uv run python -m examples.faucet_funder)" make examples EXAMPLES_EXCLUDE=faucet
EXAMPLES_EXCLUDE ?=
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
Expand All @@ -55,7 +58,7 @@ examples:
exit 1; \
fi
@failed=""; \
for example in $(EXAMPLES); do \
for example in $(filter-out $(EXAMPLES_EXCLUDE),$(EXAMPLES)); do \
echo "=== examples.$$example ==="; \
uv run python -m examples.$$example || failed="$$failed $$example"; \
done; \
Expand Down
31 changes: 26 additions & 5 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,32 @@ Every example generates fresh accounts. It funds them in one of two ways:
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 325 SUPRA from the funding account, most of it left in the generated accounts.
Without a key, each example makes its own faucet request, and the Devnet faucet limits the requests from one IP
address (across all its endpoints), so it can refuse them when several examples run in a row. A localnet faucet has no
such limit. To run all the examples against Devnet, either set `SUPRA_EXAMPLES_PRIVATE_KEY` to a funded account, or
create one from a single faucet grant with `examples/faucet_funder.py`:

```bash
SUPRA_EXAMPLES_PRIVATE_KEY="$(uv run python -m examples.faucet_funder)" make examples EXAMPLES_EXCLUDE=faucet
```

`faucet_funder.py` generates an account, requests SUPRA for it once (with the retry on `TryLater` described above),
waits until the balance shows, and prints only the private key on stdout; the address and the progress go to stderr.
On Devnet the funder holds 5,000 SUPRA from the grant, and a run of all the examples uses about 325 SUPRA of it.
`EXAMPLES_EXCLUDE=faucet` leaves out `faucet.py`, because its own request would be a second faucet request from the
same IP address.

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 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.

The scheduled workflow `.github/workflows/examples.yaml` runs the examples against Devnet that way, once a day and on
manual dispatch. It uses no repository secret: each run creates a funder account with one faucet request, masks its
key in the log, and runs `make examples EXAMPLES_EXCLUDE=faucet` from that account.

Keep private keys in environment variables or an untracked file. Do not commit them.

## Running
Expand Down Expand Up @@ -79,6 +97,8 @@ SUPRA_RPC_NODE_URL=https://rpc-devnet.supra.com SUPRA_EXAMPLES_PRIVATE_KEY=<key>
SUPRA_RPC_NODE_URL=http://localhost:27001 make examples
```

`EXAMPLES_EXCLUDE` names the examples to leave out, for example `make examples EXAMPLES_EXCLUDE="faucet rotate_key"`.

An example exits with a non-zero status when a step raises, which includes every transaction that does not end with
status `Success`.

Expand All @@ -101,7 +121,8 @@ status `Success`.
| [`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.
[`common.py`](./common.py) reads the environment and funds the generated accounts. [`faucet_funder.py`](./faucet_funder.py)
is not an example: it creates the funded account described under [Environment](#environment).

## Move packages

Expand Down
49 changes: 49 additions & 0 deletions examples/faucet_funder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Copyright © Supra
# SPDX-License-Identifier: Apache-2.0

"""Creates an account funded by one faucet grant, for running all the examples.

Run it as `python -m examples.faucet_funder`. It generates a fresh Ed25519 account,
requests SUPRA for it from the faucet once (with the retry on "TryLater" that the
examples use), waits until the balance shows the funds, and prints the account's
private key as hex on stdout, on one line and nothing else. The address and the
progress go to stderr. The output is meant to be assigned to
`SUPRA_EXAMPLES_PRIVATE_KEY`, so that the examples fund their accounts from this
account instead of each requesting from the faucet. This is for CI and for local runs
of all the examples against a network whose faucet limits the requests per IP address,
such as Devnet. It does not send transactions, and it refuses Mainnet.

The environment is the one of the examples (see `examples/README.md`):
`SUPRA_RPC_NODE_URL` and `SUPRA_FAUCET_URL`.

This is not an example.
"""

import asyncio
import sys
from contextlib import redirect_stdout

from examples.common import MAINNET_CHAIN_ID, RPC_NODE_URL, _fund_from_faucet
from supra_sdk.account import Account
from supra_sdk.clients.rest import SupraClient


async def main() -> None:
supra_client = SupraClient(RPC_NODE_URL)
try:
if await supra_client.chain_id() == MAINNET_CHAIN_ID:
raise RuntimeError("The examples do not send transactions to Mainnet")
funder = Account.generate()
print(f"Funding account: {funder.address()}", file=sys.stderr)
# The funding helper prints its progress to stdout; stdout carries only the key.
with redirect_stdout(sys.stderr):
await _fund_from_faucet(supra_client, funder.address())
finally:
await supra_client.close()

print(f"Funded {funder.address()}", file=sys.stderr)
print(funder.private_key.hex())


if __name__ == "__main__":
asyncio.run(main())
72 changes: 72 additions & 0 deletions tests/unit/test_faucet_funder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright © Supra
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations

import contextlib
import io
import unittest
from unittest import mock

from examples import faucet_funder
from supra_sdk.account_address import AccountAddress


class FakeClient:
chain_id_value = 255

def __init__(self, *_args: object, **_kwargs: object):
self.closed = False

async def chain_id(self) -> int:
return self.chain_id_value

async def close(self) -> None:
self.closed = True


class MainnetClient(FakeClient):
chain_id_value = 8


async def noisy_fund(_client: object, _address: AccountAddress) -> None:
# The funding helper reports progress with print().
print("The faucet answered TryLater; requesting again in 5 s")


class TestFaucetFunder(unittest.IsolatedAsyncioTestCase):
async def test_stdout_is_one_key_line(self):
stdout, stderr = io.StringIO(), io.StringIO()
with (
mock.patch.object(faucet_funder, "SupraClient", FakeClient),
mock.patch.object(faucet_funder, "_fund_from_faucet", noisy_fund),
contextlib.redirect_stdout(stdout),
contextlib.redirect_stderr(stderr),
):
await faucet_funder.main()

lines = stdout.getvalue().splitlines()
self.assertEqual(len(lines), 1)
self.assertTrue(stdout.getvalue().endswith("\n"))
self.assertRegex(lines[0], r"^0x[0-9a-f]{64}$")
self.assertIn("TryLater", stderr.getvalue())
self.assertIn("Funding account: 0x", stderr.getvalue())
self.assertNotIn(lines[0], stderr.getvalue())
self.assertNotIn("TryLater", stdout.getvalue())

async def test_refuses_mainnet(self):
stdout = io.StringIO()
fund = mock.AsyncMock()
with (
mock.patch.object(faucet_funder, "SupraClient", MainnetClient),
mock.patch.object(faucet_funder, "_fund_from_faucet", fund),
contextlib.redirect_stdout(stdout),
self.assertRaisesRegex(RuntimeError, "Mainnet"),
):
await faucet_funder.main()
fund.assert_not_called()
self.assertEqual(stdout.getvalue(), "")


if __name__ == "__main__":
unittest.main()
Loading