From 071466b65df54a039e791f6931883118942fe159 Mon Sep 17 00:00:00 2001 From: Santiago Date: Fri, 25 Sep 2026 17:02:05 -0300 Subject: [PATCH 1/2] feat: add client facade resolve flow --- README.md | 24 ++ src/main/java/land/tx3/sdk/Party.java | 36 +++ src/main/java/land/tx3/sdk/Profile.java | 11 +- src/main/java/land/tx3/sdk/Protocol.java | 10 + src/main/java/land/tx3/sdk/Tx3Client.java | 110 +++++++ .../java/land/tx3/sdk/Tx3ClientBuilder.java | 242 ++++++++++++++ src/main/java/land/tx3/sdk/TxBuilder.java | 64 +++- src/test/java/land/tx3/sdk/FacadeTest.java | 303 ++++++++++++++++++ 8 files changed, 797 insertions(+), 3 deletions(-) create mode 100644 src/main/java/land/tx3/sdk/Party.java create mode 100644 src/main/java/land/tx3/sdk/Tx3Client.java create mode 100644 src/main/java/land/tx3/sdk/Tx3ClientBuilder.java create mode 100644 src/test/java/land/tx3/sdk/FacadeTest.java diff --git a/README.md b/README.md index 9ba00ef..99e89ca 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,30 @@ schema-directed encoding. Shape, range, and JSON-encoding failures throw `ArgumentEncodingException`, whose `kind()`, `path()`, and `expected()` fields contain structural context without including rejected values. +Build the high-level facade from a loaded protocol, select an optional profile, bind parties, and +resolve through the same type-directed argument path: + +```java +var client = protocol.client() + .trpEndpoint(java.net.URI.create("http://localhost:8164")) + .withProfile("preprod") + .withHeader("Authorization", "Bearer ...") + .withParty("sender", land.tx3.sdk.Party.address(address)) + .withEnvValue("network", "preview") + .build(); + +var resolved = client.tx("transfer") + .arg("quantity", 10_000_000) + .resolve(); +``` + +`build()` reports missing connection settings and unknown profile or party names as +`MissingTrpEndpointException`, `UnknownProfileException`, and `UnknownPartyException`. +`Tx3Client.tx()` reports `UnknownTransactionException`, while a missing required argument at +resolve time reports `ResolutionException`. Generated clients seed the same builder with +`Tx3ClientBuilder.fromParts(...)`, bind statically known parties with `withPartyUnchecked`, and +construct canonical values with `argTagged`; that path retains no TII or parameter schema. + ## Development These are the canonical foundation checks: diff --git a/src/main/java/land/tx3/sdk/Party.java b/src/main/java/land/tx3/sdk/Party.java new file mode 100644 index 0000000..b5d10ba --- /dev/null +++ b/src/main/java/land/tx3/sdk/Party.java @@ -0,0 +1,36 @@ +package land.tx3.sdk; + +import java.util.Objects; +import java.util.Optional; + +/** A named protocol participant backed by a read-only address or a signer. */ +public final class Party { + private final Address address; + private final Signer signer; + + private Party(Address address, Signer signer) { + this.address = Objects.requireNonNull(address, "address"); + this.signer = signer; + } + + /** Creates a read-only party from an address. */ + public static Party address(Address address) { + return new Party(address, null); + } + + /** Creates a signing party, reading its address from the signer. */ + public static Party signer(Signer signer) { + Objects.requireNonNull(signer, "signer"); + return new Party(Objects.requireNonNull(signer.address(), "signer address"), signer); + } + + /** Returns the address injected into resolve arguments. */ + public Address address() { + return address; + } + + /** Returns the signer when this is a signing party. */ + public Optional signer() { + return Optional.ofNullable(signer); + } +} diff --git a/src/main/java/land/tx3/sdk/Profile.java b/src/main/java/land/tx3/sdk/Profile.java index 8251dbf..bcf5718 100644 --- a/src/main/java/land/tx3/sdk/Profile.java +++ b/src/main/java/land/tx3/sdk/Profile.java @@ -10,7 +10,13 @@ public final class Profile { private final Map environment; private final Map parties; - Profile(Map environment, Map parties) { + /** + * Creates an immutable profile from environment values and party addresses. + * + *

This constructor is public so generated clients can embed deconstructed profile data for + * {@link Tx3ClientBuilder#fromParts} without retaining a TII document. + */ + public Profile(Map environment, Map parties) { this.environment = immutableNodes(environment); this.parties = immutableNodes(parties); } @@ -30,6 +36,9 @@ private static Map immutableNodes(Map source } private static Map copyNodes(Map source) { + if (source == null) { + throw new ValidationException("profile", "profile maps must not be null"); + } var result = new LinkedHashMap(); source.forEach((key, value) -> result.put(key, value.deepCopy())); return result; diff --git a/src/main/java/land/tx3/sdk/Protocol.java b/src/main/java/land/tx3/sdk/Protocol.java index 201ad17..fca9879 100644 --- a/src/main/java/land/tx3/sdk/Protocol.java +++ b/src/main/java/land/tx3/sdk/Protocol.java @@ -139,6 +139,16 @@ public JsonNode json() { return json.deepCopy(); } + /** + * Starts configuring the high-level client for this protocol. + * + *

The returned builder owns deconstructed copies of the protocol state; a built client does + * not retain this {@code Protocol} instance. + */ + public Tx3ClientBuilder client() { + return Tx3ClientBuilder.fromProtocol(this); + } + private static ProtocolInfo readInfo(JsonNode value, String sourceName) { return new ProtocolInfo( requiredText(value, "name", sourceName), diff --git a/src/main/java/land/tx3/sdk/Tx3Client.java b/src/main/java/land/tx3/sdk/Tx3Client.java new file mode 100644 index 0000000..fbfef23 --- /dev/null +++ b/src/main/java/land/tx3/sdk/Tx3Client.java @@ -0,0 +1,110 @@ +package land.tx3.sdk; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Immutable high-level client for resolving transactions from a deconstructed Tx3 protocol. + * + *

Instances are created only by {@link Tx3ClientBuilder#build()}. Profile selection is fixed at + * build time; late party binding returns a new client. + */ +public final class Tx3Client { + private final Map transactions; + private final Map> transactionParameters; + private final Map> requiredParameters; + private final Set knownParties; + private final TrpClient trp; + private final Map parties; + private final Map environment; + + Tx3Client( + Map transactions, + Map> transactionParameters, + Map> requiredParameters, + Set knownParties, + TrpClient trp, + Map parties, + Map environment) { + this.transactions = Collections.unmodifiableMap(new LinkedHashMap<>(transactions)); + this.transactionParameters = transactionParameters; + this.requiredParameters = requiredParameters; + this.knownParties = Collections.unmodifiableSet(new LinkedHashSet<>(knownParties)); + this.trp = Objects.requireNonNull(trp, "trp"); + this.parties = Collections.unmodifiableMap(new LinkedHashMap<>(parties)); + this.environment = Collections.unmodifiableMap(new LinkedHashMap<>(environment)); + } + + /** + * Starts a declared transaction invocation. + * + * @throws UnknownTransactionException if the name is not declared + */ + public TxBuilder tx(String name) { + Objects.requireNonNull(name, "name"); + var found = + transactions.entrySet().stream() + .filter(entry -> entry.getKey().equalsIgnoreCase(name)) + .findFirst() + .orElseThrow(() -> new UnknownTransactionException(name)); + var canonicalName = found.getKey(); + return new TxBuilder( + canonicalName, + found.getValue(), + trp, + transactionParameters.getOrDefault(canonicalName, Map.of()), + requiredParameters.getOrDefault(canonicalName, Set.of()), + environment, + parties); + } + + /** + * Late-binds a declared party and returns a new client. + * + * @throws UnknownPartyException if the name is not declared + */ + public Tx3Client withParty(String name, Party party) { + var normalized = normalize(name); + if (!knownParties.contains(normalized)) throw new UnknownPartyException(normalized); + return withPartyValue(normalized, party); + } + + /** Late-binds several declared parties in iteration order; later writes win. */ + public Tx3Client withParties(Map values) { + var updated = this; + for (var entry : Objects.requireNonNull(values, "values").entrySet()) { + updated = updated.withParty(entry.getKey(), entry.getValue()); + } + return updated; + } + + /** + * Late-binds a party without declared-name validation. + * + *

This is intended for generated wrappers with names fixed by generated methods. + */ + public Tx3Client withPartyUnchecked(String name, Party party) { + return withPartyValue(normalize(name), party); + } + + private Tx3Client withPartyValue(String name, Party party) { + var updated = new LinkedHashMap<>(parties); + updated.put(name, Objects.requireNonNull(party, "party")); + return new Tx3Client( + transactions, + transactionParameters, + requiredParameters, + knownParties, + trp, + updated, + environment); + } + + private static String normalize(String name) { + return Objects.requireNonNull(name, "name").toLowerCase(java.util.Locale.ROOT); + } +} diff --git a/src/main/java/land/tx3/sdk/Tx3ClientBuilder.java b/src/main/java/land/tx3/sdk/Tx3ClientBuilder.java new file mode 100644 index 0000000..a26c0e0 --- /dev/null +++ b/src/main/java/land/tx3/sdk/Tx3ClientBuilder.java @@ -0,0 +1,242 @@ +package land.tx3.sdk; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Fluent builder for the single high-level {@link Tx3Client} facade. + * + *

Use {@link Protocol#client()} for dynamically loaded TII or {@link #fromParts} for generated + * clients. Name validation is intentionally deferred until {@link #build()}. + */ +public final class Tx3ClientBuilder { + private static final ObjectMapper JSON = new ObjectMapper(); + + private final Map transactions; + private final Map> transactionParameters; + private final Map> requiredParameters; + private final Map profiles; + private final Set knownParties; + private final Map parties = new LinkedHashMap<>(); + private final Set uncheckedPartyNames = new LinkedHashSet<>(); + private final Map headers = new LinkedHashMap<>(); + private final Map environmentOverrides = new LinkedHashMap<>(); + + private ClientOptions trpOptions; + private String profile; + private Transport transport; + + private Tx3ClientBuilder( + Map transactions, + Map> transactionParameters, + Map> requiredParameters, + Map profiles, + Set knownParties) { + this.transactions = immutableMap(transactions, "transactions"); + this.transactionParameters = immutableNestedMap(transactionParameters); + this.requiredParameters = immutableNestedSet(requiredParameters); + this.profiles = immutableMap(profiles, "profiles"); + this.knownParties = Collections.unmodifiableSet(new LinkedHashSet<>(knownParties)); + } + + /** + * Seeds a builder with code-generated runtime fragments. + * + *

Generated clients supply already-tagged values through {@link TxBuilder#argTagged}; this + * path intentionally carries no raw TII, parameter schemas, or {@link ParamType} models. + */ + public static Tx3ClientBuilder fromParts( + Map transactions, + Map profiles, + Iterable knownParties) { + Objects.requireNonNull(knownParties, "knownParties"); + var normalizedParties = new LinkedHashSet(); + knownParties.forEach(name -> normalizedParties.add(normalize(name))); + return new Tx3ClientBuilder(transactions, Map.of(), Map.of(), profiles, normalizedParties); + } + + static Tx3ClientBuilder fromProtocol(Protocol protocol) { + var tirs = new LinkedHashMap(); + var parameters = new LinkedHashMap>(); + var required = new LinkedHashMap>(); + protocol + .transactions() + .forEach( + (name, transaction) -> { + tirs.put(name, transaction.tir()); + parameters.put(name, transaction.parameters()); + var requiredForTransaction = new LinkedHashSet(); + var requiredNode = transaction.paramsSchema().path("required"); + if (requiredNode.isArray()) { + requiredNode.forEach( + value -> { + if (value.isTextual()) requiredForTransaction.add(value.textValue()); + }); + } + required.put(name, requiredForTransaction); + }); + var partyNames = new LinkedHashSet(); + protocol.parties().keySet().forEach(name -> partyNames.add(normalize(name))); + return new Tx3ClientBuilder(tirs, parameters, required, protocol.profiles(), partyNames); + } + + /** Sets complete TRP connection options. */ + public Tx3ClientBuilder trp(ClientOptions options) { + trpOptions = Objects.requireNonNull(options, "options"); + return this; + } + + /** Sets the TRP endpoint with default connection options. */ + public Tx3ClientBuilder trpEndpoint(URI endpoint) { + trpOptions = ClientOptions.forEndpoint(endpoint); + return this; + } + + /** Adds or replaces one request header; the last write wins. */ + public Tx3ClientBuilder withHeader(String key, String value) { + headers.put(Objects.requireNonNull(key, "key"), Objects.requireNonNull(value, "value")); + return this; + } + + /** Selects a protocol profile. The name is validated by {@link #build()}. */ + public Tx3ClientBuilder withProfile(String name) { + profile = name; + return this; + } + + /** Binds a declared party. The name is validated by {@link #build()}. */ + public Tx3ClientBuilder withParty(String name, Party party) { + var normalized = normalize(name); + parties.put(normalized, Objects.requireNonNull(party, "party")); + uncheckedPartyNames.remove(normalized); + return this; + } + + /** Binds several declared parties in iteration order; later writes win. */ + public Tx3ClientBuilder withParties(Map values) { + Objects.requireNonNull(values, "values").forEach(this::withParty); + return this; + } + + /** + * Binds a party without a declared-name lookup. + * + *

This is the generated-client seam; hand-written dynamic clients should use {@link + * #withParty}. + */ + public Tx3ClientBuilder withPartyUnchecked(String name, Party party) { + var normalized = normalize(name); + parties.put(normalized, Objects.requireNonNull(party, "party")); + uncheckedPartyNames.add(normalized); + return this; + } + + /** Sets one environment override; explicit values win over the selected profile. */ + public Tx3ClientBuilder withEnvValue(String key, Object value) { + environmentOverrides.put(Objects.requireNonNull(key, "key"), value); + return this; + } + + Tx3ClientBuilder transport(Transport value) { + transport = Objects.requireNonNull(value, "transport"); + return this; + } + + /** + * Validates configuration and creates the immutable high-level client. + * + * @throws MissingTrpEndpointException if no TRP options were supplied + * @throws UnknownProfileException if the selected profile is undeclared + * @throws UnknownPartyException if a checked party name is undeclared + */ + public Tx3Client build() { + if (trpOptions == null || trpOptions.endpoint() == null) { + throw new MissingTrpEndpointException(); + } + var selectedProfile = profile == null ? null : profiles.get(profile); + if (profile != null && selectedProfile == null) { + throw new UnknownProfileException(profile); + } + for (var name : parties.keySet()) { + if (!uncheckedPartyNames.contains(name) && !knownParties.contains(name)) { + throw new UnknownPartyException(name); + } + } + + var mergedHeaders = new LinkedHashMap<>(trpOptions.headers()); + mergedHeaders.putAll(headers); + var options = new ClientOptions(trpOptions.endpoint(), mergedHeaders, trpOptions.timeout()); + var trpClient = transport == null ? new TrpClient(options) : new TrpClient(options, transport); + + var boundParties = new LinkedHashMap(); + if (selectedProfile != null) { + selectedProfile + .parties() + .forEach( + (name, value) -> + boundParties.put(normalize(name), Party.address(profileAddress(name, value)))); + } + boundParties.putAll(parties); + + var environment = new LinkedHashMap(); + if (selectedProfile != null) { + selectedProfile + .environment() + .forEach((name, value) -> environment.put(name, jsonValue(value))); + } + environment.putAll(environmentOverrides); + + return new Tx3Client( + transactions, + transactionParameters, + requiredParameters, + knownParties, + trpClient, + boundParties, + environment); + } + + private static Address profileAddress(String name, JsonNode value) { + if (!value.isTextual()) { + throw new ValidationException( + "profile.parties." + name, "profile party address must be a string"); + } + return new Address(value.textValue()); + } + + private static Object jsonValue(JsonNode value) { + return JSON.convertValue(value, Object.class); + } + + private static String normalize(String name) { + return Objects.requireNonNull(name, "name").toLowerCase(java.util.Locale.ROOT); + } + + private static Map immutableMap(Map source, String name) { + return Collections.unmodifiableMap(new LinkedHashMap<>(Objects.requireNonNull(source, name))); + } + + private static Map> immutableNestedMap( + Map> source) { + var result = new LinkedHashMap>(); + source.forEach( + (name, values) -> + result.put(name, Collections.unmodifiableMap(new LinkedHashMap<>(values)))); + return Collections.unmodifiableMap(result); + } + + private static Map> immutableNestedSet(Map> source) { + var result = new LinkedHashMap>(); + source.forEach( + (name, values) -> + result.put(name, Collections.unmodifiableSet(new LinkedHashSet<>(values)))); + return Collections.unmodifiableMap(result); + } +} diff --git a/src/main/java/land/tx3/sdk/TxBuilder.java b/src/main/java/land/tx3/sdk/TxBuilder.java index 71b81d6..4f4da23 100644 --- a/src/main/java/land/tx3/sdk/TxBuilder.java +++ b/src/main/java/land/tx3/sdk/TxBuilder.java @@ -2,20 +2,46 @@ import java.util.Collections; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; -/** Collects native or already-tagged arguments for one transaction invocation. */ +/** Collects and resolves one transaction invocation. */ public final class TxBuilder { + private final String transaction; + private final TirEnvelope tir; + private final TrpClient trp; private final Map parameters; + private final Set requiredParameters; + private final Map environment; + private final Map parties; private final LinkedHashMap taggedArguments = new LinkedHashMap<>(); TxBuilder() { - this(Map.of()); + this("", null, null, Map.of(), Set.of(), Map.of(), Map.of()); } TxBuilder(Map parameters) { + this("", null, null, parameters, Set.of(), Map.of(), Map.of()); + } + + TxBuilder( + String transaction, + TirEnvelope tir, + TrpClient trp, + Map parameters, + Set requiredParameters, + Map environment, + Map parties) { + this.transaction = Objects.requireNonNull(transaction, "transaction"); + this.tir = tir; + this.trp = trp; this.parameters = Collections.unmodifiableMap(new LinkedHashMap<>(parameters)); + this.requiredParameters = Collections.unmodifiableSet(new LinkedHashSet<>(requiredParameters)); + this.environment = Collections.unmodifiableMap(new LinkedHashMap<>(environment)); + this.parties = Collections.unmodifiableMap(new LinkedHashMap<>(parties)); } /** @@ -58,4 +84,38 @@ public TxBuilder argTagged(String name, ArgValue value) { Map taggedArguments() { return Collections.unmodifiableMap(new LinkedHashMap<>(taggedArguments)); } + + /** + * Resolves this invocation through the configured TRP client. + * + *

Profile environment and party addresses are applied first. Explicit transaction arguments + * win over injected party addresses, and every dynamic argument follows its resolved parameter + * type. Values supplied through {@link #argTagged} are sent without another encoding pass. + * + * @throws ResolutionException if a required transaction parameter has no explicit or injected + * value + */ + public CompletableFuture resolve() { + if (trp == null || tir == null) { + throw new ValidationException("txBuilder", "this transaction builder cannot resolve"); + } + + var merged = new LinkedHashMap(); + parties.forEach((name, party) -> merged.put(normalize(name), party.address().value())); + taggedArguments.forEach((name, value) -> merged.put(normalize(name), value)); + + for (var required : requiredParameters) { + if (!merged.containsKey(normalize(required))) { + throw new ResolutionException( + transaction, required, "missing required transaction argument: " + required); + } + } + + var env = environment.isEmpty() ? null : new LinkedHashMap(environment); + return trp.resolve(new ResolveParams(tir, merged, env)); + } + + private static String normalize(String name) { + return name.toLowerCase(java.util.Locale.ROOT); + } } diff --git a/src/test/java/land/tx3/sdk/FacadeTest.java b/src/test/java/land/tx3/sdk/FacadeTest.java new file mode 100644 index 0000000..07714d1 --- /dev/null +++ b/src/test/java/land/tx3/sdk/FacadeTest.java @@ -0,0 +1,303 @@ +package land.tx3.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +class FacadeTest { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final URI ENDPOINT = URI.create("https://trp.example/rpc"); + + @Test + void builderDefersNamedValidationUntilBuild() { + var protocol = Protocol.fromJson(protocolJson()); + + assertInstanceOf(Tx3ClientBuilder.class, protocol.client()); + assertThrows(MissingTrpEndpointException.class, () -> protocol.client().build()); + assertThrows( + UnknownProfileException.class, + () -> protocol.client().trpEndpoint(ENDPOINT).withProfile("missing").build()); + assertThrows( + UnknownPartyException.class, + () -> + protocol + .client() + .trpEndpoint(ENDPOINT) + .withParty("missing", Party.address(new Address("addr_missing"))) + .build()); + + assertThrows( + UnknownPartyException.class, + () -> + protocol + .client() + .trpEndpoint(ENDPOINT) + .withPartyUnchecked("missing", Party.address(new Address("addr_first"))) + .withParty("missing", Party.address(new Address("addr_last"))) + .build()); + } + + @Test + void dynamicAndPartsClientsProduceEquivalentResolveRequests() throws Exception { + var protocol = Protocol.fromJson(protocolJson()); + var dynamicTransport = new CapturingTransport(); + var generatedTransport = new CapturingTransport(); + + var dynamic = + protocol + .client() + .trpEndpoint(ENDPOINT) + .withHeader("x-api-key", "secret") + .withProfile("local") + .withParty("sender", Party.address(new Address("addr_explicit"))) + .withEnvValue("fee", 9) + .transport(dynamicTransport) + .build(); + + dynamic + .tx("init") + .arg("participants", List.of(new byte[] {1, 2}, new byte[] {3, 4})) + .arg("sender", new Address("addr_argument")) + .resolve() + .join(); + + var transaction = protocol.transactions().get("init"); + var profiles = Map.of("local", protocol.profiles().get("local")); + var generated = + Tx3ClientBuilder.fromParts(Map.of("init", transaction.tir()), profiles, Set.of()) + .trpEndpoint(ENDPOINT) + .withHeader("x-api-key", "secret") + .withProfile("local") + .withPartyUnchecked("sender", Party.address(new Address("addr_explicit"))) + .withEnvValue("fee", 9) + .transport(generatedTransport) + .build(); + + generated + .tx("init") + .argTagged( + "participants", + ArgValue.list( + List.of(ArgValue.bytes(new byte[] {1, 2}), ArgValue.bytes(new byte[] {3, 4})))) + .argTagged("sender", ArgValue.address(new Address("addr_argument"))) + .resolve() + .join(); + + var dynamicParams = dynamicTransport.params(); + var generatedParams = generatedTransport.params(); + assertEquals(dynamicParams, generatedParams); + assertEquals(9, dynamicParams.path("env").path("fee").intValue()); + assertEquals( + "addr_argument", dynamicParams.path("args").path("sender").path("address").textValue()); + assertEquals( + "0x0102", + dynamicParams + .path("args") + .path("participants") + .path("list") + .get(0) + .path("bytes") + .textValue()); + assertEquals("secret", dynamicTransport.request.headers().get("x-api-key")); + } + + @Test + void recordOrderAndVariantConstructorReachCapturedRequest() throws Exception { + var transport = new CapturingTransport(); + var client = + Protocol.fromJson(complexProtocolJson()) + .client() + .trpEndpoint(ENDPOINT) + .transport(transport) + .build(); + + client + .tx("complex") + .arg("asset", Map.of("name", new byte[] {0, 17}, "policy", new byte[] {-86, -69})) + .arg("side", Map.of("Sell", Map.of("price", 5))) + .resolve() + .join(); + + var args = transport.params().path("args"); + assertEquals( + "0xaabb", + args.path("asset").path("struct").path("fields").get(0).path("bytes").textValue()); + assertEquals( + "0x0011", + args.path("asset").path("struct").path("fields").get(1).path("bytes").textValue()); + assertEquals(1, args.path("side").path("struct").path("constructor").intValue()); + assertEquals(5, args.path("side").path("struct").path("fields").get(0).path("int").intValue()); + } + + @Test + void explicitArgsOverridePartiesAndLateBindingIsValidated() throws Exception { + var transport = new CapturingTransport(); + var client = + Protocol.fromJson(protocolJson()) + .client() + .trpEndpoint(ENDPOINT) + .withParty("sender", Party.address(new Address("addr_builder"))) + .transport(transport) + .build(); + + assertThrows( + UnknownPartyException.class, + () -> client.withParty("missing", Party.address(new Address("addr_missing")))); + assertNotNull( + client.withPartyUnchecked("generated", Party.address(new Address("addr_generated")))); + assertThrows(UnknownTransactionException.class, () -> client.tx("missing")); + + client + .tx("init") + .arg("participants", List.of(new byte[] {1})) + .arg("sender", new Address("addr_argument")) + .resolve() + .join(); + assertEquals( + "addr_argument", + transport.params().path("args").path("sender").path("address").textValue()); + } + + @Test + void resolveRejectsMissingRequiredArgumentsBeforeTransport() { + var transport = new CapturingTransport(); + var client = + Protocol.fromJson(protocolJson()) + .client() + .trpEndpoint(ENDPOINT) + .transport(transport) + .build(); + + var failure = assertThrows(ResolutionException.class, () -> client.tx("init").resolve()); + assertEquals("init", failure.transaction()); + assertEquals("participants", failure.parameter()); + assertEquals(null, transport.request); + } + + private static String protocolJson() { + return """ + { + "tii": {"version": "v1beta0"}, + "protocol": {"name": "facade", "scope": "test", "version": "1.0.0"}, + "environment": { + "type": "object", + "properties": {"fee": {"type": "integer"}}, + "required": ["fee"] + }, + "parties": {"sender": {}}, + "profiles": { + "local": { + "environment": {"fee": 5}, + "parties": {"sender": "addr_profile"} + } + }, + "transactions": { + "init": { + "params": { + "type": "object", + "properties": { + "participants": { + "type": "array", + "items": {"$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes"} + }, + "sender": {"$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Address"} + }, + "required": ["participants", "sender"] + }, + "tir": {"content": "00", "encoding": "hex", "version": "v1beta0"} + } + } + } + """; + } + + private static String complexProtocolJson() { + return """ + { + "tii": {"version": "v1beta0"}, + "protocol": {"name": "complex", "scope": "test", "version": "1.0.0"}, + "components": { + "schemas": { + "AssetClass": { + "type": "object", + "properties": { + "name": {"$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes"}, + "policy": {"$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes"} + }, + "required": ["policy", "name"] + }, + "Side": { + "oneOf": [ + { + "type": "object", + "required": ["Buy"], + "properties": { + "Buy": {"type": "object", "properties": {}, "required": []} + } + }, + { + "type": "object", + "required": ["Sell"], + "properties": { + "Sell": { + "type": "object", + "properties": {"price": {"type": "integer"}}, + "required": ["price"] + } + } + } + ] + } + } + }, + "transactions": { + "complex": { + "params": { + "type": "object", + "properties": { + "asset": {"$ref": "#/components/schemas/AssetClass"}, + "side": {"$ref": "#/components/schemas/Side"} + }, + "required": ["asset", "side"] + }, + "tir": {"content": "00", "encoding": "hex", "version": "v1beta0"} + } + } + } + """; + } + + private static final class CapturingTransport implements Transport { + private TransportRequest request; + + @Override + public CompletableFuture send(TransportRequest request) { + this.request = request; + try { + var id = JSON.readTree(request.body()).path("id").textValue(); + return CompletableFuture.completedFuture( + new TransportResponse( + 200, + "{\"jsonrpc\":\"2.0\",\"result\":{\"tx\":\"00\",\"hash\":\"hash\"},\"id\":\"" + + id + + "\"}")); + } catch (Exception failure) { + return CompletableFuture.failedFuture(failure); + } + } + + private JsonNode params() throws Exception { + return JSON.readTree(request.body()).path("params"); + } + } +} From e8a2e23567b2a25f633dd777369cc0eb76fcfd15 Mon Sep 17 00:00:00 2001 From: Santiago Date: Fri, 25 Sep 2026 17:11:32 -0300 Subject: [PATCH 2/2] fix: tag injected party addresses --- src/main/java/land/tx3/sdk/TxBuilder.java | 3 ++- src/test/java/land/tx3/sdk/FacadeTest.java | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/land/tx3/sdk/TxBuilder.java b/src/main/java/land/tx3/sdk/TxBuilder.java index 4f4da23..fb2a686 100644 --- a/src/main/java/land/tx3/sdk/TxBuilder.java +++ b/src/main/java/land/tx3/sdk/TxBuilder.java @@ -101,7 +101,8 @@ public CompletableFuture resolve() { } var merged = new LinkedHashMap(); - parties.forEach((name, party) -> merged.put(normalize(name), party.address().value())); + parties.forEach( + (name, party) -> merged.put(normalize(name), ArgValue.address(party.address()))); taggedArguments.forEach((name, value) -> merged.put(normalize(name), value)); for (var required : requiredParameters) { diff --git a/src/test/java/land/tx3/sdk/FacadeTest.java b/src/test/java/land/tx3/sdk/FacadeTest.java index 07714d1..cefe0a2 100644 --- a/src/test/java/land/tx3/sdk/FacadeTest.java +++ b/src/test/java/land/tx3/sdk/FacadeTest.java @@ -166,6 +166,10 @@ void explicitArgsOverridePartiesAndLateBindingIsValidated() throws Exception { assertEquals( "addr_argument", transport.params().path("args").path("sender").path("address").textValue()); + + client.tx("init").arg("participants", List.of(new byte[] {1})).resolve().join(); + assertEquals( + "addr_builder", transport.params().path("args").path("sender").path("address").textValue()); } @Test