From 1f2b415af64a9e6428a6f639a6247fd56d7c0d50 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 24 May 2026 12:46:53 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(facade):=20port=20unified=20Tx3ClientB?= =?UTF-8?q?uilder=20from=20rust-sdk=20for=20=C2=A73.3/=C2=A73.6=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Tx3ClientBuilder with the two seeding entry points (Protocol.client() for the dynamic flow and Tx3ClientBuilder.from_parts(...) for the codegen flow), the mandatory trp/trp_endpoint setters, optional with_profile / with_party / with_parties / with_party_unchecked / with_header / with_env_value, and a single build() terminal that raises MissingTrpEndpointError, UnknownProfileError, or UnknownPartyError. New BuilderError class family under Tx3Error makes the builder errors discriminable. Removes with_profile from the built Tx3Client (profile selection is builder-only per §3.6); Tx3Client.tx(name) raises UnknownTxError at the call site instead of deferring to resolve(). TxBuilder is now source-agnostic: holds the TIR envelope, env, parties, and args directly so the dynamic and codegen flows drive an identical resolve() path. Codegen template wraps Tx3ClientBuilder.from_parts(...) instead of re-implementing client state. Generated Client adds only the typed per-tx Params dataclasses, per-tx methods, per-party setters (routing through with_party_unchecked), embedded TIR / SdkProfile constants, and (when profiles are declared) a typed Profile str-Enum. Builder exposes an internal _trp_client(client) escape hatch for tests to inject mock TRP clients without going through ClientOptions — not part of the public API. Top-level re-exports add Tx3ClientBuilder, Profile, BuilderError, MissingTrpEndpointError, ClientOptions. Existing tests (test_add_witness, test_errors) migrated to the builder API; test_facade rewritten with 17 new tests covering the builder contract. 46/46 unit tests pass. Spec: sdks/sdk-spec/api-surface/facade.md §3.3, §3.4, §3.6; sdks/sdk-spec/codegen/generated-surface.md §C.3a–d. Co-Authored-By: Claude Opus 4.7 (1M context) --- .trix/client-lib/__init__.py.hbs | 117 ++++++++---- sdk/src/tx3_sdk/__init__.py | 13 +- sdk/src/tx3_sdk/facade/__init__.py | 8 + sdk/src/tx3_sdk/facade/builder.py | 82 +++++---- sdk/src/tx3_sdk/facade/client.py | 153 +++++++++++++--- sdk/src/tx3_sdk/facade/client_builder.py | 215 +++++++++++++++++++++++ sdk/src/tx3_sdk/facade/errors.py | 23 ++- sdk/src/tx3_sdk/facade/profile.py | 19 ++ sdk/src/tx3_sdk/tii/protocol.py | 12 ++ sdk/tests/test_add_witness.py | 9 +- sdk/tests/test_errors.py | 12 +- sdk/tests/test_facade.py | 202 +++++++++++++++------ 12 files changed, 699 insertions(+), 166 deletions(-) create mode 100644 sdk/src/tx3_sdk/facade/client_builder.py create mode 100644 sdk/src/tx3_sdk/facade/profile.py diff --git a/.trix/client-lib/__init__.py.hbs b/.trix/client-lib/__init__.py.hbs index 8ea89a6..a124423 100644 --- a/.trix/client-lib/__init__.py.hbs +++ b/.trix/client-lib/__init__.py.hbs @@ -5,11 +5,18 @@ from __future__ import annotations import json from dataclasses import dataclass +from enum import Enum from typing import Any +from tx3_sdk import ( + Party, + Profile as SdkProfile, + Tx3Client, + Tx3ClientBuilder, +) from tx3_sdk.core.bytes import TirEnvelope -from tx3_sdk.trp.client import TrpClient -from tx3_sdk.trp.spec import ResolveParams, SubmitParams, SubmitResponse, TxEnvelope +from tx3_sdk.facade.builder import TxBuilder +from tx3_sdk.trp.client import ClientOptions PROTOCOL_NAME = "{{tii.protocol.name}}" PROTOCOL_VERSION = "{{tii.protocol.version}}" @@ -22,13 +29,13 @@ ENVIRONMENT_SCHEMA: dict[str, Any] = json.loads( ) {{/if}} -# Profiles embedded from the TII, keyed by name. Each value carries that -# profile's `environment` values and `parties` addresses. -PROFILES: dict[str, dict[str, Any]] = json.loads( - """{{{json tii.profiles}}}""" +{{#each tii.transactions}} +{{constantCase @key}}_TIR = TirEnvelope( + content="{{tir.content}}", + encoding="{{tir.encoding}}", + version="{{tir.version}}", ) -{{#each tii.transactions}} @dataclass class {{pascalCase @key}}Params: """Arguments for the {{@key}} transaction.""" @@ -38,51 +45,83 @@ class {{pascalCase @key}}Params: {{/each}} -{{constantCase @key}}_TIR = TirEnvelope( - content="{{tir.content}}", - encoding="{{tir.encoding}}", - version="{{tir.version}}", +{{/each}} +{{#if tii.profiles}} +{{#each tii.profiles}} +_{{constantCase @key}}_PROFILE = SdkProfile( + environment=json.loads("""{{{json this.environment}}}"""), + parties=json.loads("""{{{json this.parties}}}"""), ) +{{/each}} + +class Profile(str, Enum): + """Profiles declared by the protocol. Required argument to `Client`.""" + +{{#each tii.profiles}} + {{constantCase @key}} = "{{@key}}" {{/each}} + + +{{/if}} class Client: - """Thin protocol facade over the TRP client.""" + """Typed protocol client exposing the full transaction lifecycle. + + Wraps the runtime SDK's `Tx3Client` — all lifecycle state, party storage, + resolve/sign/submit/wait, and error handling live in the SDK. This wrapper + only adds the typed shape of per-transaction methods, per-party setters, + and the embedded protocol fragments. + """ def __init__( self, - endpoint: str, - headers: dict[str, str] | None = None, - profile: str | None = None, + options: ClientOptions, +{{#if tii.profiles}} + profile: Profile, +{{/if}} ) -> None: - self._trp = TrpClient(endpoint=endpoint, headers=headers) - self._environment: dict[str, Any] | None = None - self._parties: dict[str, str] = {} - if profile is not None: - if profile not in PROFILES: - raise ValueError(f"unknown profile {profile!r}") - selected = PROFILES[profile] - self._environment = selected.get("environment") or None - self._parties = selected.get("parties") or {} - - async def _resolve(self, tir: TirEnvelope, args: dict[str, Any]) -> TxEnvelope: - merged: dict[str, Any] = {**self._parties, **args} - return await self._trp.resolve( - ResolveParams(tir=tir, args=merged, env=self._environment) - ) + transactions: dict[str, TirEnvelope] = { {{#each tii.transactions}} + "{{@key}}": {{constantCase @key}}_TIR, +{{/each}} + } +{{#if tii.profiles}} + profiles: dict[str, SdkProfile] = { +{{#each tii.profiles}} + "{{@key}}": _{{constantCase @key}}_PROFILE, +{{/each}} + } +{{else}} + profiles: dict[str, SdkProfile] = {} +{{/if}} - async def {{snakeCase @key}}(self, args: {{pascalCase @key}}Params) -> TxEnvelope: - """Resolves the {{@key}} transaction.""" - return await self._resolve( - {{constantCase @key}}_TIR, + builder = Tx3ClientBuilder.from_parts(transactions, profiles, []) + builder = builder.trp(options) +{{#if tii.profiles}} + builder = builder.with_profile(profile.value) +{{/if}} + self._inner: Tx3Client = builder.build() + +{{#each tii.parties}} + def with_{{snakeCase @key}}(self, party: Party) -> "Client": + """Binds the `{{@key}}` party. Overrides any address declared for that + party by the selected profile. + """ + self._inner = self._inner.with_party_unchecked("{{@key}}", party) + return self + +{{/each}} +{{#each tii.transactions}} + def {{snakeCase @key}}(self, args: {{pascalCase @key}}Params) -> TxBuilder: + """Builds the `{{@key}}` transaction. Drive the returned builder with + `.resolve()` and the rest of the lifecycle chain. + """ + return self._inner.tx("{{@key}}").args( { {{#each params.properties}} "{{@key}}": args.{{snakeCase @key}}, {{/each}} - }, + } ) -{{/each}} - async def submit(self, params: SubmitParams) -> SubmitResponse: - """Submits a signed transaction to the network.""" - return await self._trp.submit(params) +{{/each}} diff --git a/sdk/src/tx3_sdk/__init__.py b/sdk/src/tx3_sdk/__init__.py index 54d4ce7..11c9227 100644 --- a/sdk/src/tx3_sdk/__init__.py +++ b/sdk/src/tx3_sdk/__init__.py @@ -2,25 +2,36 @@ from tx3_sdk.core.args import ArgValue, coerce_arg from tx3_sdk.facade.client import Tx3Client +from tx3_sdk.facade.client_builder import Tx3ClientBuilder +from tx3_sdk.facade.errors import ( + BuilderError, + MissingTrpEndpointError, +) from tx3_sdk.facade.party import Party from tx3_sdk.facade.poll import PollConfig +from tx3_sdk.facade.profile import Profile from tx3_sdk.signer.cardano import CardanoSigner from tx3_sdk.signer.ed25519 import Ed25519Signer from tx3_sdk.signer.signer import SignRequest, Signer from tx3_sdk.tii.protocol import Protocol -from tx3_sdk.trp.client import TrpClient +from tx3_sdk.trp.client import ClientOptions, TrpClient __all__ = [ "ArgValue", + "BuilderError", "CardanoSigner", + "ClientOptions", "Ed25519Signer", + "MissingTrpEndpointError", "Party", "PollConfig", + "Profile", "Protocol", "SignRequest", "Signer", "TrpClient", "Tx3Client", + "Tx3ClientBuilder", "coerce_arg", ] diff --git a/sdk/src/tx3_sdk/facade/__init__.py b/sdk/src/tx3_sdk/facade/__init__.py index ef785e1..737fbe0 100644 --- a/sdk/src/tx3_sdk/facade/__init__.py +++ b/sdk/src/tx3_sdk/facade/__init__.py @@ -2,31 +2,39 @@ from tx3_sdk.facade.builder import TxBuilder from tx3_sdk.facade.client import Tx3Client +from tx3_sdk.facade.client_builder import Tx3ClientBuilder from tx3_sdk.facade.errors import ( + BuilderError, FinalizedFailedError, FinalizedTimeoutError, MissingParamsError, + MissingTrpEndpointError, SubmitHashMismatchError, UnknownArgError, UnknownPartyError, ) from tx3_sdk.facade.party import Party from tx3_sdk.facade.poll import PollConfig +from tx3_sdk.facade.profile import Profile from tx3_sdk.facade.resolved import ResolvedTx from tx3_sdk.facade.signed import SignedTx from tx3_sdk.facade.submitted import SubmittedTx __all__ = [ + "BuilderError", "FinalizedFailedError", "FinalizedTimeoutError", "MissingParamsError", + "MissingTrpEndpointError", "Party", "PollConfig", + "Profile", "ResolvedTx", "SignedTx", "SubmitHashMismatchError", "SubmittedTx", "Tx3Client", + "Tx3ClientBuilder", "TxBuilder", "UnknownArgError", "UnknownPartyError", diff --git a/sdk/src/tx3_sdk/facade/builder.py b/sdk/src/tx3_sdk/facade/builder.py index 0a152de..618f901 100644 --- a/sdk/src/tx3_sdk/facade/builder.py +++ b/sdk/src/tx3_sdk/facade/builder.py @@ -1,70 +1,78 @@ -"""Invocation builder for resolve step of facade flow.""" +"""Source-agnostic transaction invocation builder.""" from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any +from typing import Any, Iterable from tx3_sdk.core.args import coerce_arg, normalize_arg_key -from tx3_sdk.facade.errors import MissingParamsError, UnknownArgError, UnknownPartyError +from tx3_sdk.core.bytes import TirEnvelope from tx3_sdk.facade.party import Party from tx3_sdk.facade.resolved import ResolvedTx from tx3_sdk.signer.signer import Signer -from tx3_sdk.tii.errors import MissingParamsError as TiiMissingParamsError -from tx3_sdk.tii.protocol import Protocol -from tx3_sdk.trp.client import TrpClient from tx3_sdk.trp.spec import ResolveParams -@dataclass class TxBuilder: - """Collects args and resolves a transaction through TRP.""" + """Builder for transaction invocation. - protocol: Protocol - trp: TrpClient - tx_name: str - parties: dict[str, Party] - profile: str | None = None - _args: dict[str, Any] = field(default_factory=dict) + Holds the resolve inputs directly: the TIR envelope, the environment values + from the selected profile (with builder-supplied overrides already folded + in), the bound parties, and the typed args. Drives a single `resolve()` + path regardless of whether the upstream was a runtime-loaded `Protocol` or + codegen-embedded fragments. + """ + + def __init__(self, trp: Any, tir: TirEnvelope) -> None: + self._trp = trp + self._tir = tir + self._env: dict[str, Any] = {} + self._parties: dict[str, Party] = {} + self._args: dict[str, Any] = {} + + def env(self, env: dict[str, Any]) -> "TxBuilder": + """Sets the environment values applied to this transaction.""" + self._env = dict(env) + return self + + def parties( + self, parties: Iterable[tuple[str, Party]] | dict[str, Party] + ) -> "TxBuilder": + """Attaches party definitions (case-insensitive names).""" + items = parties.items() if isinstance(parties, dict) else parties + for name, party in items: + self._parties[name.lower()] = party + return self def arg(self, name: str, value: Any) -> "TxBuilder": - """Sets one arg by key, with case-insensitive key matching.""" + """Adds a single argument (case-insensitive name).""" self._args[normalize_arg_key(name)] = coerce_arg(value) return self def args(self, values: dict[str, Any]) -> "TxBuilder": - """Sets multiple args at once.""" + """Adds multiple arguments at once.""" for key, value in values.items(): self.arg(key, value) return self async def resolve(self) -> ResolvedTx: - """Resolves this invocation via TRP and returns a `ResolvedTx`.""" - invocation = self.protocol.invoke(self.tx_name, self.profile) - protocol_parties = {key.lower() for key in self.protocol.parties.keys()} + """Resolves the transaction through the TRP client.""" + merged: dict[str, Any] = {} + merged.update(self._env) + for name, party in self._parties.items(): + merged[name] = party.party_address() + merged.update(self._args) + + envelope = await self._trp.resolve( + ResolveParams(tir=self._tir, args=merged) + ) signers: list[tuple[str, Signer]] = [] - for name, party in self.parties.items(): - if name.lower() not in protocol_parties: - raise UnknownPartyError(name) - invocation.set_arg(name, party.party_address()) + for name, party in self._parties.items(): if party.is_signer and party.signer_impl is not None: signers.append((name, party.signer_impl)) - param_keys = {name.lower() for name in invocation.params.keys()} - for key, value in self._args.items(): - if key not in param_keys: - raise UnknownArgError(key) - invocation.set_arg(key, value) - - try: - tir, args = invocation.into_resolve_request() - except TiiMissingParamsError as exc: - raise MissingParamsError(exc.params) from exc - - envelope = await self.trp.resolve(ResolveParams(tir=tir, args=args)) return ResolvedTx( - trp=self.trp, + trp=self._trp, hash=envelope.hash, tx_hex=envelope.tx, signers=signers, diff --git a/sdk/src/tx3_sdk/facade/client.py b/sdk/src/tx3_sdk/facade/client.py index 59afa60..90f995e 100644 --- a/sdk/src/tx3_sdk/facade/client.py +++ b/sdk/src/tx3_sdk/facade/client.py @@ -1,47 +1,142 @@ -"""Facade entrypoint for protocol-bound transaction building.""" +"""High-level facade client for a Tx3 protocol.""" from __future__ import annotations -from dataclasses import dataclass, field, replace +from typing import Any, Iterable, Mapping, Union +from tx3_sdk.core.bytes import TirEnvelope from tx3_sdk.facade.builder import TxBuilder +from tx3_sdk.facade.errors import UnknownPartyError from tx3_sdk.facade.party import Party -from tx3_sdk.tii.protocol import Protocol -from tx3_sdk.trp.client import TrpClient +from tx3_sdk.facade.profile import Profile +from tx3_sdk.tii.errors import UnknownTxError -@dataclass(frozen=True) class Tx3Client: - """High-level client that ties protocol, TRP client, and parties together.""" + """High-level client over a Tx3 protocol. - protocol: Protocol - trp: TrpClient - parties: dict[str, Party] = field(default_factory=dict) - profile: str | None = None + Holds the deconstructed protocol parts — per-transaction TIR envelopes, + the set of declared party names, the selected profile — plus the runtime + state (TRP client, bound parties, env overrides). Built through + `Tx3ClientBuilder` (obtained via `Protocol.client()` or + `Tx3ClientBuilder.from_parts(...)`). Profile selection is locked in at + build time: there is no profile-switching method on the built client. + """ - def with_profile(self, name: str) -> "Tx3Client": - """Returns a new client with a profile bound to future invocations.""" - return replace(self, profile=name) + def __init__( + self, + transactions: Mapping[str, TirEnvelope], + known_parties: Iterable[str], + trp: Any, + bound_parties: Mapping[str, Party] | None = None, + selected_profile: Profile | None = None, + env_overrides: Mapping[str, Any] | None = None, + ) -> None: + self._transactions: dict[str, TirEnvelope] = dict(transactions) + self._known_parties: set[str] = {name.lower() for name in known_parties} + self._trp = trp + self._bound_parties: dict[str, Party] = ( + dict(bound_parties) if bound_parties is not None else {} + ) + self._selected_profile = selected_profile + self._env_overrides: dict[str, Any] = ( + dict(env_overrides) if env_overrides is not None else {} + ) + + @classmethod + def _from_builder( + cls, + transactions: Mapping[str, TirEnvelope], + known_parties: Iterable[str], + trp: Any, + bound_parties: Mapping[str, Party], + selected_profile: Profile | None, + env_overrides: Mapping[str, Any], + ) -> "Tx3Client": + """Internal — call site is `Tx3ClientBuilder.build()`.""" + return cls( + transactions=transactions, + known_parties=known_parties, + trp=trp, + bound_parties=bound_parties, + selected_profile=selected_profile, + env_overrides=env_overrides, + ) def with_party(self, name: str, party: Party) -> "Tx3Client": - """Returns a new client with one named party binding.""" - next_parties = dict(self.parties) + """Late-binding party setter. Returns a new client with the party + bound. Validated against the protocol's declared parties. + + Raises: + UnknownPartyError: if `name` is not declared by the protocol. + """ + lower = name.lower() + if lower not in self._known_parties: + raise UnknownPartyError(lower) + next_parties = dict(self._bound_parties) + next_parties[lower] = party + return self._with_parties(next_parties) + + def with_party_unchecked(self, name: str, party: Party) -> "Tx3Client": + """Late-binding party setter that skips the declared-party lookup. + Intended for codegen-generated wrappers; hand-written code SHOULD + prefer `with_party`. + """ + next_parties = dict(self._bound_parties) next_parties[name.lower()] = party - return replace(self, parties=next_parties) + return self._with_parties(next_parties) - def with_parties(self, parties: dict[str, Party]) -> "Tx3Client": - """Returns a new client with multiple named party bindings.""" - next_parties = dict(self.parties) - for name, party in parties.items(): - next_parties[name.lower()] = party - return replace(self, parties=next_parties) + def with_parties( + self, + parties: Union[Mapping[str, Party], Iterable[tuple[str, Party]]], + ) -> "Tx3Client": + """Late-binds multiple parties at once. See `with_party`.""" + if isinstance(parties, Mapping): + items: Iterable[tuple[str, Party]] = parties.items() + else: + items = parties + next_parties = dict(self._bound_parties) + for name, party in items: + lower = name.lower() + if lower not in self._known_parties: + raise UnknownPartyError(lower) + next_parties[lower] = party + return self._with_parties(next_parties) def tx(self, name: str) -> TxBuilder: - """Starts building an invocation for a transaction name.""" - return TxBuilder( - protocol=self.protocol, - trp=self.trp, - tx_name=name, - parties=dict(self.parties), - profile=self.profile, + """Starts building a transaction invocation. + + Raises: + UnknownTxError: if `name` is not declared by the protocol. + """ + if name not in self._transactions: + raise UnknownTxError(name) + tir = self._transactions[name] + env = self._merged_env() + parties = self._merged_parties() + return TxBuilder(trp=self._trp, tir=tir).env(env).parties(parties) + + def _with_parties(self, parties: dict[str, Party]) -> "Tx3Client": + return Tx3Client( + transactions=self._transactions, + known_parties=self._known_parties, + trp=self._trp, + bound_parties=parties, + selected_profile=self._selected_profile, + env_overrides=self._env_overrides, ) + + def _merged_env(self) -> dict[str, Any]: + env: dict[str, Any] = {} + if self._selected_profile is not None: + env.update(self._selected_profile.environment) + env.update(self._env_overrides) + return env + + def _merged_parties(self) -> dict[str, Party]: + merged: dict[str, Party] = {} + if self._selected_profile is not None: + for name, address in self._selected_profile.parties.items(): + merged[name.lower()] = Party.address(address) + merged.update(self._bound_parties) + return merged diff --git a/sdk/src/tx3_sdk/facade/client_builder.py b/sdk/src/tx3_sdk/facade/client_builder.py new file mode 100644 index 0000000..8f95e86 --- /dev/null +++ b/sdk/src/tx3_sdk/facade/client_builder.py @@ -0,0 +1,215 @@ +"""Builder for `Tx3Client`.""" + +from __future__ import annotations + +from typing import Any, Iterable, Mapping, Union + +from tx3_sdk.core.bytes import TirEnvelope +from tx3_sdk.facade.client import Tx3Client +from tx3_sdk.facade.errors import MissingTrpEndpointError, UnknownPartyError +from tx3_sdk.facade.party import Party +from tx3_sdk.facade.profile import Profile +from tx3_sdk.tii.errors import UnknownProfileError +from tx3_sdk.tii.protocol import Protocol +from tx3_sdk.trp.client import ClientOptions, TrpClient + + +class Tx3ClientBuilder: + """Builds a `Tx3Client`. + + Obtained via `Protocol.client()` for the dynamic flow or + `Tx3ClientBuilder.from_parts(...)` for the codegen flow. All fallible + validation — TRP endpoint present, selected profile declared, every bound + party declared — happens in `build()`. Optional setters never raise, so + chains stay fluent. + + Example: + >>> client = ( + ... Protocol.from_file("protocol.tii") + ... .client() + ... .trp_endpoint("https://trp.example") + ... .with_profile("preprod") + ... .with_party("sender", Party.signer(signer)) + ... .build() + ... ) + """ + + def __init__( + self, + transactions: Mapping[str, TirEnvelope], + profiles: Mapping[str, Profile], + known_parties: Iterable[str], + ) -> None: + self._transactions: dict[str, TirEnvelope] = dict(transactions) + self._profiles: dict[str, Profile] = dict(profiles) + self._known_parties: set[str] = {name.lower() for name in known_parties} + self._trp_options: ClientOptions | None = None + self._trp_client_override: Any = None + self._profile: str | None = None + self._parties: dict[str, Party] = {} + self._unchecked_parties: dict[str, Party] = {} + self._env_overrides: dict[str, Any] = {} + + @classmethod + def from_parts( + cls, + transactions: Mapping[str, TirEnvelope], + profiles: Mapping[str, Profile], + known_parties: Iterable[str], + ) -> "Tx3ClientBuilder": + """Seeds a builder with already-deconstructed protocol fragments. + + Codegen-generated bindings call this with embedded per-transaction TIR + envelopes, per-profile environment + party-address maps, and + (typically) an empty known-parties set — the typed + `with__unchecked` wrapper methods bake party names in at + codegen time so runtime name validation is unnecessary. + """ + return cls(transactions, profiles, known_parties) + + @classmethod + def from_protocol(cls, protocol: Protocol) -> "Tx3ClientBuilder": + """Entry point used by `Protocol.client()`.""" + transactions: dict[str, TirEnvelope] = {} + for name, tx in protocol.transactions.items(): + tir_raw = tx.get("tir") + if not isinstance(tir_raw, dict): + continue + transactions[name] = TirEnvelope( + content=str(tir_raw["content"]), + encoding=str(tir_raw["encoding"]), + version=str(tir_raw["version"]), + ) + + profiles: dict[str, Profile] = {} + for name, spec in protocol.profiles.items(): + env = spec.get("environment", {}) + if not isinstance(env, dict): + env = {} + parties_map = spec.get("parties", {}) + if not isinstance(parties_map, dict): + parties_map = {} + profiles[name] = Profile( + environment=dict(env), + parties={str(k): str(v) for k, v in parties_map.items()}, + ) + + known_parties = set(protocol.parties.keys()) + return cls(transactions, profiles, known_parties) + + def trp(self, options: ClientOptions) -> "Tx3ClientBuilder": + """Sets the full TRP client options.""" + self._trp_options = options + return self + + def trp_endpoint(self, url: str) -> "Tx3ClientBuilder": + """Shorthand for `trp(ClientOptions(endpoint=url))`.""" + self._trp_options = ClientOptions(endpoint=url, headers={}) + return self + + def with_header(self, key: str, value: str) -> "Tx3ClientBuilder": + """Adds a single TRP request header. Initializes options to an empty + endpoint if not set — callers must still supply the endpoint via + `trp()` or `trp_endpoint()`. + """ + if self._trp_options is None: + self._trp_options = ClientOptions(endpoint="", headers={key: value}) + else: + headers = dict(self._trp_options.headers) + headers[key] = value + self._trp_options = ClientOptions( + endpoint=self._trp_options.endpoint, + headers=headers, + timeout_seconds=self._trp_options.timeout_seconds, + ) + return self + + def with_profile(self, name: str) -> "Tx3ClientBuilder": + """Selects a profile by name. Validated in `build()`.""" + self._profile = name + return self + + def with_party(self, name: str, party: Party) -> "Tx3ClientBuilder": + """Binds a party by name. The name is validated against the + protocol's declared parties in `build()`. + """ + self._parties[name.lower()] = party + return self + + def with_party_unchecked(self, name: str, party: Party) -> "Tx3ClientBuilder": + """Binds a party without validating the name against the protocol's + declared parties. Intended for codegen-generated wrappers; hand- + written code SHOULD prefer `with_party`. + """ + self._unchecked_parties[name.lower()] = party + return self + + def with_parties( + self, + parties: Union[Mapping[str, Party], Iterable[tuple[str, Party]]], + ) -> "Tx3ClientBuilder": + """Binds multiple parties at once. See `with_party`.""" + if isinstance(parties, Mapping): + items: Iterable[tuple[str, Party]] = parties.items() + else: + items = parties + for name, party in items: + self.with_party(name, party) + return self + + def with_env_value(self, key: str, value: Any) -> "Tx3ClientBuilder": + """Sets a single environment value, merged on top of the selected + profile's environment at resolve time (override wins). + """ + self._env_overrides[key] = value + return self + + def _trp_client(self, client: Any) -> "Tx3ClientBuilder": + """Internal: lets tests inject a pre-built / mock TRP client without + going through the `ClientOptions` construction path. Not part of the + public API. + """ + self._trp_client_override = client + return self + + def build(self) -> Tx3Client: + """Validates the builder state and materializes the `Tx3Client`. + + Raises: + MissingTrpEndpointError: if no TRP endpoint was supplied. + UnknownProfileError: if the selected profile is not declared. + UnknownPartyError: if any bound party is not declared. + """ + if self._trp_client_override is not None: + trp = self._trp_client_override + else: + if self._trp_options is None or not self._trp_options.endpoint: + raise MissingTrpEndpointError() + trp = TrpClient( + endpoint=self._trp_options.endpoint, + headers=dict(self._trp_options.headers), + timeout_seconds=self._trp_options.timeout_seconds, + ) + + selected_profile: Profile | None = None + if self._profile is not None: + if self._profile not in self._profiles: + raise UnknownProfileError(self._profile) + selected_profile = self._profiles[self._profile] + + for name in self._parties: + if name not in self._known_parties: + raise UnknownPartyError(name) + + bound_parties: dict[str, Party] = {} + bound_parties.update(self._parties) + bound_parties.update(self._unchecked_parties) + + return Tx3Client._from_builder( + transactions=self._transactions, + known_parties=self._known_parties, + trp=trp, + bound_parties=bound_parties, + selected_profile=selected_profile, + env_overrides=dict(self._env_overrides), + ) diff --git a/sdk/src/tx3_sdk/facade/errors.py b/sdk/src/tx3_sdk/facade/errors.py index 9b24cc6..749e414 100644 --- a/sdk/src/tx3_sdk/facade/errors.py +++ b/sdk/src/tx3_sdk/facade/errors.py @@ -2,10 +2,29 @@ from __future__ import annotations -from tx3_sdk.errors import PollingError, ResolutionError, SubmissionError +from tx3_sdk.errors import ( + PollingError, + ResolutionError, + SubmissionError, + Tx3Error, +) -class UnknownPartyError(ResolutionError): +class BuilderError(Tx3Error): + """Errors raised by `Tx3ClientBuilder.build()` and late-binding setters + on the built `Tx3Client`. Discriminate the group via `except BuilderError`, + or pick a specific subclass. + """ + + +class MissingTrpEndpointError(BuilderError): + """Raised when `Tx3ClientBuilder.build()` runs without a TRP endpoint.""" + + def __init__(self) -> None: + super().__init__("TRP endpoint not configured") + + +class UnknownPartyError(BuilderError): """Raised when a configured party is absent from protocol parties.""" def __init__(self, name: str) -> None: diff --git a/sdk/src/tx3_sdk/facade/profile.py b/sdk/src/tx3_sdk/facade/profile.py new file mode 100644 index 0000000..363cbe8 --- /dev/null +++ b/sdk/src/tx3_sdk/facade/profile.py @@ -0,0 +1,19 @@ +"""Profile value type used by `Tx3ClientBuilder`.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class Profile: + """Environment values and party-address overrides keyed by name. + + Produced either by deconstructing a loaded `Protocol` inside + `Tx3ClientBuilder.from_protocol`, or by feeding the per-profile JSON blob a + generated codegen client embeds through `Tx3ClientBuilder.from_parts`. + """ + + environment: dict[str, Any] = field(default_factory=dict) + parties: dict[str, str] = field(default_factory=dict) diff --git a/sdk/src/tx3_sdk/tii/protocol.py b/sdk/src/tx3_sdk/tii/protocol.py index 3f0dc32..6e1e7e2 100644 --- a/sdk/src/tx3_sdk/tii/protocol.py +++ b/sdk/src/tx3_sdk/tii/protocol.py @@ -102,6 +102,18 @@ def profiles(self) -> dict[str, dict[str, Any]]: """Returns the profile map from the protocol.""" return dict(self._spec["profiles"]) + def client(self) -> "Tx3ClientBuilder": # noqa: F821 — forward ref + """Returns a fresh `Tx3ClientBuilder` seeded with this protocol. + + Entry point for the dynamic flow — pairs with the codegen-flow + `Tx3ClientBuilder.from_parts(...)`. + """ + # Local import to avoid a load-time cycle: clientBuilder imports + # Protocol (type-only at runtime via `from_protocol` signature). + from tx3_sdk.facade.client_builder import Tx3ClientBuilder + + return Tx3ClientBuilder.from_protocol(self) + def invoke(self, tx_name: str, profile: str | None = None) -> Invocation: """Creates an invocation model for a known transaction name.""" tx = self._spec["transactions"].get(tx_name) diff --git a/sdk/tests/test_add_witness.py b/sdk/tests/test_add_witness.py index 140e0dc..68de057 100644 --- a/sdk/tests/test_add_witness.py +++ b/sdk/tests/test_add_witness.py @@ -54,15 +54,16 @@ def sign(self, _request) -> TxWitness: def _client(trp: _RecordingTrp, *, with_signer: bool = False) -> Tx3Client: protocol = Protocol.from_file("tests/fixtures/transfer.tii") - client = Tx3Client(protocol, trp) + builder = protocol.client()._trp_client(trp) if with_signer: registered = vkey_witness("11", "22") - client = client.with_party("sender", Party.signer(_StubSigner("addr_sender", registered))) + builder = builder.with_party("sender", Party.signer(_StubSigner("addr_sender", registered))) else: - client = client.with_party("sender", Party.address("addr_sender")) + builder = builder.with_party("sender", Party.address("addr_sender")) return ( - client.with_party("receiver", Party.address("addr_receiver")) + builder.with_party("receiver", Party.address("addr_receiver")) .with_party("middleman", Party.address("addr_middleman")) + .build() ) diff --git a/sdk/tests/test_errors.py b/sdk/tests/test_errors.py index 66dc22e..6163a68 100644 --- a/sdk/tests/test_errors.py +++ b/sdk/tests/test_errors.py @@ -1,5 +1,10 @@ -from tx3_sdk.errors import PollingError, ResolutionError, SignerError, TiiError, TrpError -from tx3_sdk.facade.errors import FinalizedTimeoutError, UnknownPartyError +from tx3_sdk.errors import PollingError, SignerError, TiiError, TrpError +from tx3_sdk.facade.errors import ( + BuilderError, + FinalizedTimeoutError, + MissingTrpEndpointError, + UnknownPartyError, +) from tx3_sdk.signer.errors import InvalidPrivateKeyError from tx3_sdk.tii.errors import UnknownTxError from tx3_sdk.trp.errors import HttpError @@ -9,5 +14,6 @@ def test_error_categories_are_discriminable() -> None: assert isinstance(UnknownTxError("transfer"), TiiError) assert isinstance(HttpError(500, "Error", "boom"), TrpError) assert isinstance(InvalidPrivateKeyError("bad key"), SignerError) - assert isinstance(UnknownPartyError("sender"), ResolutionError) + assert isinstance(UnknownPartyError("sender"), BuilderError) + assert isinstance(MissingTrpEndpointError(), BuilderError) assert isinstance(FinalizedTimeoutError("hash", 1, 0), PollingError) diff --git a/sdk/tests/test_facade.py b/sdk/tests/test_facade.py index 535b488..f4d338c 100644 --- a/sdk/tests/test_facade.py +++ b/sdk/tests/test_facade.py @@ -1,15 +1,24 @@ import pytest -from tx3_sdk import Party, PollConfig, Protocol, Tx3Client -from tx3_sdk.core.bytes import BytesEnvelope +from tx3_sdk import ( + Party, + PollConfig, + Profile, + Protocol, + Tx3Client, + Tx3ClientBuilder, +) +from tx3_sdk.core.bytes import BytesEnvelope, TirEnvelope from tx3_sdk.facade import ( + BuilderError, FinalizedFailedError, FinalizedTimeoutError, - MissingParamsError, + MissingTrpEndpointError, SubmitHashMismatchError, - UnknownArgError, UnknownPartyError, ) +from tx3_sdk.tii.errors import UnknownProfileError, UnknownTxError +from tx3_sdk.trp.client import ClientOptions from tx3_sdk.trp.spec import ( CheckStatusResponse, SubmitResponse, @@ -61,19 +70,96 @@ async def check_status(self, hashes): ) -@pytest.mark.asyncio -async def test_party_injection_and_full_chain() -> None: - protocol = Protocol.from_file("tests/fixtures/transfer.tii") - trp = MockTrpClient() - client = ( - Tx3Client(protocol, trp) +def _load_protocol() -> Protocol: + return Protocol.from_file("tests/fixtures/transfer.tii") + + +def _make_builder(trp: MockTrpClient) -> Tx3ClientBuilder: + return ( + _load_protocol() + .client() + ._trp_client(trp) .with_profile("preprod") + ) + + +def _build_client(trp: MockTrpClient) -> Tx3Client: + return ( + _make_builder(trp) .with_party("sender", Party.signer(MockSigner("addr_sender"))) .with_party("receiver", Party.address("addr_receiver")) .with_party("middleman", Party.address("addr_middleman")) + .build() + ) + + +def test_protocol_client_returns_builder() -> None: + assert isinstance(_load_protocol().client(), Tx3ClientBuilder) + + +def test_build_requires_trp_endpoint() -> None: + with pytest.raises(MissingTrpEndpointError): + _load_protocol().client().build() + + +def test_build_rejects_empty_endpoint() -> None: + with pytest.raises(MissingTrpEndpointError): + _load_protocol().client().trp_endpoint("").build() + + +def test_build_rejects_unknown_profile() -> None: + with pytest.raises(UnknownProfileError): + ( + _load_protocol() + .client() + .trp_endpoint("http://localhost:9999") + .with_profile("not-a-profile") + .build() + ) + + +def test_build_rejects_unknown_party() -> None: + with pytest.raises(UnknownPartyError): + ( + _make_builder(MockTrpClient()) + .with_party("stranger", Party.address("addr_stranger")) + .build() + ) + + +def test_with_party_unchecked_bypasses_validation() -> None: + client = ( + _make_builder(MockTrpClient()) + .with_party_unchecked("stranger", Party.address("addr_stranger")) + .build() ) + assert isinstance(client, Tx3Client) + + +def test_built_client_has_no_with_profile() -> None: + client = _build_client(MockTrpClient()) + assert not hasattr(client, "with_profile") + + +def test_built_client_with_party_validates() -> None: + client = _build_client(MockTrpClient()) + with pytest.raises(UnknownPartyError): + client.with_party("ghost", Party.address("addr_ghost")) + - submitted = await (await (await client.tx("transfer").arg("quantity", 100).resolve()).sign()).submit() +def test_missing_trp_endpoint_is_builder_error() -> None: + err = MissingTrpEndpointError() + assert isinstance(err, BuilderError) + + +@pytest.mark.asyncio +async def test_full_chain_party_injection() -> None: + trp = MockTrpClient() + client = _build_client(trp) + + submitted = await ( + await (await client.tx("transfer").arg("quantity", 100).resolve()).sign() + ).submit() status = await submitted.wait_for_confirmed(PollConfig.default()) assert trp.resolve_args["sender"] == "addr_sender" @@ -82,46 +168,59 @@ async def test_party_injection_and_full_chain() -> None: @pytest.mark.asyncio -async def test_unknown_party_raises() -> None: - protocol = Protocol.from_file("tests/fixtures/transfer.tii") +async def test_with_env_value_overrides_profile_env() -> None: trp = MockTrpClient() - client = Tx3Client(protocol, trp).with_party("ghost", Party.address("addr")) + client = ( + _make_builder(trp) + .with_party_unchecked("sender", Party.address("addr_sender")) + .with_party_unchecked("receiver", Party.address("addr_receiver")) + .with_party_unchecked("middleman", Party.address("addr_middleman")) + .with_env_value("tax", 999) + .build() + ) - with pytest.raises(UnknownPartyError): - await client.tx("transfer").arg("quantity", 1).resolve() + await client.tx("transfer").arg("quantity", 100).resolve() + assert trp.resolve_args["tax"] == 999 @pytest.mark.asyncio -async def test_unknown_arg_raises() -> None: - protocol = Protocol.from_file("tests/fixtures/transfer.tii") - trp = MockTrpClient() - client = Tx3Client(protocol, trp) - - with pytest.raises(UnknownArgError): - await client.tx("transfer").arg("not_a_param", 1).resolve() +async def test_tx_unknown_raises() -> None: + client = _build_client(MockTrpClient()) + with pytest.raises(UnknownTxError): + client.tx("not-a-tx") @pytest.mark.asyncio -async def test_missing_params_raises() -> None: - protocol = Protocol.from_file("tests/fixtures/transfer.tii") +async def test_from_parts_codegen_flow() -> None: trp = MockTrpClient() - client = Tx3Client(protocol, trp) + protocol = _load_protocol() + transactions: dict[str, TirEnvelope] = { + name: TirEnvelope( + content=str(tx["tir"]["content"]), + encoding=str(tx["tir"]["encoding"]), + version=str(tx["tir"]["version"]), + ) + for name, tx in protocol.transactions.items() + } + + client = ( + Tx3ClientBuilder.from_parts(transactions, {}, []) + ._trp_client(trp) + .with_party_unchecked("sender", Party.address("addr_sender")) + .with_party_unchecked("receiver", Party.address("addr_receiver")) + .with_party_unchecked("middleman", Party.address("addr_middleman")) + .build() + ) - with pytest.raises(MissingParamsError): - await client.tx("transfer").resolve() + resolved = await client.tx("transfer").arg("quantity", 100).resolve() + assert resolved.hash == "abc" @pytest.mark.asyncio async def test_submit_hash_mismatch_raises() -> None: - protocol = Protocol.from_file("tests/fixtures/transfer.tii") trp = MockTrpClient() trp.submit_hash = "other" - client = ( - Tx3Client(protocol, trp) - .with_party("sender", Party.signer(MockSigner("addr_sender"))) - .with_party("receiver", Party.address("addr_receiver")) - .with_party("middleman", Party.address("addr_middleman")) - ) + client = _build_client(trp) resolved = await client.tx("transfer").arg("quantity", 1).resolve() signed = await resolved.sign() @@ -131,33 +230,34 @@ async def test_submit_hash_mismatch_raises() -> None: @pytest.mark.asyncio async def test_wait_for_terminal_failure_raises() -> None: - protocol = Protocol.from_file("tests/fixtures/transfer.tii") trp = MockTrpClient() trp.status_stage = TxStage.DROPPED - client = ( - Tx3Client(protocol, trp) - .with_party("sender", Party.signer(MockSigner("addr_sender"))) - .with_party("receiver", Party.address("addr_receiver")) - .with_party("middleman", Party.address("addr_middleman")) - ) + client = _build_client(trp) - submitted = await (await (await client.tx("transfer").arg("quantity", 1).resolve()).sign()).submit() + submitted = await ( + await (await client.tx("transfer").arg("quantity", 1).resolve()).sign() + ).submit() with pytest.raises(FinalizedFailedError): await submitted.wait_for_confirmed(PollConfig(attempts=1, delay_seconds=0)) @pytest.mark.asyncio async def test_wait_timeout_raises() -> None: - protocol = Protocol.from_file("tests/fixtures/transfer.tii") trp = MockTrpClient() trp.status_stage = TxStage.PENDING - client = ( - Tx3Client(protocol, trp) - .with_party("sender", Party.signer(MockSigner("addr_sender"))) - .with_party("receiver", Party.address("addr_receiver")) - .with_party("middleman", Party.address("addr_middleman")) - ) + client = _build_client(trp) - submitted = await (await (await client.tx("transfer").arg("quantity", 1).resolve()).sign()).submit() + submitted = await ( + await (await client.tx("transfer").arg("quantity", 1).resolve()).sign() + ).submit() with pytest.raises(FinalizedTimeoutError): await submitted.wait_for_confirmed(PollConfig(attempts=1, delay_seconds=0)) + + +def test_top_level_reexports_include_builder() -> None: + import tx3_sdk + + assert tx3_sdk.Tx3ClientBuilder is Tx3ClientBuilder + assert tx3_sdk.Profile is Profile + assert tx3_sdk.MissingTrpEndpointError is MissingTrpEndpointError + assert tx3_sdk.BuilderError is BuilderError From 0d78083d7929b80666fbc3fc318e03c64182af84 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 24 May 2026 12:59:59 -0300 Subject: [PATCH 2/3] docs(readme): refresh quick start + concepts for the unified builder Updates the quick-start example to use protocol.client()...build() instead of Tx3Client(protocol, trp). Expands the concepts table with Tx3ClientBuilder, Profile, and BuilderError. Adds a from_parts walkthrough under "Skipping the runtime .tii (codegen flow)". sdk/README.md is a symlink to the root README, so both stay in sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 51 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 672d415..fc005f9 100644 --- a/README.md +++ b/README.md @@ -27,37 +27,36 @@ pip install tx3-sdk ```python import asyncio -from tx3_sdk import CardanoSigner, Party, PollConfig, Protocol, TrpClient, Tx3Client +from tx3_sdk import CardanoSigner, Party, PollConfig, Protocol async def main() -> None: # 1) Load a compiled .tii protocol protocol = Protocol.from_file("examples/transfer.tii") - # 2) Create a low-level TRP client - trp = TrpClient(endpoint="https://preprod.trp.tx3.dev") - - # 3) Configure signer and parties + # 2) Build a client: configure TRP, profile, and parties on the builder sender_signer = CardanoSigner.from_mnemonic( address="addr_test1qz...", phrase="word1 word2 ... word24", ) client = ( - Tx3Client(protocol, trp) + protocol.client() + .trp_endpoint("https://preprod.trp.tx3.dev") .with_profile("preprod") .with_party("sender", Party.signer(sender_signer)) .with_party("receiver", Party.address("addr_test1qz...")) + .build() ) - # 4) Build, resolve, sign, submit + # 3) Build, resolve, sign, submit submitted = await ( await ( await client.tx("transfer").arg("quantity", 10_000_000).resolve() ).sign() ).submit() - # 5) Wait for confirmation + # 4) Wait for confirmation status = await submitted.wait_for_confirmed(PollConfig.default()) print(f"Confirmed at stage: {status.stage}") @@ -65,14 +64,24 @@ async def main() -> None: asyncio.run(main()) ``` +All fallible validation — TRP endpoint present, profile declared, every bound +party declared — happens inside `build()`, which raises `MissingTrpEndpointError`, +`UnknownProfileError`, or `UnknownPartyError` (all under the `BuilderError` family, +rooted at `Tx3Error`). Optional setters never raise, so chains stay fluent. Profile +selection is **builder-only**: there is no profile-switching method on the built +client. Switching profiles requires a new builder. + ## Concepts | SDK Type | Glossary Term | Description | |---|---|---| -| `Protocol` | TII / Protocol | Loaded `.tii` with transactions, parties, and profiles | -| `Tx3Client` | Facade | High-level client holding protocol + TRP + party bindings | -| `TxBuilder` | Invocation builder | Collects args and resolves transactions | +| `Protocol` | TII / Protocol | Loaded `.tii` with transactions, parties, and profiles. `protocol.client()` returns a fresh `Tx3ClientBuilder` | +| `Tx3ClientBuilder` | Client builder | Fluent builder seeded by `Protocol.client()` or `Tx3ClientBuilder.from_parts(...)`; absorbs all fallible validation in `build()` | +| `Tx3Client` | Facade | Output of `Tx3ClientBuilder.build()` — owns the deconstructed protocol parts, TRP client, profile, and party bindings | +| `TxBuilder` | Invocation builder | Source-agnostic; collects args and resolves transactions | | `Party` | Party | `Party.address(...)` or `Party.signer(...)` | +| `Profile` | Profile | `{ environment, parties }` value baked into the client; embedded by codegen plugins, decomposed from `Protocol` by `from_protocol` | +| `MissingTrpEndpointError` / `UnknownPartyError` | Builder errors | Raised by `build()`; subclass of `BuilderError`, rooted at `Tx3Error` | | `Signer` | Signer | Protocol producing a `TxWitness` for a `SignRequest` | | `SignRequest` | SignRequest | Input passed to `Signer.sign`: `tx_hash_hex` + `tx_cbor_hex` | | `CardanoSigner` | Cardano Signer | BIP32-Ed25519 signer at `m/1852'/1815'/0'/0/0` | @@ -84,6 +93,26 @@ asyncio.run(main()) ## Advanced usage +### Skipping the runtime `.tii` (codegen flow) + +If you've run `trix codegen` to generate typed bindings, your generated `Client` +embeds the per-transaction TIR envelopes and per-profile data at codegen time — +no `.tii` artifact at runtime. Under the hood it seeds the same builder via +`Tx3ClientBuilder.from_parts(transactions, profiles, known_parties)` and routes +typed per-party setters through `with_party_unchecked`. You can also call +`from_parts` directly from hand-written code: + +```python +from tx3_sdk import ClientOptions, Party, Tx3ClientBuilder + +client = ( + Tx3ClientBuilder.from_parts(transactions, profiles, ["sender", "receiver"]) + .trp(ClientOptions(endpoint="http://localhost:8000")) + .with_party_unchecked("sender", Party.signer(signer)) + .build() +) +``` + ### Low-level TRP client ```python From 3c385f7ca73f1875151e8ca346d14ad410f6ea53 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 24 May 2026 13:05:35 -0300 Subject: [PATCH 3/3] test(e2e): migrate happy-path to Tx3ClientBuilder The e2e harness still constructed Tx3Client directly via the removed `Tx3Client(protocol, trp).with_profile(...)` shape. Routes through `protocol.client().trp_endpoint(...).with_profile(...).with_header(...).build()` instead; `dmtr-api-key` is now attached via the builder's `with_header` setter rather than a separately-constructed TrpClient (the builder now owns TRP client construction and lifetime). Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/tests/e2e/test_happy_path.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/sdk/tests/e2e/test_happy_path.py b/sdk/tests/e2e/test_happy_path.py index 03d22a5..677f57f 100644 --- a/sdk/tests/e2e/test_happy_path.py +++ b/sdk/tests/e2e/test_happy_path.py @@ -2,7 +2,7 @@ import pytest -from tx3_sdk import CardanoSigner, Party, PollConfig, Protocol, TrpClient, Tx3Client +from tx3_sdk import CardanoSigner, Party, PollConfig, Protocol def _require_env(name: str) -> str: @@ -21,19 +21,25 @@ async def test_e2e_happy_path() -> None: party_a_mnemonic = _require_env("TEST_PARTY_A_MNEMONIC") protocol = Protocol.from_file("tests/fixtures/transfer.tii") - headers = {"dmtr-api-key": api_key} if api_key else {} - trp = TrpClient(endpoint=endpoint, headers=headers) signer = CardanoSigner.from_mnemonic(address=party_a_address, phrase=party_a_mnemonic) party_b_address = os.getenv("TEST_PARTY_B_ADDRESS", party_a_address) _party_b_mnemonic = os.getenv("TEST_PARTY_B_MNEMONIC", party_a_mnemonic) - client = ( - Tx3Client(protocol, trp) + builder = ( + protocol.client() + .trp_endpoint(endpoint) .with_profile("preprod") - .with_party("sender", Party.signer(signer)) + ) + + if api_key: + builder = builder.with_header("dmtr-api-key", api_key) + + client = ( + builder.with_party("sender", Party.signer(signer)) .with_party("receiver", Party.address(party_b_address)) .with_party("middleman", Party.address(party_b_address)) + .build() ) submitted = await ( @@ -44,5 +50,3 @@ async def test_e2e_happy_path() -> None: status = await submitted.wait_for_confirmed(PollConfig.default()) assert status.stage.value in {"confirmed", "finalized"} - - await trp.close()