From 134abc3c78d62ee0d5f3cb0a9b9979fbaa3a7e2e Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 26 Sep 2026 10:05:08 -0300 Subject: [PATCH 1/2] feat: add transaction lifecycle --- README.md | 28 +- src/main/java/land/tx3/sdk/PollConfig.java | 27 ++ .../java/land/tx3/sdk/PollingException.java | 21 +- .../java/land/tx3/sdk/PollingRuntime.java | 49 ++++ src/main/java/land/tx3/sdk/ResolvedTx.java | 106 +++++++ src/main/java/land/tx3/sdk/SignedTx.java | 74 +++++ .../land/tx3/sdk/SubmissionException.java | 12 + src/main/java/land/tx3/sdk/SubmittedTx.java | 151 ++++++++++ src/main/java/land/tx3/sdk/Tx3Client.java | 11 +- .../java/land/tx3/sdk/Tx3ClientBuilder.java | 11 +- src/main/java/land/tx3/sdk/TxBuilder.java | 36 ++- src/main/java/land/tx3/sdk/WitnessInfo.java | 15 + src/test/java/land/tx3/sdk/SignerTest.java | 9 +- .../tx3/sdk/TransactionLifecycleTest.java | 263 ++++++++++++++++++ 14 files changed, 800 insertions(+), 13 deletions(-) create mode 100644 src/main/java/land/tx3/sdk/PollConfig.java create mode 100644 src/main/java/land/tx3/sdk/PollingRuntime.java create mode 100644 src/main/java/land/tx3/sdk/ResolvedTx.java create mode 100644 src/main/java/land/tx3/sdk/SignedTx.java create mode 100644 src/main/java/land/tx3/sdk/SubmittedTx.java create mode 100644 src/main/java/land/tx3/sdk/WitnessInfo.java create mode 100644 src/test/java/land/tx3/sdk/TransactionLifecycleTest.java diff --git a/README.md b/README.md index 1830062..38af7f7 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,8 @@ var client = protocol.client() var resolved = client.tx("transfer") .arg("quantity", 10_000_000) - .resolve(); + .resolve() + .join(); ``` `build()` reports missing connection settings and unknown profile or party names as @@ -115,6 +116,31 @@ Key inputs and derived key material are kept in defensive copies and are never w error messages. Invalid keys and malformed hashes use the SDK's typed `ValidationException`; derivation, address-binding, and cryptographic failures use `SigningException`. +## Submit and await transactions + +The high-level facade continues from `ResolvedTx` through typed signed and submitted states. Every +signer receives both the resolved hash and full transaction CBOR. Pre-computed wallet witnesses may +be attached before signing; automatic signer witnesses are submitted first, followed by attached +witnesses in attachment order. + +```java +var submitted = resolved + .addWitness(externalWitness) + .sign() + .submit() + .join(); + +var status = submitted + .waitForConfirmed(land.tx3.sdk.PollConfig.defaults()) + .join(); +``` + +`waitForConfirmed` accepts confirmed or finalized status, while `waitForFinalized` accepts only +finalized status. Dropped and rolled-back transactions fail with `PollingException.Kind.TERMINAL_STAGE`; +exhausted attempts fail with `PollingException.Kind.TIMEOUT`. Cancelling a returned polling future +cancels its in-flight status request or scheduled delay. `submit()` rejects a TRP response whose +hash differs from the signed transaction with `SubmissionException`. + ## Development These are the canonical foundation checks: diff --git a/src/main/java/land/tx3/sdk/PollConfig.java b/src/main/java/land/tx3/sdk/PollConfig.java new file mode 100644 index 0000000..ff9ec38 --- /dev/null +++ b/src/main/java/land/tx3/sdk/PollConfig.java @@ -0,0 +1,27 @@ +package land.tx3.sdk; + +import java.time.Duration; + +/** Immutable attempt and delay settings for transaction status polling. */ +public record PollConfig(int attempts, Duration delay) { + /** Default number of status requests. */ + public static final int DEFAULT_ATTEMPTS = 20; + + /** Default delay between status requests. */ + public static final Duration DEFAULT_DELAY = Duration.ofSeconds(5); + + /** Validates polling settings. Zero delay is supported for deterministic callers and tests. */ + public PollConfig { + if (attempts <= 0) { + throw new ValidationException("attempts", "poll attempts must be positive"); + } + if (delay == null || delay.isNegative()) { + throw new ValidationException("delay", "poll delay must not be null or negative"); + } + } + + /** Returns the standard configuration of 20 attempts spaced five seconds apart. */ + public static PollConfig defaults() { + return new PollConfig(DEFAULT_ATTEMPTS, DEFAULT_DELAY); + } +} diff --git a/src/main/java/land/tx3/sdk/PollingException.java b/src/main/java/land/tx3/sdk/PollingException.java index b6036a9..b05efb7 100644 --- a/src/main/java/land/tx3/sdk/PollingException.java +++ b/src/main/java/land/tx3/sdk/PollingException.java @@ -4,16 +4,35 @@ /** Reports terminal-stage failure or timeout while polling a submitted transaction. */ public final class PollingException extends Tx3Exception { + /** Stable polling failure categories. */ + public enum Kind { + TERMINAL_STAGE, + TIMEOUT + } + + private final Kind kind; private final String transactionHashHex; private final String targetStage; - /** Creates a polling error with stable transaction and target-stage context. */ + /** Creates a terminal-stage polling error. */ public PollingException(String transactionHashHex, String targetStage, String message) { + this(Kind.TERMINAL_STAGE, transactionHashHex, targetStage, message); + } + + /** Creates a polling error with stable transaction and target-stage context. */ + public PollingException( + Kind kind, String transactionHashHex, String targetStage, String message) { super(message); + this.kind = Objects.requireNonNull(kind, "kind"); this.transactionHashHex = Objects.requireNonNull(transactionHashHex, "transactionHashHex"); this.targetStage = Objects.requireNonNull(targetStage, "targetStage"); } + /** Returns whether polling failed at a terminal stage or exhausted its attempts. */ + public Kind kind() { + return kind; + } + /** Returns the transaction hash being polled. */ public String transactionHashHex() { return transactionHashHex; diff --git a/src/main/java/land/tx3/sdk/PollingRuntime.java b/src/main/java/land/tx3/sdk/PollingRuntime.java new file mode 100644 index 0000000..0a9d95a --- /dev/null +++ b/src/main/java/land/tx3/sdk/PollingRuntime.java @@ -0,0 +1,49 @@ +package land.tx3.sdk; + +import java.time.Clock; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** Scheduler and clock pair used by lifecycle polling. */ +final class PollingRuntime { + private static final ScheduledExecutorService DEFAULT_SCHEDULER = + Executors.newSingleThreadScheduledExecutor( + runnable -> { + var thread = new Thread(runnable, "tx3-status-poller"); + thread.setDaemon(true); + return thread; + }); + private static final PollingRuntime SYSTEM = + new PollingRuntime(DEFAULT_SCHEDULER, Clock.systemUTC()); + + private final ScheduledExecutorService scheduler; + private final Clock clock; + + PollingRuntime(ScheduledExecutorService scheduler, Clock clock) { + this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + static PollingRuntime system() { + return SYSTEM; + } + + Clock clock() { + return clock; + } + + CompletableFuture delay(Duration duration) { + var result = new CompletableFuture(); + var scheduled = + scheduler.schedule(() -> result.complete(null), duration.toNanos(), TimeUnit.NANOSECONDS); + result.whenComplete( + (ignored, failure) -> { + if (result.isCancelled()) scheduled.cancel(true); + }); + return result; + } +} diff --git a/src/main/java/land/tx3/sdk/ResolvedTx.java b/src/main/java/land/tx3/sdk/ResolvedTx.java new file mode 100644 index 0000000..131daf1 --- /dev/null +++ b/src/main/java/land/tx3/sdk/ResolvedTx.java @@ -0,0 +1,106 @@ +package land.tx3.sdk; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** A resolved transaction ready for signer and external witnesses. */ +public final class ResolvedTx { + private final TrpClient trp; + private final String hash; + private final String txCborHex; + private final List signers; + private final List manualWitnesses = new ArrayList<>(); + private final PollingRuntime polling; + + ResolvedTx( + TrpClient trp, + String hash, + String txCborHex, + List signers, + PollingRuntime polling) { + this.trp = Objects.requireNonNull(trp, "trp"); + this.hash = Objects.requireNonNull(hash, "hash"); + this.txCborHex = Objects.requireNonNull(txCborHex, "txCborHex"); + this.signers = List.copyOf(signers); + this.polling = Objects.requireNonNull(polling, "polling"); + } + + /** Returns the resolved transaction hash. */ + public String hash() { + return hash; + } + + /** Returns the hash supplied to registered signers. */ + public String signingHash() { + return hash; + } + + /** Returns the full hexadecimal transaction CBOR for wallet integrations. */ + public String txHex() { + return txCborHex; + } + + /** Returns the full hexadecimal transaction CBOR supplied to registered signers. */ + public String txCborHex() { + return txCborHex; + } + + /** + * Attaches a witness produced outside a registered signer and returns this transaction. + * + *

External witnesses are appended after automatic signer witnesses in attachment order. The + * SDK does not verify external witnesses against the transaction hash; TRP enforces that binding. + */ + public ResolvedTx addWitness(Witness witness) { + manualWitnesses.add(Objects.requireNonNull(witness, "witness")); + return this; + } + + /** + * Signs with every registered signer and appends all external witnesses. + * + *

External-witness-only signing is valid. Signer failures retain their typed exception. + */ + public SignedTx sign() { + var request = new SignRequest(hash, txCborHex); + var witnesses = new ArrayList(signers.size() + manualWitnesses.size()); + var info = new ArrayList(signers.size() + manualWitnesses.size()); + + for (var entry : signers) { + final Witness witness; + try { + witness = Objects.requireNonNull(entry.signer().sign(request), "signer witness"); + } catch (SigningException failure) { + throw failure; + } catch (RuntimeException failure) { + throw new SigningException(entry.address(), "signer failed", failure); + } + witnesses.add(witness); + info.add(new WitnessInfo(entry.name(), entry.address(), witness, hash, false)); + } + for (var witness : manualWitnesses) { + witnesses.add(witness); + info.add(new WitnessInfo("", null, witness, hash, true)); + } + + var wireWitnesses = witnesses.stream().map(ResolvedTx::toWireWitness).toList(); + var submit = new SubmitParams(new BytesEnvelope(txCborHex, "hex"), wireWitnesses); + return new SignedTx(trp, hash, submit, info, polling); + } + + private static TxWitness toWireWitness(Witness witness) { + return new TxWitness( + new BytesEnvelope(witness.publicKeyHex(), "hex"), + new BytesEnvelope(witness.signatureHex(), "hex"), + witness.type()); + } + + record SignerEntry(String name, Address address, Signer signer) { + SignerEntry { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(address, "address"); + Objects.requireNonNull(signer, "signer"); + } + } +} diff --git a/src/main/java/land/tx3/sdk/SignedTx.java b/src/main/java/land/tx3/sdk/SignedTx.java new file mode 100644 index 0000000..4306f5f --- /dev/null +++ b/src/main/java/land/tx3/sdk/SignedTx.java @@ -0,0 +1,74 @@ +package land.tx3.sdk; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +/** A signed transaction ready for submission. */ +public final class SignedTx { + private final TrpClient trp; + private final String hash; + private final SubmitParams submitParams; + private final List witnesses; + private final PollingRuntime polling; + + SignedTx( + TrpClient trp, + String hash, + SubmitParams submitParams, + List witnesses, + PollingRuntime polling) { + this.trp = Objects.requireNonNull(trp, "trp"); + this.hash = Objects.requireNonNull(hash, "hash"); + this.submitParams = Objects.requireNonNull(submitParams, "submitParams"); + this.witnesses = List.copyOf(witnesses); + this.polling = Objects.requireNonNull(polling, "polling"); + } + + /** Returns the signed transaction hash. */ + public String hash() { + return hash; + } + + /** Returns the immutable TRP submission payload. */ + public SubmitParams submitParams() { + return submitParams; + } + + /** Returns immutable metadata in the exact witness submission order. */ + public List witnesses() { + return witnesses; + } + + /** + * Submits this transaction and verifies that TRP returns the signed hash. + * + * @throws SubmissionException asynchronously when the returned hash differs + */ + public CompletableFuture submit() { + var pending = trp.submit(submitParams); + var result = new CompletableFuture(); + pending.whenComplete( + (response, failure) -> { + if (failure != null) { + result.completeExceptionally(failure); + } else if (!hash.equals(response.hash())) { + result.completeExceptionally( + new SubmissionException( + hash, + response.hash(), + "submitted transaction hash mismatch: expected " + + hash + + ", received " + + response.hash())); + } else { + result.complete(new SubmittedTx(trp, hash, polling)); + } + }); + result.whenComplete( + (ignored, failure) -> { + if (result.isCancelled()) pending.cancel(true); + }); + return result; + } +} diff --git a/src/main/java/land/tx3/sdk/SubmissionException.java b/src/main/java/land/tx3/sdk/SubmissionException.java index 20f573e..89e0b05 100644 --- a/src/main/java/land/tx3/sdk/SubmissionException.java +++ b/src/main/java/land/tx3/sdk/SubmissionException.java @@ -5,15 +5,27 @@ /** Reports a submit-hash mismatch or server rejection. */ public final class SubmissionException extends Tx3Exception { private final String transactionHashHex; + private final String receivedHashHex; /** Creates a submission error with the public transaction hash as stable context. */ public SubmissionException(String transactionHashHex, String message) { + this(transactionHashHex, null, message); + } + + /** Creates a submit-hash mismatch retaining both public hashes. */ + public SubmissionException(String transactionHashHex, String receivedHashHex, String message) { super(message); this.transactionHashHex = Objects.requireNonNull(transactionHashHex, "transactionHashHex"); + this.receivedHashHex = receivedHashHex; } /** Returns the submitted transaction hash. */ public String transactionHashHex() { return transactionHashHex; } + + /** Returns the hash returned by TRP, or {@code null} for failures without a response hash. */ + public String receivedHashHex() { + return receivedHashHex; + } } diff --git a/src/main/java/land/tx3/sdk/SubmittedTx.java b/src/main/java/land/tx3/sdk/SubmittedTx.java new file mode 100644 index 0000000..bf29737 --- /dev/null +++ b/src/main/java/land/tx3/sdk/SubmittedTx.java @@ -0,0 +1,151 @@ +package land.tx3.sdk; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicReference; + +/** A submitted transaction whose confirmed and finalized stages can be awaited. */ +public final class SubmittedTx { + private final TrpClient trp; + private final String hash; + private final PollingRuntime polling; + + SubmittedTx(TrpClient trp, String hash, PollingRuntime polling) { + this.trp = Objects.requireNonNull(trp, "trp"); + this.hash = Objects.requireNonNull(hash, "hash"); + this.polling = Objects.requireNonNull(polling, "polling"); + } + + /** Returns the submitted transaction hash. */ + public String hash() { + return hash; + } + + /** Resolves at Confirmed or Finalized and fails on terminal failure or timeout. */ + public CompletableFuture waitForConfirmed(PollConfig config) { + return waitFor(config, TxStage.CONFIRMED); + } + + /** Resolves only at Finalized and fails on terminal failure or timeout. */ + public CompletableFuture waitForFinalized(PollConfig config) { + return waitFor(config, TxStage.FINALIZED); + } + + private CompletableFuture waitFor(PollConfig config, TxStage target) { + Objects.requireNonNull(config, "config"); + var result = new CompletableFuture(); + var active = new AtomicReference>(); + var startedAt = polling.clock().instant(); + poll(config, target, 1, startedAt, result, active); + result.whenComplete( + (ignored, failure) -> { + if (result.isCancelled()) { + var pending = active.get(); + if (pending != null) pending.cancel(true); + } + }); + return result; + } + + private void poll( + PollConfig config, + TxStage target, + int attempt, + Instant startedAt, + CompletableFuture result, + AtomicReference> active) { + if (result.isDone()) return; + + final CompletableFuture statusFuture; + try { + statusFuture = trp.checkStatus(java.util.List.of(hash)); + } catch (RuntimeException failure) { + result.completeExceptionally(failure); + return; + } + active.set(statusFuture); + if (result.isCancelled()) { + statusFuture.cancel(true); + return; + } + + statusFuture.whenComplete( + (response, rawFailure) -> { + if (result.isDone()) return; + if (rawFailure != null) { + result.completeExceptionally(unwrap(rawFailure)); + return; + } + + var status = response.statuses().get(hash); + if (status != null) { + if (status.stage() == TxStage.FINALIZED + || (target == TxStage.CONFIRMED && status.stage() == TxStage.CONFIRMED)) { + result.complete(status); + return; + } + if (status.stage() == TxStage.DROPPED || status.stage() == TxStage.ROLLED_BACK) { + result.completeExceptionally( + new PollingException( + PollingException.Kind.TERMINAL_STAGE, + hash, + target.wireValue(), + "transaction reached terminal stage " + status.stage().wireValue())); + return; + } + } + + if (attempt >= config.attempts()) { + Duration elapsed = Duration.between(startedAt, polling.clock().instant()); + result.completeExceptionally( + new PollingException( + PollingException.Kind.TIMEOUT, + hash, + target.wireValue(), + "polling timed out after " + + config.attempts() + + " attempts (elapsed " + + elapsed + + ")")); + return; + } + + final CompletableFuture delayFuture; + try { + delayFuture = polling.delay(config.delay()); + } catch (RuntimeException failure) { + result.completeExceptionally(failure); + return; + } + active.set(delayFuture); + if (result.isCancelled()) { + delayFuture.cancel(true); + return; + } + delayFuture.whenComplete( + (ignored, delayFailure) -> { + if (result.isDone()) return; + if (delayFailure != null) { + var unwrapped = unwrap(delayFailure); + if (!(unwrapped instanceof CancellationException)) { + result.completeExceptionally(unwrapped); + } + return; + } + poll(config, target, attempt + 1, startedAt, result, active); + }); + }); + } + + private static Throwable unwrap(Throwable raw) { + var current = raw; + while (current instanceof CompletionException && current.getCause() != null) { + current = current.getCause(); + } + return current; + } +} diff --git a/src/main/java/land/tx3/sdk/Tx3Client.java b/src/main/java/land/tx3/sdk/Tx3Client.java index fbfef23..213c818 100644 --- a/src/main/java/land/tx3/sdk/Tx3Client.java +++ b/src/main/java/land/tx3/sdk/Tx3Client.java @@ -21,6 +21,7 @@ public final class Tx3Client { private final TrpClient trp; private final Map parties; private final Map environment; + private final PollingRuntime polling; Tx3Client( Map transactions, @@ -29,7 +30,8 @@ public final class Tx3Client { Set knownParties, TrpClient trp, Map parties, - Map environment) { + Map environment, + PollingRuntime polling) { this.transactions = Collections.unmodifiableMap(new LinkedHashMap<>(transactions)); this.transactionParameters = transactionParameters; this.requiredParameters = requiredParameters; @@ -37,6 +39,7 @@ public final class Tx3Client { this.trp = Objects.requireNonNull(trp, "trp"); this.parties = Collections.unmodifiableMap(new LinkedHashMap<>(parties)); this.environment = Collections.unmodifiableMap(new LinkedHashMap<>(environment)); + this.polling = Objects.requireNonNull(polling, "polling"); } /** @@ -59,7 +62,8 @@ public TxBuilder tx(String name) { transactionParameters.getOrDefault(canonicalName, Map.of()), requiredParameters.getOrDefault(canonicalName, Set.of()), environment, - parties); + parties, + polling); } /** @@ -101,7 +105,8 @@ private Tx3Client withPartyValue(String name, Party party) { knownParties, trp, updated, - environment); + environment, + polling); } private static String normalize(String name) { diff --git a/src/main/java/land/tx3/sdk/Tx3ClientBuilder.java b/src/main/java/land/tx3/sdk/Tx3ClientBuilder.java index a26c0e0..b6347e7 100644 --- a/src/main/java/land/tx3/sdk/Tx3ClientBuilder.java +++ b/src/main/java/land/tx3/sdk/Tx3ClientBuilder.java @@ -3,12 +3,14 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; +import java.time.Clock; 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.ScheduledExecutorService; /** * Fluent builder for the single high-level {@link Tx3Client} facade. @@ -32,6 +34,7 @@ public final class Tx3ClientBuilder { private ClientOptions trpOptions; private String profile; private Transport transport; + private PollingRuntime polling = PollingRuntime.system(); private Tx3ClientBuilder( Map transactions, @@ -149,6 +152,11 @@ Tx3ClientBuilder transport(Transport value) { return this; } + Tx3ClientBuilder polling(ScheduledExecutorService scheduler, Clock clock) { + polling = new PollingRuntime(scheduler, clock); + return this; + } + /** * Validates configuration and creates the immutable high-level client. * @@ -200,7 +208,8 @@ public Tx3Client build() { knownParties, trpClient, boundParties, - environment); + environment, + polling); } private static Address profileAddress(String name, JsonNode value) { diff --git a/src/main/java/land/tx3/sdk/TxBuilder.java b/src/main/java/land/tx3/sdk/TxBuilder.java index fb2a686..357ffcc 100644 --- a/src/main/java/land/tx3/sdk/TxBuilder.java +++ b/src/main/java/land/tx3/sdk/TxBuilder.java @@ -17,14 +17,15 @@ public final class TxBuilder { private final Set requiredParameters; private final Map environment; private final Map parties; + private final PollingRuntime polling; private final LinkedHashMap taggedArguments = new LinkedHashMap<>(); TxBuilder() { - this("", null, null, Map.of(), Set.of(), Map.of(), Map.of()); + this("", null, null, Map.of(), Set.of(), Map.of(), Map.of(), PollingRuntime.system()); } TxBuilder(Map parameters) { - this("", null, null, parameters, Set.of(), Map.of(), Map.of()); + this("", null, null, parameters, Set.of(), Map.of(), Map.of(), PollingRuntime.system()); } TxBuilder( @@ -34,7 +35,8 @@ public final class TxBuilder { Map parameters, Set requiredParameters, Map environment, - Map parties) { + Map parties, + PollingRuntime polling) { this.transaction = Objects.requireNonNull(transaction, "transaction"); this.tir = tir; this.trp = trp; @@ -42,6 +44,7 @@ public final class TxBuilder { this.requiredParameters = Collections.unmodifiableSet(new LinkedHashSet<>(requiredParameters)); this.environment = Collections.unmodifiableMap(new LinkedHashMap<>(environment)); this.parties = Collections.unmodifiableMap(new LinkedHashMap<>(parties)); + this.polling = Objects.requireNonNull(polling, "polling"); } /** @@ -95,7 +98,7 @@ Map taggedArguments() { * @throws ResolutionException if a required transaction parameter has no explicit or injected * value */ - public CompletableFuture resolve() { + public CompletableFuture resolve() { if (trp == null || tir == null) { throw new ValidationException("txBuilder", "this transaction builder cannot resolve"); } @@ -113,7 +116,30 @@ public CompletableFuture resolve() { } var env = environment.isEmpty() ? null : new LinkedHashMap(environment); - return trp.resolve(new ResolveParams(tir, merged, env)); + var signers = new java.util.ArrayList(); + parties.forEach( + (name, party) -> + party + .signer() + .ifPresent( + signer -> + signers.add(new ResolvedTx.SignerEntry(name, party.address(), signer)))); + + var pending = trp.resolve(new ResolveParams(tir, merged, env)); + var result = new CompletableFuture(); + pending.whenComplete( + (response, failure) -> { + if (failure != null) { + result.completeExceptionally(failure); + } else { + result.complete(new ResolvedTx(trp, response.hash(), response.tx(), signers, polling)); + } + }); + result.whenComplete( + (ignored, failure) -> { + if (result.isCancelled()) pending.cancel(true); + }); + return result; } private static String normalize(String name) { diff --git a/src/main/java/land/tx3/sdk/WitnessInfo.java b/src/main/java/land/tx3/sdk/WitnessInfo.java new file mode 100644 index 0000000..0e1cf94 --- /dev/null +++ b/src/main/java/land/tx3/sdk/WitnessInfo.java @@ -0,0 +1,15 @@ +package land.tx3.sdk; + +/** Public, non-secret metadata for one witness carried by a signed transaction. */ +public record WitnessInfo( + String party, Address address, Witness witness, String signedHashHex, boolean external) { + /** Validates required witness metadata. */ + public WitnessInfo { + if (party == null || witness == null || signedHashHex == null) { + throw new ValidationException("witnessInfo", "witness metadata must not be null"); + } + if (!external && address == null) { + throw new ValidationException("witnessInfo.address", "signer address must not be null"); + } + } +} diff --git a/src/test/java/land/tx3/sdk/SignerTest.java b/src/test/java/land/tx3/sdk/SignerTest.java index 6f0529c..eebde45 100644 --- a/src/test/java/land/tx3/sdk/SignerTest.java +++ b/src/test/java/land/tx3/sdk/SignerTest.java @@ -14,6 +14,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.security.MessageDigest; import java.util.HexFormat; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -23,12 +24,16 @@ class SignerTest { private static JsonNode vectors; @BeforeAll - static void loadVectors() throws IOException { + static void loadVectors() throws Exception { try (var input = SignerTest.class.getResourceAsStream("/fixtures/signer-vectors.json")) { if (input == null) { throw new IOException("missing signer vector fixture"); } - vectors = new ObjectMapper().readTree(input); + byte[] fixture = input.readAllBytes(); + assertEquals( + "4709a14be0b7cfbb77a31dda0c5933ee5041433f424f1b71cd20c40fe9045d08", + HEX.formatHex(MessageDigest.getInstance("SHA-256").digest(fixture))); + vectors = new ObjectMapper().readTree(fixture); } } diff --git a/src/test/java/land/tx3/sdk/TransactionLifecycleTest.java b/src/test/java/land/tx3/sdk/TransactionLifecycleTest.java new file mode 100644 index 0000000..d720cd6 --- /dev/null +++ b/src/test/java/land/tx3/sdk/TransactionLifecycleTest.java @@ -0,0 +1,263 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class TransactionLifecycleTest { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final String HASH = "00".repeat(32); + private static final String OTHER_HASH = "11".repeat(32); + private static final String CBOR = "84a40081"; + private final ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor(1); + + TransactionLifecycleTest() { + scheduler.setRemoveOnCancelPolicy(true); + } + + @AfterEach + void stopScheduler() { + scheduler.shutdownNow(); + } + + @Test + void fullChainPopulatesSignerRequestOrdersWitnessesAndAcceptsConfirmed() throws Exception { + var transport = new LifecycleTransport(); + transport.resolve(HASH, CBOR); + transport.submit(HASH); + transport.status(HASH, TxStage.PENDING); + transport.status(HASH, TxStage.CONFIRMED); + var signer = new CapturingSigner(); + + var client = + Tx3ClientBuilder.fromParts( + Map.of("transfer", new TirEnvelope("hex", "00", "v1beta0")), + Map.of(), + Set.of("sender")) + .trpEndpoint(URI.create("https://trp.example/rpc")) + .withParty("sender", Party.signer(signer)) + .transport(transport) + .polling(scheduler, Clock.fixed(Instant.EPOCH, ZoneOffset.UTC)) + .build(); + var manualOne = new Witness("22", "33", WitnessType.VKEY); + var manualTwo = new Witness("44", "55", WitnessType.VKEY); + + var resolved = client.tx("transfer").resolve().join(); + assertEquals(HASH, resolved.hash()); + assertEquals(HASH, resolved.signingHash()); + assertEquals(CBOR, resolved.txHex()); + assertEquals(CBOR, resolved.txCborHex()); + + var signed = resolved.addWitness(manualOne).addWitness(manualTwo).sign(); + assertEquals(new SignRequest(HASH, CBOR), signer.request); + assertEquals( + List.of("aa", "22", "44"), + signed.submitParams().witnesses().stream().map(w -> w.key().content()).toList()); + assertEquals( + List.of(false, true, true), + signed.witnesses().stream().map(WitnessInfo::external).toList()); + + var submitted = signed.submit().join(); + assertEquals(HASH, submitted.hash()); + var status = submitted.waitForConfirmed(new PollConfig(2, Duration.ZERO)).join(); + assertEquals(TxStage.CONFIRMED, status.stage()); + assertEquals( + List.of("trp.resolve", "trp.submit", "trp.checkStatus", "trp.checkStatus"), + transport.methods); + } + + @Test + void externalWitnessOnlySigningWorksAndSubmitHashMismatchIsTyped() { + var transport = new LifecycleTransport(); + transport.resolve(HASH, CBOR); + transport.submit(OTHER_HASH); + var client = client(transport); + + var signed = + client + .tx("transfer") + .resolve() + .join() + .addWitness(new Witness("aa", "bb", WitnessType.VKEY)) + .sign(); + assertEquals(1, signed.witnesses().size()); + assertTrue(signed.witnesses().getFirst().external()); + + var failure = assertThrows(CompletionException.class, () -> signed.submit().join()); + var mismatch = assertInstanceOf(SubmissionException.class, failure.getCause()); + assertEquals(HASH, mismatch.transactionHashHex()); + assertEquals(OTHER_HASH, mismatch.receivedHashHex()); + } + + @Test + void finalizedWaitIgnoresConfirmedAndReportsTerminalAndTimeoutKinds() { + var finalizedTransport = new LifecycleTransport(); + finalizedTransport.status(HASH, TxStage.CONFIRMED); + finalizedTransport.status(HASH, TxStage.FINALIZED); + var finalized = submitted(finalizedTransport); + assertEquals( + TxStage.FINALIZED, + finalized.waitForFinalized(new PollConfig(2, Duration.ZERO)).join().stage()); + + var droppedTransport = new LifecycleTransport(); + droppedTransport.status(HASH, TxStage.DROPPED); + var dropped = + assertThrows( + CompletionException.class, + () -> submitted(droppedTransport).waitForConfirmed(PollConfig.defaults()).join()); + assertEquals( + PollingException.Kind.TERMINAL_STAGE, + assertInstanceOf(PollingException.class, dropped.getCause()).kind()); + + var timeoutTransport = new LifecycleTransport(); + timeoutTransport.status(HASH, TxStage.PENDING); + timeoutTransport.status(HASH, TxStage.ROLLED_BACK); + var timeout = + assertThrows( + CompletionException.class, + () -> + submitted(timeoutTransport) + .waitForFinalized(new PollConfig(1, Duration.ZERO)) + .join()); + assertEquals( + PollingException.Kind.TIMEOUT, + assertInstanceOf(PollingException.class, timeout.getCause()).kind()); + } + + @Test + void cancellationStopsInFlightStatusRequestAndScheduledDelay() { + var pendingTransport = new LifecycleTransport(); + pendingTransport.pendingStatus(); + var inFlightWait = submitted(pendingTransport).waitForConfirmed(PollConfig.defaults()); + assertTrue(inFlightWait.cancel(true)); + assertTrue(pendingTransport.lastSent.isCancelled()); + + var delayedTransport = new LifecycleTransport(); + delayedTransport.status(HASH, TxStage.PENDING); + var delayedWait = + submitted(delayedTransport).waitForConfirmed(new PollConfig(2, Duration.ofDays(1))); + assertEquals(1, scheduler.getQueue().size()); + assertTrue(delayedWait.cancel(true)); + assertTrue(scheduler.getQueue().isEmpty()); + } + + @Test + void pollDefaultsAndValidationAreStable() { + assertEquals(20, PollConfig.defaults().attempts()); + assertEquals(Duration.ofSeconds(5), PollConfig.defaults().delay()); + assertEquals( + "attempts", + assertThrows(ValidationException.class, () -> new PollConfig(0, Duration.ZERO)).field()); + assertEquals( + "delay", + assertThrows(ValidationException.class, () -> new PollConfig(1, Duration.ofSeconds(-1))) + .field()); + } + + private Tx3Client client(LifecycleTransport transport) { + return Tx3ClientBuilder.fromParts( + Map.of("transfer", new TirEnvelope("hex", "00", "v1beta0")), Map.of(), Set.of()) + .trpEndpoint(URI.create("https://trp.example/rpc")) + .transport(transport) + .polling(scheduler, Clock.fixed(Instant.EPOCH, ZoneOffset.UTC)) + .build(); + } + + private SubmittedTx submitted(LifecycleTransport transport) { + var options = ClientOptions.forEndpoint(URI.create("https://trp.example/rpc")); + return new SubmittedTx( + new TrpClient(options, transport, () -> "request-id"), + HASH, + new PollingRuntime(scheduler, Clock.fixed(Instant.EPOCH, ZoneOffset.UTC))); + } + + private static final class CapturingSigner implements Signer { + private SignRequest request; + + @Override + public Address address() { + return new Address("addr_test1signer"); + } + + @Override + public Witness sign(SignRequest request) { + this.request = request; + return new Witness("aa", "bb", WitnessType.VKEY); + } + } + + private static final class LifecycleTransport implements Transport { + private final ArrayDeque> responses = new ArrayDeque<>(); + private final List methods = new java.util.ArrayList<>(); + private CompletableFuture lastSent; + + void resolve(String hash, String tx) { + responses.add(completed("{\"tx\":\"" + tx + "\",\"hash\":\"" + hash + "\"}")); + } + + void submit(String hash) { + responses.add(completed("{\"hash\":\"" + hash + "\"}")); + } + + void status(String hash, TxStage stage) { + responses.add( + completed( + "{\"statuses\":{\"" + + hash + + "\":{\"stage\":\"" + + stage.wireValue() + + "\",\"confirmations\":0,\"nonConfirmations\":0}}}")); + } + + CompletableFuture pendingStatus() { + var pending = new CompletableFuture(); + responses.add(pending); + return pending; + } + + @Override + public CompletableFuture send(TransportRequest request) { + try { + JsonNode body = JSON.readTree(request.body()); + methods.add(body.path("method").textValue()); + var response = responses.removeFirst(); + var id = body.path("id").textValue(); + lastSent = + response.thenApply( + raw -> + new TransportResponse( + raw.statusCode(), + "{\"jsonrpc\":\"2.0\",\"result\":" + + raw.body() + + ",\"id\":\"" + + id + + "\"}")); + return lastSent; + } catch (Exception failure) { + return CompletableFuture.failedFuture(failure); + } + } + + private static CompletableFuture completed(String result) { + return CompletableFuture.completedFuture(new TransportResponse(200, result)); + } + } +} From 8fb5a2f94ba51831eed12639a09b5d19d9a97854 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 26 Sep 2026 10:15:14 -0300 Subject: [PATCH 2/2] test: preserve signer fixture bytes on Windows --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 7d90e77..177d218 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ *.tii text eol=lf src/test/resources/fixtures/wire-vectors.json text eol=lf +src/test/resources/fixtures/signer-vectors.json text eol=lf