diff --git a/.github/release-train b/.github/release-train new file mode 100644 index 0000000..2856407 --- /dev/null +++ b/.github/release-train @@ -0,0 +1 @@ +0.15 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f12d2e9..0fa53b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,45 +2,139 @@ name: CI on: push: + branches: [main] pull_request: + branches: [main] permissions: contents: read +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: - build: - defaults: - run: - shell: bash + unit: + name: unit (${{ matrix.runner }}) strategy: fail-fast: false matrix: - runner: - - ubuntu-24.04 - - ubuntu-24.04-arm - - windows-2025 - - windows-11-arm - - macos-15-intel - - macos-14 + runner: [ubuntu-24.04, macos-15, windows-2025] runs-on: ${{ matrix.runner }} + timeout-minutes: 20 steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" cache: maven - - name: Check formatting - run: ./mvnw -B -ntp spotless:check - - name: Run unit tests - run: ./mvnw -B -ntp test - - name: Verify package - run: ./mvnw -B -ntp verify - - name: Resolve package from an external consumer - run: ./mvnw -B -ntp install && ./mvnw -B -ntp -f examples/consumer/pom.xml package + - run: ./mvnw -B -ntp spotless:check + shell: bash + - run: ./mvnw -B -ntp test + shell: bash + - run: ./mvnw -B -ntp verify + shell: bash + - name: Build and run a clean external consumer + shell: bash + run: | + set -euo pipefail + repository="${RUNNER_TEMP}/consumer-m2" + consumer="${RUNNER_TEMP}/consumer-app" + cp -R examples/consumer "$consumer" + ./mvnw -B -ntp -Drevision=0.15.0-SNAPSHOT -Dmaven.repo.local="$repository" install + ./mvnw -B -ntp -Dmaven.repo.local="$repository" \ + -Dtx3.sdk.version=0.15.0-SNAPSHOT \ + -f "$consumer/pom.xml" verify exec:java - name: Record dependency tree + shell: bash run: ./mvnw -B -ntp dependency:tree -DoutputFile=target/dependency-tree.txt + - name: Inspect package contents + shell: bash + run: | + set -euo pipefail + jar tf target/tx3-sdk-0.15.0.jar | tee target/package-contents.txt + grep -qx 'META-INF/LICENSE' target/package-contents.txt + grep -qx 'land/tx3/sdk/Tx3Client.class' target/package-contents.txt - uses: actions/upload-artifact@v4 with: - name: dependency-tree-${{ matrix.runner }} - path: target/dependency-tree.txt + name: package-evidence-${{ matrix.runner }} + path: | + target/tx3-sdk-0.15.0.jar + target/package-contents.txt + target/dependency-tree.txt + + e2e: + name: live TRP e2e + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + CI: "true" + TRP_ENDPOINT_PREPROD: ${{ secrets.TRP_ENDPOINT_PREPROD }} + TRP_API_KEY_PREPROD: ${{ secrets.TRP_API_KEY_PREPROD }} + TEST_PARTY_A_ADDRESS: ${{ secrets.TEST_PARTY_A_ADDRESS }} + TEST_PARTY_A_MNEMONIC: ${{ secrets.TEST_PARTY_A_MNEMONIC }} + TEST_PARTY_B_ADDRESS: ${{ secrets.TEST_PARTY_B_ADDRESS }} + TEST_PARTY_B_MNEMONIC: ${{ secrets.TEST_PARTY_B_MNEMONIC }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + - name: Require canonical e2e configuration + shell: bash + run: | + set -euo pipefail + for name in TRP_ENDPOINT_PREPROD TRP_API_KEY_PREPROD TEST_PARTY_A_ADDRESS TEST_PARTY_A_MNEMONIC TEST_PARTY_B_ADDRESS TEST_PARTY_B_MNEMONIC; do + if [ -z "${!name:-}" ]; then + echo "Missing required e2e configuration: $name" >&2 + exit 1 + fi + done + - run: ./mvnw -B -ntp -Pe2e verify + + codegen: + name: generated Java client + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + - uses: dtolnay/rust-toolchain@1.91 + - name: Install this checkout as the snapshot runtime + run: ./mvnw -B -ntp -Drevision=0.15.0-SNAPSHOT -Dmaven.repo.local="${RUNNER_TEMP}/codegen-m2" -DskipTests install + - name: Build pinned tx3c + uses: actions/checkout@v4 + with: + repository: tx3-lang/tx3 + ref: 456f68503a1306c865b17666a9ee1f1d801bd23b + path: .tx3c-source + persist-credentials: false + - run: cargo build -p tx3c --locked + working-directory: .tx3c-source + - name: Render and compile canonical fixtures + shell: bash + run: | + set -euo pipefail + for fixture in transfer complex; do + output="${RUNNER_TEMP}/generated-${fixture}" + .tx3c-source/target/debug/tx3c codegen \ + --tii "src/test/resources/fixtures/${fixture}.tii" \ + --template java-client \ + --output "$output" + ./mvnw -B -ntp -f "$output/pom.xml" \ + -Dmaven.repo.local="${RUNNER_TEMP}/codegen-m2" \ + -Dtx3.sdk.version=0.15.0-SNAPSHOT verify + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fb5b3dd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + name: publish Maven Central package + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + server-id: central + server-username: MAVEN_CENTRAL_USERNAME + server-password: MAVEN_CENTRAL_TOKEN + gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + - name: Require release credentials + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} + MAVEN_GPG_PRIVATE_KEY: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: | + set -euo pipefail + for name in MAVEN_CENTRAL_USERNAME MAVEN_CENTRAL_TOKEN MAVEN_GPG_PRIVATE_KEY MAVEN_GPG_PASSPHRASE; do + if [ -z "${!name:-}" ]; then + echo "Missing required release secret: $name" >&2 + exit 1 + fi + done + - name: Validate tag, package version, and fleet train + run: | + set -euo pipefail + if [ "$(git cat-file -t "refs/tags/${GITHUB_REF_NAME}")" != "tag" ]; then + echo "Release tag must be annotated: ${GITHUB_REF_NAME}" >&2 + exit 1 + fi + scripts/check-release-version.sh "${GITHUB_REF_NAME}" + - name: Verify signing identity + run: | + set -euo pipefail + expected="6F26BB15DE0BACF2150B7C359BFB64674ED089FE" + actual="$(gpg --batch --with-colons --fingerprint | awk -F: '$1 == "fpr" { print $10; exit }')" + if [ "$actual" != "$expected" ]; then + echo "Imported signing key fingerprint does not match the approved identity." >&2 + exit 1 + fi + - name: Test, package, sign, and publish + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: ./mvnw -B -ntp -Prelease deploy + - name: Verify clean Maven Central consumption + run: | + set -euo pipefail + repository="$(mktemp -d)" + consumer="$(mktemp -d)" + cp -R examples/consumer/. "$consumer" + ./mvnw -B -ntp -Dmaven.repo.local="$repository" \ + -Dtx3.sdk.version="${GITHUB_REF_NAME#v}" \ + -f "$consumer/pom.xml" verify exec:java diff --git a/README.md b/README.md index 38af7f7..75073e5 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,10 @@ var status = submitted `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`. +cancels its in-flight status request or scheduled delay. Status polling retries transient network and +timeout failures, HTTP 408/425/429 responses, and HTTP 5xx responses within the configured attempt +limit; other transport failures are returned immediately. `submit()` rejects a TRP response whose hash +differs from the signed transaction with `SubmissionException`. ## Development @@ -161,6 +163,47 @@ To record the resolved dependency tree exactly as CI does: The test suite is deterministic and needs no TRP endpoint or credentials. +Unit tests and live tests are selected independently. The ordinary `test` and `verify` commands +exclude the `e2e` JUnit tag. To exercise the canonical transfer lifecycle against preprod, set +`TRP_ENDPOINT_PREPROD`, `TRP_API_KEY_PREPROD`, `TEST_PARTY_A_ADDRESS`, +`TEST_PARTY_A_MNEMONIC`, `TEST_PARTY_B_ADDRESS`, and `TEST_PARTY_B_MNEMONIC`, then run: + +```shell +./mvnw -B -ntp -Pe2e verify +``` + +Without those variables a local e2e run is skipped with the missing names. CI treats any missing +value as an error. The live suite loads the pinned `transfer.tii`, resolves, signs with party A, +submits, and waits for confirmed status; finalized polling remains covered by mocked-TRP unit tests +because preprod does not report finalized transactions through `checkStatus`. The suite also checks +typed missing-argument and bad-endpoint failures. Secret values are never logged or stored by the +tests. + +## Generated clients + +The `java-client` template is built into `tx3c`. Generate typed bindings from a TII document and +compile them against this runtime with: + +```shell +tx3c codegen --tii transfer.tii --template java-client --output generated-client +./mvnw -B -ntp -f generated-client/pom.xml verify +``` + +Generated clients use the same `Tx3ClientBuilder`, `ArgValue`, lifecycle, signer, and typed error +APIs shown above and do not need the source TII at runtime. CI renders the canonical transfer and +complex fixtures with the pinned tx3c revision and compiles both from a clean output directory. + +## Release mechanics + +Releases are driven only by annotated `vMAJOR.MINOR.PATCH` tags. The tag must exactly match the +POM version and the `MAJOR.MINOR` value in `.github/release-train`. The release workflow reruns the +checks, builds the main, source, and Javadoc artifacts, verifies the approved signing-key +fingerprint, signs the artifacts, publishes `land.tx3:tx3-sdk` through the Maven Central Portal, +waits for publication, and resolves the released version from a fresh Maven repository before +running the external consumer. Publishing credentials and signing material are supplied only by +the restricted organization secrets documented for repository operators; local builds do not +need them. + ## Platform scope The supported baseline is Java SE 21 on macOS, Linux, and Windows, on x64 and ARM64 where hosted diff --git a/examples/consumer/pom.xml b/examples/consumer/pom.xml index 6824c23..19d5121 100644 --- a/examples/consumer/pom.xml +++ b/examples/consumer/pom.xml @@ -7,6 +7,7 @@ tx3-sdk-consumer 1.0.0 + 0.15.0 21 UTF-8 @@ -14,7 +15,7 @@ land.tx3 tx3-sdk - 0.15.0 + ${tx3.sdk.version} @@ -34,6 +35,14 @@ maven-jar-plugin 3.5.1 + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + example.Consumer + + diff --git a/pom.xml b/pom.xml index 6e1a543..4d33833 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ land.tx3 tx3-sdk - 0.15.0 + ${revision} Tx3 SDK for Java Java client library for Tx3 protocols. https://github.com/tx3-lang/java-sdk @@ -35,11 +35,13 @@ + 0.15.0 UTF-8 21 0.7.2 2.22.2 6.1.3 + true @@ -88,6 +90,18 @@ + + + src/main/resources + + + ${project.basedir} + META-INF + + LICENSE + + + org.apache.maven.plugins @@ -111,6 +125,29 @@ org.apache.maven.plugins maven-surefire-plugin 3.6.0 + + e2e + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.6.0 + + e2e + + **/*E2eTest.java + + ${skipITs} + + + + + integration-test + verify + + + org.apache.maven.plugins @@ -179,4 +216,72 @@ + + + + e2e + + false + + + + release + + + + org.apache.maven.plugins + maven-source-plugin + 3.4.0 + + + attach-sources + jar-no-fork + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.12.0 + + + attach-javadocs + jar + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + true + + --pinentry-mode + loopback + + + + + sign-artifacts + verify + sign + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.11.0 + true + + central + true + published + + + + + + diff --git a/scripts/check-release-version.sh b/scripts/check-release-version.sh new file mode 100755 index 0000000..f4db6b1 --- /dev/null +++ b/scripts/check-release-version.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +tag="${1:-}" +if [[ ! "$tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "Release tag must match vMAJOR.MINOR.PATCH: $tag" >&2 + exit 1 +fi + +tag_version="${tag#v}" +project_version="$(./mvnw -B -ntp help:evaluate -Dexpression=project.version -DforceStdout -q)" +if [[ "$tag_version" != "$project_version" ]]; then + echo "Tag version ($tag_version) does not match pom.xml version ($project_version)." >&2 + exit 1 +fi + +train="$(tr -d '[:space:]' < .github/release-train)" +tag_train="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" +if [[ "$tag_train" != "$train" ]]; then + echo "Tag MAJOR.MINOR ($tag_train) does not match the fleet release train ($train)." >&2 + exit 1 +fi + +echo "Validated Java SDK release $tag_version on fleet train $train." diff --git a/src/main/java/land/tx3/sdk/SubmittedTx.java b/src/main/java/land/tx3/sdk/SubmittedTx.java index bf29737..a0ea6de 100644 --- a/src/main/java/land/tx3/sdk/SubmittedTx.java +++ b/src/main/java/land/tx3/sdk/SubmittedTx.java @@ -77,7 +77,12 @@ private void poll( (response, rawFailure) -> { if (result.isDone()) return; if (rawFailure != null) { - result.completeExceptionally(unwrap(rawFailure)); + var failure = unwrap(rawFailure); + if (isRetryableStatusFailure(failure)) { + scheduleNext(config, target, attempt, startedAt, result, active); + } else { + result.completeExceptionally(failure); + } return; } @@ -99,48 +104,71 @@ private void poll( } } - 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; - } + scheduleNext(config, target, attempt, startedAt, result, active); + }); + } - 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); + private void scheduleNext( + PollConfig config, + TxStage target, + int attempt, + Instant startedAt, + CompletableFuture result, + AtomicReference> active) { + 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; } - 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); - }); + poll(config, target, attempt + 1, startedAt, result, active); }); } + private static boolean isRetryableStatusFailure(Throwable failure) { + if (!(failure instanceof TransportException transport)) return false; + if (transport.failure() == TransportFailure.NETWORK + || transport.failure() == TransportFailure.TIMEOUT) { + return true; + } + if (transport.failure() != TransportFailure.HTTP_STATUS || transport.httpStatus() == null) { + return false; + } + int status = transport.httpStatus(); + return status == 408 || status == 425 || status == 429 || status >= 500; + } + private static Throwable unwrap(Throwable raw) { var current = raw; while (current instanceof CompletionException && current.getCause() != null) { diff --git a/src/main/java/land/tx3/sdk/TxBuilder.java b/src/main/java/land/tx3/sdk/TxBuilder.java index 357ffcc..45060bc 100644 --- a/src/main/java/land/tx3/sdk/TxBuilder.java +++ b/src/main/java/land/tx3/sdk/TxBuilder.java @@ -91,9 +91,10 @@ Map 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. + *

Profile environment and party addresses are applied first as resolver arguments. Explicit + * transaction arguments win over injected values, 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 @@ -103,7 +104,7 @@ public CompletableFuture resolve() { throw new ValidationException("txBuilder", "this transaction builder cannot resolve"); } - var merged = new LinkedHashMap(); + var merged = new LinkedHashMap(environment); parties.forEach( (name, party) -> merged.put(normalize(name), ArgValue.address(party.address()))); taggedArguments.forEach((name, value) -> merged.put(normalize(name), value)); @@ -115,7 +116,6 @@ public CompletableFuture resolve() { } } - var env = environment.isEmpty() ? null : new LinkedHashMap(environment); var signers = new java.util.ArrayList(); parties.forEach( (name, party) -> @@ -125,7 +125,7 @@ public CompletableFuture resolve() { signer -> signers.add(new ResolvedTx.SignerEntry(name, party.address(), signer)))); - var pending = trp.resolve(new ResolveParams(tir, merged, env)); + var pending = trp.resolve(new ResolveParams(tir, merged)); var result = new CompletableFuture(); pending.whenComplete( (response, failure) -> { diff --git a/src/test/java/land/tx3/sdk/FacadeTest.java b/src/test/java/land/tx3/sdk/FacadeTest.java index cefe0a2..68057f2 100644 --- a/src/test/java/land/tx3/sdk/FacadeTest.java +++ b/src/test/java/land/tx3/sdk/FacadeTest.java @@ -4,6 +4,7 @@ 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 static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -96,7 +97,8 @@ void dynamicAndPartsClientsProduceEquivalentResolveRequests() throws Exception { var dynamicParams = dynamicTransport.params(); var generatedParams = generatedTransport.params(); assertEquals(dynamicParams, generatedParams); - assertEquals(9, dynamicParams.path("env").path("fee").intValue()); + assertEquals(9, dynamicParams.path("args").path("fee").intValue()); + assertTrue(dynamicParams.path("env").isMissingNode()); assertEquals( "addr_argument", dynamicParams.path("args").path("sender").path("address").textValue()); assertEquals( diff --git a/src/test/java/land/tx3/sdk/ProtocolTest.java b/src/test/java/land/tx3/sdk/ProtocolTest.java index 4624f6d..a8e1f41 100644 --- a/src/test/java/land/tx3/sdk/ProtocolTest.java +++ b/src/test/java/land/tx3/sdk/ProtocolTest.java @@ -116,7 +116,7 @@ void discriminatesReadJsonAndStructureFailures() { void fixturesMatchTheFounderBoundCanonicalHashes() throws Exception { var digest = MessageDigest.getInstance("SHA-256"); assertEquals( - "8d5d715f300e618373b588af96f0cfb9b0a3904b3a34fc0038f72ebb1695da31", + "02da9f4a6710dd4821cf219c5f373d14e3c933519953068c1b03e38947568d32", HexFormat.of().formatHex(digest.digest(java.nio.file.Files.readAllBytes(TRANSFER)))); assertEquals( "0a7195b22fe3262a87110a89ee83f75d1295257c193d8859165842aeeea2ae83", diff --git a/src/test/java/land/tx3/sdk/TransactionE2eTest.java b/src/test/java/land/tx3/sdk/TransactionE2eTest.java new file mode 100644 index 0000000..b679a91 --- /dev/null +++ b/src/test/java/land/tx3/sdk/TransactionE2eTest.java @@ -0,0 +1,119 @@ +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 java.net.URI; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Live tests for the canonical transfer protocol against preprod TRP. */ +@Tag("e2e") +class TransactionE2eTest { + private static final List REQUIRED_ENV = + List.of( + "TRP_ENDPOINT_PREPROD", + "TRP_API_KEY_PREPROD", + "TEST_PARTY_A_ADDRESS", + "TEST_PARTY_A_MNEMONIC", + "TEST_PARTY_B_ADDRESS", + "TEST_PARTY_B_MNEMONIC"); + + @Test + void transferCompletesResolveSignSubmitAndConfirm() { + var env = environment(); + var client = liveClient(env); + + var resolved = client.tx("transfer").arg("quantity", 10_000_000).resolve().join(); + var signed = resolved.sign(); + assertEquals(resolved.hash(), signed.hash()); + + var submitted = signed.submit().join(); + var confirmed = submitted.waitForConfirmed(PollConfig.defaults()).join(); + assertTrue(confirmed.stage() == TxStage.CONFIRMED || confirmed.stage() == TxStage.FINALIZED); + } + + @Test + void missingArgumentIsReportedBeforeTransport() { + var env = environment(); + var failure = + assertThrows(ResolutionException.class, () -> liveClient(env).tx("transfer").resolve()); + assertEquals("quantity", failure.parameter()); + } + + @Test + void unreachableEndpointProducesTypedTransportFailure() { + environment(); + var protocol = Protocol.fromFile(fixture()); + var client = + protocol + .client() + .trp( + new ClientOptions( + URI.create("http://127.0.0.1:1"), java.util.Map.of(), Duration.ofSeconds(2))) + .withProfile("preprod") + .withParty("sender", Party.address(new Address("addr_test1unreachable"))) + .withParty("receiver", Party.address(new Address("addr_test1unreachable"))) + .withParty("middleman", Party.address(new Address("addr_test1unreachable"))) + .build(); + + var failure = + assertThrows( + CompletionException.class, + () -> client.tx("transfer").arg("quantity", 10_000_000).resolve().join()); + assertInstanceOf(TransportException.class, failure.getCause()); + } + + private static Tx3Client liveClient(E2eEnvironment env) { + var protocol = Protocol.fromFile(fixture()); + var signer = new CardanoSigner(env.partyAMnemonic(), new Address(env.partyAAddress())); + return protocol + .client() + .trpEndpoint(URI.create(env.endpoint())) + .withHeader("dmtr-api-key", env.apiKey()) + .withProfile("preprod") + .withParty("sender", Party.signer(signer)) + .withParty("receiver", Party.address(new Address(env.partyBAddress()))) + .withParty("middleman", Party.address(new Address(env.partyBAddress()))) + .build(); + } + + private static Path fixture() { + return Path.of("src/test/resources/fixtures/transfer.tii"); + } + + private static E2eEnvironment environment() { + var missing = new ArrayList(); + for (var name : REQUIRED_ENV) { + if (System.getenv(name) == null || System.getenv(name).isBlank()) missing.add(name); + } + if (!missing.isEmpty()) { + var message = "Missing e2e configuration: " + String.join(", ", missing); + if (System.getenv("CI") != null) throw new AssertionError(message); + Assumptions.abort(message); + } + return new E2eEnvironment( + System.getenv("TRP_ENDPOINT_PREPROD"), + System.getenv("TRP_API_KEY_PREPROD"), + System.getenv("TEST_PARTY_A_ADDRESS"), + System.getenv("TEST_PARTY_A_MNEMONIC"), + System.getenv("TEST_PARTY_B_ADDRESS"), + System.getenv("TEST_PARTY_B_MNEMONIC")); + } + + private record E2eEnvironment( + String endpoint, + String apiKey, + String partyAAddress, + String partyAMnemonic, + String partyBAddress, + String partyBMnemonic) {} +} diff --git a/src/test/java/land/tx3/sdk/TransactionLifecycleTest.java b/src/test/java/land/tx3/sdk/TransactionLifecycleTest.java index d720cd6..96f57fe 100644 --- a/src/test/java/land/tx3/sdk/TransactionLifecycleTest.java +++ b/src/test/java/land/tx3/sdk/TransactionLifecycleTest.java @@ -142,6 +142,25 @@ void finalizedWaitIgnoresConfirmedAndReportsTerminalAndTimeoutKinds() { assertInstanceOf(PollingException.class, timeout.getCause()).kind()); } + @Test + void pollingRetriesTransientStatusTransportFailures() { + var transport = new LifecycleTransport(); + transport.failure( + new TransportException( + TransportFailure.HTTP_STATUS, + "TRP returned HTTP status 502", + null, + 502, + null, + "bad gateway")); + transport.status(HASH, TxStage.CONFIRMED); + + var status = submitted(transport).waitForConfirmed(new PollConfig(2, Duration.ZERO)).join(); + + assertEquals(TxStage.CONFIRMED, status.stage()); + assertEquals(List.of("trp.checkStatus", "trp.checkStatus"), transport.methods); + } + @Test void cancellationStopsInFlightStatusRequestAndScheduledDelay() { var pendingTransport = new LifecycleTransport(); @@ -233,6 +252,10 @@ CompletableFuture pendingStatus() { return pending; } + void failure(RuntimeException failure) { + responses.add(CompletableFuture.failedFuture(failure)); + } + @Override public CompletableFuture send(TransportRequest request) { try { diff --git a/src/test/resources/fixtures/README.md b/src/test/resources/fixtures/README.md index 8209496..6e5ba1b 100644 --- a/src/test/resources/fixtures/README.md +++ b/src/test/resources/fixtures/README.md @@ -1,10 +1,10 @@ # Canonical TII fixtures These test fixtures are copied without modification from the Tx3 SDK specification at umbrella -inspection revision `d9bfbaf891f3c67a96087ecaa84ae78e5c2584a3`: +inspection revision `2bc0b261fa6cc69643ff0fac769171424078de37`: - `transfer.tii` from `solution/sdks/sdk-spec/test-vectors/transfer/transfer.tii` - (`sha256:8d5d715f300e618373b588af96f0cfb9b0a3904b3a34fc0038f72ebb1695da31`) + (`sha256:02da9f4a6710dd4821cf219c5f373d14e3c933519953068c1b03e38947568d32`) - `complex.tii` from `solution/sdks/sdk-spec/test-vectors/complex-types/complex.tii` (`sha256:0a7195b22fe3262a87110a89ee83f75d1295257c193d8859165842aeeea2ae83`) diff --git a/src/test/resources/fixtures/transfer.tii b/src/test/resources/fixtures/transfer.tii index 9f00265..15754dd 100644 --- a/src/test/resources/fixtures/transfer.tii +++ b/src/test/resources/fixtures/transfer.tii @@ -49,7 +49,7 @@ "type": "object" }, "tir": { - "content": "ab6466656573a1694576616c506172616d6a457870656374466565736a7265666572656e6365738066696e7075747381a3646e616d6566736f75726365657574786f73a1694576616c506172616da16b457870656374496e7075748266736f75726365a56761646472657373a1694576616c506172616da16b45787065637456616c7565826673656e64657267416464726573736a6d696e5f616d6f756e74a16b4576616c4275696c74496ea16341646482a16b4576616c4275696c74496ea16341646482a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a16b4576616c4275696c74496ea16341646482a1694576616c506172616da16b45787065637456616c756582687175616e7469747963496e74a1694576616c506172616da16b45787065637456616c7565826374617863496e74a1694576616c506172616d6a45787065637446656573a16c4576616c436f6d70696c6572a16e436f6d707574654d696e5574786fa1664e756d6265720263726566644e6f6e65646d616e79f46a636f6c6c61746572616cf46872656465656d6572644e6f6e65676f75747075747383a46761646472657373a1694576616c506172616da16b45787065637456616c756582687265636569766572674164647265737365646174756d644e6f6e6566616d6f756e74a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a1694576616c506172616da16b45787065637456616c756582687175616e7469747963496e74686f7074696f6e616cf4a46761646472657373a1694576616c506172616da16b45787065637456616c756582696d6964646c656d616e674164647265737365646174756d644e6f6e6566616d6f756e74a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a1694576616c506172616da16b45787065637456616c7565826374617863496e74686f7074696f6e616cf4a46761646472657373a1694576616c506172616da16b45787065637456616c7565826673656e646572674164647265737365646174756d644e6f6e6566616d6f756e74a16b4576616c4275696c74496ea16353756282a16b4576616c4275696c74496ea16353756282a16a4576616c436f65726365a16a496e746f417373657473a1694576616c506172616da16b457870656374496e7075748266736f75726365a56761646472657373a1694576616c506172616da16b45787065637456616c7565826673656e64657267416464726573736a6d696e5f616d6f756e74a16b4576616c4275696c74496ea16341646482a16b4576616c4275696c74496ea16341646482a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a16b4576616c4275696c74496ea16341646482a1694576616c506172616da16b45787065637456616c756582687175616e7469747963496e74a1694576616c506172616da16b45787065637456616c7565826374617863496e74a1694576616c506172616d6a45787065637446656573a16c4576616c436f6d70696c6572a16e436f6d707574654d696e5574786fa1664e756d6265720263726566644e6f6e65646d616e79f46a636f6c6c61746572616cf4a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a16b4576616c4275696c74496ea16341646482a1694576616c506172616da16b45787065637456616c756582687175616e7469747963496e74a1694576616c506172616da16b45787065637456616c7565826374617863496e74a1694576616c506172616d6a45787065637446656573686f7074696f6e616cf46876616c6964697479f6656d696e747380656275726e7380656164686f63806a636f6c6c61746572616c80677369676e657273f6686d6574616461746180", + "content": "ab6466656573a1694576616c506172616d6a457870656374466565736a7265666572656e6365738066696e7075747381a3646e616d6566736f75726365657574786f73a1694576616c506172616da16b457870656374496e7075748266736f75726365a56761646472657373a1694576616c506172616da16b45787065637456616c7565826673656e64657267416464726573736a6d696e5f616d6f756e74a16b4576616c4275696c74496ea16341646482a16b4576616c4275696c74496ea16341646482a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a16b4576616c4275696c74496ea16341646482a1694576616c506172616da16b45787065637456616c756582687175616e7469747963496e74a1694576616c506172616da16b45787065637456616c7565826374617863496e74a1694576616c506172616d6a45787065637446656573a16c4576616c436f6d70696c6572a16e436f6d707574654d696e5574786fa1664e756d6265720263726566644e6f6e65646d616e79f56a636f6c6c61746572616cf46872656465656d6572644e6f6e65676f75747075747383a46761646472657373a1694576616c506172616da16b45787065637456616c756582687265636569766572674164647265737365646174756d644e6f6e6566616d6f756e74a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a1694576616c506172616da16b45787065637456616c756582687175616e7469747963496e74686f7074696f6e616cf4a46761646472657373a1694576616c506172616da16b45787065637456616c756582696d6964646c656d616e674164647265737365646174756d644e6f6e6566616d6f756e74a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a1694576616c506172616da16b45787065637456616c7565826374617863496e74686f7074696f6e616cf4a46761646472657373a1694576616c506172616da16b45787065637456616c7565826673656e646572674164647265737365646174756d644e6f6e6566616d6f756e74a16b4576616c4275696c74496ea16353756282a16b4576616c4275696c74496ea16353756282a16a4576616c436f65726365a16a496e746f417373657473a1694576616c506172616da16b457870656374496e7075748266736f75726365a56761646472657373a1694576616c506172616da16b45787065637456616c7565826673656e64657267416464726573736a6d696e5f616d6f756e74a16b4576616c4275696c74496ea16341646482a16b4576616c4275696c74496ea16341646482a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a16b4576616c4275696c74496ea16341646482a1694576616c506172616da16b45787065637456616c756582687175616e7469747963496e74a1694576616c506172616da16b45787065637456616c7565826374617863496e74a1694576616c506172616d6a45787065637446656573a16c4576616c436f6d70696c6572a16e436f6d707574654d696e5574786fa1664e756d6265720263726566644e6f6e65646d616e79f56a636f6c6c61746572616cf4a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a16b4576616c4275696c74496ea16341646482a1694576616c506172616da16b45787065637456616c756582687175616e7469747963496e74a1694576616c506172616da16b45787065637456616c7565826374617863496e74a1694576616c506172616d6a45787065637446656573686f7074696f6e616cf46876616c6964697479f6656d696e747380656275726e7380656164686f63806a636f6c6c61746572616c80677369676e657273f6686d6574616461746180", "encoding": "hex", "version": "v1beta0" }