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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions src/main/java/land/tx3/sdk/PollConfig.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
21 changes: 20 additions & 1 deletion src/main/java/land/tx3/sdk/PollingException.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
49 changes: 49 additions & 0 deletions src/main/java/land/tx3/sdk/PollingRuntime.java
Original file line number Diff line number Diff line change
@@ -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<Void> delay(Duration duration) {
var result = new CompletableFuture<Void>();
var scheduled =
scheduler.schedule(() -> result.complete(null), duration.toNanos(), TimeUnit.NANOSECONDS);
result.whenComplete(
(ignored, failure) -> {
if (result.isCancelled()) scheduled.cancel(true);
});
return result;
}
}
106 changes: 106 additions & 0 deletions src/main/java/land/tx3/sdk/ResolvedTx.java
Original file line number Diff line number Diff line change
@@ -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<SignerEntry> signers;
private final List<Witness> manualWitnesses = new ArrayList<>();
private final PollingRuntime polling;

ResolvedTx(
TrpClient trp,
String hash,
String txCborHex,
List<SignerEntry> 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.
*
* <p>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.
*
* <p>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<Witness>(signers.size() + manualWitnesses.size());
var info = new ArrayList<WitnessInfo>(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("<external>", 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");
}
}
}
74 changes: 74 additions & 0 deletions src/main/java/land/tx3/sdk/SignedTx.java
Original file line number Diff line number Diff line change
@@ -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<WitnessInfo> witnesses;
private final PollingRuntime polling;

SignedTx(
TrpClient trp,
String hash,
SubmitParams submitParams,
List<WitnessInfo> 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<WitnessInfo> witnesses() {
return witnesses;
}

/**
* Submits this transaction and verifies that TRP returns the signed hash.
*
* @throws SubmissionException asynchronously when the returned hash differs
*/
public CompletableFuture<SubmittedTx> submit() {
var pending = trp.submit(submitParams);
var result = new CompletableFuture<SubmittedTx>();
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;
}
}
12 changes: 12 additions & 0 deletions src/main/java/land/tx3/sdk/SubmissionException.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading
Loading