diff --git a/CHANGELOG.md b/CHANGELOG.md index 268658a..c066f40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 commands; kind, source, receiver, amount, fee and memo of a sent command. - `AccountNotFoundError`, a `ValueError`, from `get_account`. - `execute_query`, for custom GraphQL documents, as in the other SDKs. +- ITN methods for harness support, which need a daemon with + MinaProtocol/mina#19616: `commit_id`, `scheduled_transactions`, + `schedule_payments_with_handle`, `schedule_zkapp_commands_with_handle` and + `create_accounts` (`CreateAccountsDetails`, `CreatedAccounts`). Live tests + run with `MINA_ITN_HARNESS=1`, and `create_accounts` also needs + `MINA_ITN_FEE_PAYER`. `spec/` is mina-sdk-spec v0.2.0. - ITN client, `mina_sdk.itn` (extra `itn`, which adds `cryptography`): `ItnClient` for the daemon's ITN GraphQL server (`--itn-graphql-port`), with ed25519 request signing (`ItnKey`), the `auth` handshake, sequence diff --git a/README.md b/README.md index c3effba..a12f636 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,21 @@ with ItnClient("http://127.0.0.1:3086/graphql", key) as itn: | `set_zkapp_command_limit(limit)` | `zkAppCommandLimit` | | `execute_query(query, variables, query_name)` | any document, sequenced and signed | +These need a daemon with MinaProtocol/mina#19616; older daemons answer them +with a GraphQL error: + +| Method | GraphQL | +|--------|---------| +| `commit_id()` | `auth { commitId }`: the daemon's git commit | +| `scheduled_transactions()` | `scheduledTransactions`: handles of the running schedulers | +| `schedule_payments_with_handle(PaymentsDetails, handle)` | `schedulePayments` with a caller-chosen handle | +| `schedule_zkapp_commands_with_handle(ZkappCommandsDetails, handle)` | `scheduleZkappCommands` with a caller-chosen handle | +| `create_accounts(CreateAccountsDetails, handle=None)` | `createAccounts`: keys at once, funding in the background under the handle | + +A handle is a UUID that the caller chooses and records before the call. A +call with the handle of a running scheduler starts nothing and returns that +handle, so these calls may be repeated after a transport error. + Requests of one client are sent one at a time, because the daemon accepts only its exact next sequence number; the client is safe to share between threads. A sequenced request is never repeated after a transport error, diff --git a/spec/ITN.md b/spec/ITN.md index 0d6c459..82f549f 100644 --- a/spec/ITN.md +++ b/spec/ITN.md @@ -57,6 +57,36 @@ A client must: | `StopDaemon` | `stop_daemon` | `StopDaemon` | `stopDaemon` | `stop_daemon` | [delay seconds], [clean config] | string | | `ZkappCommandLimit` | `set_zkapp_command_limit` | `SetZkappCommandLimit` | `setZkappCommandLimit` | `set_zkapp_command_limit` | limit or null | limit now in force | +### Operations for harness support + +These operations need a daemon with MinaProtocol/mina#19616. Older daemons +reject them, so a client uses them only against such daemons; the operations +above do not change. A client can call `CommitId` first: an older daemon +answers it with a GraphQL error. + +| Operation | Rust | Go | JS | Python | Arguments | Returns | +|:--|:--|:--|:--|:--|:--|:--| +| `CommitId` | `commit_id` | `CommitID` | `commitId` | `commit_id` | – | the daemon's git commit | +| `ScheduledTransactions` | `scheduled_transactions` | `ScheduledTransactions` | `scheduledTransactions` | `scheduled_transactions` | – | handles of the running schedulers | +| `SchedulePaymentsWithHandle` | `schedule_payments_with_handle` | `SchedulePaymentsWithHandle` | `schedulePaymentsWithHandle` | `schedule_payments_with_handle` | `PaymentsDetails`, handle | handle | +| `ScheduleZkappCommandsWithHandle` | `schedule_zkapp_commands_with_handle` | `ScheduleZkappCommandsWithHandle` | `scheduleZkappCommandsWithHandle` | `schedule_zkapp_commands_with_handle` | `ZkappCommandsDetails`, handle | handle | +| `CreateAccounts` | `create_accounts` | `CreateAccounts` | `createAccounts` | `create_accounts` | `CreateAccountsDetails`, [handle] | handle and the new accounts (public and private key) | + +- **Handles.** A handle is a UUID that the client chooses. The client records + it before it sends the request, so that it can find the scheduler again + after a lost response or a restart. A request with the handle of a running + scheduler starts nothing and returns that handle; so a repeat after a + transport error is safe for these mutations, unlike the others. +- **`ScheduledTransactions`** lists the handles of the running payment and + zkApp schedulers and account-creation jobs. A client that lost its own + record can stop each of them with `StopScheduledTransactions`. +- **`CreateAccounts`** replaces `mina advanced itn-create-accounts`, which + talks to the daemon over its bin_prot RPC. It returns the keys at once and + funds the accounts in the background under the handle: a client waits until + `ScheduledTransactions` no longer lists the handle. The amount is divided + among the accounts, and each account pays the account creation fee out of + its share. + The input types `PaymentsDetails`, `ZkappCommandsDetails` and `GatingUpdate` have the fields of [`schema/itn.graphql`](https://github.com/o1-labs/mina-sdk-spec/blob/main/schema/itn.graphql). Every SDK also has a custom-query method that signs and sequences any document. diff --git a/spec/VERSION b/spec/VERSION index 5366600..ec714c3 100644 --- a/spec/VERSION +++ b/spec/VERSION @@ -1 +1 @@ -v0.1.2 +v0.2.0-rc.1 diff --git a/spec/itn-operations.graphql b/spec/itn-operations.graphql index 14ad9ed..0588712 100644 --- a/spec/itn-operations.graphql +++ b/spec/itn-operations.graphql @@ -63,3 +63,36 @@ mutation StopDaemon($delaySeconds: Int, $cleanConfig: Boolean) { mutation ZkappCommandLimit($limit: Int) { zkAppCommandLimit(limit: $limit) } + +# The following operations need a daemon with MinaProtocol/mina's ITN harness +# support (caller-supplied handles, createAccounts, scheduledTransactions, +# commitId). Older daemons reject them, so clients use them only against such +# daemons; the operations above are unchanged. + +query CommitId { + auth { + commitId + } +} + +query ScheduledTransactions { + scheduledTransactions +} + +mutation SchedulePaymentsWithHandle($input: PaymentsDetails!, $handle: String!) { + schedulePayments(input: $input, handle: $handle) +} + +mutation ScheduleZkappCommandsWithHandle($input: ZkappCommandsDetails!, $handle: String!) { + scheduleZkappCommands(input: $input, handle: $handle) +} + +mutation CreateAccounts($input: CreateAccountsDetails!, $handle: String) { + createAccounts(input: $input, handle: $handle) { + handle + accounts { + publicKey + privateKey + } + } +} diff --git a/src/mina_sdk/itn/__init__.py b/src/mina_sdk/itn/__init__.py index f78c439..a5ffb85 100644 --- a/src/mina_sdk/itn/__init__.py +++ b/src/mina_sdk/itn/__init__.py @@ -20,6 +20,9 @@ ) from mina_sdk.itn.key import ItnKey from mina_sdk.itn.types import ( + CreateAccountsDetails, + CreatedAccount, + CreatedAccounts, GatingUpdate, ItnAuth, ItnLog, @@ -30,6 +33,9 @@ ) __all__ = [ + "CreateAccountsDetails", + "CreatedAccount", + "CreatedAccounts", "GatingUpdate", "InvalidItnKeyError", "ItnAuth", diff --git a/src/mina_sdk/itn/client.py b/src/mina_sdk/itn/client.py index 1005365..2ba5e41 100644 --- a/src/mina_sdk/itn/client.py +++ b/src/mina_sdk/itn/client.py @@ -41,6 +41,9 @@ from mina_sdk.itn.errors import ItnHttpError, ItnSequencingError, ItnUnauthorizedError from mina_sdk.itn.key import ItnKey from mina_sdk.itn.types import ( + CreateAccountsDetails, + CreatedAccount, + CreatedAccounts, GatingUpdate, ItnAuth, ItnLog, @@ -319,6 +322,65 @@ def set_zkapp_command_limit(self, limit: int | None) -> int | None: ) return data["zkAppCommandLimit"] + # The following methods need a daemon with MinaProtocol/mina#19616; older + # daemons answer them with a GraphQL error. A handle is a UUID that the + # caller chooses and records before the call. A call with the handle of a + # running scheduler starts nothing and returns that handle, so these calls + # may be repeated after a transport error. + + def commit_id(self) -> str: + """The git commit of the daemon's build.""" + data = self.execute_query(queries.COMMIT_ID, None, "itn_commit_id") + return data["auth"]["commitId"] + + def scheduled_transactions(self) -> list[str]: + """Handles of the running payment and zkApp schedulers and account-creation jobs.""" + data = self.execute_query( + queries.SCHEDULED_TRANSACTIONS, None, "itn_scheduled_transactions" + ) + return list(data["scheduledTransactions"]) + + def schedule_payments_with_handle(self, details: PaymentsDetails, handle: str) -> str: + """Start sending payments under ``handle``; returns it.""" + data = self.execute_query( + queries.SCHEDULE_PAYMENTS_WITH_HANDLE, + {"input": details.to_variables(), "handle": handle}, + "itn_schedule_payments_with_handle", + ) + return data["schedulePayments"] + + def schedule_zkapp_commands_with_handle( + self, details: ZkappCommandsDetails, handle: str + ) -> str: + """Start sending zkApp commands under ``handle``; returns it.""" + data = self.execute_query( + queries.SCHEDULE_ZKAPP_COMMANDS_WITH_HANDLE, + {"input": details.to_variables(), "handle": handle}, + "itn_schedule_zkapp_commands_with_handle", + ) + return data["scheduleZkappCommands"] + + def create_accounts( + self, details: CreateAccountsDetails, handle: str | None = None + ) -> CreatedAccounts: + """Create ``details.num_accounts`` accounts and fund them in the + background; the keys are returned at once. Wait until + ``scheduled_transactions`` no longer lists the returned handle. ``None`` + lets the daemon choose the handle.""" + data = self.execute_query( + queries.CREATE_ACCOUNTS, + {"input": details.to_variables(), "handle": handle}, + "itn_create_accounts", + ) + created = data["createAccounts"] + return CreatedAccounts( + handle=created["handle"], + accounts=[ + CreatedAccount(public_key=a["publicKey"], private_key=a["privateKey"]) + for a in created["accounts"] + ], + ) + def _parse_auth(data: dict[str, Any]) -> ItnAuth: auth = data.get("auth") diff --git a/src/mina_sdk/itn/queries.py b/src/mina_sdk/itn/queries.py index 9ac0c43..f90ca30 100644 --- a/src/mina_sdk/itn/queries.py +++ b/src/mina_sdk/itn/queries.py @@ -61,6 +61,37 @@ zkAppCommandLimit(limit: $limit) }""" +# The following operations need a daemon with MinaProtocol/mina#19616; older +# daemons answer them with a GraphQL error. + +COMMIT_ID = """query CommitId { + auth { + commitId + } +}""" + +SCHEDULED_TRANSACTIONS = """query ScheduledTransactions { + scheduledTransactions +}""" + +SCHEDULE_PAYMENTS_WITH_HANDLE = """mutation SchedulePaymentsWithHandle($input: PaymentsDetails!, $handle: String!) { + schedulePayments(input: $input, handle: $handle) +}""" + +SCHEDULE_ZKAPP_COMMANDS_WITH_HANDLE = """mutation ScheduleZkappCommandsWithHandle($input: ZkappCommandsDetails!, $handle: String!) { + scheduleZkappCommands(input: $input, handle: $handle) +}""" + +CREATE_ACCOUNTS = """mutation CreateAccounts($input: CreateAccountsDetails!, $handle: String) { + createAccounts(input: $input, handle: $handle) { + handle + accounts { + publicKey + privateKey + } + } +}""" + # Every ITN document of the specification, for the conformance test. ALL_ITN_DOCUMENTS = [ AUTH, @@ -73,4 +104,9 @@ UPDATE_GATING, STOP_DAEMON, ZKAPP_COMMAND_LIMIT, + COMMIT_ID, + SCHEDULED_TRANSACTIONS, + SCHEDULE_PAYMENTS_WITH_HANDLE, + SCHEDULE_ZKAPP_COMMANDS_WITH_HANDLE, + CREATE_ACCOUNTS, ] diff --git a/src/mina_sdk/itn/types.py b/src/mina_sdk/itn/types.py index 05cebe3..5741073 100644 --- a/src/mina_sdk/itn/types.py +++ b/src/mina_sdk/itn/types.py @@ -199,3 +199,46 @@ def to_variables(self) -> dict[str, Any]: "bannedPeers": [p.to_variables() for p in self.banned_peers], "trustedPeers": [p.to_variables() for p in self.trusted_peers], } + + +@dataclass(frozen=True) +class CreateAccountsDetails: + """The input of ``create_accounts``. + + Attributes: + fee_payer: Base58 private key of the account that funds the new accounts. + num_accounts: Number of new accounts. + fee: Fee of each zkApp command that creates accounts. + amount: Divided among the new accounts; each pays the account creation + fee out of its share. + """ + + fee_payer: str + num_accounts: int + fee: Currency + amount: Currency + + def to_variables(self) -> dict[str, Any]: + return { + "feePayer": self.fee_payer, + "numAccounts": self.num_accounts, + "fee": self.fee.to_nanomina_str(), + "amount": self.amount.to_nanomina_str(), + } + + +@dataclass(frozen=True) +class CreatedAccount: + """A new account of ``create_accounts``: public and base58 private key.""" + + public_key: str + private_key: str + + +@dataclass(frozen=True) +class CreatedAccounts: + """The result of ``create_accounts``. ``scheduled_transactions`` lists the + handle until the background job that funds the accounts ends.""" + + handle: str + accounts: list[CreatedAccount] diff --git a/tests/test_itn.py b/tests/test_itn.py index cf99766..41df3f5 100644 --- a/tests/test_itn.py +++ b/tests/test_itn.py @@ -15,6 +15,8 @@ from mina_sdk import Currency, DaemonConnectionError, GraphQLError from mina_sdk.itn import ( + CreateAccountsDetails, + CreatedAccount, GatingUpdate, InvalidItnKeyError, ItnClient, @@ -272,3 +274,60 @@ def test_variables_of_the_operations(key): assert variables[4]["input"]["trustedPeers"] == [ {"host": "1.2.3.4", "libp2pPort": 1, "peerId": "p"} ] + + +@respx.mock +def test_harness_support_operations(key): + def answer(query): + if "commitId" in query: + return {"auth": {"commitId": "abc123"}} + if "scheduledTransactions" in query: + return {"scheduledTransactions": ["h1", "h2"]} + if "createAccounts" in query: + return { + "createAccounts": { + "handle": "h3", + "accounts": [{"publicKey": "B62qa", "privateKey": "EKa"}], + } + } + return {"schedulePayments": "h4"} + + server = MockItnServer(key) + data_for = server.data + + def respond(request): + server.data = answer(json.loads(request.content)["query"]) + return server(request) + + respx.post(URL).mock(side_effect=respond) + assert data_for == {} + details = CreateAccountsDetails( + fee_payer="EKfee", + num_accounts=2, + fee=Currency.from_nanomina(100), + amount=Currency.from_nanomina(5000), + ) + payments = PaymentsDetails( + duration_min=1, + tps=0.5, + memo_prefix="m", + max_fee=Currency.from_nanomina(20), + min_fee=Currency.from_nanomina(10), + amount=Currency.from_nanomina(1), + receiver="B62qr", + ) + with _client(key) as itn: + assert itn.commit_id() == "abc123" + assert itn.scheduled_transactions() == ["h1", "h2"] + created = itn.create_accounts(details) + assert created.handle == "h3" + assert created.accounts == [CreatedAccount(public_key="B62qa", private_key="EKa")] + itn.create_accounts(details, "u1") + assert itn.schedule_payments_with_handle(payments, "u2") == "h4" + variables = [r.get("variables") for r in server.requests if r["query"] != queries.AUTH] + assert variables[2] == { + "input": {"feePayer": "EKfee", "numAccounts": 2, "fee": "100", "amount": "5000"}, + "handle": None, + } + assert variables[3]["handle"] == "u1" + assert variables[4]["handle"] == "u2" diff --git a/tests/test_itn_integration.py b/tests/test_itn_integration.py index ce8a250..e8ad424 100644 --- a/tests/test_itn_integration.py +++ b/tests/test_itn_integration.py @@ -9,11 +9,19 @@ import contextlib import os +import time +import uuid import pytest -from mina_sdk import GraphQLError -from mina_sdk.itn import GatingUpdate, ItnClient, ItnKey, ItnUnauthorizedError +from mina_sdk import Currency, GraphQLError +from mina_sdk.itn import ( + CreateAccountsDetails, + GatingUpdate, + ItnClient, + ItnKey, + ItnUnauthorizedError, +) URI = os.environ.get("MINA_ITN_URI", "") SEED = os.environ.get("MINA_ITN_KEY", "") @@ -74,3 +82,41 @@ def test_update_gating_empty(): # An empty update with isolate=False leaves the node's gating open. with _client() as itn: assert isinstance(itn.update_gating(GatingUpdate()), str) + + +# Operations for harness support (MinaProtocol/mina#19616): only with +# MINA_ITN_HARNESS=1, because older daemons do not have them. +harness = pytest.mark.skipif( + os.environ.get("MINA_ITN_HARNESS") != "1", reason="MINA_ITN_HARNESS=1 not set" +) + + +@harness +def test_commit_id_and_listing(): + with _client() as itn: + assert len(itn.commit_id()) >= 7 + assert isinstance(itn.scheduled_transactions(), list) + + +# createAccounts sends transactions, so it also needs MINA_ITN_FEE_PAYER: the +# base58 private key of a funded account. +@harness +@pytest.mark.skipif(not os.environ.get("MINA_ITN_FEE_PAYER"), reason="MINA_ITN_FEE_PAYER not set") +def test_create_accounts(): + handle = str(uuid.uuid4()) + details = CreateAccountsDetails( + fee_payer=os.environ["MINA_ITN_FEE_PAYER"], + num_accounts=3, + fee=Currency("0.1"), + amount=Currency("6"), + ) + with _client() as itn: + created = itn.create_accounts(details, handle) + assert created.handle == handle + assert len(created.accounts) == 3 + assert itn.create_accounts(details, handle).accounts[0] == created.accounts[0] + for _ in range(120): + if handle not in itn.scheduled_transactions(): + return + time.sleep(5) + pytest.fail(f"handle {handle} still listed after 10 minutes")