From 13bbe0913d4f2ddb954e74e6836520053555a6cc Mon Sep 17 00:00:00 2001 From: KRIS <307999362+kris-nana@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:51:32 +0100 Subject: [PATCH 1/2] feat(backend): Stellar key custody & transaction signing service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds server-side signing and submission of Stellar/Soroban transactions. Callers hand over the operations to execute and nothing else — no source account, no sequence number, no fee, no key — and the service sees the transaction through to a terminal state. Custody sits behind a SigningProvider interface with no method that can return a key. LocalSigningProvider parses configured seeds once at startup and holds only KeyPairs; KmsSigningProvider delegates to an external gateway so the secret key never enters this process, and verifies every returned signature against the reference's known public key before use. Selection is by profile property, so swapping one for the other is configuration rather than code. SecretRedactor backstops every string that reaches a log or the last_error column, and the request DTOs reject anything shaped like a secret seed outright. Sequence numbers come from a leased pool of channel accounts claimed with SELECT ... FOR UPDATE SKIP LOCKED: N concurrent submissions get N distinct accounts and N distinct sequence numbers with no coordination beyond the database. A lease is only returned as AVAILABLE when the transaction actually reached a ledger; anything that never landed forces a resync against the chain, which is what stops one lost transaction from poisoning an account permanently. Submission runs as three claim-one-row workers. The signed envelope is durable before it is broadcast, never after, and the broadcast phase begins by asking the network about the hash it holds — so a restart resumes rather than double-submits. Failures are classified from the decoded TransactionResult and each gets the recovery it needs: tx_bad_seq resyncs and rebuilds, tx_insufficient_fee fee-bumps, tx_too_late rebuilds with fresh time bounds, and a failed simulation is terminal before anything is signed. Fee bumps double the fee until the ceiling, which turns a stall into a bounded state instead of unbounded spend. Adds network.lightsail:stellar-sdk, the maintained successor to the archived official Java SDK. This reverses decision 1 of ESCROW_ORCHESTRATION.md, which found no such SDK on Maven Central; every acceptance criterion here needs real XDR, and hand-rolling it was what that decision rightly refused to do. SorobanRpcClient still treats envelopes and results as opaque strings everywhere except getAccountSequence, where a ledger entry can only be addressed and read as XDR. Tests cover the concurrency criterion against real PostgreSQL (8 threads released by a barrier, 8 distinct sequence numbers), the full pipeline phase by phase, the KMS gateway contract over MockWebServer, the HTTP/problem+json contract and ADMIN gating, and the key-material guarantees. CI now runs ./mvnw -B verify with the existing Maven cache. Closes #40 --- .github/workflows/test.yml | 8 +- backend-api/.env.example | 19 + backend-api/README.md | 39 ++ backend-api/docs/STELLAR_SIGNING.md | 378 +++++++++++ backend-api/pom.xml | 17 + .../escrow/rpc/SimulateTransactionResult.java | 20 + .../api/escrow/rpc/SorobanRpcClient.java | 96 ++- .../api/handler/GlobalExceptionHandler.java | 79 +++ .../api/signing/SigningProperties.java | 134 ++++ .../signing/api/ChannelAccountController.java | 118 ++++ .../signing/api/ChannelAccountResponse.java | 37 + .../api/RegisterChannelAccountRequest.java | 28 + .../signing/api/SubmitTransactionRequest.java | 52 ++ .../api/TransactionSigningController.java | 97 +++ .../api/TransactionSubmissionResponse.java | 63 ++ .../signing/custody/KmsSigningProvider.java | 211 ++++++ .../signing/custody/LocalSigningProvider.java | 113 ++++ .../api/signing/custody/SecretRedactor.java | 52 ++ .../api/signing/custody/SigningProvider.java | 54 ++ .../custody/SigningProviderException.java | 21 + .../custody/UnknownKeyReferenceException.java | 21 + .../api/signing/model/ChannelAccount.java | 108 +++ .../signing/model/ChannelAccountStatus.java | 31 + .../model/SubmissionFailureReason.java | 62 ++ .../api/signing/model/SubmissionStatus.java | 48 ++ .../signing/model/TransactionSubmission.java | 172 +++++ .../repository/ChannelAccountRepository.java | 70 ++ .../TransactionSubmissionRepository.java | 51 ++ ...nnelAccountAlreadyRegisteredException.java | 13 + .../service/ChannelAccountBusyException.java | 13 + .../service/ChannelAccountLeaseService.java | 225 +++++++ .../ChannelAccountNotFoundException.java | 9 + .../service/ChannelAccountService.java | 107 +++ .../signing/service/FailureClassifier.java | 76 +++ .../NoChannelAccountAvailableException.java | 15 + .../api/signing/service/SequenceLease.java | 18 + .../service/SubmissionNotFoundException.java | 9 + .../signing/service/TransactionAssembler.java | 145 ++++ .../service/TransactionAssemblyException.java | 20 + .../signing/service/TransactionSigner.java | 63 ++ .../TransactionSubmissionInserter.java | 49 ++ .../service/TransactionSubmissionService.java | 588 ++++++++++++++++ .../src/main/resources/application.properties | 58 ++ .../api/escrow/rpc/SorobanRpcClientTest.java | 100 +++ .../ChannelAccountLeaseIntegrationTest.java | 361 ++++++++++ .../api/signing/StellarTestFixtures.java | 173 +++++ .../TransactionSubmissionIntegrationTest.java | 636 ++++++++++++++++++ .../api/signing/api/SigningApiTest.java | 274 ++++++++ .../api/SigningRequestValidationTest.java | 109 +++ .../custody/KmsSigningProviderTest.java | 188 ++++++ .../custody/LocalSigningProviderTest.java | 116 ++++ .../signing/custody/SecretRedactorTest.java | 66 ++ .../service/FailureClassifierTest.java | 61 ++ .../service/TransactionAssemblerTest.java | 161 +++++ 54 files changed, 5845 insertions(+), 7 deletions(-) create mode 100644 backend-api/docs/STELLAR_SIGNING.md create mode 100644 backend-api/src/main/java/com/guildworkman/api/escrow/rpc/SimulateTransactionResult.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/SigningProperties.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/api/ChannelAccountController.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/api/ChannelAccountResponse.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/api/RegisterChannelAccountRequest.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/api/SubmitTransactionRequest.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/api/TransactionSigningController.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/api/TransactionSubmissionResponse.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/custody/KmsSigningProvider.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/custody/LocalSigningProvider.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/custody/SecretRedactor.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/custody/SigningProvider.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/custody/SigningProviderException.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/custody/UnknownKeyReferenceException.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/model/ChannelAccount.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/model/ChannelAccountStatus.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/model/SubmissionFailureReason.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/model/SubmissionStatus.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/model/TransactionSubmission.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/repository/ChannelAccountRepository.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/repository/TransactionSubmissionRepository.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountAlreadyRegisteredException.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountBusyException.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountLeaseService.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountNotFoundException.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountService.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/FailureClassifier.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/NoChannelAccountAvailableException.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/SequenceLease.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/SubmissionNotFoundException.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionAssembler.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionAssemblyException.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSigner.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSubmissionInserter.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSubmissionService.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/ChannelAccountLeaseIntegrationTest.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/StellarTestFixtures.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/TransactionSubmissionIntegrationTest.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/api/SigningApiTest.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/api/SigningRequestValidationTest.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/custody/KmsSigningProviderTest.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/custody/LocalSigningProviderTest.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/custody/SecretRedactorTest.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/service/FailureClassifierTest.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/service/TransactionAssemblerTest.java diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9020c2c..3540561 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,9 +42,13 @@ jobs: java-version: '17' distribution: 'temurin' cache: maven - - name: Run tests + # `verify` runs the full test suite and then packages the application, so + # a change that tests green but can't be built still fails the workflow. + # Maven's dependency cache is handled by setup-java's `cache: maven` + # above, keyed on pom.xml. + - name: Build and test env: DATABASE_URL: jdbc:postgresql://localhost:5432/service DATABASE_USERNAME: postgres DATABASE_PASSWORD: password - run: ./mvnw -B test + run: ./mvnw -B verify diff --git a/backend-api/.env.example b/backend-api/.env.example index 21e38da..7319f93 100644 --- a/backend-api/.env.example +++ b/backend-api/.env.example @@ -22,4 +22,23 @@ PAYSTACK_SECRET_KEY= PAYSTACK_VERIFY_URL=https://api.paystack.co/transaction/verify PAYSTACK_INITIATE_URL=https://api.paystack.co/transaction/initialize +# Stellar key custody & transaction signing (backend-api/docs/STELLAR_SIGNING.md). +# `local` reads seeds from the STELLAR_LOCAL_KEYS_* variables below and is for +# development and CI only. Real environments run `kms`, where the secret key +# never leaves the gateway and none of the seed variables are set at all. +STELLAR_SIGNING_PROVIDER=local +STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 +SOROBAN_RPC_URL=https://soroban-testnet.stellar.org + +# One entry per channel account, keyed by the reference used to register it: +# STELLAR_LOCAL_KEYS_=S... -> stellar.signing.local.keys. +# These are secret seeds. Keep them in .env (git-ignored), never in a commit, +# and use throwaway testnet accounts. +STELLAR_LOCAL_KEYS_CHANNEL1= +STELLAR_LOCAL_KEYS_CHANNEL2= + +# KMS/HSM gateway, used when STELLAR_SIGNING_PROVIDER=kms. +STELLAR_KMS_URL= +STELLAR_KMS_API_KEY= + H2_CONSOLE_ENABLED=false diff --git a/backend-api/README.md b/backend-api/README.md index 351ad90..3062cb8 100644 --- a/backend-api/README.md +++ b/backend-api/README.md @@ -111,6 +111,11 @@ Core JPA entities in `data/models/`: ([`docs/APPOINTMENT_BOOKING.md`](docs/APPOINTMENT_BOOKING.md)) - Consultations: booking a consultation and scheduling client/worker availability - Skilled-worker profile management, skill listing, and geo lookup (`/nearby`) +- Server-side Stellar/Soroban transaction signing — callers hand over operations, + not a signed envelope or a key. Pluggable custody (local seeds in development, + an external KMS/HSM in production), concurrency-safe sequence numbers from a + channel-account pool, and fee-bump retries under a bounded fee ceiling + ([`docs/STELLAR_SIGNING.md`](docs/STELLAR_SIGNING.md)) - Payment initiation/verification via **Paystack** (service layer + `PaymentServiceImpl`) - Transactional email sending (mail service, provider-agnostic API key + URL config) - Reviews and worker ratings (`ReviewServiceImpl`) @@ -171,6 +176,15 @@ export the values into your shell, IDE run configuration, or `docker run --env-f | `paystack.verify.payment.url` | `PAYSTACK_VERIFY_URL` | `https://api.paystack.co/transaction/verify` | | `paystack.initiate.payment` | `PAYSTACK_INITIATE_URL` | `https://api.paystack.co/transaction/initialize` | | `spring.h2.console.enabled` | `H2_CONSOLE_ENABLED` | `false` | +| `stellar.signing.provider` | `STELLAR_SIGNING_PROVIDER` | `local` (use `kms` in production) | +| `stellar.signing.network-passphrase` | `STELLAR_NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | +| `stellar.signing.local.keys.` | `STELLAR_LOCAL_KEYS_` | *(empty — development seeds; never commit)* | +| `stellar.signing.kms.url` | `STELLAR_KMS_URL` | *(empty — required when `provider=kms`)* | +| `stellar.signing.kms.api-key` | `STELLAR_KMS_API_KEY` | *(empty — required when `provider=kms`)* | + +The remaining `stellar.signing.*` knobs (fee ceiling, lease TTL, retry/backoff, +worker poll intervals) are listed in +[`docs/STELLAR_SIGNING.md`](docs/STELLAR_SIGNING.md#configuration). > **Security note:** this repository's git history (both the old `secret.properties` > committed file and, briefly, this repo's own earlier state) contains real @@ -281,6 +295,31 @@ well-formed, it just arrived second. These endpoints are unwrapped (no `ApiResponse` envelope) — they return the reservation or availability object directly, as the escrow endpoints do. +### Stellar signing — `/api/v1/stellar` + +Server-side signing and submission of Stellar/Soroban transactions. Callers +submit an unsigned envelope carrying only the **operations** to execute; the +service leases a channel account, sets the source, sequence number, fee and +time bounds, simulates (for Soroban invocations), signs through the configured +custody backend and sees the transaction through to a terminal state, +fee-bumping it if it stalls. Full design notes: +[`docs/STELLAR_SIGNING.md`](docs/STELLAR_SIGNING.md). + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| POST | `/transactions` | Bearer | Sign and submit (202). Body: `idempotencyKey`, `unsignedTransactionXdr`, optional `reference` and `extraSignerKeyRefs`. Idempotent — see `X-Idempotent-Replay` | +| GET | `/transactions/{id}` | Bearer | A submission's current state | +| GET | `/transactions?reference=` | Bearer | Find submissions by the caller's own reference | +| POST | `/channel-accounts` | ADMIN | Register a channel account by key **reference** (201) | +| GET | `/channel-accounts` | ADMIN | List the pool with lease state | +| GET | `/channel-accounts/{id}` | ADMIN | One pool member | +| POST | `/channel-accounts/{id}/disable` | ADMIN | Take an account out of the pool (409 while leased) | +| POST | `/channel-accounts/{id}/enable` | ADMIN | Return it as `NEEDS_RESYNC` | +| POST | `/channel-accounts/{id}/resync` | ADMIN | Re-read its sequence number from the network | + +No request or response here ever carries key material: accounts are identified +by key *reference*, and no endpoint returns a signed envelope. + ### `SkilledWorkerController` — `/api/v1/skilledWorker` | Method | Path | Purpose | diff --git a/backend-api/docs/STELLAR_SIGNING.md b/backend-api/docs/STELLAR_SIGNING.md new file mode 100644 index 0000000..1259bcc --- /dev/null +++ b/backend-api/docs/STELLAR_SIGNING.md @@ -0,0 +1,378 @@ +# Stellar Key Custody & Transaction Signing Service + +Implements [#40](https://github.com/workman-labs/guildworkman-core/issues/40): +server-side signing and submission of Stellar/Soroban transactions, with a +custody abstraction that keeps secret keys out of this process in production, +concurrency-safe sequence-number allocation, and submission tracking that +survives a restart without double-submitting. + +The short version of what this changes for callers: previously they had to +build **and sign** a transaction themselves and hand us the finished envelope +(`POST /api/v1/escrow/orchestrations`, issue #21). Now they can hand us the +*operations* and nothing else — no source account, no sequence number, no fee, +no key — and the service does the rest. + +## New dependencies + +One: **`network.lightsail:stellar-sdk:3.1.0`**. + +This reverses decision 1 of +[`ESCROW_ORCHESTRATION.md`](ESCROW_ORCHESTRATION.md), which said no Java +Stellar/Soroban SDK existed on Maven Central and therefore relayed +already-signed envelopes as opaque strings. That was accurate about the +*official* SDK — `stellar/java-stellar-sdk` was archived by SDF — but +incomplete: `network.lightsail:stellar-sdk` is its actively maintained +successor, published to Maven Central, targeting Java 8+ bytecode, and +depending only on things this project already has or can carry (OkHttp 4.12, +Gson, Bouncy Castle, commons-codec). + +The reversal isn't optional for this feature. Every one of the issue's +acceptance criteria needs real XDR: + +| Requirement | XDR it needs | +|---|---| +| Set the sequence number on a transaction | Build and re-encode a `TransactionEnvelope` | +| Sign with a key we hold | Compute the transaction hash over the network passphrase | +| Fee-bump a stalled transaction | Wrap an envelope in a `FeeBumpTransaction` | +| Distinguish `tx_bad_seq` from `tx_insufficient_fee` | Decode `TransactionResult` | +| Read a channel account's real sequence | Encode a `LedgerKey`, decode a `LedgerEntry` | + +Hand-rolling that binary encoding was the thing decision 1 (rightly) refused to +do. With a maintained SDK available, the trade-off it was weighing no longer +holds. + +`SorobanRpcClient` keeps its old behaviour everywhere it can: envelopes, +hashes and result blobs still pass through `sendTransaction` / +`getTransaction` / `simulateTransaction` as opaque strings. The single place +it now decodes XDR is `getAccountSequence`, because a ledger entry can only be +*addressed* by an XDR `LedgerKey` and only read back out of XDR — there is no +string-shaped alternative. + +## Schema / migrations + +No migration file, for the same reason as every other table here: this +codebase has no Flyway/Liquibase and manages schema through Hibernate's +`spring.jpa.hibernate.ddl-auto=update`, with the entity classes as the source +of truth. Two new tables: + +- **`stellar_channel_accounts`** (`ChannelAccount.java`) — the pool. Unique on + `account_id` and on `key_ref`, indexed on `status`. +- **`stellar_transaction_submissions`** (`TransactionSubmission.java`) — one + row per logical submission. Unique on `idempotency_key`; indexed on + `(status, next_attempt_at)` for the worker claim query, on + `transaction_hash`, and on `reference`. + +Both tables are new, so `ddl-auto=update` simply creates them on first deploy +and no-ops thereafter; there is no existing column or data they could conflict +with. The known limitation of this approach — it cannot express a safe rename +or type change — is unchanged and app-wide, not specific to this feature. + +## Key material + +This is the part worth reading closely, since the failure mode is +unrecoverable: a leaked Stellar secret seed is a drained account. + +- **`SigningProvider` has no method that can return a key.** `providerId`, + `supports`, `publicKey`, `sign` — that's the whole interface. There is no + `secretSeed()` to accidentally call, log, or serialize. + `LocalSigningProviderTest` asserts the method set by reflection, so adding + one later fails the build. +- **In production the key is never in this process.** With + `stellar.signing.provider=kms`, `KmsSigningProvider` sends the 32-byte + transaction hash to a gateway and receives a signature. Nothing else crosses + the wire, and the gateway contract is two endpoints of plain JSON, so it can + front AWS KMS, GCP KMS, Vault Transit or an HSM without this class changing. +- **Every returned signature is verified** against the key reference's known + public key before use. A gateway signing with the wrong key fails here, + loudly, instead of surfacing seconds later as an opaque `txBAD_AUTH` that has + already cost a sequence number. +- **In development the seed never leaves `KeyPair`.** `LocalSigningProvider` + parses configured seeds once at startup and holds only `KeyPair` instances. + A bad seed fails startup with a message naming *only the key reference*, and + the parse exception's cause is dropped deliberately — strkey parsers + habitually quote the input that broke them. +- **Nothing is registered or submitted using key material.** Channel accounts + are registered by *reference*; the account id is derived from custody. A + Stellar seed is 56 characters of letters and digits, which is exactly the + shape of a plausible alias, so the request DTOs carry an explicit + negative-lookahead pattern rejecting anything seed-shaped — a fat-fingered + paste is a `400`, not a secret in a database column and an access log. +- **`SecretRedactor` is the backstop.** Every string persisted to `last_error` + or written to a log passes through it. It replaces full-width `S…` strkeys + only: account ids (`G…`), contract ids (`C…`) and muxed accounts (`M…`) share + the alphabet but not the version character and are left intact, because + redacting them would blind operators to the identifiers they diagnose with. +- **Secrets are confined to holder classes with overridden `toString()`** — + `SigningProperties.Local` prints key *names* only, `SigningProperties.Kms` + masks the API key — so a stray `log.debug("{}", props)` can't leak them. +- **No response body contains an envelope.** `TransactionSubmissionResponse` + exposes public chain data (accounts, sequence, fee, hashes, ledger) and our + own bookkeeping. The signed envelope, which carries signatures, is not in it. + +## Architecture decisions + +1. **Sequence numbers come from a leased pool of channel accounts, not from a + counter.** Stellar validates a transaction's sequence number as *exactly* + the source account's current sequence plus one. Handing out `n` and `n+1` + concurrently on one account parallelises nothing: the network takes + whichever arrives first and rejects the other with `txBAD_SEQ`. Real + concurrency has to come from having more accounts. So + `ChannelAccountLeaseService.acquire` claims one free account with + `SELECT … FOR UPDATE SKIP LOCKED` (Hibernate's `-2` lock timeout hint), + allocates its next sequence, and marks it `LEASED` — N concurrent + submissions therefore get N distinct accounts and N distinct sequence + numbers, with no coordination beyond the database. + `ChannelAccountLeaseIntegrationTest` proves exactly that against real + PostgreSQL, with a `CyclicBarrier` so the threads genuinely contend. + +2. **`SKIP LOCKED` for channel accounts, plain `FOR UPDATE` for submissions.** + They're different questions. "Give me *any* free account" is what + `SKIP LOCKED` is for — a locked row is simply not the one you want, so + skipping it is free parallelism. "Give me *this* submission row" can't skip + anything; two workers reaching the same row must serialise, which plain + `FOR UPDATE` does. Using `SKIP LOCKED` there would silently drop work. + +3. **A lease is only released as `AVAILABLE` if the transaction reached a + ledger.** This is the subtlest correctness property in the feature. A + sequence number allocated to a transaction that never lands is *skipped*, + and every later transaction from that account would then be rejected — the + local counter has run ahead of the chain. So confirmation *and* on-chain + failure both release to `AVAILABLE` (both spend the number), while anything + that never landed — a fee ceiling hit, lapsed time bounds, a dead-lettered + row — releases to `NEEDS_RESYNC`, and the next lease re-reads the account's + real sequence from the network first. That single rule is what stops one + lost transaction from poisoning an account permanently. + +4. **The service rebuilds the caller's operations rather than relaying their + envelope.** Signing commits to source account, sequence, fee *and* time + bounds — all four live inside the signed payload. A service that accepts + pre-signed envelopes (as #21 does) therefore has exactly one recovery move + available: send the same bytes again. Keeping only the caller's + *operations* is what makes every recovery path below possible. The caller's + own source/sequence/fee/time-bounds are placeholders and are always + replaced; preconditions beyond time bounds (minimum sequence age, ledger + bounds, extra signers) are **rejected** rather than silently dropped, since + they constrain precisely the fields this service owns. + +5. **The envelope is durable before it is broadcast, never after.** That + ordering is the restart-safety trick. A process that dies anywhere in the + broadcast phase comes back holding the exact hash it may have sent, and the + phase *begins* by asking the network about that hash rather than signing + something new. Even a genuine resend can't double-execute — identical bytes + have an identical hash, which Soroban RPC answers with `DUPLICATE` — but the + pre-check adds correct accounting: a transaction that landed while we were + away is recognised as confirmed rather than re-sent and re-polled. + +6. **Each failure code gets the recovery it actually needs.** Treating a + rejection as an opaque "it failed" produces the two classic bugs this + feature exists to avoid. `FailureClassifier` decodes the + `TransactionResult`: + + | Result code | Reason | What happens | + |---|---|---| + | `txBAD_SEQ` | `BAD_SEQUENCE` | Release for resync, rebuild — retrying unchanged would reproduce it forever | + | `txINSUFFICIENT_FEE` | `INSUFFICIENT_FEE` | Fee-bump — failing here would throw away a landable transaction | + | `txTOO_LATE` / `txTOO_EARLY` | `TOO_LATE` | Rebuild with fresh time bounds | + | `txMALFORMED`, `txMISSING_OPERATION`, `txSOROBAN_INVALID`, `txNOT_SUPPORTED` | `MALFORMED` | Terminal | + | `txBAD_AUTH`, `txBAD_AUTH_EXTRA` | `BAD_AUTH` | Terminal | + | `txINSUFFICIENT_BALANCE`, `txNO_ACCOUNT` | `INSUFFICIENT_BALANCE` | Terminal — an operator has to fund the account | + | `txFAILED` | `ON_CHAIN_FAILED` | Terminal, sequence consumed | + | anything undecodable | `UNKNOWN` | Bounded retries — a future protocol code shouldn't crash a worker | + +7. **Soroban invocations are simulated before they're signed.** A contract call + simulation says cannot succeed is failed terminally with + `SIMULATION_FAILED`. Submitting it anyway would burn a fee and a sequence + number to learn what simulation just reported for free. Simulation also + supplies the `SorobanTransactionData` the transaction needs, and its + resource fee is what the builder adds on top of the inclusion fee — which is + why the final fee is read back off the built transaction rather than + computed. + +8. **The fee ceiling is ours to enforce.** `FeeBumpTransaction.createWithFee` + performs no validation whatsoever — `TransactionAssemblerTest` pins that + behaviour, including that it will happily accept a fee *lower* than the + inner transaction's. So `stellar.signing.fee.max-total-stroops` is checked + twice in our code: before signing, and before every bump. + +9. **Three workers, not one.** Prepare, broadcast and confirm each claim their + own rows with their own backoff, so a slow ledger never blocks new signing + and a signing outage never blocks confirmations. Same claim-one-row-per-tick + shape as `EscrowOrchestrationService`, which also naturally throttles how + many requests hit an unhealthy RPC endpoint at once. + +## Why this always terminates + +Worth stating explicitly, because "retry until it lands" is exactly how a +signing service ends up either spending unbounded fees or polling a dead +transaction forever. Three bounds, and every path hits one of them: + +- **Fee bumps are bounded by the ceiling.** Each bump multiplies the fee + (default ×2); the first that would cross `fee.max-total-stroops` fails the + submission with `FEE_CEILING_REACHED` instead. Stalling is a bounded state, + not an open-ended one. +- **A lapsed transaction is rebuilt, not polled.** Past its `maxTime` the + network can never include it, so there is no verdict left to wait for. +- **Rebuilds and retries are bounded by `retry.max-attempts`,** after which the + row moves to `DEAD_LETTER` for an operator. +- **A crashed holder's lease expires.** `sweepExpiredLeases` reclaims it — + but conservatively: a lease whose submission is still non-terminal has its + TTL extended rather than reclaimed, because that transaction may be in the + mempool right now and reusing its sequence number would race it. Only an + orphan (the process died between committing the lease and committing the + submission) is reclaimed, always to `NEEDS_RESYNC`. + +## Retry, backoff & observability + +- **Capped exponential backoff with jitter**, identical in shape to + `escrow.orchestration.retry.*`: doubling from `base-delay`, capped at + `max-delay`, randomised by ± `jitter`. A batch of submissions that failed + together (an RPC outage) must not come back in lockstep and reproduce it. +- **Every state transition is logged with the submission id** and, once + signed, the transaction hash — so one submission's whole life is greppable. + Fee bumps log both hashes, since after a bump either may be the one that + lands. +- **No metrics, no circuit breaker** — same reasoning as + `ESCROW_ORCHESTRATION.md`: there is no Micrometer or Resilience4j anywhere in + this codebase, and introducing either is a cross-cutting infrastructure + choice that deserves its own PR rather than being smuggled into a feature. + What bounds the damage meanwhile: per-call HTTP timeouts, bounded attempts + into `DEAD_LETTER`, and one-row-per-tick claiming. + +## API behavior + +- **`POST /api/v1/stellar/transactions` always returns `202 Accepted`.** + Signing needs a channel account, simulation needs a network round-trip, and + inclusion needs a ledger to close; none of that fits inside a request. + Callers poll `GET /{id}`, or look submissions up by their own `reference`. +- **Idempotent on `idempotencyKey`**, with `X-Idempotent-Replay: true|false` + telling you which case happened — the same contract the escrow endpoints + use, and for a sharper reason here: a duplicate wouldn't merely duplicate + bookkeeping, it would lease a second account, consume a second sequence + number and put a second transaction on-chain for one logical request. +- **Envelope and key-reference validation happen synchronously**, on the + caller's own request, so a malformed envelope or an unknown key reference is + a `400` they can act on rather than a dead-lettered row nobody is watching. +- **Errors are RFC 7807 `application/problem+json`**, via the existing + `GlobalExceptionHandler` — no new error contract. `SigningProviderException` + is deliberately answered with a fixed detail string: the exception already + redacts itself, and this is a second guarantee that nothing from the signing + path reaches a client body. +- **`GET /{id}` reports current state, not an audit log.** Per-attempt detail + lives in application logs, correlated by submission id. + +## Operations + +- **Sizing the pool.** The pool size *is* the concurrency limit: one in-flight + transaction per account. `GET /api/v1/stellar/channel-accounts` is the health + view — a pool where every member is `LEASED` is about to start answering + `503 no-channel-account-available`. Every account must be funded on-chain + before it can be leased; an unfunded one is refused with a message saying so. +- **Adding an account** is `POST /api/v1/stellar/channel-accounts` with a key + reference. The account id is derived through custody, so an account this + deployment can't sign for cannot be added. It starts `NEEDS_RESYNC` and its + sequence is read from the network on first use, which keeps registration free + of RPC dependencies. +- **Rotating one out**: `POST /{id}/disable`. Refused with `409` while the + account is leased — disabling mid-transaction would strand its sequence + number. `POST /{id}/enable` returns it as `NEEDS_RESYNC`, never straight to + `AVAILABLE`. +- **`POST /{id}/resync`** is the manual escape hatch for an account whose + cached sequence has drifted, typically because something outside this service + also used it. The service does this by itself after any failure that implies + drift; the endpoint is for the cases an operator spots first. Also refused + while leased: mid-lease the chain still reports the sequence *before* the + in-flight transaction, so writing it back would hand the next lease a number + already in use. +- **A `DEAD_LETTER` row needs a human.** It has exhausted its attempts; its + channel account has already been released for resync, so the pool is not + stuck. Read `last_error` and `failure_reason`, fix the cause, and resubmit + under a new idempotency key. +- **Switching to KMS** is configuration only: set + `stellar.signing.provider=kms` plus the gateway URL and token, and unset the + local seeds. The two providers are interchangeable behind `SigningProvider` + precisely so this is not a code change. + +## Endpoints + +| Method | Path | Auth | Description | +|---|---|---|---| +| `POST` | `/api/v1/stellar/transactions` | Bearer | Sign and submit a transaction (idempotent; `202`) | +| `GET` | `/api/v1/stellar/transactions/{id}` | Bearer | Fetch a submission's current state | +| `GET` | `/api/v1/stellar/transactions?reference=` | Bearer | Find submissions by the caller's own reference | +| `POST` | `/api/v1/stellar/channel-accounts` | Bearer + ADMIN | Register a channel account by key reference (`201`) | +| `GET` | `/api/v1/stellar/channel-accounts` | Bearer + ADMIN | List the pool with lease state | +| `GET` | `/api/v1/stellar/channel-accounts/{id}` | Bearer + ADMIN | Fetch one pool member | +| `POST` | `/api/v1/stellar/channel-accounts/{id}/disable` | Bearer + ADMIN | Take an account out of the pool | +| `POST` | `/api/v1/stellar/channel-accounts/{id}/enable` | Bearer + ADMIN | Return it as `NEEDS_RESYNC` | +| `POST` | `/api/v1/stellar/channel-accounts/{id}/resync` | Bearer + ADMIN | Re-read its sequence from the network | + +All endpoints are documented in OpenAPI (`/swagger-ui.html`) via springdoc +annotations on the controllers. + +## Configuration + +| Property | Default | Purpose | +|---|---|---| +| `stellar.signing.provider` | `local` | Active custody backend: `local` or `kms` | +| `stellar.signing.network-passphrase` | `Test SDF Network ; September 2015` | Network the hash is signed over; must match `soroban.rpc.url`'s network | +| `stellar.signing.fee-source-key-ref` | *(empty)* | Key that pays for fee bumps; empty means the channel account pays for its own | +| `stellar.signing.local.keys.` | — | Development seeds, supplied through the environment. Never committed | +| `stellar.signing.kms.url` | *(empty)* | Signing gateway base URL. Required when `provider=kms` | +| `stellar.signing.kms.api-key` | *(empty)* | Bearer credential for the gateway | +| `stellar.signing.kms.request-timeout` | `PT5S` | Per-call gateway timeout | +| `stellar.signing.transaction-timeout` | `PT120S` | `maxTime` bound written into every built transaction | +| `stellar.signing.stall-after` | `PT30S` | Unconfirmed-in-mempool period before a fee bump. Keep well under `transaction-timeout` | +| `stellar.signing.lease-ttl` | `PT5M` | How long a lease survives without progress — the crash-recovery bound | +| `stellar.signing.lease-sweep-delay-ms` | `30000` | Lease sweeper interval | +| `stellar.signing.fee.base-stroops` | `100` | Inclusion fee per operation (100 is the network minimum) | +| `stellar.signing.fee.max-total-stroops` | `1000000` | Hard ceiling on one transaction's total fee (0.1 XLM) | +| `stellar.signing.fee.bump-multiplier` | `2.0` | Multiplier applied on each fee bump | +| `stellar.signing.retry.max-attempts` | `5` | Attempts before `DEAD_LETTER` | +| `stellar.signing.retry.base-delay` | `PT1S` | Backoff for attempt 1; doubles thereafter | +| `stellar.signing.retry.max-delay` | `PT64S` | Ceiling on the pre-jitter backoff | +| `stellar.signing.retry.jitter` | `0.2` | ± fraction to randomise the backoff by | +| `stellar.signing.prepare-poll-delay-ms` | `1000` | `preparePending()` interval | +| `stellar.signing.broadcast-poll-delay-ms` | `1000` | `broadcastSigned()` interval | +| `stellar.signing.confirm-poll-delay-ms` | `1000` | `pollBroadcast()` interval | + +Environment-variable names for all of these are in +[`.env.example`](../.env.example). + +## Tests + +`./mvnw verify` runs everything. The ones worth knowing about: + +- **`ChannelAccountLeaseIntegrationTest`** — the acceptance criterion, against + real PostgreSQL: 8 threads released simultaneously by a `CyclicBarrier` get 8 + distinct accounts and 8 distinct sequence numbers; an exhausted pool refuses + the surplus rather than reusing an account; the consumed/unconsumed release + paths and the lease sweeper's conservative half are covered too. An in-memory + database would test nothing here — `SKIP LOCKED` is the mechanism under test. +- **`TransactionSubmissionIntegrationTest`** — the pipeline, phase by phase, + with the RPC stubbed: the happy path (including verifying that the persisted + envelope really carries the leased sequence and a valid signature by the + channel key), idempotent replay, restart safety, each failure classification, + stall→fee-bump, the ceiling loop terminating, expiry→rebuild, landing as the + pre-bump hash, and dead-lettering. +- **`KmsSigningProviderTest`** — the gateway contract over `MockWebServer`, + including the two misconfiguration cases (wrong key, non-verifying signature) + that must fail here rather than at the network. +- **`SigningApiTest`** — status codes, the replay header, the problem+json + contract, and the authorisation rules. +- **`LocalSigningProviderTest`, `SecretRedactorTest`, `SigningRequestValidationTest`** — + the key-material guarantees listed under [Key material](#key-material). + +## Follow-ups (out of scope for this PR) + +- A reference implementation of the KMS gateway itself (this PR defines and + consumes the contract; standing up an AWS KMS or Vault-backed signer behind + it is deployment work). +- Automatic channel-account funding/top-up. Today an underfunded account + surfaces as `INSUFFICIENT_BALANCE` and an operator refills it. +- A persisted per-attempt audit trail, if grepping logs by submission id proves + insufficient operationally. +- Micrometer metrics — pool utilisation and fee spend are the two that would + earn their keep — once Actuator/Micrometer exists app-wide. +- Migrating `/api/v1/escrow/orchestrations` onto this service, so escrow + operations stop requiring client-side signing. Deliberately not folded in + here: it changes an existing public contract and deserves its own PR. diff --git a/backend-api/pom.xml b/backend-api/pom.xml index 7452dc4..70b6648 100644 --- a/backend-api/pom.xml +++ b/backend-api/pom.xml @@ -129,6 +129,19 @@ json-patch 1.13 + + + network.lightsail + stellar-sdk + 3.1.0 + junit junit @@ -191,6 +204,10 @@ 3600000 3600000 3600000 + 3600000 + 3600000 + 3600000 + 3600000 diff --git a/backend-api/src/main/java/com/guildworkman/api/escrow/rpc/SimulateTransactionResult.java b/backend-api/src/main/java/com/guildworkman/api/escrow/rpc/SimulateTransactionResult.java new file mode 100644 index 0000000..b5a8810 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/escrow/rpc/SimulateTransactionResult.java @@ -0,0 +1,20 @@ +package com.guildworkman.api.escrow.rpc; + +/** + * Result of a Soroban RPC {@code simulateTransaction} call — the dry run that + * tells us what a contract invocation will cost and touch before anything is + * signed or broadcast. + * + * @param error simulation error text, or {@code null} when the simulation succeeded + * @param minResourceFee resource fee (stroops) the transaction must carry on top of its inclusion fee, or {@code null} + * @param transactionData base64 {@code SorobanTransactionData} XDR (the footprint) to attach to the transaction + * @param requiresRestore true when the RPC returned a {@code restorePreamble}: archived ledger entries must be + * restored before this invocation can succeed, so submitting it as-is would only waste a fee + */ +public record SimulateTransactionResult(String error, Long minResourceFee, String transactionData, + boolean requiresRestore) { + + public boolean isSuccess() { + return error == null && !requiresRestore; + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/escrow/rpc/SorobanRpcClient.java b/backend-api/src/main/java/com/guildworkman/api/escrow/rpc/SorobanRpcClient.java index 1b6ef31..6560c8a 100644 --- a/backend-api/src/main/java/com/guildworkman/api/escrow/rpc/SorobanRpcClient.java +++ b/backend-api/src/main/java/com/guildworkman/api/escrow/rpc/SorobanRpcClient.java @@ -11,6 +11,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; +import org.stellar.sdk.KeyPair; +import org.stellar.sdk.xdr.LedgerEntry; +import org.stellar.sdk.xdr.LedgerEntryType; +import org.stellar.sdk.xdr.LedgerKey; import java.io.IOException; import java.time.Duration; @@ -18,11 +22,18 @@ import java.util.function.Consumer; /** - * Thin JSON-RPC 2.0 client for the Soroban RPC methods this service needs: - * submitting a signed transaction and polling its outcome. Transaction - * envelopes and results are treated as opaque base64 XDR strings — this - * client never encodes or decodes XDR itself, it only relays what the - * caller (which builds and signs transactions client-side) hands it. + * Thin JSON-RPC 2.0 client for the Soroban RPC methods this backend needs: + * simulating, submitting and polling transactions, and reading an account's + * sequence number. + * + *

Transaction envelopes and results stay opaque base64 XDR strings as they + * pass through {@link #sendTransaction}, {@link #getTransaction} and + * {@link #simulateTransaction} — the escrow orchestration flow relays + * envelopes its callers signed and this client has no reason to look inside + * them. {@link #getAccountSequence} is the one exception, and unavoidably so: + * a ledger entry can only be addressed by an XDR {@code LedgerKey} and only + * read back out of XDR, so it encodes one and decodes the other using the + * Stellar SDK (see {@code docs/STELLAR_SIGNING.md}). * *

Every call gets its own JSON-RPC {@code id} (a random UUID), reused as * a correlation id in logs and exception messages so a single round-trip can @@ -79,6 +90,81 @@ public GetTransactionResult getTransaction(String hash) { ledger); } + /** + * Dry-runs a transaction: returns the resource fee and footprint a Soroban + * invocation needs, or the reason it can't succeed. Used by the signing + * service before it signs anything, so a contract call that is going to + * fail costs nothing and never consumes a sequence number. + * + * @param transactionXdr base64 {@code TransactionEnvelope} XDR; may be unsigned — simulation ignores signatures + */ + public SimulateTransactionResult simulateTransaction(String transactionXdr) { + JsonNode result = call("simulateTransaction", params -> params.put("transaction", transactionXdr)); + Long minResourceFee = result.hasNonNull("minResourceFee") + ? Long.valueOf(result.get("minResourceFee").asText()) + : null; + return new SimulateTransactionResult( + textOrNull(result, "error"), + minResourceFee, + textOrNull(result, "transactionData"), + result.hasNonNull("restorePreamble")); + } + + /** + * Reads an account's current sequence number straight from the ledger, via + * a {@code getLedgerEntries} lookup of its {@code ACCOUNT} entry. + * + *

This is the ground truth a channel account is resynced against after + * a transaction that never landed (see {@code ChannelAccountLeaseService}): + * the database's idea of the next sequence is a local optimisation, the + * chain's is the one the network validates against. + * + * @return the account's sequence number, or {@code null} if the account doesn't exist on this network yet + */ + public Long getAccountSequence(String accountId) { + String ledgerKey = accountLedgerKey(accountId); + JsonNode result = call("getLedgerEntries", params -> params.putArray("keys").add(ledgerKey)); + + JsonNode entries = result.get("entries"); + if (entries == null || !entries.isArray() || entries.isEmpty()) { + return null; + } + JsonNode entry = entries.get(0); + // soroban-rpc has published this field as both `xdr` and `dataXdr` + // across versions; accept either rather than pinning to one release. + String entryXdr = entry.hasNonNull("xdr") ? entry.get("xdr").asText() + : entry.hasNonNull("dataXdr") ? entry.get("dataXdr").asText() : null; + if (entryXdr == null) { + throw new SorobanRpcException("Soroban RPC getLedgerEntries returned an entry without XDR for account " + + accountId); + } + try { + LedgerEntry.LedgerEntryData data = LedgerEntry.LedgerEntryData.fromXdrBase64(entryXdr); + if (data.getAccount() == null) { + throw new SorobanRpcException("Soroban RPC getLedgerEntries returned a non-account entry for " + + accountId); + } + return data.getAccount().getSeqNum().getSequenceNumber().getInt64(); + } catch (IOException ex) { + throw new SorobanRpcException("Could not decode the ledger entry for account " + accountId, ex); + } + } + + /** Builds the base64 {@code LedgerKey} that addresses an account's ledger entry. */ + private static String accountLedgerKey(String accountId) { + try { + LedgerKey key = LedgerKey.builder() + .discriminant(LedgerEntryType.ACCOUNT) + .account(LedgerKey.LedgerKeyAccount.builder() + .accountID(KeyPair.fromAccountId(accountId).getXdrAccountId()) + .build()) + .build(); + return key.toXdrBase64(); + } catch (IOException ex) { + throw new SorobanRpcException("Could not encode a ledger key for account " + accountId, ex); + } + } + private JsonNode call(String method, Consumer paramsBuilder) { String requestId = UUID.randomUUID().toString(); diff --git a/backend-api/src/main/java/com/guildworkman/api/handler/GlobalExceptionHandler.java b/backend-api/src/main/java/com/guildworkman/api/handler/GlobalExceptionHandler.java index 58465e9..273c67c 100644 --- a/backend-api/src/main/java/com/guildworkman/api/handler/GlobalExceptionHandler.java +++ b/backend-api/src/main/java/com/guildworkman/api/handler/GlobalExceptionHandler.java @@ -6,6 +6,14 @@ import com.guildworkman.api.escrow.service.EscrowOrchestrationNotFoundException; import com.guildworkman.api.escrow.service.ReconciliationRequeueNotAllowedException; import com.guildworkman.api.exceptions.*; +import com.guildworkman.api.signing.custody.SigningProviderException; +import com.guildworkman.api.signing.custody.UnknownKeyReferenceException; +import com.guildworkman.api.signing.service.ChannelAccountAlreadyRegisteredException; +import com.guildworkman.api.signing.service.ChannelAccountBusyException; +import com.guildworkman.api.signing.service.ChannelAccountNotFoundException; +import com.guildworkman.api.signing.service.NoChannelAccountAvailableException; +import com.guildworkman.api.signing.service.SubmissionNotFoundException; +import com.guildworkman.api.signing.service.TransactionAssemblyException; import jakarta.validation.ConstraintViolationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -125,6 +133,77 @@ public ResponseEntity handleReconciliationRequeueNotAllowed(Recon "Reconciliation requeue not allowed", exception.getMessage()); } + @ExceptionHandler(SubmissionNotFoundException.class) + public ResponseEntity handleSubmissionNotFound(SubmissionNotFoundException exception) { + return respond(HttpStatus.NOT_FOUND, "transaction-submission-not-found", + "Transaction submission not found", exception.getMessage()); + } + + @ExceptionHandler(ChannelAccountNotFoundException.class) + public ResponseEntity handleChannelAccountNotFound(ChannelAccountNotFoundException exception) { + return respond(HttpStatus.NOT_FOUND, "channel-account-not-found", + "Channel account not found", exception.getMessage()); + } + + @ExceptionHandler(ChannelAccountAlreadyRegisteredException.class) + public ResponseEntity handleChannelAccountAlreadyRegistered( + ChannelAccountAlreadyRegisteredException exception) { + return respond(HttpStatus.CONFLICT, "channel-account-already-registered", + "Channel account already registered", exception.getMessage()); + } + + /** Leased is a temporary state, not a bad request: the same call succeeds once the lease ends. */ + @ExceptionHandler(ChannelAccountBusyException.class) + public ResponseEntity handleChannelAccountBusy(ChannelAccountBusyException exception) { + return respond(HttpStatus.CONFLICT, "channel-account-busy", + "Channel account is in use", exception.getMessage()); + } + + /** + * An empty pool is a capacity problem on our side, so it's a 503 with a + * {@code Retry-After} rather than a 4xx blaming the caller. The same + * exception covers an account that isn't funded on-chain, which an operator + * likewise fixes without the caller changing anything. + */ + @ExceptionHandler(NoChannelAccountAvailableException.class) + public ResponseEntity handleNoChannelAccountAvailable(NoChannelAccountAvailableException exception) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .header(HttpHeaders.RETRY_AFTER, "5") + .body(ProblemDetails.of(HttpStatus.SERVICE_UNAVAILABLE, "no-channel-account-available", + "No channel account available", exception.getMessage())); + } + + /** A rejected envelope: unparseable, a fee bump, too many operations, unsupported preconditions. */ + @ExceptionHandler(TransactionAssemblyException.class) + public ResponseEntity handleTransactionAssembly(TransactionAssemblyException exception) { + return respond(HttpStatus.BAD_REQUEST, "transaction-assembly-failed", + "Transaction could not be assembled", exception.getMessage()); + } + + /** + * The caller named a key this deployment's custody backend doesn't hold — + * a bad reference, not a server fault. The message carries only the + * reference, which is an alias by construction. + */ + @ExceptionHandler(UnknownKeyReferenceException.class) + public ResponseEntity handleUnknownKeyReference(UnknownKeyReferenceException exception) { + return respond(HttpStatus.BAD_REQUEST, "unknown-key-reference", + "Unknown key reference", exception.getMessage()); + } + + /** + * Custody itself failed (KMS unreachable, signature rejected). Deliberately + * answered with a fixed detail string: {@code SigningProviderException} + * redacts its own message, and this adds a second guarantee that nothing + * from the signing path reaches a client body. + */ + @ExceptionHandler(SigningProviderException.class) + public ResponseEntity handleSigningProvider(SigningProviderException exception) { + log.error("Signing provider failure", exception); + return respond(HttpStatus.SERVICE_UNAVAILABLE, "signing-unavailable", + "Signing unavailable", "The signing service could not sign this request. Please try again later."); + } + @ExceptionHandler(InvalidPasswordException.class) public ResponseEntity handleInvalidPasswordException(InvalidPasswordException exception) { return respond(HttpStatus.BAD_REQUEST, "invalid-password", diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/SigningProperties.java b/backend-api/src/main/java/com/guildworkman/api/signing/SigningProperties.java new file mode 100644 index 0000000..ea48bcd --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/SigningProperties.java @@ -0,0 +1,134 @@ +package com.guildworkman.api.signing; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Binds {@code stellar.signing.*}: which custody backend is active, the + * network being signed for, and the fee/retry/lease policy the submission + * workers apply. + * + *

The one genuinely secret value here is {@code stellar.signing.local.keys.*} + * (development seeds, supplied through environment variables and never + * committed). It is deliberately confined to {@link Local}, whose + * {@code toString} is overridden so a stray {@code log.debug("{}", props)} + * can't print it — see {@code docs/STELLAR_SIGNING.md} ("Key material"). + */ +@Component +@ConfigurationProperties(prefix = "stellar.signing") +@Getter +@Setter +public class SigningProperties { + + /** Active custody backend: {@code local} or {@code kms}. */ + private String provider = "local"; + + /** + * Network passphrase the transaction hash is computed over. Signing for + * the wrong network produces signatures that are valid nowhere, so this + * must match the network {@code soroban.rpc.url} points at. + */ + private String networkPassphrase = "Test SDF Network ; September 2015"; + + /** + * Key reference used as the fee source when a stalled transaction is + * fee-bumped. Empty means "the channel account's own key", which is + * always a valid fee source for its own transaction. + */ + private String feeSourceKeyRef = ""; + + /** How long a built transaction stays valid (its {@code maxTime} time bound). */ + private Duration transactionTimeout = Duration.ofSeconds(120); + + /** + * How long a broadcast transaction may sit unconfirmed before it's + * treated as stalled in the mempool and fee-bumped. + */ + private Duration stallAfter = Duration.ofSeconds(30); + + /** How long a channel-account lease is held before the sweeper may reclaim it. */ + private Duration leaseTtl = Duration.ofMinutes(5); + + private final Local local = new Local(); + private final Kms kms = new Kms(); + private final Fee fee = new Fee(); + private final Retry retry = new Retry(); + + /** Development/test custody: seeds supplied by configuration. */ + @Getter + @Setter + public static class Local { + + /** + * keyRef → {@code S…} secret seed. Populated from the environment + * (e.g. {@code STELLAR_LOCAL_KEY_CHANNEL_1}); never hard-coded and + * never committed. + */ + private Map keys = new LinkedHashMap<>(); + + /** Redacted on purpose: the values in {@link #keys} are secret seeds. */ + @Override + public String toString() { + return "SigningProperties.Local(keyRefs=" + keys.keySet() + ")"; + } + } + + /** Production custody: signing delegated to an external KMS/HSM gateway. */ + @Getter + @Setter + public static class Kms { + + /** Base URL of the signing gateway, e.g. {@code https://kms.internal/stellar}. */ + private String url = ""; + + /** Bearer credential for the gateway. Never logged, never returned. */ + private String apiKey = ""; + + private Duration requestTimeout = Duration.ofSeconds(5); + + @Override + public String toString() { + return "SigningProperties.Kms(url=" + url + ", apiKey=***, requestTimeout=" + requestTimeout + ")"; + } + } + + /** + * Fee policy, in stroops (1 XLM = 10,000,000 stroops). + * + *

{@link #maxTotalStroops} is the bounded ceiling the issue asks for: + * fee bumps double the fee each time, and the first bump that would cross + * this ceiling instead moves the submission to a terminal + * {@code FEE_CEILING_REACHED} failure. No amount of stalling can make this + * service spend more than the ceiling on one transaction. + */ + @Getter + @Setter + public static class Fee { + + /** Inclusion fee per operation for the first attempt. 100 stroops is the network minimum. */ + private long baseStroops = 100; + + /** Hard ceiling on the total fee of any one transaction (default 0.1 XLM). */ + private long maxTotalStroops = 1_000_000; + + /** Multiplier applied to the current total fee on each fee bump. */ + private double bumpMultiplier = 2.0; + } + + /** Backoff for retryable failures, mirroring {@code escrow.orchestration.retry.*}. */ + @Getter + @Setter + public static class Retry { + + private int maxAttempts = 5; + private Duration baseDelay = Duration.ofSeconds(1); + private Duration maxDelay = Duration.ofSeconds(64); + private double jitter = 0.2; + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/api/ChannelAccountController.java b/backend-api/src/main/java/com/guildworkman/api/signing/api/ChannelAccountController.java new file mode 100644 index 0000000..de542b9 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/api/ChannelAccountController.java @@ -0,0 +1,118 @@ +package com.guildworkman.api.signing.api; + +import com.guildworkman.api.signing.service.ChannelAccountLeaseService; +import com.guildworkman.api.signing.service.ChannelAccountService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * Operator management of the channel-account pool. + * + *

The pool is what makes concurrent submissions possible at all: Stellar + * requires each transaction to carry exactly the source account's next + * sequence number, so two transactions signed for one account at the same time + * are two transactions competing for one number. One account per in-flight + * transaction sidesteps that, and this controller is how operators size and + * repair that pool. + * + *

Every endpoint is ADMIN-only. Nothing here accepts or returns key + * material — accounts are registered by key reference and resolved through + * custody — but pool composition and lease state are still operational + * information that ordinary callers have no business reading. + */ +@RestController +@RequestMapping("/api/v1/stellar/channel-accounts") +@RequiredArgsConstructor +@Validated +@PreAuthorize("hasRole('ADMIN')") +@SecurityRequirement(name = "bearerAuth") +public class ChannelAccountController { + + private final ChannelAccountService channelAccounts; + private final ChannelAccountLeaseService leases; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Register a channel account", + description = "ADMIN only. Adds the account behind a key reference to the pool; the account id is " + + "derived from the active signing provider, so a key this deployment cannot sign with " + + "cannot be registered. The account starts NEEDS_RESYNC — its sequence number is read from " + + "the network on first use, which keeps registration free of RPC dependencies.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Registered"), + @ApiResponse(responseCode = "400", description = "Key reference unknown to the signing provider"), + @ApiResponse(responseCode = "409", description = "Key reference or account already registered") + }) + public ChannelAccountResponse register(@Valid @RequestBody RegisterChannelAccountRequest request) { + return ChannelAccountResponse.from(channelAccounts.register(request.keyRef())); + } + + @GetMapping + @Operation(summary = "List the pool", + description = "ADMIN only. Every registered account with its status, next sequence number and " + + "current lease, if any. The primary pool-health view: a pool where every account is " + + "LEASED is a pool that is about to start rejecting submissions.") + public List list() { + return channelAccounts.list().stream().map(ChannelAccountResponse::from).toList(); + } + + @GetMapping("/{id}") + @Operation(summary = "Fetch one channel account") + @ApiResponses(@ApiResponse(responseCode = "404", description = "No such channel account")) + public ChannelAccountResponse get(@PathVariable Long id) { + return ChannelAccountResponse.from(channelAccounts.get(id)); + } + + @PostMapping("/{id}/disable") + @Operation(summary = "Take an account out of the pool", + description = "ADMIN only. Stops the account being leased for new submissions — e.g. because it is " + + "being drained of funds or rotated out. Refused with 409 while a lease is outstanding, " + + "since disabling an account mid-transaction would strand its sequence number.") + @ApiResponses({ + @ApiResponse(responseCode = "404", description = "No such channel account"), + @ApiResponse(responseCode = "409", description = "Account is currently leased") + }) + public ChannelAccountResponse disable(@PathVariable Long id) { + return ChannelAccountResponse.from(channelAccounts.disable(id)); + } + + @PostMapping("/{id}/enable") + @Operation(summary = "Return an account to the pool", + description = "ADMIN only. Marks a disabled account NEEDS_RESYNC rather than AVAILABLE: however " + + "long it sat out, its sequence number has to be re-read from the network before the pool " + + "trusts it again.") + @ApiResponses(@ApiResponse(responseCode = "404", description = "No such channel account")) + public ChannelAccountResponse enable(@PathVariable Long id) { + return ChannelAccountResponse.from(channelAccounts.enable(id)); + } + + @PostMapping("/{id}/resync") + @Operation(summary = "Re-read an account's sequence number from the network", + description = "ADMIN only. The manual escape hatch for a pool member whose cached sequence has " + + "drifted — typically because the account was also used by something outside this service. " + + "The service does this by itself after any failure that suggests drift; this endpoint is " + + "for the cases an operator spots first.") + @ApiResponses({ + @ApiResponse(responseCode = "404", description = "No such channel account"), + @ApiResponse(responseCode = "409", description = "Account is currently leased, or not funded on-chain") + }) + public ChannelAccountResponse resync(@PathVariable Long id) { + return ChannelAccountResponse.from(leases.resync(id)); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/api/ChannelAccountResponse.java b/backend-api/src/main/java/com/guildworkman/api/signing/api/ChannelAccountResponse.java new file mode 100644 index 0000000..aac7e7a --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/api/ChannelAccountResponse.java @@ -0,0 +1,37 @@ +package com.guildworkman.api.signing.api; + +import com.guildworkman.api.signing.model.ChannelAccount; + +import java.time.Instant; + +/** + * Public view of a pool member. {@code accountId} is a public key and + * {@code keyRef} an alias — there is nothing secret in this shape, which is + * what makes a pool-health endpoint safe to expose to operators at all. + */ +public record ChannelAccountResponse( + Long id, + String accountId, + String keyRef, + String status, + long nextSequence, + Long leasedBySubmissionId, + Instant leasedAt, + Instant leaseExpiresAt, + Instant lastSyncedAt, + Instant createdAt) { + + public static ChannelAccountResponse from(ChannelAccount account) { + return new ChannelAccountResponse( + account.getId(), + account.getAccountId(), + account.getKeyRef(), + account.getStatus().name(), + account.getNextSequence(), + account.getLeasedBySubmissionId(), + account.getLeasedAt(), + account.getLeaseExpiresAt(), + account.getLastSyncedAt(), + account.getCreatedAt()); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/api/RegisterChannelAccountRequest.java b/backend-api/src/main/java/com/guildworkman/api/signing/api/RegisterChannelAccountRequest.java new file mode 100644 index 0000000..0ce8764 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/api/RegisterChannelAccountRequest.java @@ -0,0 +1,28 @@ +package com.guildworkman.api.signing.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * Adds one account to the channel-account pool. + * + *

Only a key reference is accepted. The account id is derived from + * custody, so the pool can't contain an account this deployment is unable to + * sign for, and no request to this API ever needs to carry key material. + * + *

A secret seed happens to fit the shape of an alias — 56 characters, no + * punctuation — so "aliases only" is enforced rather than assumed: the second + * pattern rejects anything shaped like a Stellar seed outright, turning a + * fat-fingered paste into a {@code 400} instead of a secret sitting in a + * database column and an access log. + * + * @param keyRef alias the active {@code SigningProvider} resolves to a signing key + */ +public record RegisterChannelAccountRequest( + @NotBlank + @Size(max = 64) + @Pattern(regexp = "[A-Za-z0-9._-]+", message = "must be a key reference, not key material") + @Pattern(regexp = "^(?!S[A-Z2-7]{55}$).*$", message = "must not be a secret seed") + String keyRef) { +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/api/SubmitTransactionRequest.java b/backend-api/src/main/java/com/guildworkman/api/signing/api/SubmitTransactionRequest.java new file mode 100644 index 0000000..b845eb3 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/api/SubmitTransactionRequest.java @@ -0,0 +1,52 @@ +package com.guildworkman.api.signing.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** + * Submits one transaction for server-side signing and submission. + * + *

The envelope carries operations only: this service replaces its + * source account, sequence number, fee and time bounds when it builds the + * real transaction on a leased channel account (see + * {@code TransactionAssembler}). Callers therefore don't need to know which + * account will send it, don't need a sequence number, and — the point of the + * whole feature — don't need a key. + * + * @param idempotencyKey caller-generated; resubmitting it returns the original submission rather than + * signing a second transaction + * @param reference caller's own correlation handle (appointment id, escrow id, …); optional + * @param unsignedTransactionXdr base64 {@code TransactionEnvelope} XDR holding the operations to execute + * @param extraSignerKeyRefs key references (never keys) that must co-sign alongside the channel + * account, e.g. a contract-admin key authorising the call + */ +public record SubmitTransactionRequest( + + @NotBlank @Size(max = 128) String idempotencyKey, + + @Size(max = 128) String reference, + + // 65536 chars accommodates a Soroban invocation with a large footprint + // (well beyond the ~2KB a typical invoke encodes to) while still + // bounding the request against an oversized or malicious payload. + @NotBlank @Size(max = 65536) + @Pattern(regexp = "^[A-Za-z0-9+/]+={0,2}$", message = "must be base64-encoded") + String unsignedTransactionXdr, + + // Three is a deliberate ceiling: each extra signer is another custody + // round-trip on the critical path of a database transaction holding a + // channel-account lease. + @Size(max = 3, message = "at most 3 extra signers are supported") + List<@NotBlank @Size(max = 64) + @Pattern(regexp = "[A-Za-z0-9._-]+", message = "must be a key reference, not key material") + @Pattern(regexp = "^(?!S[A-Z2-7]{55}$).*$", message = "must not be a secret seed") + String> extraSignerKeyRefs) { + + /** @return the extra signer references, never {@code null}. */ + public List extraSignerKeyRefsOrEmpty() { + return extraSignerKeyRefs == null ? List.of() : extraSignerKeyRefs; + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/api/TransactionSigningController.java b/backend-api/src/main/java/com/guildworkman/api/signing/api/TransactionSigningController.java new file mode 100644 index 0000000..8932673 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/api/TransactionSigningController.java @@ -0,0 +1,97 @@ +package com.guildworkman.api.signing.api; + +import com.guildworkman.api.signing.service.TransactionSubmissionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * Server-side signing and submission of Stellar/Soroban transactions. + * + *

Submission is asynchronous by design and always answers {@code 202 + * Accepted}: signing needs a channel account, simulation needs a network + * round-trip, and inclusion needs a ledger to close. Callers poll + * {@code GET /{id}} (or look the submission up by their own + * {@code reference}) for the outcome. + * + *

Resubmitting an {@code idempotencyKey} returns the original submission + * with {@code X-Idempotent-Replay: true} rather than signing a second + * transaction — the same contract the escrow orchestration endpoints use, and + * for a sharper reason here: a duplicate would consume a second sequence + * number and put a second transaction on-chain. + * + *

Authentication is required (the API's default), and no endpoint here + * exposes an envelope's signatures or anything derived from key material. + * See {@code docs/STELLAR_SIGNING.md}. + */ +@RestController +@RequestMapping("/api/v1/stellar/transactions") +@RequiredArgsConstructor +@Validated +@SecurityRequirement(name = "bearerAuth") +public class TransactionSigningController { + + /** {@code true}/{@code false} on the submit response; see the class Javadoc. */ + public static final String IDEMPOTENT_REPLAY_HEADER = "X-Idempotent-Replay"; + + private final TransactionSubmissionService service; + + @PostMapping + @ResponseStatus(HttpStatus.ACCEPTED) + @Operation(summary = "Sign and submit a transaction", + description = "Takes an unsigned envelope carrying the operations to execute. The service leases a " + + "channel account, sets the source account, sequence number, fee and time bounds, simulates " + + "(for Soroban invocations), signs with the configured custody backend and submits — " + + "fee-bumping the transaction if it stalls. Idempotent on idempotencyKey: a repeat returns the " + + "original submission, flagged by the X-Idempotent-Replay response header.") + @ApiResponses({ + @ApiResponse(responseCode = "202", description = "Accepted (newly created, or replayed)"), + @ApiResponse(responseCode = "400", + description = "Malformed envelope, unsupported preconditions, or an unknown key reference"), + @ApiResponse(responseCode = "401", description = "Missing or invalid bearer token") + }) + public ResponseEntity submit(@Valid @RequestBody SubmitTransactionRequest request) { + var outcome = service.submit(request); + return ResponseEntity.status(HttpStatus.ACCEPTED) + .header(IDEMPOTENT_REPLAY_HEADER, String.valueOf(outcome.replayed())) + .body(TransactionSubmissionResponse.from(outcome.submission())); + } + + @GetMapping("/{id}") + @Operation(summary = "Fetch a submission's current state", + description = "Current status, failure reason, transaction hash(es), fee and fee-bump count. " + + "Reports the current state, not a per-attempt audit log — individual attempts are in the " + + "application logs, correlated by submission id.") + public TransactionSubmissionResponse get(@PathVariable Long id) { + return TransactionSubmissionResponse.from(service.get(id)); + } + + @GetMapping + @Operation(summary = "Find submissions by the caller's own reference", + description = "Looks up submissions by the correlation handle supplied at submit time, for callers " + + "that would rather not store our submission ids.") + public List findByReference( + @RequestParam @NotBlank @Size(max = 128) String reference) { + return service.findByReference(reference).stream() + .map(TransactionSubmissionResponse::from) + .toList(); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/api/TransactionSubmissionResponse.java b/backend-api/src/main/java/com/guildworkman/api/signing/api/TransactionSubmissionResponse.java new file mode 100644 index 0000000..93006c4 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/api/TransactionSubmissionResponse.java @@ -0,0 +1,63 @@ +package com.guildworkman.api.signing.api; + +import com.guildworkman.api.signing.model.TransactionSubmission; + +import java.time.Instant; + +/** + * Public view of a submission. + * + *

Everything here is either public chain data (account, sequence, fee, + * hashes, ledger) or our own bookkeeping. Notably absent, and deliberately: + * the signed envelope — which carries signatures — and anything resembling + * key material. {@link #keyRef} is an alias, not a key; see + * {@code SigningProvider}. + */ +public record TransactionSubmissionResponse( + Long id, + String idempotencyKey, + String reference, + String status, + String failureReason, + String sourceAccount, + String keyRef, + String signingProvider, + Long sequenceNumber, + Long feeStroops, + int feeBumpCount, + String transactionHash, + String innerTransactionHash, + Long ledgerSequence, + int attempts, + String lastError, + Instant validUntil, + Instant createdAt, + Instant signedAt, + Instant broadcastAt, + Instant confirmedAt) { + + public static TransactionSubmissionResponse from(TransactionSubmission submission) { + return new TransactionSubmissionResponse( + submission.getId(), + submission.getIdempotencyKey(), + submission.getReference(), + submission.getStatus().name(), + submission.getFailureReason().name(), + submission.getSourceAccount(), + submission.getKeyRef(), + submission.getSigningProvider(), + submission.getSequenceNumber(), + submission.getFeeStroops(), + submission.getFeeBumpCount(), + submission.getTransactionHash(), + submission.getInnerTransactionHash(), + submission.getLedgerSequence(), + submission.getAttempts(), + submission.getLastError(), + submission.getValidUntil(), + submission.getCreatedAt(), + submission.getSignedAt(), + submission.getBroadcastAt(), + submission.getConfirmedAt()); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/custody/KmsSigningProvider.java b/backend-api/src/main/java/com/guildworkman/api/signing/custody/KmsSigningProvider.java new file mode 100644 index 0000000..ea53170 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/custody/KmsSigningProvider.java @@ -0,0 +1,211 @@ +package com.guildworkman.api.signing.custody; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.guildworkman.api.signing.SigningProperties; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.stellar.sdk.KeyPair; + +import java.io.IOException; +import java.time.Duration; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Production custody: this process never sees a private key. Signing is + * delegated to an external KMS/HSM gateway over HTTP, and only the 32-byte + * transaction hash crosses the wire. + * + *

The gateway contract is deliberately small — two endpoints, no vendor + * types — so it can front AWS KMS, GCP KMS, Vault Transit, an HSM, or a + * bespoke signer without this class changing: + * + *

+ * GET  {url}/keys/{keyRef}   -> {"publicKey": "G..."}
+ * POST {url}/sign            <- {"keyRef": "...", "message": "<base64 32-byte hash>", "algorithm": "ed25519"}
+ *                            -> {"publicKey": "G...", "signature": "<base64 64-byte signature>"}
+ * 
+ * + *

Every signature is verified against the key reference's known public key + * before it's returned. A gateway that is misconfigured (signing with the + * wrong key, or a stale key after a rotation) therefore fails here, loudly, + * rather than producing an envelope the network rejects with an opaque + * {@code txBAD_AUTH} some seconds later. + * + *

Public keys are cached per key reference for the process's lifetime. + * They are public data; a rotation of the underlying key material behind a + * reference requires a restart, which is the safer default — silently picking + * up a new public key mid-flight would invalidate in-flight transactions + * signed against the old one. + */ +@Component +@ConditionalOnProperty(name = "stellar.signing.provider", havingValue = "kms") +public class KmsSigningProvider implements SigningProvider { + + public static final String PROVIDER_ID = "kms"; + + private static final Logger log = LoggerFactory.getLogger(KmsSigningProvider.class); + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + + /** Caps how much of a gateway response is ever logged or put in an exception message. */ + private static final int MAX_LOGGED_BODY_LENGTH = 300; + + private final OkHttpClient httpClient; + private final ObjectMapper objectMapper; + private final SigningProperties properties; + private final Map publicKeyCache = new ConcurrentHashMap<>(); + + public KmsSigningProvider(OkHttpClient httpClient, ObjectMapper objectMapper, SigningProperties properties) { + this.objectMapper = objectMapper; + this.properties = properties; + Duration timeout = properties.getKms().getRequestTimeout(); + // Same treatment as SorobanRpcClient: the shared app-wide OkHttpClient + // bean keeps its own defaults, this copy gets the KMS timeout budget so + // an unresponsive gateway can't pin a submission worker thread. + this.httpClient = httpClient.newBuilder() + .callTimeout(timeout) + .connectTimeout(timeout) + .readTimeout(timeout) + .writeTimeout(timeout) + .build(); + if (properties.getKms().getUrl() == null || properties.getKms().getUrl().isBlank()) { + throw new IllegalStateException( + "stellar.signing.provider=kms requires stellar.signing.kms.url to be set"); + } + } + + @Override + public String providerId() { + return PROVIDER_ID; + } + + /** + * Resolves the reference against the gateway. Unlike the local provider — + * which knows its whole key set up front — this is a network call, so a + * gateway outage reports as a {@link SigningProviderException} rather than + * being mistaken for "no such key". + */ + @Override + public boolean supports(String keyRef) { + if (keyRef == null || keyRef.isBlank()) { + return false; + } + try { + publicKey(keyRef); + return true; + } catch (UnknownKeyReferenceException ex) { + return false; + } + } + + @Override + public String publicKey(String keyRef) { + String cached = publicKeyCache.get(keyRef); + if (cached != null) { + return cached; + } + Request request = new Request.Builder() + .url(baseUrl() + "/keys/" + keyRef) + .header("Authorization", "Bearer " + properties.getKms().getApiKey()) + .get() + .build(); + JsonNode body = execute(request, keyRef, "keys"); + String publicKey = text(body, "publicKey"); + if (publicKey == null || publicKey.isBlank()) { + throw new SigningProviderException( + "KMS returned no publicKey for keyRef='" + keyRef + "'"); + } + publicKeyCache.put(keyRef, publicKey); + log.info("KMS key resolved keyRef={} publicKey={}", keyRef, publicKey); + return publicKey; + } + + @Override + public byte[] sign(String keyRef, byte[] message) { + String expectedPublicKey = publicKey(keyRef); + + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("keyRef", keyRef); + payload.put("message", Base64.getEncoder().encodeToString(message)); + payload.put("algorithm", "ed25519"); + + Request request = new Request.Builder() + .url(baseUrl() + "/sign") + .header("Authorization", "Bearer " + properties.getKms().getApiKey()) + .post(RequestBody.create(payload.toString(), JSON)) + .build(); + + JsonNode body = execute(request, keyRef, "sign"); + String encodedSignature = text(body, "signature"); + if (encodedSignature == null || encodedSignature.isBlank()) { + throw new SigningProviderException("KMS returned no signature for keyRef='" + keyRef + "'"); + } + + byte[] signature; + try { + signature = Base64.getDecoder().decode(encodedSignature); + } catch (IllegalArgumentException ex) { + throw new SigningProviderException("KMS returned a non-base64 signature for keyRef='" + keyRef + "'"); + } + + String signingPublicKey = text(body, "publicKey"); + if (signingPublicKey != null && !signingPublicKey.equals(expectedPublicKey)) { + throw new SigningProviderException("KMS signed keyRef='" + keyRef + "' with an unexpected key: " + + "expected " + expectedPublicKey + ", got " + signingPublicKey); + } + if (!KeyPair.fromAccountId(expectedPublicKey).verify(message, signature)) { + throw new SigningProviderException( + "KMS signature for keyRef='" + keyRef + "' failed verification against " + expectedPublicKey); + } + return signature; + } + + private String baseUrl() { + String url = properties.getKms().getUrl(); + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + private JsonNode execute(Request request, String keyRef, String operation) { + try (Response response = httpClient.newCall(request).execute()) { + String responseBody = response.body() == null ? "" : response.body().string(); + if (response.code() == 404) { + throw new UnknownKeyReferenceException(keyRef, PROVIDER_ID); + } + if (!response.isSuccessful()) { + throw new SigningProviderException("KMS " + operation + " for keyRef='" + keyRef + "' failed: HTTP " + + response.code() + " " + truncate(responseBody)); + } + return objectMapper.readTree(responseBody); + } catch (IOException ex) { + throw new SigningProviderException( + "KMS " + operation + " for keyRef='" + keyRef + "' failed: " + ex.getMessage(), ex); + } + } + + private static String text(JsonNode node, String field) { + return node != null && node.hasNonNull(field) ? node.get(field).asText() : null; + } + + private static String truncate(String value) { + if (value == null || value.length() <= MAX_LOGGED_BODY_LENGTH) { + return value; + } + return value.substring(0, MAX_LOGGED_BODY_LENGTH) + "…(truncated)"; + } + + /** The API key stays out of every rendering of this object. */ + @Override + public String toString() { + return "KmsSigningProvider(url=" + baseUrl() + ", cachedKeyRefs=" + publicKeyCache.keySet() + ")"; + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/custody/LocalSigningProvider.java b/backend-api/src/main/java/com/guildworkman/api/signing/custody/LocalSigningProvider.java new file mode 100644 index 0000000..587413b --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/custody/LocalSigningProvider.java @@ -0,0 +1,113 @@ +package com.guildworkman.api.signing.custody; + +import com.guildworkman.api.signing.SigningProperties; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import org.stellar.sdk.KeyPair; + +/** + * Development and test custody: Ed25519 seeds come from configuration + * ({@code stellar.signing.local.keys.}, supplied through environment + * variables) and signing happens in this JVM. + * + *

Not for production. A seed in an environment variable is readable + * by anything that can read the process environment; production deployments + * set {@code stellar.signing.provider=kms} and get + * {@link KmsSigningProvider} instead, where the key never enters this + * process at all. The two are interchangeable behind {@link SigningProvider} + * precisely so that swap is a configuration change, not a code change. + * + *

Seeds are parsed once at startup into {@link KeyPair} instances and the + * plaintext strings are never held by this class, never logged (startup logs + * the key references and the derived public keys, both of which are + * public information) and never handed back out — {@link SigningProvider} has + * no method that could return one. An unparseable seed fails startup with a + * message that names only the reference, so a typo'd key can't get echoed + * into a log by the exception itself. + */ +@Component +@ConditionalOnProperty(name = "stellar.signing.provider", havingValue = "local", matchIfMissing = true) +public class LocalSigningProvider implements SigningProvider { + + public static final String PROVIDER_ID = "local"; + + private static final Logger log = LoggerFactory.getLogger(LocalSigningProvider.class); + + private final SigningProperties properties; + private final Map keyPairs = new LinkedHashMap<>(); + + public LocalSigningProvider(SigningProperties properties) { + this.properties = properties; + } + + @PostConstruct + void loadKeys() { + properties.getLocal().getKeys().forEach((keyRef, seed) -> { + if (seed == null || seed.isBlank()) { + throw new IllegalStateException("Empty secret seed configured for keyRef='" + keyRef + "'"); + } + try { + keyPairs.put(keyRef, KeyPair.fromSecretSeed(seed.trim())); + } catch (RuntimeException ex) { + // Deliberately drops the cause: strkey parsers are prone to + // quoting the offending input in their message. + throw new IllegalStateException( + "Invalid Stellar secret seed configured for keyRef='" + keyRef + "'"); + } + }); + if (keyPairs.isEmpty()) { + log.warn("Local signing provider active with no keys configured — " + + "set stellar.signing.local.keys. to sign anything"); + } else { + keyPairs.forEach((keyRef, keyPair) -> + log.info("Local signing key loaded keyRef={} publicKey={}", keyRef, keyPair.getAccountId())); + } + } + + @Override + public String providerId() { + return PROVIDER_ID; + } + + @Override + public boolean supports(String keyRef) { + return keyRef != null && keyPairs.containsKey(keyRef); + } + + @Override + public String publicKey(String keyRef) { + return require(keyRef).getAccountId(); + } + + @Override + public byte[] sign(String keyRef, byte[] message) { + return require(keyRef).sign(message); + } + + /** @return the key references this provider can resolve (aliases, not key material). */ + public Set keyRefs() { + return Set.copyOf(keyPairs.keySet()); + } + + private KeyPair require(String keyRef) { + KeyPair keyPair = keyPairs.get(keyRef); + if (keyPair == null) { + throw new UnknownKeyReferenceException(keyRef, PROVIDER_ID); + } + return keyPair; + } + + /** Never renders key material, whatever a caller interpolates this into. */ + @Override + public String toString() { + return "LocalSigningProvider(keyRefs=" + keyPairs.keySet() + ")"; + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/custody/SecretRedactor.java b/backend-api/src/main/java/com/guildworkman/api/signing/custody/SecretRedactor.java new file mode 100644 index 0000000..f8b37f6 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/custody/SecretRedactor.java @@ -0,0 +1,52 @@ +package com.guildworkman.api.signing.custody; + +import java.util.regex.Pattern; + +/** + * Last line of defence for the "no key material in logs, responses or + * exception messages" rule. + * + *

Nothing in this package deliberately puts a secret seed into a string — + * the {@link SigningProvider} contract has no way to hand one out, and + * {@link LocalSigningProvider} keeps seeds inside {@code KeyPair} instances it + * never exposes. This class exists for the paths we don't fully control: + * error text coming back from a KMS, an exception message from a third-party + * library that echoed its input, an operator pasting a seed into a request + * field it doesn't belong in. Every string this feature persists to + * {@code last_error} or writes to a log passes through {@link #redact} first, + * so a mistake anywhere upstream degrades to a redaction marker instead of a + * leaked key. + * + *

A Stellar Ed25519 secret seed is a 56-character base32 strkey beginning + * with {@code S}. Account ids ({@code G…}), contract ids ({@code C…}) and + * muxed accounts ({@code M…}) share the alphabet but not the version + * character, so they're deliberately left intact — redacting them would blind + * operators to exactly the identifiers they need for diagnosis. + */ +public final class SecretRedactor { + + public static final String MARKER = "S***REDACTED***"; + + /** + * The lookarounds stop a longer base32 run (a chunk of XDR, say) from + * matching on a coincidental interior {@code S}: only a full-width strkey + * standing on its own token boundary is treated as a seed. + */ + private static final Pattern SECRET_SEED = Pattern.compile("(?no implementation ever + * returns, logs, or serializes a secret seed, and nothing above this + * interface can ask for one — there is deliberately no {@code secretSeed()} + * method to call. + * + *

Callers address keys by a key reference — a short logical alias + * such as {@code channel-1} or {@code escrow-admin}. A key reference is not + * secret: it is safe to store in the database, return in an API response and + * write to a log. Which concrete key it resolves to is the provider's + * business, and changes with the deployment (a local seed in development, a + * KMS/HSM key id in production) without any caller changing. + * + *

The message passed to {@link #sign} is Stellar's transaction hash + * (SHA-256 of the signature base, i.e. {@code AbstractTransaction.hash()}), + * which is what makes a remote KMS implementation possible at all: only 32 + * bytes cross the wire, never the transaction and never the key. + * + * @see LocalSigningProvider development/test custody, seeds from configuration + * @see KmsSigningProvider production custody, signing delegated to an external KMS/HSM + */ +public interface SigningProvider { + + /** + * Stable identifier of the custody backend ({@code local}, {@code kms}), + * recorded on every submission so an operator can tell which custody + * backend produced a signature after the fact. + */ + String providerId(); + + /** @return true if this provider can resolve {@code keyRef}. */ + boolean supports(String keyRef); + + /** + * @return the Ed25519 public key for {@code keyRef} as a Stellar account + * strkey ({@code G...}). Public by definition — safe to log and return. + * @throws UnknownKeyReferenceException if {@code keyRef} is not configured + */ + String publicKey(String keyRef); + + /** + * Signs {@code message} (a 32-byte transaction hash) with the key behind + * {@code keyRef}. + * + * @return the raw 64-byte Ed25519 signature + * @throws UnknownKeyReferenceException if {@code keyRef} is not configured + * @throws SigningProviderException if the custody backend could not produce a signature + */ + byte[] sign(String keyRef, byte[] message); +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/custody/SigningProviderException.java b/backend-api/src/main/java/com/guildworkman/api/signing/custody/SigningProviderException.java new file mode 100644 index 0000000..27b8230 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/custody/SigningProviderException.java @@ -0,0 +1,21 @@ +package com.guildworkman.api.signing.custody; + +/** + * Raised when a custody backend could not produce a signature — a KMS that is + * unreachable, a rejected key policy, a malformed signature coming back. + * + *

Messages are constructed from the key reference and the + * backend's own error text only; see {@link SecretRedactor} for the + * belt-and-braces scrub applied to anything derived from an external response + * before it reaches a log line or a persisted {@code lastError}. + */ +public class SigningProviderException extends RuntimeException { + + public SigningProviderException(String message) { + super(SecretRedactor.redact(message)); + } + + public SigningProviderException(String message, Throwable cause) { + super(SecretRedactor.redact(message), cause); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/custody/UnknownKeyReferenceException.java b/backend-api/src/main/java/com/guildworkman/api/signing/custody/UnknownKeyReferenceException.java new file mode 100644 index 0000000..c4740c4 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/custody/UnknownKeyReferenceException.java @@ -0,0 +1,21 @@ +package com.guildworkman.api.signing.custody; + +/** + * The requested key reference isn't configured for the active + * {@link SigningProvider}. Carries the reference (an alias, never key + * material) so an operator can see which alias is missing from the + * deployment's configuration. + */ +public class UnknownKeyReferenceException extends RuntimeException { + + private final String keyRef; + + public UnknownKeyReferenceException(String keyRef, String providerId) { + super("No key configured for keyRef='" + keyRef + "' in signing provider '" + providerId + "'"); + this.keyRef = keyRef; + } + + public String getKeyRef() { + return keyRef; + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/model/ChannelAccount.java b/backend-api/src/main/java/com/guildworkman/api/signing/model/ChannelAccount.java new file mode 100644 index 0000000..70f568d --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/model/ChannelAccount.java @@ -0,0 +1,108 @@ +package com.guildworkman.api.signing.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +/** + * One Stellar account in the channel-account pool, plus the lease that makes + * concurrent submission safe. + * + *

Why channel accounts at all. Stellar requires a transaction's + * sequence number to be exactly the source account's current sequence plus + * one. Submitting from a single account therefore serializes every + * transaction the backend sends, and one transaction stuck in the mempool + * blocks every transaction behind it. Submitting from a single account + * in parallel is worse: the second transaction to reach a validator + * with the same number is rejected {@code txBAD_SEQ}. A pool of accounts — + * each leased to at most one in-flight transaction — buys real parallelism + * (pool size) while keeping each account's sequence strictly serial. + * + *

The invariant this row exists to hold: a {@code LEASED} account + * has exactly one submission allowed to use {@link #nextSequence}, and the + * lease is only released once that submission reaches a terminal state. + * {@code ChannelAccountLeaseService} takes the lease with + * {@code SELECT … FOR UPDATE SKIP LOCKED}, so N concurrent allocators take N + * different accounts instead of queueing on one. + * + *

{@link #nextSequence} is incremented at lease time, not at confirmation + * time, so two submissions can never be handed the same number even if the + * first one is still in flight. The correction for a transaction that never + * lands is {@link ChannelAccountStatus#NEEDS_RESYNC} — see + * {@code ChannelAccountLeaseService#release}. + * + *

No key material lives here: {@link #keyRef} is an alias resolved by the + * active {@code SigningProvider}, and {@link #accountId} is a public key. + */ +@Entity +@Table(name = "stellar_channel_accounts", + uniqueConstraints = { + @UniqueConstraint(name = "uk_stellar_channel_account_id", columnNames = "account_id"), + @UniqueConstraint(name = "uk_stellar_channel_key_ref", columnNames = "key_ref") + }, + indexes = @Index(name = "idx_stellar_channel_status", columnList = "status")) +@Getter +@Setter +@NoArgsConstructor +public class ChannelAccount { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** Stellar account strkey ({@code G…}) — the transaction source account. Public information. */ + @Column(name = "account_id", nullable = false, length = 56, updatable = false) + private String accountId; + + /** Alias the {@code SigningProvider} resolves to this account's signing key. Never a secret. */ + @Column(name = "key_ref", nullable = false, length = 64) + private String keyRef; + + /** Sequence number the next transaction built on this account will carry. */ + @Column(name = "next_sequence", nullable = false) + private long nextSequence; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ChannelAccountStatus status = ChannelAccountStatus.NEEDS_RESYNC; + + /** Submission currently holding the lease, or {@code null} when free. */ + @Column(name = "leased_by_submission_id") + private Long leasedBySubmissionId; + + @Column(name = "leased_at") + private Instant leasedAt; + + /** + * When the sweeper may reclaim a lease whose holder died. Set from + * {@code stellar.signing.lease-ttl} at lease time. + */ + @Column(name = "lease_expires_at") + private Instant leaseExpiresAt; + + @Column(name = "last_synced_at") + private Instant lastSyncedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @Version + private long version; + + public boolean isLeasable() { + return status == ChannelAccountStatus.AVAILABLE || status == ChannelAccountStatus.NEEDS_RESYNC; + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/model/ChannelAccountStatus.java b/backend-api/src/main/java/com/guildworkman/api/signing/model/ChannelAccountStatus.java new file mode 100644 index 0000000..d2a017d --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/model/ChannelAccountStatus.java @@ -0,0 +1,31 @@ +package com.guildworkman.api.signing.model; + +/** + * Lifecycle of one channel account in the pool. + * + *

+ *   AVAILABLE ──lease──▶ LEASED ──confirmed/on-chain failure──▶ AVAILABLE
+ *       ▲                   │
+ *       │                   └──anything that didn't consume the sequence──▶ NEEDS_RESYNC
+ *       └──resync from chain──────────────────────────────────────────────────┘
+ * 
+ */ +public enum ChannelAccountStatus { + + /** Free to lease; {@code nextSequence} is believed to match the chain. */ + AVAILABLE, + + /** Exclusively held by one in-flight submission — the invariant that keeps sequence numbers gap-free. */ + LEASED, + + /** + * The account's sequence may have drifted from the chain (a transaction we + * allocated a sequence for never landed, or a {@code txBAD_SEQ} came back). + * Still leasable, but the next lease re-reads the real sequence from the + * network first. + */ + NEEDS_RESYNC, + + /** Taken out of the pool by an operator; never leased. */ + DISABLED +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/model/SubmissionFailureReason.java b/backend-api/src/main/java/com/guildworkman/api/signing/model/SubmissionFailureReason.java new file mode 100644 index 0000000..3bcd88f --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/model/SubmissionFailureReason.java @@ -0,0 +1,62 @@ +package com.guildworkman.api.signing.model; + +/** + * Why a submission failed — the machine-readable half of the error contract, + * returned alongside a human-readable {@code lastError}. + * + *

The Soroban/Horizon failure modes the issue calls out each land on their + * own constant because each has its own recovery path, and conflating them + * produces exactly the wrong response: retrying a {@code txBAD_SEQ} without + * resyncing the sequence just reproduces it, while not retrying a + * {@code txINSUFFICIENT_FEE} abandons a transaction that a fee bump would have + * landed. See {@code TransactionSubmissionService} for the handling and + * {@code FailureClassifier} for how a {@code TransactionResult} XDR becomes + * one of these. + */ +public enum SubmissionFailureReason { + + /** No failure recorded. */ + NONE, + + /** Simulation rejected the transaction before it was ever signed. Terminal: the contract call itself is wrong. */ + SIMULATION_FAILED, + + /** {@code txBAD_SEQ} — our sequence number disagreed with the chain. Resync the channel account and rebuild. */ + BAD_SEQUENCE, + + /** {@code txINSUFFICIENT_FEE} — outbid in the mempool. Fee-bump immediately, up to the ceiling. */ + INSUFFICIENT_FEE, + + /** + * {@code txTOO_LATE} — the time bounds expired before inclusion. Rebuild with fresh bounds. Also covers + * {@code txTOO_EARLY}, which shouldn't arise (this service never sets a minimum time) but has the same fix. + */ + TOO_LATE, + + /** A fee bump would have crossed {@code stellar.signing.fee.max-total-stroops}. Terminal by policy. */ + FEE_CEILING_REACHED, + + /** {@code txMALFORMED}/{@code txMISSING_OPERATION}/{@code txSOROBAN_INVALID} — the envelope is wrong. Terminal. */ + MALFORMED, + + /** {@code txBAD_AUTH}/{@code txBAD_AUTH_EXTRA} — signatures didn't satisfy the account's signers. Terminal. */ + BAD_AUTH, + + /** {@code txINSUFFICIENT_BALANCE}/{@code txNO_ACCOUNT} — the channel account can't pay. Terminal until funded. */ + INSUFFICIENT_BALANCE, + + /** Included in a ledger and failed there. Terminal — the sequence number is spent. */ + ON_CHAIN_FAILED, + + /** The pool had no free channel account. Retried, not failed, until attempts run out. */ + NO_CHANNEL_ACCOUNT, + + /** The custody backend could not sign. Retried while attempts remain. */ + SIGNING_FAILED, + + /** Soroban RPC was unreachable or errored. Retried while attempts remain. */ + RPC_ERROR, + + /** A result code we don't have a specific recovery path for. Retried while attempts remain. */ + UNKNOWN +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/model/SubmissionStatus.java b/backend-api/src/main/java/com/guildworkman/api/signing/model/SubmissionStatus.java new file mode 100644 index 0000000..c65860b --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/model/SubmissionStatus.java @@ -0,0 +1,48 @@ +package com.guildworkman.api.signing.model; + +import java.util.EnumSet; +import java.util.Set; + +/** + * Lifecycle of one transaction submission. + * + *

+ *   PENDING ──lease + simulate + sign──▶ SIGNED ──sendTransaction──▶ BROADCAST
+ *      ▲                                                               │
+ *      │                                                   getTransaction
+ *      │                                                               ▼
+ *      └──rebuild (txBAD_SEQ / txTOO_LATE)──── CONFIRMED / FAILED / DEAD_LETTER
+ * 
+ * + *

{@link #SIGNED} is the state that makes restart safety possible: the + * signed envelope and its hash are committed before the envelope is + * ever handed to the network, so a process that dies mid-broadcast comes back + * knowing exactly what it may have already sent, and asks the network about + * that hash instead of signing something new. + */ +public enum SubmissionStatus { + + /** Accepted from the caller; no channel account leased and nothing signed yet. */ + PENDING, + + /** Built, simulated and signed; the envelope and hash are durable. May or may not have been broadcast. */ + SIGNED, + + /** Handed to Soroban RPC and accepted into the mempool. Awaiting inclusion in a ledger. */ + BROADCAST, + + /** Included in a ledger and succeeded. Terminal. */ + CONFIRMED, + + /** Rejected, or included and failed on-chain, with no recovery path left. Terminal. */ + FAILED, + + /** Retries exhausted without reaching a decision. Terminal, and an operator's problem. */ + DEAD_LETTER; + + private static final Set TERMINAL = EnumSet.of(CONFIRMED, FAILED, DEAD_LETTER); + + public boolean isTerminal() { + return TERMINAL.contains(this); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/model/TransactionSubmission.java b/backend-api/src/main/java/com/guildworkman/api/signing/model/TransactionSubmission.java new file mode 100644 index 0000000..6511c53 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/model/TransactionSubmission.java @@ -0,0 +1,172 @@ +package com.guildworkman.api.signing.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +/** + * The durable record of one transaction this service signs and submits — the + * write-ahead log that lets a restart resume instead of double-submitting. + * + *

The caller supplies {@link #unsignedTransactionXdr}: an envelope carrying + * the operations it wants executed. Source account, sequence number, fee and + * time bounds on that envelope are placeholders — this service replaces all + * four when it builds the real transaction against a leased channel account, + * which is what makes rebuilding possible when a sequence goes stale or time + * bounds lapse. (Contrast {@code EscrowOrchestrationRequest}, which relays an + * envelope the caller has already signed and therefore can never rebuild.) + * + *

Ordering rule for restart safety. {@link #signedEnvelopeXdr} and + * {@link #transactionHash} are committed while the row is + * {@link SubmissionStatus#SIGNED}, strictly before {@code sendTransaction} is + * called. Recovery therefore never has to guess: any row that is + * {@code SIGNED} or {@code BROADCAST} has a known hash, and the recovery path + * asks the network about that hash before it considers sending anything. Even + * a resend is harmless — the same envelope hashes to the same transaction, so + * Soroban RPC answers {@code DUPLICATE} rather than executing it twice. + * + *

{@link #transactionHash} tracks the envelope currently in the mempool, + * which after a fee bump is the outer fee-bump transaction; + * {@link #innerTransactionHash} keeps the original so a transaction that + * landed on its own, just as we bumped it, is still recognised. + */ +@Entity +@Table(name = "stellar_transaction_submissions", + uniqueConstraints = @UniqueConstraint( + name = "uk_stellar_submission_idempotency_key", columnNames = "idempotency_key"), + indexes = { + @Index(name = "idx_stellar_submission_status", columnList = "status,next_attempt_at"), + @Index(name = "idx_stellar_submission_hash", columnList = "transaction_hash"), + @Index(name = "idx_stellar_submission_reference", columnList = "reference") + }) +@Getter +@Setter +@NoArgsConstructor +public class TransactionSubmission { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** Caller-generated key; resubmitting it returns the original submission instead of signing a second transaction. */ + @Column(name = "idempotency_key", nullable = false, updatable = false, length = 128) + private String idempotencyKey; + + /** Caller's own correlation handle (appointment id, escrow id, …). Opaque here. */ + @Column(length = 128) + private String reference; + + /** Base64 {@code TransactionEnvelope} XDR supplying the operations to execute. Never signed as-is. */ + @Lob + @Column(name = "unsigned_transaction_xdr", nullable = false, updatable = false) + private String unsignedTransactionXdr; + + /** + * Comma-separated key references that must co-sign in addition to the + * channel account (e.g. a contract-admin key). Aliases only — see + * {@code SigningProvider}. + */ + @Column(name = "extra_signer_key_refs", length = 256, updatable = false) + private String extraSignerKeyRefs; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private SubmissionStatus status = SubmissionStatus.PENDING; + + @Enumerated(EnumType.STRING) + @Column(name = "failure_reason", nullable = false, length = 24) + private SubmissionFailureReason failureReason = SubmissionFailureReason.NONE; + + /** Which custody backend produced the signature ({@code local}/{@code kms}). */ + @Column(name = "signing_provider", length = 32) + private String signingProvider; + + @Column(name = "channel_account_id") + private Long channelAccountId; + + /** Channel account this transaction is built on, denormalised so the record survives pool changes. */ + @Column(name = "source_account", length = 56) + private String sourceAccount; + + /** Key reference the channel account was signed with — the fee-bump signer too, unless one is configured. */ + @Column(name = "key_ref", length = 64) + private String keyRef; + + @Column(name = "sequence_number") + private Long sequenceNumber; + + /** Total fee of the envelope currently in flight, in stroops. */ + @Column(name = "fee_stroops") + private Long feeStroops; + + @Column(name = "fee_bump_count", nullable = false) + private int feeBumpCount; + + /** Signed envelope currently in flight — after a fee bump, the fee-bump envelope. */ + @Lob + @Column(name = "signed_envelope_xdr") + private String signedEnvelopeXdr; + + /** Hash of {@link #signedEnvelopeXdr}: what {@code getTransaction} is polled with. */ + @Column(name = "transaction_hash", length = 64) + private String transactionHash; + + /** Hash of the inner transaction, unchanged across fee bumps. */ + @Column(name = "inner_transaction_hash", length = 64) + private String innerTransactionHash; + + @Column(name = "ledger_sequence") + private Long ledgerSequence; + + /** + * The signed envelope's {@code maxTime} time bound. Past this instant the + * network can never include it, whatever the mempool says — which is what + * lets the poller decide a transaction is dead instead of waiting for a + * verdict that will never come. + */ + @Column(name = "valid_until") + private Instant validUntil; + + /** Base64 {@code TransactionResult} XDR of a terminal outcome, for post-hoc diagnosis. */ + @Column(name = "result_xdr", length = 2000) + private String resultXdr; + + @Column(nullable = false) + private int attempts; + + @Column(name = "next_attempt_at", nullable = false) + private Instant nextAttemptAt = Instant.now(); + + /** Always passed through {@code SecretRedactor} before it's set. */ + @Column(name = "last_error", length = 1000) + private String lastError; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "signed_at") + private Instant signedAt; + + @Column(name = "broadcast_at") + private Instant broadcastAt; + + @Column(name = "confirmed_at") + private Instant confirmedAt; + + @Version + private long version; +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/repository/ChannelAccountRepository.java b/backend-api/src/main/java/com/guildworkman/api/signing/repository/ChannelAccountRepository.java new file mode 100644 index 0000000..3c0a227 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/repository/ChannelAccountRepository.java @@ -0,0 +1,70 @@ +package com.guildworkman.api.signing.repository; + +import com.guildworkman.api.signing.model.ChannelAccount; +import com.guildworkman.api.signing.model.ChannelAccountStatus; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +public interface ChannelAccountRepository extends JpaRepository { + + Optional findByAccountId(String accountId); + + Optional findByKeyRef(String keyRef); + + List findByStatus(ChannelAccountStatus status); + + long countByStatus(ChannelAccountStatus status); + + /** + * Takes the next leasable account with {@code SELECT … FOR UPDATE SKIP + * LOCKED} — the whole reason concurrent submissions don't collide on a + * sequence number. + * + *

Plain {@code FOR UPDATE} (what {@code EscrowOrchestrationRequestRepository.claimNext} + * uses) would be wrong here. There, blocking is the desired behaviour: + * competing workers want that row, and waiting for the winner to + * commit costs nothing but a little latency. Here the workers want + * any free account, so waiting on a locked row would serialize + * the pool down to one submission at a time and throw away the parallelism + * the pool exists to provide. {@code SKIP LOCKED} (the {@code -2} lock + * timeout hint, which Hibernate renders as Postgres's {@code SKIP LOCKED}) + * makes each concurrent allocator step over accounts another transaction + * is already holding and take the next one instead. + * + *

Postgres evaluates the {@code LIMIT} above the locking step, so a + * one-row page still returns one unlocked row rather than + * "nothing, because the first row was busy". With N accounts free, N + * concurrent allocators get N different accounts — and therefore N + * different sequence numbers. + * + *

Deadlock-free by construction: every caller locks exactly one row and + * holds no other channel-account lock while doing so, so there is no + * wait-cycle to form. Rows in {@code NEEDS_RESYNC} are included because + * they are leasable — the lease just has to re-read the account's real + * sequence from the network first. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "-2")) + @Query("select c from ChannelAccount c where c.status in :statuses order by c.id") + List claimLeasable(@Param("statuses") List statuses, Pageable pageable); + + /** + * Leases whose holder never came back — the process died between taking + * the lease and finishing with it. Reclaimed by + * {@code ChannelAccountLeaseService#sweepExpiredLeases()}, which checks the + * holding submission's state before releasing anything. + */ + @Query("select c from ChannelAccount c where c.status = com.guildworkman.api.signing.model.ChannelAccountStatus.LEASED " + + "and c.leaseExpiresAt is not null and c.leaseExpiresAt <= :now order by c.leaseExpiresAt") + List findExpiredLeases(@Param("now") Instant now, Pageable pageable); +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/repository/TransactionSubmissionRepository.java b/backend-api/src/main/java/com/guildworkman/api/signing/repository/TransactionSubmissionRepository.java new file mode 100644 index 0000000..9d857ec --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/repository/TransactionSubmissionRepository.java @@ -0,0 +1,51 @@ +package com.guildworkman.api.signing.repository; + +import com.guildworkman.api.signing.model.SubmissionStatus; +import com.guildworkman.api.signing.model.TransactionSubmission; +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface TransactionSubmissionRepository extends JpaRepository { + + Optional findByIdempotencyKey(String idempotencyKey); + + Optional findByTransactionHash(String transactionHash); + + List findByReference(String reference); + + long countByStatus(SubmissionStatus status); + + /** + * Claims due rows for the given statuses via {@code SELECT … FOR UPDATE}, + * exactly as {@code EscrowOrchestrationRequestRepository.claimNext} does: + * a second worker's select blocks until the first commits, then + * re-evaluates the {@code WHERE} clause and skips the row it no longer + * matches. Locking in {@code order by s.id} and claiming a single-row page + * keeps concurrent callers from forming a wait-cycle. + * + *

Blocking is the right semantic here (unlike + * {@code ChannelAccountRepository#claimLeasable}): two workers racing for + * the same submission must not both process it, and there is no + * "equivalent other row" to fall back to. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select s from TransactionSubmission s where s.status in :statuses and s.nextAttemptAt <= :now order by s.id") + List claimNext(@Param("statuses") Set statuses, + @Param("now") Instant now, Pageable pageable); + + /** Non-terminal submissions holding a given channel account's lease, used by the lease sweeper. */ + @Query("select s from TransactionSubmission s where s.channelAccountId = :channelAccountId " + + "and s.status not in (com.guildworkman.api.signing.model.SubmissionStatus.CONFIRMED, " + + "com.guildworkman.api.signing.model.SubmissionStatus.FAILED, " + + "com.guildworkman.api.signing.model.SubmissionStatus.DEAD_LETTER)") + List findActiveByChannelAccountId(@Param("channelAccountId") Long channelAccountId); +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountAlreadyRegisteredException.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountAlreadyRegisteredException.java new file mode 100644 index 0000000..29bae0a --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountAlreadyRegisteredException.java @@ -0,0 +1,13 @@ +package com.guildworkman.api.signing.service; + +/** + * The account (or the key reference) is already in the pool. Registering it + * twice would put two rows in charge of one account's sequence number, which + * is precisely the collision the pool exists to prevent. + */ +public class ChannelAccountAlreadyRegisteredException extends RuntimeException { + + public ChannelAccountAlreadyRegisteredException(String message) { + super(message); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountBusyException.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountBusyException.java new file mode 100644 index 0000000..79f843b --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountBusyException.java @@ -0,0 +1,13 @@ +package com.guildworkman.api.signing.service; + +/** + * The account is leased to an in-flight submission, so the requested change + * would strand that transaction's sequence number. Retry once the holding + * submission reaches a terminal state. + */ +public class ChannelAccountBusyException extends RuntimeException { + + public ChannelAccountBusyException(String message) { + super(message); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountLeaseService.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountLeaseService.java new file mode 100644 index 0000000..417a3a3 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountLeaseService.java @@ -0,0 +1,225 @@ +package com.guildworkman.api.signing.service; + +import com.guildworkman.api.escrow.rpc.SorobanRpcClient; +import com.guildworkman.api.escrow.rpc.SorobanRpcException; +import com.guildworkman.api.signing.SigningProperties; +import com.guildworkman.api.signing.model.ChannelAccount; +import com.guildworkman.api.signing.model.ChannelAccountStatus; +import com.guildworkman.api.signing.model.TransactionSubmission; +import com.guildworkman.api.signing.repository.ChannelAccountRepository; +import com.guildworkman.api.signing.repository.TransactionSubmissionRepository; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +/** + * Allocates sequence numbers safely under concurrency, by leasing channel + * accounts. + * + *

The invariant

+ * A channel account is leased to at most one in-flight submission at a + * time, and its sequence number is incremented when the lease is taken. + * Those two facts together give the property the issue asks for: N concurrent + * submissions get N distinct sequence numbers, because they are necessarily + * working on N distinct accounts, and each account hands out each number once. + * + *

Why leasing, rather than "increment a counter"

+ * Stellar validates a transaction's sequence number as exactly the + * source account's current sequence plus one. Handing out {@code n} and + * {@code n+1} concurrently on one account doesn't parallelise anything: the + * network accepts whichever arrives first and rejects the other with + * {@code txBAD_SEQ}. Real parallelism has to come from having more accounts, + * which is what the pool is. {@code SELECT … FOR UPDATE SKIP LOCKED} + * (see {@link ChannelAccountRepository#claimLeasable}) is what makes taking + * "any free account" contention-free. + * + *

The correction path

+ * A sequence number allocated for a transaction that never lands is + * skipped, and every later transaction on that account would then be + * rejected — the local counter has run ahead of the chain. So a lease is only + * released back to {@link ChannelAccountStatus#AVAILABLE} when the + * transaction actually reached a ledger (success or on-chain failure both + * consume the number). Anything else — a fee ceiling hit, expired time + * bounds, a dead-lettered submission — releases to + * {@link ChannelAccountStatus#NEEDS_RESYNC}, and the next lease re-reads the + * account's real sequence from the network before using it. That single rule + * is what stops one lost transaction from poisoning an account forever. + */ +@Service +@RequiredArgsConstructor +public class ChannelAccountLeaseService { + + private static final Logger log = LoggerFactory.getLogger(ChannelAccountLeaseService.class); + + private static final List LEASABLE = + List.of(ChannelAccountStatus.AVAILABLE, ChannelAccountStatus.NEEDS_RESYNC); + + private final ChannelAccountRepository channelAccounts; + private final TransactionSubmissionRepository submissions; + private final SorobanRpcClient sorobanRpcClient; + private final SigningProperties properties; + + /** + * Takes an exclusive lease on a free channel account and allocates its + * next sequence number. + * + *

Runs in its own transaction ({@code REQUIRES_NEW}) so the lease is + * durable the moment it's granted, independently of whatever the caller + * does next. If the caller's transaction then rolls back, the lease is + * still committed and simply looks like a crashed holder — reclaimed by + * {@link #sweepExpiredLeases()} once its TTL passes. The alternative + * (leasing inside the caller's transaction) would let a rollback silently + * un-allocate a sequence number that a signed envelope already carries. + * + * @throws NoChannelAccountAvailableException if the pool has nothing free, or the chosen account isn't on-chain + * @throws SorobanRpcException if a resync couldn't reach the network + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public SequenceLease acquire(Long submissionId) { + ChannelAccount account = channelAccounts.claimLeasable(LEASABLE, PageRequest.of(0, 1)) + .stream() + .findFirst() + .orElseThrow(() -> new NoChannelAccountAvailableException( + "No channel account is free; " + channelAccounts.countByStatus(ChannelAccountStatus.LEASED) + + " leased, " + channelAccounts.countByStatus(ChannelAccountStatus.DISABLED) + + " disabled")); + + if (account.getStatus() == ChannelAccountStatus.NEEDS_RESYNC) { + resyncFromChain(account); + } + + long sequenceNumber = account.getNextSequence(); + Instant now = Instant.now(); + Instant expiresAt = now.plus(properties.getLeaseTtl()); + + account.setNextSequence(sequenceNumber + 1); + account.setStatus(ChannelAccountStatus.LEASED); + account.setLeasedBySubmissionId(submissionId); + account.setLeasedAt(now); + account.setLeaseExpiresAt(expiresAt); + channelAccounts.save(account); + + log.debug("Channel account leased channelAccountId={} accountId={} sequence={} submissionId={}", + account.getId(), account.getAccountId(), sequenceNumber, submissionId); + return new SequenceLease(account.getId(), account.getAccountId(), account.getKeyRef(), sequenceNumber, + expiresAt); + } + + /** + * Releases a lease. + * + * @param sequenceConsumed whether the transaction reached a ledger. {@code true} for a confirmed transaction + * and for one that failed on-chain (both spend the sequence number); + * {@code false} for anything that never landed, which leaves the account's local + * sequence ahead of the chain and therefore needing a resync. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void release(Long channelAccountId, boolean sequenceConsumed) { + if (channelAccountId == null) { + return; + } + channelAccounts.findById(channelAccountId).ifPresent(account -> { + if (account.getStatus() != ChannelAccountStatus.LEASED) { + // Already released — a lease sweep and a worker can both reach + // this for the same account; releasing twice is a no-op, not a + // problem, so long as we don't overwrite a fresher lease. + return; + } + account.setStatus(sequenceConsumed + ? ChannelAccountStatus.AVAILABLE + : ChannelAccountStatus.NEEDS_RESYNC); + account.setLeasedBySubmissionId(null); + account.setLeasedAt(null); + account.setLeaseExpiresAt(null); + channelAccounts.save(account); + log.debug("Channel account released channelAccountId={} status={} sequenceConsumed={}", + account.getId(), account.getStatus(), sequenceConsumed); + }); + } + + /** + * Reclaims leases whose holder never came back. + * + *

Deliberately conservative: a lease whose submission is still + * non-terminal is not reclaimed, because that transaction may be + * sitting in the mempool right now and reusing its sequence number would + * race it. Such a lease only has its TTL pushed out — the submission + * workers will drive it to a terminal state and release it properly. Only + * a lease with no active submission behind it (the process died between + * committing the lease and committing the submission) is released here, + * and always to {@code NEEDS_RESYNC}, since we can't know whether its + * sequence number was used. + */ + @Scheduled(fixedDelayString = "${stellar.signing.lease-sweep-delay-ms:30000}") + @Transactional + public void sweepExpiredLeases() { + Instant now = Instant.now(); + List expired = channelAccounts.findExpiredLeases(now, PageRequest.of(0, 20)); + for (ChannelAccount account : expired) { + List active = submissions.findActiveByChannelAccountId(account.getId()); + if (!active.isEmpty()) { + account.setLeaseExpiresAt(now.plus(properties.getLeaseTtl())); + channelAccounts.save(account); + log.debug("Channel account lease extended channelAccountId={} activeSubmissions={}", + account.getId(), active.size()); + continue; + } + account.setStatus(ChannelAccountStatus.NEEDS_RESYNC); + account.setLeasedBySubmissionId(null); + account.setLeasedAt(null); + account.setLeaseExpiresAt(null); + channelAccounts.save(account); + log.warn("Reclaimed an orphaned channel-account lease channelAccountId={} accountId={}; " + + "sequence will be resynced from the network before reuse", + account.getId(), account.getAccountId()); + } + } + + /** + * Operator-triggered resync of one account against the chain (see the ADMIN + * endpoint). + * + *

Refused while the account is leased. A resync answers "what does the + * network think this account's sequence is", and mid-lease the honest + * answer is stale: the leased transaction hasn't landed yet, so the chain + * still reports the sequence before it — writing that back would + * hand the next lease a number the in-flight transaction is already using. + */ + @Transactional + public ChannelAccount resync(Long channelAccountId) { + ChannelAccount account = channelAccounts.findById(channelAccountId) + .orElseThrow(() -> new ChannelAccountNotFoundException(channelAccountId)); + if (account.getStatus() == ChannelAccountStatus.LEASED) { + throw new ChannelAccountBusyException("Channel account " + channelAccountId + + " is currently leased by submission " + account.getLeasedBySubmissionId() + + "; a resync now would reuse that submission's sequence number"); + } + resyncFromChain(account); + account.setStatus(ChannelAccountStatus.AVAILABLE); + return channelAccounts.save(account); + } + + /** + * Replaces the account's local sequence with the chain's, +1 — the number + * the next transaction from this account must carry. + */ + private void resyncFromChain(ChannelAccount account) { + Long onChainSequence = sorobanRpcClient.getAccountSequence(account.getAccountId()); + if (onChainSequence == null) { + throw new NoChannelAccountAvailableException("Channel account " + account.getAccountId() + + " does not exist on this network — fund it before it can be used"); + } + account.setNextSequence(onChainSequence + 1); + account.setLastSyncedAt(Instant.now()); + log.info("Channel account resynced channelAccountId={} accountId={} nextSequence={}", + account.getId(), account.getAccountId(), account.getNextSequence()); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountNotFoundException.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountNotFoundException.java new file mode 100644 index 0000000..6a9a97a --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountNotFoundException.java @@ -0,0 +1,9 @@ +package com.guildworkman.api.signing.service; + +/** No channel account with the given id. */ +public class ChannelAccountNotFoundException extends RuntimeException { + + public ChannelAccountNotFoundException(Long id) { + super("Channel account " + id + " not found"); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountService.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountService.java new file mode 100644 index 0000000..a0aa343 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountService.java @@ -0,0 +1,107 @@ +package com.guildworkman.api.signing.service; + +import com.guildworkman.api.signing.model.ChannelAccount; +import com.guildworkman.api.signing.model.ChannelAccountStatus; +import com.guildworkman.api.signing.repository.ChannelAccountRepository; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Operator-facing management of the channel-account pool: registering + * accounts, taking one out of service, putting it back. + * + *

An account is registered by key reference, never by account id. + * The public key is then resolved through the active + * {@code SigningProvider}, which makes an unsignable account impossible to + * register: if custody can't produce a public key for the reference, there is + * nothing to add to the pool. It also means the same operator request works + * unchanged against a local seed in development and an HSM in production. + * + *

New accounts start {@link ChannelAccountStatus#NEEDS_RESYNC} rather than + * {@code AVAILABLE}: the pool has no idea what the account's sequence number + * is until it reads it from the network, and the first lease does exactly + * that. Registration therefore needs no network round-trip and can't be left + * half-done by an RPC outage. + */ +@Service +@RequiredArgsConstructor +public class ChannelAccountService { + + private static final Logger log = LoggerFactory.getLogger(ChannelAccountService.class); + + private final ChannelAccountRepository channelAccounts; + private final TransactionSigner signer; + + @Transactional + public ChannelAccount register(String keyRef) { + String accountId = signer.publicKey(keyRef); + + channelAccounts.findByKeyRef(keyRef).ifPresent(existing -> { + throw new ChannelAccountAlreadyRegisteredException( + "keyRef '" + keyRef + "' is already registered as channel account " + existing.getId()); + }); + channelAccounts.findByAccountId(accountId).ifPresent(existing -> { + throw new ChannelAccountAlreadyRegisteredException( + "Account " + accountId + " is already registered as channel account " + existing.getId()); + }); + + ChannelAccount account = new ChannelAccount(); + account.setAccountId(accountId); + account.setKeyRef(keyRef); + account.setStatus(ChannelAccountStatus.NEEDS_RESYNC); + account.setNextSequence(0); + ChannelAccount saved = channelAccounts.save(account); + log.info("Channel account registered id={} accountId={} keyRef={}", + saved.getId(), saved.getAccountId(), keyRef); + return saved; + } + + @Transactional(readOnly = true) + public List list() { + return channelAccounts.findAll(); + } + + @Transactional(readOnly = true) + public ChannelAccount get(Long id) { + return channelAccounts.findById(id).orElseThrow(() -> new ChannelAccountNotFoundException(id)); + } + + /** + * Takes an account out of the pool. A leased account is left leased — + * disabling it under an in-flight transaction would strand that + * transaction's sequence number — so the flag takes effect once the + * current submission finishes with it. + */ + @Transactional + public ChannelAccount disable(Long id) { + ChannelAccount account = get(id); + if (account.getStatus() == ChannelAccountStatus.LEASED) { + throw new ChannelAccountBusyException("Channel account " + id + + " is currently leased by submission " + account.getLeasedBySubmissionId() + + "; retry once that submission reaches a terminal state"); + } + account.setStatus(ChannelAccountStatus.DISABLED); + log.info("Channel account disabled id={} accountId={}", id, account.getAccountId()); + return channelAccounts.save(account); + } + + /** + * Returns a disabled account to the pool as {@code NEEDS_RESYNC}: however + * long it sat out, its sequence has to be re-read before it's trusted. + */ + @Transactional + public ChannelAccount enable(Long id) { + ChannelAccount account = get(id); + if (account.getStatus() == ChannelAccountStatus.LEASED) { + return account; + } + account.setStatus(ChannelAccountStatus.NEEDS_RESYNC); + log.info("Channel account enabled id={} accountId={}", id, account.getAccountId()); + return channelAccounts.save(account); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/FailureClassifier.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/FailureClassifier.java new file mode 100644 index 0000000..06a3507 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/FailureClassifier.java @@ -0,0 +1,76 @@ +package com.guildworkman.api.signing.service; + +import com.guildworkman.api.signing.model.SubmissionFailureReason; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.stellar.sdk.xdr.TransactionResult; +import org.stellar.sdk.xdr.TransactionResultCode; + +/** + * Decodes a {@code TransactionResult} XDR into the failure mode it actually + * represents, so each one can get the recovery it needs. + * + *

Soroban RPC reports a rejected transaction as a base64 blob + * ({@code errorResultXdr} on {@code sendTransaction}, {@code resultXdr} on + * {@code getTransaction}). Treating that blob as an opaque "it failed" is + * what produces the two classic bugs this feature is meant to avoid: retrying + * a {@code txBAD_SEQ} unchanged, which reproduces it forever, and giving up + * on a {@code txINSUFFICIENT_FEE}, which throws away a transaction a fee bump + * would have landed. + * + *

A result we can't decode is {@link SubmissionFailureReason#UNKNOWN} + * rather than an exception: an unrecognised code from a future protocol + * version should degrade to bounded retries, not crash a worker. + */ +@Component +public class FailureClassifier { + + private static final Logger log = LoggerFactory.getLogger(FailureClassifier.class); + + /** + * @param resultXdr base64 {@code TransactionResult} XDR, or {@code null} + * @return the failure mode, or {@link SubmissionFailureReason#UNKNOWN} if it can't be decoded + */ + public SubmissionFailureReason classify(String resultXdr) { + TransactionResultCode code = resultCode(resultXdr); + if (code == null) { + return SubmissionFailureReason.UNKNOWN; + } + return switch (code) { + case txBAD_SEQ -> SubmissionFailureReason.BAD_SEQUENCE; + case txINSUFFICIENT_FEE -> SubmissionFailureReason.INSUFFICIENT_FEE; + case txTOO_LATE, txTOO_EARLY -> SubmissionFailureReason.TOO_LATE; + case txMALFORMED, txMISSING_OPERATION, txSOROBAN_INVALID, txNOT_SUPPORTED -> + SubmissionFailureReason.MALFORMED; + case txBAD_AUTH, txBAD_AUTH_EXTRA -> SubmissionFailureReason.BAD_AUTH; + case txINSUFFICIENT_BALANCE, txNO_ACCOUNT -> SubmissionFailureReason.INSUFFICIENT_BALANCE; + case txFAILED, txFEE_BUMP_INNER_FAILED -> SubmissionFailureReason.ON_CHAIN_FAILED; + default -> SubmissionFailureReason.UNKNOWN; + }; + } + + /** @return the raw result code name for diagnostics (e.g. {@code txBAD_SEQ}), or {@code null} if undecodable. */ + public String resultCodeName(String resultXdr) { + TransactionResultCode code = resultCode(resultXdr); + return code == null ? null : code.name(); + } + + private TransactionResultCode resultCode(String resultXdr) { + if (resultXdr == null || resultXdr.isBlank()) { + return null; + } + try { + TransactionResult result = TransactionResult.fromXdrBase64(resultXdr); + return result.getResult().getDiscriminant(); + } catch (Exception ex) { + // Includes IOException from the XDR decoder and any runtime error + // a malformed blob triggers inside it. Diagnostic only: the blob + // itself is never logged, since a result XDR echoes transaction + // contents back. + log.debug("Could not decode a TransactionResult XDR ({} chars): {}", + resultXdr.length(), ex.getClass().getSimpleName()); + return null; + } + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/NoChannelAccountAvailableException.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/NoChannelAccountAvailableException.java new file mode 100644 index 0000000..1fe4297 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/NoChannelAccountAvailableException.java @@ -0,0 +1,15 @@ +package com.guildworkman.api.signing.service; + +/** + * Every channel account in the pool is currently leased (or none is usable). + * Not an error the caller did anything to cause: the submission stays + * {@code PENDING} and is retried, so the practical effect of a busy pool is + * queueing, not failure. A pool that is permanently exhausted wants more + * accounts registered — see {@code docs/STELLAR_SIGNING.md} ("Operations"). + */ +public class NoChannelAccountAvailableException extends RuntimeException { + + public NoChannelAccountAvailableException(String message) { + super(message); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/SequenceLease.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/SequenceLease.java new file mode 100644 index 0000000..cb446a7 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/SequenceLease.java @@ -0,0 +1,18 @@ +package com.guildworkman.api.signing.service; + +import java.time.Instant; + +/** + * An exclusive hold on one channel account, together with the sequence number + * allocated from it. Held from the moment a submission is about to be built + * until that submission reaches a terminal state. + * + * @param channelAccountId the pool row backing the lease + * @param accountId the Stellar account strkey ({@code G…}) to build the transaction on + * @param keyRef alias the {@code SigningProvider} resolves to that account's signing key + * @param sequenceNumber the sequence number this transaction must carry; allocated to this lease alone + * @param expiresAt when a sweeper may reclaim the lease if the holder never comes back + */ +public record SequenceLease(Long channelAccountId, String accountId, String keyRef, long sequenceNumber, + Instant expiresAt) { +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/SubmissionNotFoundException.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/SubmissionNotFoundException.java new file mode 100644 index 0000000..4e7959c --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/SubmissionNotFoundException.java @@ -0,0 +1,9 @@ +package com.guildworkman.api.signing.service; + +/** No transaction submission with the given id. */ +public class SubmissionNotFoundException extends RuntimeException { + + public SubmissionNotFoundException(Long id) { + super("Transaction submission " + id + " not found"); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionAssembler.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionAssembler.java new file mode 100644 index 0000000..fc46a83 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionAssembler.java @@ -0,0 +1,145 @@ +package com.guildworkman.api.signing.service; + +import com.guildworkman.api.signing.SigningProperties; +import org.springframework.stereotype.Component; +import org.stellar.sdk.AbstractTransaction; +import org.stellar.sdk.Account; +import org.stellar.sdk.FeeBumpTransaction; +import org.stellar.sdk.Network; +import org.stellar.sdk.Transaction; +import org.stellar.sdk.TransactionBuilder; +import org.stellar.sdk.operations.Operation; + +import java.util.Arrays; + +/** + * Turns the caller's envelope into a transaction this service can actually + * submit: same operations, but anchored to a leased channel account with a + * freshly allocated sequence number, our fee, and our time bounds. + * + *

Why rebuild instead of relaying. Signing a transaction commits to + * its source account, sequence number, fee and time bounds — all four are + * inside the signed payload. A service that accepted pre-signed envelopes + * (as the escrow orchestration flow does) therefore has exactly one move + * available when something goes wrong, which is to send the same bytes again. + * Keeping the caller's operations and rebuilding everything around + * them is what makes the recovery paths in {@code TransactionSubmissionService} + * possible: a stale sequence or lapsed time bounds are re-derived and the + * transaction is signed again, rather than being abandoned. + * + *

The caller's source account, sequence, fee and time bounds are placeholders + * and are always replaced. Preconditions beyond time bounds (minimum sequence + * age, ledger bounds, extra signers) are rejected rather than silently + * dropped — they constrain the very fields this service owns, so honouring + * them and managing sequences centrally are mutually exclusive. + */ +@Component +public class TransactionAssembler { + + /** + * Enough for a batched classic operation set, while bounding the fee any + * single submission can commit us to (fee is per-operation). + */ + static final int MAX_OPERATIONS = 20; + + private final SigningProperties properties; + private final Network network; + + public TransactionAssembler(SigningProperties properties) { + this.properties = properties; + this.network = new Network(properties.getNetworkPassphrase()); + } + + /** The network every transaction here is hashed and signed against. */ + public Network network() { + return network; + } + + /** + * Parses a caller-supplied envelope. + * + * @throws TransactionAssemblyException if it isn't a plain, non-empty transaction envelope + */ + public Transaction parse(String envelopeXdr) { + AbstractTransaction parsed; + try { + parsed = AbstractTransaction.fromEnvelopeXdr(envelopeXdr, network); + } catch (RuntimeException ex) { + throw new TransactionAssemblyException("Could not parse the transaction envelope XDR: " + ex.getMessage()); + } + if (parsed instanceof FeeBumpTransaction) { + throw new TransactionAssemblyException("Fee-bump envelopes are not accepted: submit the inner " + + "transaction's operations and let the service fee-bump it if it stalls"); + } + if (!(parsed instanceof Transaction transaction)) { + throw new TransactionAssemblyException("Unsupported transaction envelope type"); + } + Operation[] operations = transaction.getOperations(); + if (operations == null || operations.length == 0) { + throw new TransactionAssemblyException("The transaction envelope carries no operations"); + } + if (operations.length > MAX_OPERATIONS) { + throw new TransactionAssemblyException("The transaction envelope carries " + operations.length + + " operations; the maximum is " + MAX_OPERATIONS); + } + if (transaction.getPreconditions() != null && transaction.getPreconditions().hasV2()) { + throw new TransactionAssemblyException("Preconditions other than time bounds are not supported: " + + "this service owns the source account, sequence number and validity window"); + } + return transaction; + } + + /** + * Rebuilds {@code source}'s operations onto the leased channel account. + * + *

{@code Account(accountId, sequence - 1)} is not an off-by-one: + * {@link TransactionBuilder} increments the account's sequence when it + * builds, so seeding it one below the allocated number produces a + * transaction carrying exactly that number. + * + * @param inclusionFeePerOperation inclusion (bid) fee per operation, in stroops. For a Soroban transaction the + * builder adds the resource fee from {@code sorobanDataXdr} on top, which is + * why the caller reads the final total back off the built transaction rather + * than computing it + * @param sorobanDataXdr base64 {@code SorobanTransactionData} from simulation, or {@code null} for a + * classic transaction + */ + public Transaction rebuild(Transaction source, SequenceLease lease, long inclusionFeePerOperation, + String sorobanDataXdr) { + TransactionBuilder builder = new TransactionBuilder( + new Account(lease.accountId(), lease.sequenceNumber() - 1), network) + .addOperations(Arrays.asList(source.getOperations())) + .setBaseFee(inclusionFeePerOperation) + .setTimeout(properties.getTransactionTimeout().toSeconds()); + + if (source.getMemo() != null) { + builder.addMemo(source.getMemo()); + } + if (sorobanDataXdr != null && !sorobanDataXdr.isBlank()) { + builder.setSorobanData(sorobanDataXdr); + } + try { + return builder.build(); + } catch (RuntimeException ex) { + throw new TransactionAssemblyException("Could not build the transaction: " + ex.getMessage()); + } + } + + /** + * Wraps a stalled transaction in a fee bump paying {@code totalFeeStroops}. + * + *

A fee bump keeps the inner transaction — and therefore its sequence + * number, operations and signatures — exactly as it was, and only offers + * the network more to include it. That's why it is the right tool for a + * transaction stuck in the mempool: no sequence is consumed, nothing is + * re-authorised, and if the original happens to land first the bump simply + * becomes irrelevant. + */ + public FeeBumpTransaction feeBump(Transaction inner, String feeSourceAccountId, long totalFeeStroops) { + try { + return FeeBumpTransaction.createWithFee(feeSourceAccountId, totalFeeStroops, inner); + } catch (RuntimeException ex) { + throw new TransactionAssemblyException("Could not build a fee-bump transaction: " + ex.getMessage()); + } + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionAssemblyException.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionAssemblyException.java new file mode 100644 index 0000000..8a3db73 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionAssemblyException.java @@ -0,0 +1,20 @@ +package com.guildworkman.api.signing.service; + +import com.guildworkman.api.signing.custody.SecretRedactor; + +/** + * The caller's envelope could not be turned into a submittable transaction — + * unparseable XDR, a fee-bump envelope where a plain transaction was + * expected, or no operations to execute. Always the request's fault, never + * the network's, so it is terminal rather than retried. + */ +public class TransactionAssemblyException extends RuntimeException { + + public TransactionAssemblyException(String message) { + super(SecretRedactor.redact(message)); + } + + public TransactionAssemblyException(String message, Throwable cause) { + super(SecretRedactor.redact(message), cause); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSigner.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSigner.java new file mode 100644 index 0000000..cd16850 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSigner.java @@ -0,0 +1,63 @@ +package com.guildworkman.api.signing.service; + +import com.guildworkman.api.signing.custody.SigningProvider; +import com.guildworkman.api.signing.custody.SigningProviderException; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.stellar.sdk.AbstractTransaction; +import org.stellar.sdk.KeyPair; +import org.stellar.sdk.xdr.DecoratedSignature; +import org.stellar.sdk.xdr.Signature; + +/** + * Applies signatures from the active {@link SigningProvider} to a transaction. + * + *

This is the only place in the application that attaches a signature, and + * it works entirely in public values: it hands the provider the transaction + * hash and gets back 64 bytes. No private key ever reaches this class — which + * is what lets the same code path serve an in-process development key and a + * remote HSM without knowing which it's talking to. + * + *

Every signature is verified against the key reference's public key + * before it is attached. A bad signature discovered here costs nothing; the + * same signature discovered by the network costs a round trip and comes back + * as an opaque {@code txBAD_AUTH} that looks like a dozen other problems. + */ +@Component +@RequiredArgsConstructor +public class TransactionSigner { + + private static final Logger log = LoggerFactory.getLogger(TransactionSigner.class); + + private final SigningProvider signingProvider; + + public String providerId() { + return signingProvider.providerId(); + } + + /** @return the public account ({@code G…}) behind a key reference. */ + public String publicKey(String keyRef) { + return signingProvider.publicKey(keyRef); + } + + /** + * Signs {@code transaction} with {@code keyRef} and attaches the result. + * + * @throws SigningProviderException if the custody backend fails, or returns a signature that doesn't verify + */ + public void sign(AbstractTransaction transaction, String keyRef) { + byte[] message = transaction.hash(); + byte[] signature = signingProvider.sign(keyRef, message); + + KeyPair publicKeyPair = KeyPair.fromAccountId(signingProvider.publicKey(keyRef)); + if (signature == null || !publicKeyPair.verify(message, signature)) { + throw new SigningProviderException("Signature produced for keyRef='" + keyRef + + "' does not verify against " + publicKeyPair.getAccountId()); + } + transaction.addSignature(new DecoratedSignature(publicKeyPair.getSignatureHint(), new Signature(signature))); + log.debug("Transaction signed keyRef={} signer={} provider={}", + keyRef, publicKeyPair.getAccountId(), signingProvider.providerId()); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSubmissionInserter.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSubmissionInserter.java new file mode 100644 index 0000000..5ff3ee2 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSubmissionInserter.java @@ -0,0 +1,49 @@ +package com.guildworkman.api.signing.service; + +import com.guildworkman.api.signing.api.SubmitTransactionRequest; +import com.guildworkman.api.signing.model.SubmissionFailureReason; +import com.guildworkman.api.signing.model.SubmissionStatus; +import com.guildworkman.api.signing.model.TransactionSubmission; +import com.guildworkman.api.signing.repository.TransactionSubmissionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * Isolated insert so a unique-key race on {@code idempotency_key} aborts only + * this nested transaction, leaving the caller's able to re-read the winner. + * Same pattern, and the same reasoning, as {@code EscrowOrchestrationInserter} + * and {@code ChainEventInserter}: only a database unique index can atomically + * pick one winner among concurrent requests carrying the same key, and + * {@code REQUIRES_NEW} is what makes losing that race cost nothing but a + * re-read. + * + *

Idempotency matters more here than it does for those two. A duplicate + * row in this table wouldn't merely duplicate bookkeeping — it would lease a + * second channel account, consume a second sequence number and put a second + * transaction on-chain for one logical request. + */ +@Service +@RequiredArgsConstructor +public class TransactionSubmissionInserter { + + private final TransactionSubmissionRepository repository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public TransactionSubmission insert(SubmitTransactionRequest request) { + TransactionSubmission entity = new TransactionSubmission(); + entity.setIdempotencyKey(request.idempotencyKey()); + entity.setReference(request.reference()); + entity.setUnsignedTransactionXdr(request.unsignedTransactionXdr()); + if (!request.extraSignerKeyRefsOrEmpty().isEmpty()) { + entity.setExtraSignerKeyRefs(String.join(",", request.extraSignerKeyRefsOrEmpty())); + } + entity.setStatus(SubmissionStatus.PENDING); + entity.setFailureReason(SubmissionFailureReason.NONE); + entity.setNextAttemptAt(Instant.now()); + return repository.saveAndFlush(entity); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSubmissionService.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSubmissionService.java new file mode 100644 index 0000000..918dbfd --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSubmissionService.java @@ -0,0 +1,588 @@ +package com.guildworkman.api.signing.service; + +import com.guildworkman.api.escrow.rpc.GetTransactionResult; +import com.guildworkman.api.escrow.rpc.SendTransactionResult; +import com.guildworkman.api.escrow.rpc.SimulateTransactionResult; +import com.guildworkman.api.escrow.rpc.SorobanRpcClient; +import com.guildworkman.api.escrow.rpc.SorobanRpcException; +import com.guildworkman.api.signing.SigningProperties; +import com.guildworkman.api.signing.api.SubmitTransactionRequest; +import com.guildworkman.api.signing.custody.SecretRedactor; +import com.guildworkman.api.signing.custody.SigningProviderException; +import com.guildworkman.api.signing.custody.UnknownKeyReferenceException; +import com.guildworkman.api.signing.model.SubmissionFailureReason; +import com.guildworkman.api.signing.model.SubmissionStatus; +import com.guildworkman.api.signing.model.TransactionSubmission; +import com.guildworkman.api.signing.repository.TransactionSubmissionRepository; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.stellar.sdk.AbstractTransaction; +import org.stellar.sdk.FeeBumpTransaction; +import org.stellar.sdk.Transaction; + +import java.time.Instant; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Signs, submits and sees through to a terminal state every transaction the + * backend sends to Soroban. + * + *

The pipeline

+ * Three independent workers, each claiming its own rows, so a slow ledger + * never blocks new signing and each phase keeps its own backoff: + *
    + *
  1. {@link #preparePending()} — {@code PENDING → SIGNED}: leases a channel + * account, rebuilds the caller's operations onto it, simulates (Soroban + * only), signs, and commits the envelope and its hash.
  2. + *
  3. {@link #broadcastSigned()} — {@code SIGNED → BROADCAST}: asks the + * network about the hash first, then sends.
  4. + *
  5. {@link #pollBroadcast()} — {@code BROADCAST → terminal}, fee-bumping + * a transaction that stalls.
  6. + *
+ * + *

Restart safety

+ * The envelope is durable before it is broadcast, never after. That ordering + * is the whole trick: a process that dies anywhere in phase 2 comes back + * holding the exact hash it may have sent, and phase 2 begins by asking the + * network about that hash rather than signing something new. Even a genuine + * resend cannot double-execute — the same envelope has the same hash, so + * Soroban RPC answers {@code DUPLICATE}. What the pre-check adds on top is + * accounting: a transaction that landed while we were away is recognised as + * confirmed instead of being re-sent and re-polled. + * + *

Failure handling

+ * Each failure mode gets the recovery it needs rather than a blanket retry — + * see {@link SubmissionFailureReason} for the mapping, and + * {@link #handleSendFailure} for the branches. In particular a + * {@code txBAD_SEQ} resyncs the channel account against the chain before + * rebuilding (retrying it unchanged would only reproduce it), while a + * {@code txINSUFFICIENT_FEE} goes straight to a fee bump. + * + *

Why this always terminates

+ * A stalled transaction is fee-bumped, and each bump multiplies the fee until + * the next one would cross {@code stellar.signing.fee.max-total-stroops}, at + * which point the submission fails terminally. A transaction whose time + * bounds lapse can never be included, so the poller stops waiting for a + * verdict and rebuilds instead. Rebuilds are bounded by + * {@code stellar.signing.retry.max-attempts}, after which the submission is + * dead-lettered for an operator. No path polls forever. + */ +@Service +@RequiredArgsConstructor +public class TransactionSubmissionService { + + private static final Logger log = LoggerFactory.getLogger(TransactionSubmissionService.class); + + private final TransactionSubmissionRepository submissions; + private final TransactionSubmissionInserter inserter; + private final ChannelAccountLeaseService leases; + private final TransactionAssembler assembler; + private final TransactionSigner signer; + private final FailureClassifier failureClassifier; + private final SorobanRpcClient sorobanRpcClient; + private final SigningProperties properties; + + // --- API surface -------------------------------------------------------- + + /** @param replayed true when {@code submission} already existed for this idempotency key. */ + public record SubmitOutcome(TransactionSubmission submission, boolean replayed) { + } + + /** + * Accepts a transaction for signing and submission. + * + *

The envelope is parsed and the extra signer references are resolved + * here, synchronously, so a malformed envelope or an unknown key + * reference comes back as a {@code 400} on the caller's own request + * instead of surfacing minutes later as a dead-lettered row nobody is + * watching. + */ + @Transactional(readOnly = true) + public SubmitOutcome submit(SubmitTransactionRequest request) { + assembler.parse(request.unsignedTransactionXdr()); + for (String keyRef : request.extraSignerKeyRefsOrEmpty()) { + signer.publicKey(keyRef); + } + return submissions.findByIdempotencyKey(request.idempotencyKey()) + .map(existing -> new SubmitOutcome(existing, true)) + .orElseGet(() -> insertIdempotently(request)); + } + + @Transactional(readOnly = true) + public TransactionSubmission get(Long id) { + return submissions.findById(id).orElseThrow(() -> new SubmissionNotFoundException(id)); + } + + @Transactional(readOnly = true) + public List findByReference(String reference) { + return submissions.findByReference(reference); + } + + private SubmitOutcome insertIdempotently(SubmitTransactionRequest request) { + try { + TransactionSubmission created = inserter.insert(request); + log.info("Transaction submission created id={} reference={}", created.getId(), created.getReference()); + return new SubmitOutcome(created, false); + } catch (DataIntegrityViolationException ex) { + // Lost the unique-key race; the nested insert rolled back on its + // own, so the winner is readable here. This call is a replay too. + TransactionSubmission winner = submissions.findByIdempotencyKey(request.idempotencyKey()) + .orElseThrow(() -> new IllegalStateException( + "Submission not found after idempotent-guard violation for key=" + + request.idempotencyKey(), ex)); + return new SubmitOutcome(winner, true); + } + } + + // --- Phase 1: lease, build, simulate, sign ------------------------------ + + @Scheduled(fixedDelayString = "${stellar.signing.prepare-poll-delay-ms:1000}") + @Transactional + public void preparePending() { + submissions.claimNext(EnumSet.of(SubmissionStatus.PENDING), Instant.now(), PageRequest.of(0, 1)) + .stream().findFirst().ifPresent(this::prepareOne); + } + + void prepareOne(TransactionSubmission entity) { + Transaction source; + try { + source = assembler.parse(entity.getUnsignedTransactionXdr()); + } catch (TransactionAssemblyException ex) { + // The envelope was parseable when it was accepted, so this is a + // stored-data problem, not a transient one. Terminal. + fail(entity, SubmissionFailureReason.MALFORMED, ex.getMessage(), false); + return; + } + + SequenceLease lease; + try { + lease = leases.acquire(entity.getId()); + } catch (NoChannelAccountAvailableException ex) { + scheduleRetry(entity, SubmissionFailureReason.NO_CHANNEL_ACCOUNT, ex.getMessage(), null); + return; + } catch (SorobanRpcException ex) { + scheduleRetry(entity, SubmissionFailureReason.RPC_ERROR, ex.getMessage(), null); + return; + } + + try { + Transaction draft = assembler.rebuild(source, lease, properties.getFee().getBaseStroops(), null); + + String sorobanData = null; + if (draft.isSorobanTransaction()) { + SimulateTransactionResult simulation = sorobanRpcClient.simulateTransaction( + draft.toEnvelopeXdrBase64()); + if (!simulation.isSuccess()) { + // A contract call that can't succeed must never be signed: + // submitting it would burn a fee and a sequence number to + // learn what simulation just told us for free. + String detail = simulation.requiresRestore() + ? "Simulation requires restoring archived ledger entries before this invocation can run" + : "Simulation failed: " + simulation.error(); + fail(entity, SubmissionFailureReason.SIMULATION_FAILED, detail, false); + releaseLease(lease.channelAccountId(), false); + return; + } + sorobanData = simulation.transactionData(); + draft = assembler.rebuild(source, lease, properties.getFee().getBaseStroops(), sorobanData); + } + + long totalFee = draft.getFee(); + if (totalFee > properties.getFee().getMaxTotalStroops()) { + fail(entity, SubmissionFailureReason.FEE_CEILING_REACHED, + "Transaction fee " + totalFee + " stroops exceeds the ceiling of " + + properties.getFee().getMaxTotalStroops() + " stroops", false); + releaseLease(lease.channelAccountId(), false); + return; + } + + signer.sign(draft, lease.keyRef()); + for (String extraKeyRef : extraSignerKeyRefs(entity)) { + signer.sign(draft, extraKeyRef); + } + + Instant now = Instant.now(); + entity.setChannelAccountId(lease.channelAccountId()); + entity.setSourceAccount(lease.accountId()); + entity.setKeyRef(lease.keyRef()); + entity.setSequenceNumber(lease.sequenceNumber()); + entity.setFeeStroops(totalFee); + entity.setSignedEnvelopeXdr(draft.toEnvelopeXdrBase64()); + entity.setTransactionHash(draft.hashHex()); + entity.setInnerTransactionHash(draft.hashHex()); + entity.setValidUntil(maxTime(draft)); + entity.setSigningProvider(signer.providerId()); + entity.setStatus(SubmissionStatus.SIGNED); + entity.setFailureReason(SubmissionFailureReason.NONE); + entity.setLastError(null); + entity.setSignedAt(now); + entity.setNextAttemptAt(now); + submissions.save(entity); + + log.info("Transaction signed id={} sourceAccount={} sequence={} fee={} hash={} provider={}", + entity.getId(), entity.getSourceAccount(), entity.getSequenceNumber(), totalFee, + entity.getTransactionHash(), entity.getSigningProvider()); + } catch (UnknownKeyReferenceException | SigningProviderException ex) { + scheduleRetry(entity, SubmissionFailureReason.SIGNING_FAILED, ex.getMessage(), lease.channelAccountId()); + } catch (SorobanRpcException ex) { + scheduleRetry(entity, SubmissionFailureReason.RPC_ERROR, ex.getMessage(), lease.channelAccountId()); + } catch (TransactionAssemblyException ex) { + fail(entity, SubmissionFailureReason.MALFORMED, ex.getMessage(), false); + releaseLease(lease.channelAccountId(), false); + } + } + + // --- Phase 2: broadcast ------------------------------------------------- + + @Scheduled(fixedDelayString = "${stellar.signing.broadcast-poll-delay-ms:1000}") + @Transactional + public void broadcastSigned() { + submissions.claimNext(EnumSet.of(SubmissionStatus.SIGNED), Instant.now(), PageRequest.of(0, 1)) + .stream().findFirst().ifPresent(this::broadcastOne); + } + + void broadcastOne(TransactionSubmission entity) { + try { + // Restart safety: this row's envelope may already be on the + // network — from a previous process, or from this one dying after + // the RPC call and before the commit. Ask before sending. + GetTransactionResult known = sorobanRpcClient.getTransaction(entity.getTransactionHash()); + if (known.isSuccess()) { + log.info("Transaction id={} hash={} was already on-chain before broadcast; not resubmitting", + entity.getId(), entity.getTransactionHash()); + confirm(entity, known); + return; + } + if (known.isFailed()) { + onChainFailure(entity, known.resultXdr()); + return; + } + + SendTransactionResult result = sorobanRpcClient.sendTransaction(entity.getSignedEnvelopeXdr()); + if (result.isAccepted()) { + Instant now = Instant.now(); + entity.setStatus(SubmissionStatus.BROADCAST); + entity.setBroadcastAt(now); + entity.setNextAttemptAt(now); + entity.setFailureReason(SubmissionFailureReason.NONE); + entity.setLastError(null); + submissions.save(entity); + log.info("Transaction broadcast id={} hash={} rpcStatus={} feeBumps={}", + entity.getId(), entity.getTransactionHash(), result.status(), entity.getFeeBumpCount()); + } else if (result.isRetryable()) { + scheduleRetry(entity, SubmissionFailureReason.RPC_ERROR, + "Soroban RPC busy (TRY_AGAIN_LATER)", null); + } else { + handleSendFailure(entity, result.errorResultXdr()); + } + } catch (SorobanRpcException ex) { + scheduleRetry(entity, SubmissionFailureReason.RPC_ERROR, ex.getMessage(), null); + } + } + + /** + * Applies the recovery path for a transaction the network refused to + * accept. A refusal at this stage means nothing landed, so no sequence + * number was consumed — which is exactly why every rebuild here releases + * its channel account for resync. + */ + private void handleSendFailure(TransactionSubmission entity, String errorResultXdr) { + SubmissionFailureReason reason = failureClassifier.classify(errorResultXdr); + String codeName = failureClassifier.resultCodeName(errorResultXdr); + String detail = "Soroban RPC rejected the transaction" + + (codeName == null ? "" : " (" + codeName + ")"); + entity.setResultXdr(errorResultXdr); + + switch (reason) { + case BAD_SEQUENCE, TOO_LATE -> + // The envelope is dead either way: its sequence number is + // wrong, or its window has closed. Rebuilding is the only move + // that can work — and for BAD_SEQUENCE, releasing the account + // for resync is what stops the next attempt reproducing it. + rebuild(entity, reason, detail); + case INSUFFICIENT_FEE -> feeBump(entity, detail); + case MALFORMED, BAD_AUTH, INSUFFICIENT_BALANCE, ON_CHAIN_FAILED -> + fail(entity, reason, detail, false); + default -> scheduleRetry(entity, reason, detail, null); + } + } + + // --- Phase 3: poll to a terminal state, fee-bumping a stall ------------- + + @Scheduled(fixedDelayString = "${stellar.signing.confirm-poll-delay-ms:1000}") + @Transactional + public void pollBroadcast() { + submissions.claimNext(EnumSet.of(SubmissionStatus.BROADCAST), Instant.now(), PageRequest.of(0, 1)) + .stream().findFirst().ifPresent(this::pollOne); + } + + void pollOne(TransactionSubmission entity) { + try { + GetTransactionResult result = sorobanRpcClient.getTransaction(entity.getTransactionHash()); + if (result.isSuccess()) { + confirm(entity, result); + return; + } + if (result.isFailed()) { + onChainFailure(entity, result.resultXdr()); + return; + } + + // NOT_FOUND. After a fee bump there are two hashes in play and the + // original may be the one that landed. + if (entity.getFeeBumpCount() > 0 && entity.getInnerTransactionHash() != null + && !entity.getInnerTransactionHash().equals(entity.getTransactionHash())) { + GetTransactionResult inner = sorobanRpcClient.getTransaction(entity.getInnerTransactionHash()); + if (inner.isSuccess()) { + log.info("Transaction id={} landed as its pre-fee-bump envelope hash={}", + entity.getId(), entity.getInnerTransactionHash()); + confirm(entity, inner); + return; + } + if (inner.isFailed()) { + onChainFailure(entity, inner.resultXdr()); + return; + } + } + + Instant now = Instant.now(); + if (entity.getValidUntil() != null && now.isAfter(entity.getValidUntil())) { + // Past its time bounds the network can never include it, so + // there is no verdict left to wait for. Rebuild rather than + // poll something that is already dead. + rebuild(entity, SubmissionFailureReason.TOO_LATE, + "Transaction validity window closed at " + entity.getValidUntil() + " without inclusion"); + return; + } + if (entity.getBroadcastAt() != null + && now.isAfter(entity.getBroadcastAt().plus(properties.getStallAfter()))) { + feeBump(entity, "Stalled in the mempool for more than " + properties.getStallAfter()); + return; + } + entity.setNextAttemptAt(nextAttemptAt(1)); + submissions.save(entity); + } catch (SorobanRpcException ex) { + scheduleRetry(entity, SubmissionFailureReason.RPC_ERROR, ex.getMessage(), null); + } + } + + /** + * Wraps the in-flight transaction in a fee bump paying more, and sends it + * back round phase 2. + * + *

A fee bump leaves the inner transaction — sequence number, + * operations, signatures — untouched and only raises what we're willing to + * pay for inclusion, which is why it's safe to apply to a transaction that + * may still be in the mempool: whichever envelope lands, the same + * operations execute exactly once. + * + *

The fee doubles each time (configurable) and the first bump that + * would cross the ceiling ends the submission instead. That bound is what + * turns "stalled" into a terminal state rather than an open-ended one. + */ + private void feeBump(TransactionSubmission entity, String cause) { + long currentFee = entity.getFeeStroops() == null + ? properties.getFee().getBaseStroops() + : entity.getFeeStroops(); + long ceiling = properties.getFee().getMaxTotalStroops(); + long bumpedFee = Math.max( + currentFee + properties.getFee().getBaseStroops(), + Math.round(currentFee * properties.getFee().getBumpMultiplier())); + + if (bumpedFee > ceiling) { + fail(entity, SubmissionFailureReason.FEE_CEILING_REACHED, + cause + "; a bump to " + bumpedFee + " stroops would exceed the ceiling of " + ceiling, false); + return; + } + + try { + AbstractTransaction current = AbstractTransaction.fromEnvelopeXdr( + entity.getSignedEnvelopeXdr(), assembler.network()); + Transaction inner = current instanceof FeeBumpTransaction feeBumped + ? feeBumped.getInnerTransaction() + : (Transaction) current; + + String feeSourceKeyRef = properties.getFeeSourceKeyRef().isBlank() + ? entity.getKeyRef() + : properties.getFeeSourceKeyRef(); + FeeBumpTransaction bumped = assembler.feeBump(inner, signer.publicKey(feeSourceKeyRef), bumpedFee); + signer.sign(bumped, feeSourceKeyRef); + + entity.setSignedEnvelopeXdr(bumped.toEnvelopeXdrBase64()); + entity.setTransactionHash(bumped.hashHex()); + entity.setFeeStroops(bumpedFee); + entity.setFeeBumpCount(entity.getFeeBumpCount() + 1); + // Back to SIGNED so phase 2 broadcasts it — including the + // already-on-chain pre-check against the new hash. + entity.setStatus(SubmissionStatus.SIGNED); + entity.setFailureReason(SubmissionFailureReason.NONE); + entity.setLastError(SecretRedactor.redact(cause)); + entity.setNextAttemptAt(Instant.now()); + submissions.save(entity); + + log.info("Transaction fee-bumped id={} innerHash={} newHash={} fee={} bumps={} cause={}", + entity.getId(), entity.getInnerTransactionHash(), entity.getTransactionHash(), + bumpedFee, entity.getFeeBumpCount(), cause); + } catch (UnknownKeyReferenceException | SigningProviderException ex) { + scheduleRetry(entity, SubmissionFailureReason.SIGNING_FAILED, ex.getMessage(), null); + } catch (TransactionAssemblyException ex) { + fail(entity, SubmissionFailureReason.MALFORMED, ex.getMessage(), false); + } + } + + // --- Terminal transitions ---------------------------------------------- + + private void confirm(TransactionSubmission entity, GetTransactionResult result) { + entity.setStatus(SubmissionStatus.CONFIRMED); + entity.setFailureReason(SubmissionFailureReason.NONE); + entity.setLedgerSequence(result.ledger()); + entity.setResultXdr(result.resultXdr()); + entity.setConfirmedAt(Instant.now()); + entity.setLastError(null); + submissions.save(entity); + // The transaction reached a ledger, so its sequence number is spent + // exactly as the pool's counter assumes: the account can go straight + // back to AVAILABLE without a resync. + releaseLease(entity.getChannelAccountId(), true); + log.info("Transaction confirmed id={} hash={} ledger={}", + entity.getId(), entity.getTransactionHash(), result.ledger()); + } + + /** Included in a ledger and failed there — terminal, because the sequence number is spent. */ + private void onChainFailure(TransactionSubmission entity, String resultXdr) { + String codeName = failureClassifier.resultCodeName(resultXdr); + entity.setResultXdr(resultXdr); + fail(entity, SubmissionFailureReason.ON_CHAIN_FAILED, + "Transaction failed on-chain" + (codeName == null ? "" : " (" + codeName + ")"), true); + } + + private void fail(TransactionSubmission entity, SubmissionFailureReason reason, String error, + boolean sequenceConsumed) { + entity.setStatus(SubmissionStatus.FAILED); + entity.setFailureReason(reason); + entity.setLastError(SecretRedactor.redact(error)); + submissions.save(entity); + releaseLease(entity.getChannelAccountId(), sequenceConsumed); + log.warn("Transaction submission failed id={} reason={} hash={}: {}", + entity.getId(), reason, entity.getTransactionHash(), entity.getLastError()); + } + + /** + * Throws the signed envelope away and starts again from + * {@code PENDING} — a fresh channel account, a fresh sequence number and + * fresh time bounds. Only ever used for envelopes the network has already + * refused (or that expired unsent), which is what makes discarding them + * safe: they can never be included later. + */ + private void rebuild(TransactionSubmission entity, SubmissionFailureReason reason, String error) { + entity.setAttempts(entity.getAttempts() + 1); + if (entity.getAttempts() >= properties.getRetry().getMaxAttempts()) { + deadLetter(entity, error + " (no attempts left to rebuild)"); + return; + } + Long channelAccountId = entity.getChannelAccountId(); + entity.setStatus(SubmissionStatus.PENDING); + entity.setFailureReason(reason); + entity.setLastError(SecretRedactor.redact(error)); + entity.setSignedEnvelopeXdr(null); + entity.setTransactionHash(null); + entity.setInnerTransactionHash(null); + entity.setSequenceNumber(null); + entity.setChannelAccountId(null); + entity.setSourceAccount(null); + entity.setFeeStroops(null); + entity.setFeeBumpCount(0); + entity.setValidUntil(null); + entity.setSignedAt(null); + entity.setBroadcastAt(null); + entity.setNextAttemptAt(nextAttemptAt(entity.getAttempts())); + submissions.save(entity); + // Nothing landed, so the sequence number allocated to the old envelope + // was skipped: the account has to be resynced before it's used again. + releaseLease(channelAccountId, false); + log.info("Transaction submission id={} will be rebuilt attempts={} reason={}: {}", + entity.getId(), entity.getAttempts(), reason, error); + } + + private void scheduleRetry(TransactionSubmission entity, SubmissionFailureReason reason, String error, + Long channelAccountIdToRelease) { + entity.setAttempts(entity.getAttempts() + 1); + if (entity.getAttempts() >= properties.getRetry().getMaxAttempts()) { + deadLetter(entity, error); + return; + } + entity.setFailureReason(reason); + entity.setLastError(SecretRedactor.redact(error)); + entity.setNextAttemptAt(nextAttemptAt(entity.getAttempts())); + submissions.save(entity); + if (channelAccountIdToRelease != null) { + releaseLease(channelAccountIdToRelease, false); + } + log.info("Transaction submission id={} retry scheduled attempts={} nextAttemptAt={} reason={}: {}", + entity.getId(), entity.getAttempts(), entity.getNextAttemptAt(), reason, entity.getLastError()); + } + + private void deadLetter(TransactionSubmission entity, String error) { + entity.setStatus(SubmissionStatus.DEAD_LETTER); + entity.setLastError(SecretRedactor.redact(error)); + submissions.save(entity); + releaseLease(entity.getChannelAccountId(), false); + log.warn("Transaction submission id={} moved to DEAD_LETTER after {} attempts: {}", + entity.getId(), entity.getAttempts(), entity.getLastError()); + } + + private void releaseLease(Long channelAccountId, boolean sequenceConsumed) { + if (channelAccountId == null) { + return; + } + leases.release(channelAccountId, sequenceConsumed); + } + + // --- Helpers ------------------------------------------------------------ + + private List extraSignerKeyRefs(TransactionSubmission entity) { + if (entity.getExtraSignerKeyRefs() == null || entity.getExtraSignerKeyRefs().isBlank()) { + return List.of(); + } + return Arrays.stream(entity.getExtraSignerKeyRefs().split(",")) + .map(String::trim) + .filter(ref -> !ref.isEmpty()) + .toList(); + } + + /** The built transaction's {@code maxTime} bound, as an instant. */ + private static Instant maxTime(Transaction transaction) { + if (transaction.getTimeBounds() == null || transaction.getTimeBounds().getMaxTime() == null) { + return null; + } + long maxTime = transaction.getTimeBounds().getMaxTime().longValue(); + return maxTime == 0 ? null : Instant.ofEpochSecond(maxTime); + } + + /** + * Capped exponential backoff with jitter, matching + * {@code EscrowOrchestrationService}: a batch of submissions that failed + * together (an RPC outage, say) must not come back in lockstep and + * reproduce the outage. + */ + private Instant nextAttemptAt(int attempts) { + long baseMillis = Math.max(1, properties.getRetry().getBaseDelay().toMillis()); + long capMillis = Math.max(baseMillis, properties.getRetry().getMaxDelay().toMillis()); + int shift = Math.min(Math.max(attempts - 1, 0), 20); // guards against overflow on shift + long raw = baseMillis << shift; + long capped = (raw < 0 || raw > capMillis) ? capMillis : raw; // raw < 0 means it overflowed + double jitter = Math.max(0, properties.getRetry().getJitter()); + double randomOffset = jitter <= 0 ? 0 : ThreadLocalRandom.current().nextDouble(-jitter, jitter); + long delayMillis = Math.max(1, Math.round(capped * (1.0 + randomOffset))); + return Instant.now().plusMillis(delayMillis); + } +} diff --git a/backend-api/src/main/resources/application.properties b/backend-api/src/main/resources/application.properties index 8e76cdb..75ec321 100644 --- a/backend-api/src/main/resources/application.properties +++ b/backend-api/src/main/resources/application.properties @@ -88,6 +88,64 @@ booking.reservation.expiry-sweep-batch-size=${BOOKING_EXPIRY_SWEEP_BATCH_SIZE:10 # a raw placeholder, not through SlotReservationProperties. booking.expiry-sweep-delay-ms=${BOOKING_EXPIRY_SWEEP_DELAY_MS:30000} +# Stellar key custody & transaction signing. +# - provider: `local` signs with seeds from configuration (development and +# CI); `kms` delegates every signature to an external gateway and never +# sees a secret key. Only one provider bean exists at a time. +# - network-passphrase: the hash a signature commits to includes this, so it +# must describe the same network SOROBAN_RPC_URL points at. Signing for +# the wrong network yields signatures that are valid nowhere. +# - fee-source-key-ref: key that pays for fee bumps. Empty means the channel +# account pays for its own bump, which is always valid. +stellar.signing.provider=${STELLAR_SIGNING_PROVIDER:local} +stellar.signing.network-passphrase=${STELLAR_NETWORK_PASSPHRASE:Test SDF Network ; September 2015} +stellar.signing.fee-source-key-ref=${STELLAR_FEE_SOURCE_KEY_REF:} + +# Local (development) custody. Seeds are supplied per key reference through the +# environment and MUST NOT be committed: +# STELLAR_LOCAL_KEYS_CHANNEL1=SB... -> stellar.signing.local.keys.channel1 +# Deliberately no default: a missing seed is a startup-time failure to sign, +# which is a far better outcome than a shared key baked into the repository. +# The `kms` provider needs no key configuration at all — only a gateway. +stellar.signing.kms.url=${STELLAR_KMS_URL:} +stellar.signing.kms.api-key=${STELLAR_KMS_API_KEY:} +stellar.signing.kms.request-timeout=${STELLAR_KMS_TIMEOUT:PT5S} + +# Time bound written into every built transaction, and how long a broadcast +# transaction may stay unconfirmed before it's treated as stuck behind a low +# fee and bumped. stall-after must be comfortably under transaction-timeout, +# or the transaction expires before a bump can help it. +stellar.signing.transaction-timeout=${STELLAR_TRANSACTION_TIMEOUT:PT120S} +stellar.signing.stall-after=${STELLAR_STALL_AFTER:PT30S} + +# How long a channel-account lease survives without progress before the +# sweeper reclaims it. This is the crash-recovery bound: a process that dies +# mid-submission holds its account for at most this long. +stellar.signing.lease-ttl=${STELLAR_LEASE_TTL:PT5M} +stellar.signing.lease-sweep-delay-ms=${STELLAR_LEASE_SWEEP_DELAY_MS:30000} + +# Fee policy in stroops (1 XLM = 10,000,000 stroops). Each bump multiplies the +# current fee by bump-multiplier; the first bump that would exceed +# max-total-stroops fails the submission terminally instead. This ceiling is +# what stops a congested network turning a retry loop into unbounded spend. +stellar.signing.fee.base-stroops=${STELLAR_FEE_BASE_STROOPS:100} +stellar.signing.fee.max-total-stroops=${STELLAR_FEE_MAX_TOTAL_STROOPS:1000000} +stellar.signing.fee.bump-multiplier=${STELLAR_FEE_BUMP_MULTIPLIER:2.0} + +# Retry/backoff for retryable submission failures, same shape as +# escrow.orchestration.retry.*. Exhausting max-attempts dead-letters the +# submission rather than retrying forever. +stellar.signing.retry.max-attempts=${STELLAR_RETRY_MAX_ATTEMPTS:5} +stellar.signing.retry.base-delay=${STELLAR_RETRY_BASE_DELAY:PT1S} +stellar.signing.retry.max-delay=${STELLAR_RETRY_MAX_DELAY:PT64S} +stellar.signing.retry.jitter=${STELLAR_RETRY_JITTER:0.2} + +# How often each stage of the submission pipeline polls for work. Read by +# @Scheduled as raw placeholders, hence outside the bound *.retry/*.fee groups. +stellar.signing.prepare-poll-delay-ms=${STELLAR_PREPARE_POLL_DELAY_MS:1000} +stellar.signing.broadcast-poll-delay-ms=${STELLAR_BROADCAST_POLL_DELAY_MS:1000} +stellar.signing.confirm-poll-delay-ms=${STELLAR_CONFIRM_POLL_DELAY_MS:1000} + # JWT authentication. # - secret: HMAC-256 signing key. MUST be overridden in every real # environment via JWT_SECRET (>= 32 bytes). The baked-in default exists diff --git a/backend-api/src/test/java/com/guildworkman/api/escrow/rpc/SorobanRpcClientTest.java b/backend-api/src/test/java/com/guildworkman/api/escrow/rpc/SorobanRpcClientTest.java index 3f8f61e..afa6966 100644 --- a/backend-api/src/test/java/com/guildworkman/api/escrow/rpc/SorobanRpcClientTest.java +++ b/backend-api/src/test/java/com/guildworkman/api/escrow/rpc/SorobanRpcClientTest.java @@ -1,6 +1,7 @@ package com.guildworkman.api.escrow.rpc; import com.fasterxml.jackson.databind.ObjectMapper; +import com.guildworkman.api.signing.StellarTestFixtures; import okhttp3.OkHttpClient; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -8,6 +9,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.stellar.sdk.KeyPair; import java.io.IOException; @@ -83,6 +85,104 @@ void throwsOnHttpError() { .hasMessageContaining("500"); } + // --- simulateTransaction ------------------------------------------------- + + @Test + void simulateTransactionParsesASuccessfulSimulation() throws InterruptedException { + server.enqueue(new MockResponse() + .setBody("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"minResourceFee\":\"12345\"," + + "\"transactionData\":\"AAAAsoroban==\"}}") + .addHeader("Content-Type", "application/json")); + + SimulateTransactionResult result = client.simulateTransaction("AAAA=="); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.minResourceFee()).isEqualTo(12345L); + assertThat(result.transactionData()).isEqualTo("AAAAsoroban=="); + assertThat(result.requiresRestore()).isFalse(); + + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getBody().readUtf8()).contains("\"method\":\"simulateTransaction\""); + } + + /** A simulation error is a normal result, not an RPC failure — the caller decides what it means. */ + @Test + void simulateTransactionReportsAnErrorWithoutThrowing() { + server.enqueue(new MockResponse() + .setBody("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"error\":\"HostError: contract panicked\"}}") + .addHeader("Content-Type", "application/json")); + + SimulateTransactionResult result = client.simulateTransaction("AAAA=="); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.error()).contains("contract panicked"); + } + + /** Archived state needs restoring first; the invocation as submitted cannot run. */ + @Test + void simulateTransactionFlagsARestorePreamble() { + server.enqueue(new MockResponse() + .setBody("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"transactionData\":\"AAAA\"," + + "\"restorePreamble\":{\"minResourceFee\":\"100\",\"transactionData\":\"BBBB\"}}}") + .addHeader("Content-Type", "application/json")); + + SimulateTransactionResult result = client.simulateTransaction("AAAA=="); + + assertThat(result.requiresRestore()).isTrue(); + assertThat(result.isSuccess()).isFalse(); + } + + // --- getAccountSequence -------------------------------------------------- + + @Test + void getAccountSequenceDecodesTheAccountLedgerEntry() throws InterruptedException { + String accountId = KeyPair.random().getAccountId(); + server.enqueue(new MockResponse() + .setBody("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"entries\":[{\"xdr\":\"" + + StellarTestFixtures.accountLedgerEntryXdr(accountId, 987_654_321L) + "\"}]}}") + .addHeader("Content-Type", "application/json")); + + assertThat(client.getAccountSequence(accountId)).isEqualTo(987_654_321L); + + RecordedRequest recorded = server.takeRequest(); + assertThat(recorded.getBody().readUtf8()) + .contains("\"method\":\"getLedgerEntries\"") + .contains("\"keys\":["); + } + + /** soroban-rpc has shipped this field under both names; both must work. */ + @Test + void getAccountSequenceAcceptsTheDataXdrFieldName() { + String accountId = KeyPair.random().getAccountId(); + server.enqueue(new MockResponse() + .setBody("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"entries\":[{\"dataXdr\":\"" + + StellarTestFixtures.accountLedgerEntryXdr(accountId, 42L) + "\"}]}}") + .addHeader("Content-Type", "application/json")); + + assertThat(client.getAccountSequence(accountId)).isEqualTo(42L); + } + + /** An unfunded account has no ledger entry — null, so the caller can say so plainly. */ + @Test + void getAccountSequenceReturnsNullForAnAccountThatIsNotOnChain() { + server.enqueue(new MockResponse() + .setBody("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"entries\":[]}}") + .addHeader("Content-Type", "application/json")); + + assertThat(client.getAccountSequence(KeyPair.random().getAccountId())).isNull(); + } + + @Test + void getAccountSequenceRejectsAnEntryWithoutXdr() { + server.enqueue(new MockResponse() + .setBody("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"entries\":[{\"lastModifiedLedgerSeq\":7}]}}") + .addHeader("Content-Type", "application/json")); + + assertThatThrownBy(() -> client.getAccountSequence(KeyPair.random().getAccountId())) + .isInstanceOf(SorobanRpcException.class) + .hasMessageContaining("without XDR"); + } + // --- request-XDR log/exception safety ----------------------------------- @Test diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/ChannelAccountLeaseIntegrationTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/ChannelAccountLeaseIntegrationTest.java new file mode 100644 index 0000000..db63ff1 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/ChannelAccountLeaseIntegrationTest.java @@ -0,0 +1,361 @@ +package com.guildworkman.api.signing; + +import com.guildworkman.api.escrow.rpc.SorobanRpcClient; +import com.guildworkman.api.signing.model.ChannelAccount; +import com.guildworkman.api.signing.model.ChannelAccountStatus; +import com.guildworkman.api.signing.model.SubmissionStatus; +import com.guildworkman.api.signing.model.TransactionSubmission; +import com.guildworkman.api.signing.repository.ChannelAccountRepository; +import com.guildworkman.api.signing.repository.TransactionSubmissionRepository; +import com.guildworkman.api.signing.service.ChannelAccountBusyException; +import com.guildworkman.api.signing.service.ChannelAccountLeaseService; +import com.guildworkman.api.signing.service.ChannelAccountService; +import com.guildworkman.api.signing.service.NoChannelAccountAvailableException; +import com.guildworkman.api.signing.service.SequenceLease; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.stellar.sdk.KeyPair; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; + +/** + * The concurrency contract the whole feature rests on: N submissions running + * at once get N distinct sequence numbers, on N distinct accounts, with no + * coordination beyond the database. + * + *

This runs against real PostgreSQL — {@code SELECT … FOR UPDATE SKIP + * LOCKED} is the mechanism under test, so an in-memory database would test + * nothing. Only the Soroban RPC is stubbed. + */ +@SpringBootTest(properties = { + "stellar.signing.prepare-poll-delay-ms=3600000", + "stellar.signing.broadcast-poll-delay-ms=3600000", + "stellar.signing.confirm-poll-delay-ms=3600000", + "stellar.signing.lease-sweep-delay-ms=3600000", + "chain.events.poll-delay-ms=3600000", + "escrow.orchestration.submit-poll-delay-ms=3600000", + "escrow.orchestration.confirm-poll-delay-ms=3600000", + "escrow.reconciliation.poll-delay-ms=3600000", + "booking.expiry-sweep-delay-ms=3600000", + "spring.task.scheduling.enabled=false" +}) +class ChannelAccountLeaseIntegrationTest { + + private static final int POOL_SIZE = 8; + + /** keyRef → key pair, so the test can assert which account a lease actually landed on. */ + private static final Map KEYS = new LinkedHashMap<>(); + + @DynamicPropertySource + static void localSigningKeys(DynamicPropertyRegistry registry) { + for (int i = 0; i < POOL_SIZE; i++) { + String keyRef = "leasetest" + i; + KeyPair keyPair = KeyPair.random(); + KEYS.put(keyRef, keyPair); + registry.add("stellar.signing.local.keys." + keyRef, () -> String.valueOf(keyPair.getSecretSeed())); + } + } + + @Autowired + private ChannelAccountService channelAccountService; + + @Autowired + private ChannelAccountLeaseService leases; + + @Autowired + private ChannelAccountRepository channelAccounts; + + @Autowired + private TransactionSubmissionRepository submissions; + + @MockBean + private SorobanRpcClient sorobanRpcClient; + + /** + * Real accounts have independent sequence numbers, so the stub gives each + * one its own — a shared constant would make "distinct sequence numbers" + * unprovable for reasons that have nothing to do with the leasing code. + */ + private final Map onChainSequences = new HashMap<>(); + + @BeforeEach + void cleanSlate() { + reset(sorobanRpcClient); + submissions.deleteAll(); + channelAccounts.deleteAll(); + onChainSequences.clear(); + + long base = 1_000_000L; + for (KeyPair keyPair : KEYS.values()) { + onChainSequences.put(keyPair.getAccountId(), base); + base += 10_000L; + } + when(sorobanRpcClient.getAccountSequence(anyString())) + .thenAnswer(invocation -> onChainSequences.get(invocation.getArgument(0, String.class))); + } + + private List registerPool(int size) { + List registered = new ArrayList<>(); + KEYS.keySet().stream().limit(size).forEach(keyRef -> registered.add(channelAccountService.register(keyRef))); + return registered; + } + + // --- the acceptance criterion ------------------------------------------- + + @Test + void concurrentAcquisitionsGetDistinctAccountsAndDistinctSequenceNumbers() throws Exception { + registerPool(POOL_SIZE); + + // A barrier makes the threads actually contend, rather than trickling + // through one at a time and passing for the wrong reason. + CyclicBarrier start = new CyclicBarrier(POOL_SIZE); + ExecutorService pool = Executors.newFixedThreadPool(POOL_SIZE); + List> tasks = new ArrayList<>(); + for (int i = 0; i < POOL_SIZE; i++) { + long submissionId = 1000L + i; + tasks.add(() -> { + start.await(30, TimeUnit.SECONDS); + return leases.acquire(submissionId); + }); + } + + List> futures = pool.invokeAll(tasks, 60, TimeUnit.SECONDS); + List acquired = new ArrayList<>(); + for (Future future : futures) { + acquired.add(future.get(10, TimeUnit.SECONDS)); + } + pool.shutdown(); + assertThat(pool.awaitTermination(20, TimeUnit.SECONDS)).isTrue(); + + assertThat(acquired).hasSize(POOL_SIZE); + assertThat(acquired).extracting(SequenceLease::channelAccountId).doesNotHaveDuplicates(); + assertThat(acquired).extracting(SequenceLease::accountId).doesNotHaveDuplicates(); + assertThat(acquired).extracting(SequenceLease::sequenceNumber).doesNotHaveDuplicates(); + + // Each lease carries exactly the chain's sequence + 1 for its account. + for (SequenceLease lease : acquired) { + assertThat(lease.sequenceNumber()).isEqualTo(onChainSequences.get(lease.accountId()) + 1); + } + assertThat(channelAccounts.countByStatus(ChannelAccountStatus.LEASED)).isEqualTo(POOL_SIZE); + } + + /** + * With more contenders than accounts, the surplus must be turned away + * cleanly — never handed a duplicate sequence number, which is the failure + * mode that would put two conflicting transactions on the network. + */ + @Test + void anExhaustedPoolRefusesRatherThanReusingAnAccount() throws Exception { + int accounts = 3; + int contenders = POOL_SIZE; + registerPool(accounts); + + ExecutorService pool = Executors.newFixedThreadPool(contenders); + List> tasks = new ArrayList<>(); + for (int i = 0; i < contenders; i++) { + long submissionId = 2000L + i; + tasks.add(() -> { + try { + return leases.acquire(submissionId); + } catch (NoChannelAccountAvailableException ex) { + return null; + } + }); + } + + List acquired = new ArrayList<>(); + for (Future future : pool.invokeAll(tasks, 60, TimeUnit.SECONDS)) { + SequenceLease lease = future.get(10, TimeUnit.SECONDS); + if (lease != null) { + acquired.add(lease); + } + } + pool.shutdown(); + assertThat(pool.awaitTermination(20, TimeUnit.SECONDS)).isTrue(); + + assertThat(acquired).hasSize(accounts); + assertThat(acquired).extracting(SequenceLease::channelAccountId).doesNotHaveDuplicates(); + } + + // --- the correction path ------------------------------------------------- + + @Test + void aConsumedSequenceLetsTheAccountBeReusedWithTheNextNumber() { + ChannelAccount account = registerPool(1).get(0); + long onChain = onChainSequences.get(account.getAccountId()); + + SequenceLease first = leases.acquire(1L); + leases.release(first.channelAccountId(), true); + + assertThat(channelAccounts.findById(account.getId()).orElseThrow().getStatus()) + .isEqualTo(ChannelAccountStatus.AVAILABLE); + + SequenceLease second = leases.acquire(2L); + + assertThat(first.sequenceNumber()).isEqualTo(onChain + 1); + // No resync needed, so the counter simply advances. + assertThat(second.sequenceNumber()).isEqualTo(onChain + 2); + } + + /** + * The subtle one. A transaction that never landed leaves the pool's counter + * one ahead of the chain; reusing it unchanged would earn a + * {@code txBAD_SEQ} on every subsequent transaction from that account. + * Releasing unconsumed must therefore force a re-read. + */ + @Test + void anUnconsumedSequenceForcesAResyncAndRewindsTheCounter() { + ChannelAccount account = registerPool(1).get(0); + long onChain = onChainSequences.get(account.getAccountId()); + + SequenceLease first = leases.acquire(1L); + leases.release(first.channelAccountId(), false); + + assertThat(channelAccounts.findById(account.getId()).orElseThrow().getStatus()) + .isEqualTo(ChannelAccountStatus.NEEDS_RESYNC); + + SequenceLease second = leases.acquire(2L); + + assertThat(second.sequenceNumber()).isEqualTo(onChain + 1).isEqualTo(first.sequenceNumber()); + } + + @Test + void releasingAnAccountThatIsNotLeasedIsANoOp() { + ChannelAccount account = registerPool(1).get(0); + leases.release(account.getId(), true); + leases.release(null, true); + + assertThat(channelAccounts.findById(account.getId()).orElseThrow().getStatus()) + .isEqualTo(ChannelAccountStatus.NEEDS_RESYNC); + } + + @Test + void anAccountThatIsNotOnChainCannotBeLeased() { + ChannelAccount account = registerPool(1).get(0); + onChainSequences.remove(account.getAccountId()); + + assertThatThrownBy(() -> leases.acquire(1L)) + .isInstanceOf(NoChannelAccountAvailableException.class) + .hasMessageContaining("fund it"); + } + + // --- the sweeper --------------------------------------------------------- + + /** + * Crash recovery. A lease with no live submission behind it is an orphan: + * the process died between committing the lease and committing the row + * that would have used it. + */ + @Test + void theSweeperReclaimsAnOrphanedLease() { + ChannelAccount account = registerPool(1).get(0); + leases.acquire(999L); + expireLease(account.getId()); + + leases.sweepExpiredLeases(); + + ChannelAccount swept = channelAccounts.findById(account.getId()).orElseThrow(); + assertThat(swept.getStatus()).isEqualTo(ChannelAccountStatus.NEEDS_RESYNC); + assertThat(swept.getLeasedBySubmissionId()).isNull(); + } + + /** + * And the conservative half: a lease whose transaction may still be in the + * mempool is extended, not reclaimed. Reusing its sequence number would + * race the transaction it belongs to. + */ + @Test + void theSweeperExtendsALeaseWhoseSubmissionIsStillInFlight() { + ChannelAccount account = registerPool(1).get(0); + SequenceLease lease = leases.acquire(1L); + + TransactionSubmission inFlight = new TransactionSubmission(); + inFlight.setIdempotencyKey("sweep-" + System.nanoTime()); + inFlight.setUnsignedTransactionXdr(StellarTestFixtures.unsignedEnvelope()); + inFlight.setStatus(SubmissionStatus.BROADCAST); + inFlight.setChannelAccountId(lease.channelAccountId()); + inFlight.setNextAttemptAt(Instant.now()); + submissions.saveAndFlush(inFlight); + + expireLease(account.getId()); + leases.sweepExpiredLeases(); + + ChannelAccount swept = channelAccounts.findById(account.getId()).orElseThrow(); + assertThat(swept.getStatus()).isEqualTo(ChannelAccountStatus.LEASED); + assertThat(swept.getLeaseExpiresAt()).isAfter(Instant.now()); + } + + // --- operator management ------------------------------------------------- + + @Test + void registeringDerivesTheAccountFromCustodyAndRefusesDuplicates() { + String keyRef = KEYS.keySet().iterator().next(); + ChannelAccount registered = channelAccountService.register(keyRef); + + assertThat(registered.getAccountId()).isEqualTo(KEYS.get(keyRef).getAccountId()); + assertThat(registered.getStatus()).isEqualTo(ChannelAccountStatus.NEEDS_RESYNC); + assertThatThrownBy(() -> channelAccountService.register(keyRef)) + .isInstanceOf(com.guildworkman.api.signing.service.ChannelAccountAlreadyRegisteredException.class); + } + + @Test + void aLeasedAccountCanBeNeitherDisabledNorResynced() { + ChannelAccount account = registerPool(1).get(0); + leases.acquire(1L); + + assertThatThrownBy(() -> channelAccountService.disable(account.getId())) + .isInstanceOf(ChannelAccountBusyException.class); + assertThatThrownBy(() -> leases.resync(account.getId())) + .isInstanceOf(ChannelAccountBusyException.class); + } + + @Test + void aDisabledAccountIsSkippedByTheLeaseQuery() { + List pool = registerPool(2); + channelAccountService.disable(pool.get(0).getId()); + + SequenceLease first = leases.acquire(1L); + assertThat(first.channelAccountId()).isEqualTo(pool.get(1).getId()); + + assertThatThrownBy(() -> leases.acquire(2L)) + .isInstanceOf(NoChannelAccountAvailableException.class); + } + + @Test + void reEnablingAnAccountRequiresAResyncBeforeItIsTrustedAgain() { + ChannelAccount account = registerPool(1).get(0); + channelAccountService.disable(account.getId()); + + assertThat(channelAccountService.enable(account.getId()).getStatus()) + .isEqualTo(ChannelAccountStatus.NEEDS_RESYNC); + } + + /** Backdates the lease so the sweeper considers it expired, without sleeping. */ + private void expireLease(Long channelAccountId) { + ChannelAccount account = channelAccounts.findById(channelAccountId).orElseThrow(); + account.setLeaseExpiresAt(Instant.now().minusSeconds(60)); + channelAccounts.saveAndFlush(account); + } +} diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/StellarTestFixtures.java b/backend-api/src/test/java/com/guildworkman/api/signing/StellarTestFixtures.java new file mode 100644 index 0000000..944db71 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/StellarTestFixtures.java @@ -0,0 +1,173 @@ +package com.guildworkman.api.signing; + +import org.stellar.sdk.Account; +import org.stellar.sdk.AssetTypeNative; +import org.stellar.sdk.KeyPair; +import org.stellar.sdk.Network; +import org.stellar.sdk.Transaction; +import org.stellar.sdk.TransactionBuilder; +import org.stellar.sdk.operations.ExtendFootprintTTLOperation; +import org.stellar.sdk.operations.PaymentOperation; +import org.stellar.sdk.xdr.AccountEntry; +import org.stellar.sdk.xdr.Int64; +import org.stellar.sdk.xdr.LedgerEntry; +import org.stellar.sdk.xdr.LedgerEntryType; +import org.stellar.sdk.xdr.LedgerFootprint; +import org.stellar.sdk.xdr.LedgerKey; +import org.stellar.sdk.xdr.SorobanResources; +import org.stellar.sdk.xdr.SorobanTransactionData; +import org.stellar.sdk.xdr.OperationResult; +import org.stellar.sdk.xdr.SequenceNumber; +import org.stellar.sdk.xdr.Signer; +import org.stellar.sdk.xdr.String32; +import org.stellar.sdk.xdr.Thresholds; +import org.stellar.sdk.xdr.TransactionResult; +import org.stellar.sdk.xdr.TransactionResultCode; +import org.stellar.sdk.xdr.Uint32; +import org.stellar.sdk.xdr.XdrString; +import org.stellar.sdk.xdr.XdrUnsignedInteger; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Builders for the two kinds of Stellar XDR the signing tests need: an + * unsigned envelope to submit, and a {@code TransactionResult} standing in for + * a network rejection. + * + *

Everything here is real XDR produced by the SDK rather than a hand-pasted + * constant, so the tests exercise the same parse/decode paths production does + * and don't quietly rot if the envelope format moves under them. + */ +public final class StellarTestFixtures { + + /** The network the tests sign against; matches the default network passphrase. */ + public static final Network NETWORK = Network.TESTNET; + + private StellarTestFixtures() { + } + + public static KeyPair randomKeyPair() { + return KeyPair.random(); + } + + public static String randomKeyRef(String prefix) { + return prefix + "-" + UUID.randomUUID().toString().substring(0, 8); + } + + /** A one-payment envelope with placeholder source/sequence/fee, as a caller would submit it. */ + public static String unsignedEnvelope() { + return unsignedEnvelope(1); + } + + /** + * @param operationCount how many payment operations to include — the knob the + * operation-limit test turns + */ + public static String unsignedEnvelope(int operationCount) { + return unsignedTransaction(operationCount).toEnvelopeXdrBase64(); + } + + public static Transaction unsignedTransaction(int operationCount) { + KeyPair source = KeyPair.random(); + KeyPair destination = KeyPair.random(); + TransactionBuilder builder = new TransactionBuilder(new Account(source.getAccountId(), 0L), NETWORK) + .setBaseFee(100) + .setTimeout(180); + for (int i = 0; i < operationCount; i++) { + builder.addOperation(PaymentOperation.builder() + .destination(destination.getAccountId()) + .asset(new AssetTypeNative()) + .amount(BigDecimal.valueOf(1L + i)) + .build()); + } + return builder.build(); + } + + /** + * An envelope the service will recognise as a Soroban transaction and + * therefore simulate before signing. {@code ExtendFootprintTTL} is the + * cheapest Soroban operation to construct — what matters is only that + * {@code Transaction#isSorobanTransaction()} answers true. + */ + public static String unsignedSorobanEnvelope() { + KeyPair source = KeyPair.random(); + return new TransactionBuilder(new Account(source.getAccountId(), 0L), NETWORK) + .setBaseFee(100) + .setTimeout(180) + .addOperation(ExtendFootprintTTLOperation.builder().extendTo(1000L).build()) + .build() + .toEnvelopeXdrBase64(); + } + + /** + * Base64 {@code SorobanTransactionData} of the kind simulation returns — + * an empty footprint and the given resource fee, which the transaction + * builder adds on top of the inclusion fee. + */ + public static String sorobanTransactionData(long resourceFeeStroops) { + LedgerFootprint footprint = new LedgerFootprint(new LedgerKey[0], new LedgerKey[0]); + SorobanResources resources = new SorobanResources(footprint, + new Uint32(new XdrUnsignedInteger(0)), + new Uint32(new XdrUnsignedInteger(0)), + new Uint32(new XdrUnsignedInteger(0))); + SorobanTransactionData.SorobanTransactionDataExt ext = + new SorobanTransactionData.SorobanTransactionDataExt(); + ext.setDiscriminant(0); + SorobanTransactionData data = new SorobanTransactionData(ext, resources, new Int64(resourceFeeStroops)); + try { + return data.toXdrBase64(); + } catch (IOException ex) { + throw new IllegalStateException("Could not encode SorobanTransactionData", ex); + } + } + + /** + * Base64 {@code LedgerEntryData} for a funded account at {@code sequence}, + * as {@code getLedgerEntries} returns it — the shape + * {@code SorobanRpcClient#getAccountSequence} decodes. + */ + public static String accountLedgerEntryXdr(String accountId, long sequence) { + AccountEntry account = new AccountEntry(); + account.setAccountID(KeyPair.fromAccountId(accountId).getXdrAccountId()); + account.setBalance(new Int64(100_000_000L)); + account.setSeqNum(new SequenceNumber(new Int64(sequence))); + account.setNumSubEntries(new Uint32(new XdrUnsignedInteger(0))); + account.setFlags(new Uint32(new XdrUnsignedInteger(0))); + account.setHomeDomain(new String32(new XdrString(""))); + account.setThresholds(new Thresholds(new byte[] {1, 0, 0, 0})); + account.setSigners(new Signer[0]); + account.setExt(new AccountEntry.AccountEntryExt(0, null)); + + LedgerEntry.LedgerEntryData data = new LedgerEntry.LedgerEntryData(); + data.setDiscriminant(LedgerEntryType.ACCOUNT); + data.setAccount(account); + try { + return data.toXdrBase64(); + } catch (IOException ex) { + throw new IllegalStateException("Could not encode an account ledger entry", ex); + } + } + + /** Base64 {@code TransactionResult} carrying {@code code}, as Soroban RPC returns on a rejection. */ + public static String transactionResultXdr(TransactionResultCode code) { + TransactionResult.TransactionResultResult result = new TransactionResult.TransactionResultResult(); + result.setDiscriminant(code); + if (code == TransactionResultCode.txSUCCESS || code == TransactionResultCode.txFAILED) { + // The only two arms of the union that carry per-operation results. + result.setResults(new OperationResult[0]); + } + TransactionResult transactionResult = new TransactionResult(); + transactionResult.setFeeCharged(new Int64(100L)); + transactionResult.setResult(result); + TransactionResult.TransactionResultExt ext = new TransactionResult.TransactionResultExt(); + ext.setDiscriminant(0); + transactionResult.setExt(ext); + try { + return transactionResult.toXdrBase64(); + } catch (IOException ex) { + throw new IllegalStateException("Could not encode a TransactionResult for " + code, ex); + } + } +} diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/TransactionSubmissionIntegrationTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/TransactionSubmissionIntegrationTest.java new file mode 100644 index 0000000..0895ccf --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/TransactionSubmissionIntegrationTest.java @@ -0,0 +1,636 @@ +package com.guildworkman.api.signing; + +import com.guildworkman.api.escrow.rpc.GetTransactionResult; +import com.guildworkman.api.escrow.rpc.SendTransactionResult; +import com.guildworkman.api.escrow.rpc.SimulateTransactionResult; +import com.guildworkman.api.escrow.rpc.SorobanRpcClient; +import com.guildworkman.api.escrow.rpc.SorobanRpcException; +import com.guildworkman.api.signing.api.SubmitTransactionRequest; +import com.guildworkman.api.signing.model.ChannelAccount; +import com.guildworkman.api.signing.model.ChannelAccountStatus; +import com.guildworkman.api.signing.model.SubmissionFailureReason; +import com.guildworkman.api.signing.model.SubmissionStatus; +import com.guildworkman.api.signing.model.TransactionSubmission; +import com.guildworkman.api.signing.repository.ChannelAccountRepository; +import com.guildworkman.api.signing.repository.TransactionSubmissionRepository; +import com.guildworkman.api.signing.service.ChannelAccountService; +import com.guildworkman.api.signing.service.SubmissionNotFoundException; +import com.guildworkman.api.signing.service.TransactionAssemblyException; +import com.guildworkman.api.signing.service.TransactionSubmissionService; +import com.guildworkman.api.signing.custody.UnknownKeyReferenceException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.stellar.sdk.AbstractTransaction; +import org.stellar.sdk.FeeBumpTransaction; +import org.stellar.sdk.KeyPair; +import org.stellar.sdk.Network; +import org.stellar.sdk.Transaction; +import org.stellar.sdk.xdr.TransactionResultCode; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * The submission pipeline end to end, against real PostgreSQL and a stubbed + * Soroban RPC. + * + *

The workers are driven by hand rather than by their schedulers, so each + * test states exactly which phase it is exercising and the assertions are + * about state transitions rather than about timing. + */ +@SpringBootTest(properties = { + "stellar.signing.prepare-poll-delay-ms=3600000", + "stellar.signing.broadcast-poll-delay-ms=3600000", + "stellar.signing.confirm-poll-delay-ms=3600000", + "stellar.signing.lease-sweep-delay-ms=3600000", + "stellar.signing.fee.base-stroops=100", + "stellar.signing.fee.max-total-stroops=1000", + "stellar.signing.retry.max-attempts=3", + "stellar.signing.retry.base-delay=PT0.001S", + "stellar.signing.retry.max-delay=PT0.002S", + "stellar.signing.stall-after=PT30S", + "chain.events.poll-delay-ms=3600000", + "escrow.orchestration.submit-poll-delay-ms=3600000", + "escrow.orchestration.confirm-poll-delay-ms=3600000", + "escrow.reconciliation.poll-delay-ms=3600000", + "booking.expiry-sweep-delay-ms=3600000", + "spring.task.scheduling.enabled=false" +}) +class TransactionSubmissionIntegrationTest { + + private static final Map KEYS = new LinkedHashMap<>(); + private static final long ON_CHAIN_SEQUENCE = 5_000L; + + @DynamicPropertySource + static void localSigningKeys(DynamicPropertyRegistry registry) { + for (int i = 0; i < 2; i++) { + String keyRef = "pipeline" + i; + KeyPair keyPair = KeyPair.random(); + KEYS.put(keyRef, keyPair); + registry.add("stellar.signing.local.keys." + keyRef, () -> String.valueOf(keyPair.getSecretSeed())); + } + } + + @Autowired + private TransactionSubmissionService service; + + @Autowired + private ChannelAccountService channelAccountService; + + @Autowired + private TransactionSubmissionRepository submissions; + + @Autowired + private ChannelAccountRepository channelAccounts; + + @Autowired + private SigningProperties properties; + + @MockBean + private SorobanRpcClient rpc; + + @BeforeEach + void cleanSlate() { + reset(rpc); + submissions.deleteAll(); + channelAccounts.deleteAll(); + when(rpc.getAccountSequence(anyString())).thenReturn(ON_CHAIN_SEQUENCE); + KEYS.keySet().forEach(channelAccountService::register); + } + + // --- helpers ------------------------------------------------------------ + + private static SubmitTransactionRequest request(String envelopeXdr) { + return new SubmitTransactionRequest("idem-" + UUID.randomUUID(), "ref-1", envelopeXdr, null); + } + + private TransactionSubmission submitAndPrepare() { + Long id = service.submit(request(StellarTestFixtures.unsignedEnvelope())).submission().getId(); + service.preparePending(); + return reload(id); + } + + private TransactionSubmission reload(Long id) { + return submissions.findById(id).orElseThrow(); + } + + private static GetTransactionResult notFound() { + return new GetTransactionResult("NOT_FOUND", null, null); + } + + private static GetTransactionResult success(long ledger) { + return new GetTransactionResult("SUCCESS", + StellarTestFixtures.transactionResultXdr(TransactionResultCode.txSUCCESS), ledger); + } + + private static SendTransactionResult rejectedWith(TransactionResultCode code) { + return new SendTransactionResult(null, "ERROR", StellarTestFixtures.transactionResultXdr(code)); + } + + private ChannelAccountStatus statusOf(Long channelAccountId) { + return channelAccounts.findById(channelAccountId).orElseThrow().getStatus(); + } + + /** Backdates the row's timestamps so a phase-3 poll sees a stall or an expiry, without sleeping. */ + private TransactionSubmission backdate(TransactionSubmission entity, Instant broadcastAt, Instant validUntil) { + entity.setBroadcastAt(broadcastAt); + entity.setValidUntil(validUntil); + entity.setNextAttemptAt(Instant.now().minusSeconds(1)); + return submissions.saveAndFlush(entity); + } + + // --- submit ------------------------------------------------------------- + + @Test + void submitStoresAPendingRequestAndIsIdempotent() { + SubmitTransactionRequest request = request(StellarTestFixtures.unsignedEnvelope()); + + var first = service.submit(request); + var second = service.submit(request); + + assertThat(first.replayed()).isFalse(); + assertThat(second.replayed()).isTrue(); + assertThat(second.submission().getId()).isEqualTo(first.submission().getId()); + assertThat(reload(first.submission().getId()).getStatus()).isEqualTo(SubmissionStatus.PENDING); + assertThat(submissions.count()).isEqualTo(1); + } + + /** + * Validation that needs the envelope's contents happens on the caller's own + * request. Deferring it would turn a client mistake into a dead-lettered + * row discovered minutes later by nobody. + */ + @Test + void submitRejectsAnUnusableEnvelopeSynchronously() { + assertThatThrownBy(() -> service.submit(request("AAAAsomethingthatisnotanenvelope"))) + .isInstanceOf(TransactionAssemblyException.class); + assertThat(submissions.count()).isZero(); + } + + @Test + void submitRejectsAnUnknownExtraSignerReferenceSynchronously() { + SubmitTransactionRequest request = new SubmitTransactionRequest( + "idem-" + UUID.randomUUID(), null, StellarTestFixtures.unsignedEnvelope(), List.of("not-a-key")); + + assertThatThrownBy(() -> service.submit(request)) + .isInstanceOf(UnknownKeyReferenceException.class); + assertThat(submissions.count()).isZero(); + } + + @Test + void submissionsAreRetrievableByIdAndByCallerReference() { + Long id = service.submit(request(StellarTestFixtures.unsignedEnvelope())).submission().getId(); + + assertThat(service.get(id).getReference()).isEqualTo("ref-1"); + assertThat(service.findByReference("ref-1")).extracting(TransactionSubmission::getId).containsExactly(id); + assertThatThrownBy(() -> service.get(id + 9999)).isInstanceOf(SubmissionNotFoundException.class); + } + + // --- phase 1: prepare --------------------------------------------------- + + @Test + void preparingSignsTheTransactionOntoALeasedChannelAccount() { + TransactionSubmission prepared = submitAndPrepare(); + + assertThat(prepared.getStatus()).isEqualTo(SubmissionStatus.SIGNED); + assertThat(prepared.getSequenceNumber()).isEqualTo(ON_CHAIN_SEQUENCE + 1); + assertThat(prepared.getSigningProvider()).isEqualTo("local"); + assertThat(prepared.getTransactionHash()).isNotBlank(); + assertThat(prepared.getValidUntil()).isAfter(Instant.now()); + assertThat(statusOf(prepared.getChannelAccountId())).isEqualTo(ChannelAccountStatus.LEASED); + + // The signed envelope really carries the leased account's sequence, + // really is signed, and the signature really is the channel key's. + Transaction signed = (Transaction) AbstractTransaction.fromEnvelopeXdr( + prepared.getSignedEnvelopeXdr(), new Network(properties.getNetworkPassphrase())); + assertThat(signed.getSequenceNumber()).isEqualTo(ON_CHAIN_SEQUENCE + 1); + assertThat(signed.getSourceAccount()).isEqualTo(prepared.getSourceAccount()); + assertThat(signed.getSignatures()).hasSize(1); + assertThat(KeyPair.fromAccountId(prepared.getSourceAccount()) + .verify(signed.hash(), signed.getSignatures().get(0).getSignature().getSignature())).isTrue(); + } + + /** No secret ever reaches the row, whatever else it records. */ + @Test + void nothingPersistedLooksLikeKeyMaterial() { + TransactionSubmission prepared = submitAndPrepare(); + + assertThat(prepared.getKeyRef()).isIn(KEYS.keySet()); + assertThat(com.guildworkman.api.signing.custody.SecretRedactor + .containsSecret(prepared.getSignedEnvelopeXdr())).isFalse(); + assertThat(com.guildworkman.api.signing.custody.SecretRedactor + .containsSecret(prepared.toString())).isFalse(); + } + + @Test + void anEmptyPoolLeavesTheSubmissionPendingForARetry() { + channelAccounts.deleteAll(); + Long id = service.submit(request(StellarTestFixtures.unsignedEnvelope())).submission().getId(); + + service.preparePending(); + + TransactionSubmission retried = reload(id); + assertThat(retried.getStatus()).isEqualTo(SubmissionStatus.PENDING); + assertThat(retried.getFailureReason()).isEqualTo(SubmissionFailureReason.NO_CHANNEL_ACCOUNT); + assertThat(retried.getAttempts()).isEqualTo(1); + assertThat(retried.getNextAttemptAt()).isAfter(Instant.now().minusSeconds(1)); + } + + /** + * A contract call that simulation says cannot succeed must never be signed: + * submitting it would burn a fee and a sequence number to learn what + * simulation just reported for free. + */ + @Test + void aFailedSimulationIsTerminalAndNeverBroadcast() { + when(rpc.simulateTransaction(anyString())) + .thenReturn(new SimulateTransactionResult("HostError: contract panicked", null, null, false)); + Long id = service.submit(request(StellarTestFixtures.unsignedSorobanEnvelope())).submission().getId(); + + service.preparePending(); + + TransactionSubmission failed = reload(id); + assertThat(failed.getStatus()).isEqualTo(SubmissionStatus.FAILED); + assertThat(failed.getFailureReason()).isEqualTo(SubmissionFailureReason.SIMULATION_FAILED); + assertThat(failed.getLastError()).contains("contract panicked"); + assertThat(failed.getSignedEnvelopeXdr()).isNull(); + verify(rpc, never()).sendTransaction(anyString()); + // Nothing landed, so the sequence it was given has to be re-read. + assertThat(channelAccounts.countByStatus(ChannelAccountStatus.NEEDS_RESYNC)).isEqualTo(KEYS.size()); + } + + @Test + void aSorobanTransactionIsSimulatedAndCarriesTheResourceFee() { + when(rpc.simulateTransaction(anyString())) + .thenReturn(new SimulateTransactionResult(null, 400L, + StellarTestFixtures.sorobanTransactionData(400L), false)); + Long id = service.submit(request(StellarTestFixtures.unsignedSorobanEnvelope())).submission().getId(); + + service.preparePending(); + + TransactionSubmission prepared = reload(id); + assertThat(prepared.getStatus()).isEqualTo(SubmissionStatus.SIGNED); + // 100 stroops inclusion fee for the single operation, plus the 400 + // stroop resource fee simulation reported. + assertThat(prepared.getFeeStroops()).isEqualTo(500L); + } + + /** The ceiling is checked before signing as well as before each bump. */ + @Test + void aTransactionThatAlreadyExceedsTheFeeCeilingIsRefused() { + when(rpc.simulateTransaction(anyString())) + .thenReturn(new SimulateTransactionResult(null, 50_000L, + StellarTestFixtures.sorobanTransactionData(50_000L), false)); + Long id = service.submit(request(StellarTestFixtures.unsignedSorobanEnvelope())).submission().getId(); + + service.preparePending(); + + TransactionSubmission failed = reload(id); + assertThat(failed.getStatus()).isEqualTo(SubmissionStatus.FAILED); + assertThat(failed.getFailureReason()).isEqualTo(SubmissionFailureReason.FEE_CEILING_REACHED); + } + + // --- phase 2: broadcast ------------------------------------------------- + + @Test + void broadcastSendsTheSignedEnvelopeAndRecordsTheHash() { + TransactionSubmission prepared = submitAndPrepare(); + when(rpc.getTransaction(anyString())).thenReturn(notFound()); + when(rpc.sendTransaction(anyString())) + .thenReturn(new SendTransactionResult(prepared.getTransactionHash(), "PENDING", null)); + + service.broadcastSigned(); + + TransactionSubmission broadcast = reload(prepared.getId()); + assertThat(broadcast.getStatus()).isEqualTo(SubmissionStatus.BROADCAST); + assertThat(broadcast.getBroadcastAt()).isNotNull(); + verify(rpc).sendTransaction(prepared.getSignedEnvelopeXdr()); + } + + /** + * Restart safety. The envelope is durable before it is sent, so a process + * that died mid-broadcast comes back holding the hash it may have already + * submitted — and asks about it rather than signing something new. + */ + @Test + void aTransactionThatLandedWhileWeWereAwayIsConfirmedRatherThanResent() { + TransactionSubmission prepared = submitAndPrepare(); + when(rpc.getTransaction(prepared.getTransactionHash())).thenReturn(success(4242L)); + + service.broadcastSigned(); + + TransactionSubmission confirmed = reload(prepared.getId()); + assertThat(confirmed.getStatus()).isEqualTo(SubmissionStatus.CONFIRMED); + assertThat(confirmed.getLedgerSequence()).isEqualTo(4242L); + verify(rpc, never()).sendTransaction(anyString()); + // It reached a ledger, so the sequence number was genuinely spent. + assertThat(statusOf(confirmed.getChannelAccountId())).isEqualTo(ChannelAccountStatus.AVAILABLE); + } + + @Test + void aBusyRpcIsRetriedRatherThanFailed() { + TransactionSubmission prepared = submitAndPrepare(); + when(rpc.getTransaction(anyString())).thenReturn(notFound()); + when(rpc.sendTransaction(anyString())) + .thenReturn(new SendTransactionResult(null, "TRY_AGAIN_LATER", null)); + + service.broadcastSigned(); + + TransactionSubmission retried = reload(prepared.getId()); + assertThat(retried.getStatus()).isEqualTo(SubmissionStatus.SIGNED); + assertThat(retried.getFailureReason()).isEqualTo(SubmissionFailureReason.RPC_ERROR); + assertThat(retried.getAttempts()).isEqualTo(1); + } + + @Test + void anRpcOutageIsRetriedRatherThanFailed() { + TransactionSubmission prepared = submitAndPrepare(); + when(rpc.getTransaction(anyString())).thenThrow(new SorobanRpcException("connection reset")); + + service.broadcastSigned(); + + assertThat(reload(prepared.getId()).getStatus()).isEqualTo(SubmissionStatus.SIGNED); + assertThat(reload(prepared.getId()).getFailureReason()).isEqualTo(SubmissionFailureReason.RPC_ERROR); + } + + // --- failure classification --------------------------------------------- + + /** + * The classic trap: retrying a {@code txBAD_SEQ} unchanged reproduces it + * forever. The envelope has to be discarded and the account re-read from + * the chain before anything is signed again. + */ + @Test + void aBadSequenceRebuildsAfterResyncingTheAccount() { + TransactionSubmission prepared = submitAndPrepare(); + Long channelAccountId = prepared.getChannelAccountId(); + when(rpc.getTransaction(anyString())).thenReturn(notFound()); + when(rpc.sendTransaction(anyString())).thenReturn(rejectedWith(TransactionResultCode.txBAD_SEQ)); + + service.broadcastSigned(); + + TransactionSubmission rebuilt = reload(prepared.getId()); + assertThat(rebuilt.getStatus()).isEqualTo(SubmissionStatus.PENDING); + assertThat(rebuilt.getFailureReason()).isEqualTo(SubmissionFailureReason.BAD_SEQUENCE); + assertThat(rebuilt.getSignedEnvelopeXdr()).isNull(); + assertThat(rebuilt.getTransactionHash()).isNull(); + assertThat(rebuilt.getSequenceNumber()).isNull(); + assertThat(rebuilt.getAttempts()).isEqualTo(1); + assertThat(statusOf(channelAccountId)).isEqualTo(ChannelAccountStatus.NEEDS_RESYNC); + + // And the rebuild really does re-read the chain rather than reusing the + // local counter, which is what stops the failure repeating. + when(rpc.getAccountSequence(anyString())).thenReturn(ON_CHAIN_SEQUENCE + 7); + service.preparePending(); + assertThat(reload(prepared.getId()).getSequenceNumber()).isEqualTo(ON_CHAIN_SEQUENCE + 8); + } + + @Test + void aLapsedValidityWindowRebuildsRatherThanRetryingTheSameEnvelope() { + TransactionSubmission prepared = submitAndPrepare(); + when(rpc.getTransaction(anyString())).thenReturn(notFound()); + when(rpc.sendTransaction(anyString())).thenReturn(rejectedWith(TransactionResultCode.txTOO_LATE)); + + service.broadcastSigned(); + + TransactionSubmission rebuilt = reload(prepared.getId()); + assertThat(rebuilt.getStatus()).isEqualTo(SubmissionStatus.PENDING); + assertThat(rebuilt.getFailureReason()).isEqualTo(SubmissionFailureReason.TOO_LATE); + assertThat(rebuilt.getSignedEnvelopeXdr()).isNull(); + } + + @Test + void anUnauthorisedOrMalformedTransactionIsTerminal() { + TransactionSubmission prepared = submitAndPrepare(); + when(rpc.getTransaction(anyString())).thenReturn(notFound()); + when(rpc.sendTransaction(anyString())).thenReturn(rejectedWith(TransactionResultCode.txBAD_AUTH)); + + service.broadcastSigned(); + + TransactionSubmission failed = reload(prepared.getId()); + assertThat(failed.getStatus()).isEqualTo(SubmissionStatus.FAILED); + assertThat(failed.getFailureReason()).isEqualTo(SubmissionFailureReason.BAD_AUTH); + assertThat(failed.getLastError()).contains("txBAD_AUTH"); + } + + @Test + void anInsufficientFeeGoesStraightToAFeeBump() { + TransactionSubmission prepared = submitAndPrepare(); + String originalHash = prepared.getTransactionHash(); + when(rpc.getTransaction(anyString())).thenReturn(notFound()); + when(rpc.sendTransaction(anyString())).thenReturn(rejectedWith(TransactionResultCode.txINSUFFICIENT_FEE)); + + service.broadcastSigned(); + + TransactionSubmission bumped = reload(prepared.getId()); + assertThat(bumped.getStatus()).isEqualTo(SubmissionStatus.SIGNED); + assertThat(bumped.getFeeBumpCount()).isEqualTo(1); + assertThat(bumped.getFeeStroops()).isEqualTo(200L); + assertThat(bumped.getTransactionHash()).isNotEqualTo(originalHash); + assertThat(bumped.getInnerTransactionHash()).isEqualTo(originalHash); + } + + // --- phase 3: poll, stall, fee bump ------------------------------------- + + @Test + void aTransactionIncludedInALedgerIsConfirmedAndReleasesItsAccount() { + TransactionSubmission broadcast = broadcastOne(); + when(rpc.getTransaction(anyString())).thenReturn(success(777L)); + + service.pollBroadcast(); + + TransactionSubmission confirmed = reload(broadcast.getId()); + assertThat(confirmed.getStatus()).isEqualTo(SubmissionStatus.CONFIRMED); + assertThat(confirmed.getConfirmedAt()).isNotNull(); + assertThat(confirmed.getLedgerSequence()).isEqualTo(777L); + assertThat(statusOf(confirmed.getChannelAccountId())).isEqualTo(ChannelAccountStatus.AVAILABLE); + } + + /** Included and failed there: terminal, because the sequence number is spent either way. */ + @Test + void aTransactionThatFailsOnChainIsTerminalAndTheSequenceCounts() { + TransactionSubmission broadcast = broadcastOne(); + when(rpc.getTransaction(anyString())).thenReturn(new GetTransactionResult("FAILED", + StellarTestFixtures.transactionResultXdr(TransactionResultCode.txFAILED), 778L)); + + service.pollBroadcast(); + + TransactionSubmission failed = reload(broadcast.getId()); + assertThat(failed.getStatus()).isEqualTo(SubmissionStatus.FAILED); + assertThat(failed.getFailureReason()).isEqualTo(SubmissionFailureReason.ON_CHAIN_FAILED); + assertThat(statusOf(failed.getChannelAccountId())).isEqualTo(ChannelAccountStatus.AVAILABLE); + } + + @Test + void aTransactionStalledInTheMempoolIsFeeBumped() { + TransactionSubmission broadcast = broadcastOne(); + String originalHash = broadcast.getTransactionHash(); + backdate(broadcast, Instant.now().minusSeconds(120), Instant.now().plusSeconds(600)); + when(rpc.getTransaction(anyString())).thenReturn(notFound()); + + service.pollBroadcast(); + + TransactionSubmission bumped = reload(broadcast.getId()); + assertThat(bumped.getStatus()).isEqualTo(SubmissionStatus.SIGNED); + assertThat(bumped.getFeeBumpCount()).isEqualTo(1); + assertThat(bumped.getFeeStroops()).isEqualTo(200L); + assertThat(bumped.getLastError()).contains("Stalled"); + + // The bump really is a fee bump wrapping the original transaction. + AbstractTransaction envelope = AbstractTransaction.fromEnvelopeXdr( + bumped.getSignedEnvelopeXdr(), new Network(properties.getNetworkPassphrase())); + assertThat(envelope).isInstanceOf(FeeBumpTransaction.class); + assertThat(((FeeBumpTransaction) envelope).getInnerTransaction().hashHex()).isEqualTo(originalHash); + } + + /** + * The termination proof. Bumps double the fee, and the first that would + * cross the ceiling ends the submission — so "stalled" is a bounded state, + * not an open-ended one, and no amount of congestion turns a retry loop + * into unbounded spend. + */ + @Test + void feeBumpsStopAtTheCeilingRatherThanEscalatingForever() { + TransactionSubmission broadcast = broadcastOne(); + when(rpc.getTransaction(anyString())).thenReturn(notFound()); + + long ceiling = properties.getFee().getMaxTotalStroops(); + int bumps = 0; + TransactionSubmission current = broadcast; + while (current.getStatus() != SubmissionStatus.FAILED && bumps < 20) { + // Put it back in flight so the next poll sees a stall again. + current.setStatus(SubmissionStatus.BROADCAST); + backdate(current, Instant.now().minusSeconds(120), Instant.now().plusSeconds(600)); + service.pollBroadcast(); + current = reload(broadcast.getId()); + bumps++; + assertThat(current.getFeeStroops()).isLessThanOrEqualTo(ceiling); + } + + assertThat(current.getStatus()).isEqualTo(SubmissionStatus.FAILED); + assertThat(current.getFailureReason()).isEqualTo(SubmissionFailureReason.FEE_CEILING_REACHED); + assertThat(statusOf(broadcast.getChannelAccountId())).isEqualTo(ChannelAccountStatus.NEEDS_RESYNC); + } + + /** + * Past its time bounds the network can never include it, so there is no + * verdict left to wait for — polling it forever would be the bug. + */ + @Test + void anExpiredTransactionIsRebuiltRatherThanPolledForever() { + TransactionSubmission broadcast = broadcastOne(); + backdate(broadcast, Instant.now().minusSeconds(300), Instant.now().minusSeconds(10)); + when(rpc.getTransaction(anyString())).thenReturn(notFound()); + + service.pollBroadcast(); + + TransactionSubmission rebuilt = reload(broadcast.getId()); + assertThat(rebuilt.getStatus()).isEqualTo(SubmissionStatus.PENDING); + assertThat(rebuilt.getFailureReason()).isEqualTo(SubmissionFailureReason.TOO_LATE); + assertThat(rebuilt.getFeeBumpCount()).isZero(); + } + + /** + * After a bump two hashes are in flight and either may land. Missing that + * would leave a confirmed transaction stuck in {@code BROADCAST} and its + * channel account leased. + */ + @Test + void aTransactionThatLandedAsItsPreBumpEnvelopeIsStillRecognised() { + TransactionSubmission broadcast = broadcastOne(); + String innerHash = broadcast.getTransactionHash(); + backdate(broadcast, Instant.now().minusSeconds(120), Instant.now().plusSeconds(600)); + when(rpc.getTransaction(anyString())).thenReturn(notFound()); + service.pollBroadcast(); + + TransactionSubmission bumped = reload(broadcast.getId()); + bumped.setStatus(SubmissionStatus.BROADCAST); + backdate(bumped, Instant.now(), Instant.now().plusSeconds(600)); + + when(rpc.getTransaction(bumped.getTransactionHash())).thenReturn(notFound()); + when(rpc.getTransaction(innerHash)).thenReturn(success(999L)); + + service.pollBroadcast(); + + TransactionSubmission confirmed = reload(broadcast.getId()); + assertThat(confirmed.getStatus()).isEqualTo(SubmissionStatus.CONFIRMED); + assertThat(confirmed.getLedgerSequence()).isEqualTo(999L); + } + + // --- bounded retries ---------------------------------------------------- + + /** Nothing retries forever: exhausting the budget hands the row to an operator. */ + @Test + void repeatedFailuresEndInTheDeadLetterState() { + TransactionSubmission prepared = submitAndPrepare(); + when(rpc.getTransaction(anyString())).thenThrow(new SorobanRpcException("rpc down")); + + for (int i = 0; i < properties.getRetry().getMaxAttempts() + 1; i++) { + TransactionSubmission current = reload(prepared.getId()); + current.setNextAttemptAt(Instant.now().minusSeconds(1)); + submissions.saveAndFlush(current); + service.broadcastSigned(); + } + + TransactionSubmission dead = reload(prepared.getId()); + assertThat(dead.getStatus()).isEqualTo(SubmissionStatus.DEAD_LETTER); + assertThat(dead.getAttempts()).isEqualTo(properties.getRetry().getMaxAttempts()); + assertThat(statusOf(prepared.getChannelAccountId())).isEqualTo(ChannelAccountStatus.NEEDS_RESYNC); + } + + /** Workers claim one row at a time, so a stuck submission can't starve the queue. */ + @Test + void twoSubmissionsGetTwoAccountsAndTwoSequenceNumbers() { + Long first = service.submit(request(StellarTestFixtures.unsignedEnvelope())).submission().getId(); + Long second = service.submit(request(StellarTestFixtures.unsignedEnvelope())).submission().getId(); + + service.preparePending(); + service.preparePending(); + + TransactionSubmission a = reload(first); + TransactionSubmission b = reload(second); + assertThat(a.getStatus()).isEqualTo(SubmissionStatus.SIGNED); + assertThat(b.getStatus()).isEqualTo(SubmissionStatus.SIGNED); + assertThat(a.getChannelAccountId()).isNotEqualTo(b.getChannelAccountId()); + assertThat(a.getSourceAccount()).isNotEqualTo(b.getSourceAccount()); + assertThat(a.getTransactionHash()).isNotEqualTo(b.getTransactionHash()); + } + + private TransactionSubmission broadcastOne() { + TransactionSubmission prepared = submitAndPrepare(); + when(rpc.getTransaction(anyString())).thenReturn(notFound()); + when(rpc.sendTransaction(anyString())) + .thenReturn(new SendTransactionResult(prepared.getTransactionHash(), "PENDING", null)); + service.broadcastSigned(); + reset(rpc); + when(rpc.getAccountSequence(anyString())).thenReturn(ON_CHAIN_SEQUENCE); + TransactionSubmission broadcast = reload(prepared.getId()); + assertThat(broadcast.getStatus()).isEqualTo(SubmissionStatus.BROADCAST); + return broadcast; + } + + /** Sanity: the pool this test class relies on really was registered. */ + @Test + void theTestPoolIsRegistered() { + assertThat(channelAccounts.count()).isEqualTo(KEYS.size()); + for (ChannelAccount account : channelAccounts.findAll()) { + assertThat(account.getAccountId()).isEqualTo(KEYS.get(account.getKeyRef()).getAccountId()); + } + } +} diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/api/SigningApiTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/api/SigningApiTest.java new file mode 100644 index 0000000..c6579af --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/api/SigningApiTest.java @@ -0,0 +1,274 @@ +package com.guildworkman.api.signing.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.guildworkman.api.escrow.rpc.SorobanRpcClient; +import com.guildworkman.api.handler.ProblemDetails; +import com.guildworkman.api.signing.StellarTestFixtures; +import com.guildworkman.api.signing.repository.ChannelAccountRepository; +import com.guildworkman.api.signing.repository.TransactionSubmissionRepository; +import com.guildworkman.api.signing.service.ChannelAccountService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.stellar.sdk.KeyPair; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The HTTP surface: status codes, the idempotent-replay header, the RFC 7807 + * error contract shared with the rest of the API, and the authorisation rules — + * signing requires authentication, pool management requires ADMIN. + */ +@SpringBootTest(properties = { + "stellar.signing.prepare-poll-delay-ms=3600000", + "stellar.signing.broadcast-poll-delay-ms=3600000", + "stellar.signing.confirm-poll-delay-ms=3600000", + "stellar.signing.lease-sweep-delay-ms=3600000", + "chain.events.poll-delay-ms=3600000", + "escrow.orchestration.submit-poll-delay-ms=3600000", + "escrow.orchestration.confirm-poll-delay-ms=3600000", + "escrow.reconciliation.poll-delay-ms=3600000", + "booking.expiry-sweep-delay-ms=3600000", + "spring.task.scheduling.enabled=false" +}) +@AutoConfigureMockMvc +class SigningApiTest { + + private static final String KEY_REF = "apitest1"; + private static KeyPair keyPair; + + @DynamicPropertySource + static void localSigningKeys(DynamicPropertyRegistry registry) { + keyPair = KeyPair.random(); + registry.add("stellar.signing.local.keys." + KEY_REF, () -> String.valueOf(keyPair.getSecretSeed())); + } + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TransactionSubmissionRepository submissions; + + @Autowired + private ChannelAccountRepository channelAccounts; + + @Autowired + private ChannelAccountService channelAccountService; + + @MockBean + private SorobanRpcClient rpc; + + @BeforeEach + void cleanSlate() { + submissions.deleteAll(); + channelAccounts.deleteAll(); + when(rpc.getAccountSequence(anyString())).thenReturn(4_000L); + } + + private String submitBody() throws Exception { + return objectMapper.writeValueAsString(new SubmitTransactionRequest( + "idem-" + UUID.randomUUID(), "ref-api", StellarTestFixtures.unsignedEnvelope(), null)); + } + + // --- submission --------------------------------------------------------- + + @Test + @WithMockUser + void submittingAcceptsTheRequestAndReportsItAsNew() throws Exception { + mockMvc.perform(post("/api/v1/stellar/transactions") + .contentType(MediaType.APPLICATION_JSON) + .content(submitBody())) + .andExpect(status().isAccepted()) + .andExpect(header().string(TransactionSigningController.IDEMPOTENT_REPLAY_HEADER, "false")) + .andExpect(jsonPath("$.status").value("PENDING")) + .andExpect(jsonPath("$.reference").value("ref-api")) + // Never in a response body, whatever else is. + .andExpect(jsonPath("$.signedEnvelopeXdr").doesNotExist()) + .andExpect(jsonPath("$.unsignedTransactionXdr").doesNotExist()); + } + + @Test + @WithMockUser + void resubmittingTheSameKeyIsFlaggedAsAReplay() throws Exception { + String body = submitBody(); + + mockMvc.perform(post("/api/v1/stellar/transactions") + .contentType(MediaType.APPLICATION_JSON).content(body)); + mockMvc.perform(post("/api/v1/stellar/transactions") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isAccepted()) + .andExpect(header().string(TransactionSigningController.IDEMPOTENT_REPLAY_HEADER, "true")); + + assertThat(submissions.count()).isEqualTo(1); + } + + @Test + @WithMockUser + void aMalformedEnvelopeIsAProblemJsonBadRequest() throws Exception { + String body = objectMapper.writeValueAsString(new SubmitTransactionRequest( + "idem-" + UUID.randomUUID(), null, "AAAAnotanenvelope", null)); + + mockMvc.perform(post("/api/v1/stellar/transactions") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(ProblemDetails.CONTENT_TYPE)) + .andExpect(jsonPath("$.title").value("Transaction could not be assembled")); + } + + @Test + @WithMockUser + void anUnknownKeyReferenceIsAProblemJsonBadRequest() throws Exception { + String body = objectMapper.writeValueAsString(new SubmitTransactionRequest( + "idem-" + UUID.randomUUID(), null, StellarTestFixtures.unsignedEnvelope(), List.of("nosuchkey"))); + + mockMvc.perform(post("/api/v1/stellar/transactions") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(ProblemDetails.CONTENT_TYPE)) + .andExpect(jsonPath("$.title").value("Unknown key reference")); + } + + @Test + @WithMockUser + void validationFailuresUseTheSharedFieldErrorShape() throws Exception { + mockMvc.perform(post("/api/v1/stellar/transactions") + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(ProblemDetails.CONTENT_TYPE)) + .andExpect(jsonPath("$.errors.idempotencyKey").exists()) + .andExpect(jsonPath("$.errors.unsignedTransactionXdr").exists()); + } + + @Test + @WithMockUser + void anUnknownSubmissionIsAProblemJsonNotFound() throws Exception { + mockMvc.perform(get("/api/v1/stellar/transactions/999999")) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith(ProblemDetails.CONTENT_TYPE)) + .andExpect(jsonPath("$.title").value("Transaction submission not found")); + } + + @Test + @WithMockUser + void submissionsAreLookedUpByCallerReference() throws Exception { + mockMvc.perform(post("/api/v1/stellar/transactions") + .contentType(MediaType.APPLICATION_JSON).content(submitBody())); + + mockMvc.perform(get("/api/v1/stellar/transactions").param("reference", "ref-api")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].reference").value("ref-api")); + } + + /** Signing on someone's behalf is never anonymous. */ + @Test + void submittingRequiresAuthentication() throws Exception { + mockMvc.perform(post("/api/v1/stellar/transactions") + .contentType(MediaType.APPLICATION_JSON).content(submitBody())) + .andExpect(status().isUnauthorized()); + } + + // --- channel-account pool ----------------------------------------------- + + @Test + @WithMockUser(roles = "ADMIN") + void anAdminCanRegisterAndListPoolMembers() throws Exception { + String body = objectMapper.writeValueAsString(new RegisterChannelAccountRequest(KEY_REF)); + + mockMvc.perform(post("/api/v1/stellar/channel-accounts") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accountId").value(keyPair.getAccountId())) + .andExpect(jsonPath("$.keyRef").value(KEY_REF)) + .andExpect(jsonPath("$.status").value("NEEDS_RESYNC")); + + mockMvc.perform(get("/api/v1/stellar/channel-accounts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].accountId").value(keyPair.getAccountId())); + } + + @Test + @WithMockUser(roles = "ADMIN") + void registeringTheSameKeyTwiceIsAConflict() throws Exception { + String body = objectMapper.writeValueAsString(new RegisterChannelAccountRequest(KEY_REF)); + mockMvc.perform(post("/api/v1/stellar/channel-accounts") + .contentType(MediaType.APPLICATION_JSON).content(body)); + + mockMvc.perform(post("/api/v1/stellar/channel-accounts") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isConflict()) + .andExpect(content().contentTypeCompatibleWith(ProblemDetails.CONTENT_TYPE)) + .andExpect(jsonPath("$.title").value("Channel account already registered")); + } + + @Test + @WithMockUser(roles = "ADMIN") + void registeringAKeyCustodyDoesNotHoldIsABadRequest() throws Exception { + String body = objectMapper.writeValueAsString(new RegisterChannelAccountRequest("nosuchkey")); + + mockMvc.perform(post("/api/v1/stellar/channel-accounts") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.title").value("Unknown key reference")); + } + + @Test + @WithMockUser(roles = "ADMIN") + void disableEnableAndResyncDriveThePoolMemberThroughItsStates() throws Exception { + Long id = channelAccountService.register(KEY_REF).getId(); + + mockMvc.perform(post("/api/v1/stellar/channel-accounts/" + id + "/resync")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("AVAILABLE")) + .andExpect(jsonPath("$.nextSequence").value(4001)); + + mockMvc.perform(post("/api/v1/stellar/channel-accounts/" + id + "/disable")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("DISABLED")); + + mockMvc.perform(post("/api/v1/stellar/channel-accounts/" + id + "/enable")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("NEEDS_RESYNC")); + } + + @Test + @WithMockUser(roles = "ADMIN") + void anUnknownChannelAccountIsAProblemJsonNotFound() throws Exception { + mockMvc.perform(get("/api/v1/stellar/channel-accounts/999999")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.title").value("Channel account not found")); + } + + /** Pool composition and lease state are operational detail, not public API. */ + @Test + @WithMockUser + void anOrdinaryUserCannotSeeOrChangeThePool() throws Exception { + mockMvc.perform(get("/api/v1/stellar/channel-accounts")) + .andExpect(status().isForbidden()); + mockMvc.perform(post("/api/v1/stellar/channel-accounts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new RegisterChannelAccountRequest(KEY_REF)))) + .andExpect(status().isForbidden()); + } +} diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/api/SigningRequestValidationTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/api/SigningRequestValidationTest.java new file mode 100644 index 0000000..0e601ff --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/api/SigningRequestValidationTest.java @@ -0,0 +1,109 @@ +package com.guildworkman.api.signing.api; + +import com.guildworkman.api.signing.StellarTestFixtures; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.stellar.sdk.KeyPair; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Request-level validation for the signing endpoints. The load-bearing case is + * the last one: a Stellar secret seed is 56 characters of letters and digits, + * which is exactly the shape of a plausible key alias, so "references only" + * has to be enforced rather than assumed — otherwise a fat-fingered paste ends + * up in a database column and an access log. + */ +class SigningRequestValidationTest { + + private static ValidatorFactory factory; + private static Validator validator; + + @BeforeAll + static void setUp() { + factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @AfterAll + static void tearDown() { + factory.close(); + } + + private static SubmitTransactionRequest submit(String envelopeXdr, List extraSigners) { + return new SubmitTransactionRequest("idem-1", "appointment-42", envelopeXdr, extraSigners); + } + + private static Set invalidFields(Object request) { + return validator.validate(request).stream() + .map(ConstraintViolation::getPropertyPath) + .map(Object::toString) + .collect(java.util.stream.Collectors.toSet()); + } + + @Test + void acceptsARealEnvelopeWithNoExtraSigners() { + assertThat(validator.validate(submit(StellarTestFixtures.unsignedEnvelope(), null))).isEmpty(); + } + + @Test + void requiresAnIdempotencyKeyAndAnEnvelope() { + assertThat(invalidFields(new SubmitTransactionRequest(" ", null, "", null))) + .contains("idempotencyKey", "unsignedTransactionXdr"); + } + + @Test + void rejectsANonBase64Envelope() { + assertThat(invalidFields(submit("not base64!", null))).contains("unsignedTransactionXdr"); + } + + @Test + void rejectsAnOversizedEnvelope() { + assertThat(invalidFields(submit("A".repeat(65_537), null))).contains("unsignedTransactionXdr"); + } + + @Test + void boundsTheNumberOfExtraSigners() { + assertThat(invalidFields(submit(StellarTestFixtures.unsignedEnvelope(), List.of("a", "b", "c", "d")))) + .contains("extraSignerKeyRefs"); + } + + @Test + void rejectsASecretSeedPastedAsAnExtraSignerReference() { + String seed = String.valueOf(KeyPair.random().getSecretSeed()); + + Set> violations = + validator.validate(submit(StellarTestFixtures.unsignedEnvelope(), List.of(seed))); + + assertThat(violations).isNotEmpty(); + assertThat(violations).anyMatch(v -> v.getMessage().contains("must not be a secret seed")); + } + + @Test + void rejectsASecretSeedPastedAsAChannelAccountReference() { + String seed = String.valueOf(KeyPair.random().getSecretSeed()); + + assertThat(validator.validate(new RegisterChannelAccountRequest(seed))) + .anyMatch(v -> v.getMessage().contains("must not be a secret seed")); + } + + @Test + void acceptsAnOrdinaryChannelAccountReference() { + assertThat(validator.validate(new RegisterChannelAccountRequest("channel-1"))).isEmpty(); + } + + /** An account id is public data but still isn't a key reference. */ + @Test + void rejectsPunctuationAndWhitespaceInAKeyReference() { + assertThat(invalidFields(new RegisterChannelAccountRequest("channel 1"))).contains("keyRef"); + assertThat(invalidFields(new RegisterChannelAccountRequest(""))).contains("keyRef"); + } +} diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/custody/KmsSigningProviderTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/custody/KmsSigningProviderTest.java new file mode 100644 index 0000000..915a773 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/custody/KmsSigningProviderTest.java @@ -0,0 +1,188 @@ +package com.guildworkman.api.signing.custody; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.guildworkman.api.signing.SigningProperties; +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.stellar.sdk.KeyPair; + +import java.io.IOException; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The KMS provider against a stubbed gateway. What's under test is the + * contract this service depends on holding: the hash goes out, a signature + * comes back, and anything that isn't a signature by the expected key is + * refused here rather than by the network. + */ +class KmsSigningProviderTest { + + private static final byte[] MESSAGE = "transaction-hash-stand-in-32byte".getBytes(); + + private MockWebServer server; + private SigningProperties properties; + private KmsSigningProvider provider; + private KeyPair signingKey; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + + signingKey = KeyPair.random(); + properties = new SigningProperties(); + properties.setProvider("kms"); + properties.getKms().setUrl(server.url("/stellar").toString()); + properties.getKms().setApiKey("gateway-token"); + + provider = new KmsSigningProvider(new OkHttpClient(), new ObjectMapper(), properties); + } + + @AfterEach + void tearDown() throws IOException { + server.shutdown(); + } + + private void enqueueKeyLookup(KeyPair keyPair) { + server.enqueue(new MockResponse() + .addHeader("Content-Type", "application/json") + .setBody("{\"publicKey\":\"" + keyPair.getAccountId() + "\"}")); + } + + private void enqueueSignature(KeyPair signer, KeyPair reportedAs) { + String signature = Base64.getEncoder().encodeToString(signer.sign(MESSAGE)); + server.enqueue(new MockResponse() + .addHeader("Content-Type", "application/json") + .setBody("{\"publicKey\":\"" + reportedAs.getAccountId() + "\",\"signature\":\"" + signature + "\"}")); + } + + @Test + void resolvesAndCachesAPublicKey() throws InterruptedException { + enqueueKeyLookup(signingKey); + + assertThat(provider.publicKey("channel1")).isEqualTo(signingKey.getAccountId()); + assertThat(provider.publicKey("channel1")).isEqualTo(signingKey.getAccountId()); + + RecordedRequest lookup = server.takeRequest(); + assertThat(lookup.getPath()).isEqualTo("/stellar/keys/channel1"); + assertThat(lookup.getHeader("Authorization")).isEqualTo("Bearer gateway-token"); + // Cached: the second call never reached the gateway. + assertThat(server.getRequestCount()).isEqualTo(1); + } + + @Test + void signsThroughTheGatewayAndSendsOnlyTheMessage() throws InterruptedException { + enqueueKeyLookup(signingKey); + enqueueSignature(signingKey, signingKey); + + byte[] signature = provider.sign("channel1", MESSAGE); + + assertThat(KeyPair.fromAccountId(signingKey.getAccountId()).verify(MESSAGE, signature)).isTrue(); + + server.takeRequest(); // the key lookup + RecordedRequest signRequest = server.takeRequest(); + assertThat(signRequest.getPath()).isEqualTo("/stellar/sign"); + assertThat(signRequest.getMethod()).isEqualTo("POST"); + String body = signRequest.getBody().readUtf8(); + assertThat(body) + .contains("\"keyRef\":\"channel1\"") + .contains("\"algorithm\":\"ed25519\"") + .contains(Base64.getEncoder().encodeToString(MESSAGE)); + assertThat(SecretRedactor.containsSecret(body)).isFalse(); + } + + /** + * A gateway signing with the wrong key (a botched rotation, a mixed-up + * reference) is caught here. Letting it through would surface seconds + * later as an opaque {@code txBAD_AUTH} from the network, having already + * spent a sequence number to find out. + */ + @Test + void rejectsASignatureFromAnUnexpectedKey() { + KeyPair other = KeyPair.random(); + enqueueKeyLookup(signingKey); + enqueueSignature(other, other); + + assertThatThrownBy(() -> provider.sign("channel1", MESSAGE)) + .isInstanceOf(SigningProviderException.class) + .hasMessageContaining("unexpected key"); + } + + /** A gateway that lies about which key it used is still caught by verification. */ + @Test + void rejectsASignatureThatDoesNotVerify() { + KeyPair other = KeyPair.random(); + enqueueKeyLookup(signingKey); + enqueueSignature(other, signingKey); + + assertThatThrownBy(() -> provider.sign("channel1", MESSAGE)) + .isInstanceOf(SigningProviderException.class) + .hasMessageContaining("failed verification"); + } + + @Test + void aMissingKeyIsAnUnknownReferenceNotAnOutage() { + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"error\":\"no such key\"}")); + + assertThatThrownBy(() -> provider.publicKey("channel1")) + .isInstanceOf(UnknownKeyReferenceException.class) + .hasMessageContaining("channel1"); + } + + @Test + void supportsDistinguishesAnUnknownKeyFromAGatewayFailure() { + server.enqueue(new MockResponse().setResponseCode(404).setBody("")); + assertThat(provider.supports("channel1")).isFalse(); + + server.enqueue(new MockResponse().setResponseCode(503).setBody("gateway down")); + assertThatThrownBy(() -> provider.supports("channel2")) + .isInstanceOf(SigningProviderException.class); + } + + @Test + void aGatewayErrorBodyIsTruncatedBeforeItReachesTheMessage() { + server.enqueue(new MockResponse().setResponseCode(500).setBody("x".repeat(5000))); + + assertThatThrownBy(() -> provider.publicKey("channel1")) + .isInstanceOf(SigningProviderException.class) + .hasMessageContaining("HTTP 500") + .hasMessageContaining("(truncated)") + .satisfies(ex -> assertThat(ex.getMessage().length()).isLessThan(1000)); + } + + @Test + void aNonBase64SignatureIsRejected() { + enqueueKeyLookup(signingKey); + server.enqueue(new MockResponse() + .addHeader("Content-Type", "application/json") + .setBody("{\"signature\":\"not base64 at all!!\"}")); + + assertThatThrownBy(() -> provider.sign("channel1", MESSAGE)) + .isInstanceOf(SigningProviderException.class) + .hasMessageContaining("non-base64"); + } + + @Test + void neverRendersTheApiKey() { + assertThat(provider.toString()).doesNotContain("gateway-token"); + } + + /** Misconfiguration should stop the context starting, not fail on the first signature. */ + @Test + void refusesToStartWithoutAGatewayUrl() { + SigningProperties unconfigured = new SigningProperties(); + unconfigured.setProvider("kms"); + + assertThatThrownBy(() -> new KmsSigningProvider(new OkHttpClient(), new ObjectMapper(), unconfigured)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("stellar.signing.kms.url"); + } +} diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/custody/LocalSigningProviderTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/custody/LocalSigningProviderTest.java new file mode 100644 index 0000000..25dedd0 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/custody/LocalSigningProviderTest.java @@ -0,0 +1,116 @@ +package com.guildworkman.api.signing.custody; + +import com.guildworkman.api.signing.SigningProperties; +import org.junit.jupiter.api.Test; +import org.stellar.sdk.KeyPair; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LocalSigningProviderTest { + + private static LocalSigningProvider providerFor(String keyRef, String seed) { + SigningProperties properties = new SigningProperties(); + properties.getLocal().getKeys().put(keyRef, seed); + LocalSigningProvider provider = new LocalSigningProvider(properties); + provider.loadKeys(); + return provider; + } + + @Test + void signsWithTheConfiguredKeyAndTheSignatureVerifies() { + KeyPair keyPair = KeyPair.random(); + LocalSigningProvider provider = providerFor("channel1", String.valueOf(keyPair.getSecretSeed())); + byte[] message = "a 32-byte-ish transaction hash".getBytes(StandardCharsets.UTF_8); + + byte[] signature = provider.sign("channel1", message); + + assertThat(provider.providerId()).isEqualTo("local"); + assertThat(provider.publicKey("channel1")).isEqualTo(keyPair.getAccountId()); + assertThat(KeyPair.fromAccountId(keyPair.getAccountId()).verify(message, signature)).isTrue(); + } + + @Test + void reportsWhichReferencesItCanResolve() { + LocalSigningProvider provider = providerFor("channel1", String.valueOf(KeyPair.random().getSecretSeed())); + + assertThat(provider.supports("channel1")).isTrue(); + assertThat(provider.supports("channel2")).isFalse(); + assertThat(provider.supports(null)).isFalse(); + assertThat(provider.keyRefs()).containsExactly("channel1"); + } + + @Test + void unknownReferenceIsRejectedRatherThanSignedWithSomethingElse() { + LocalSigningProvider provider = providerFor("channel1", String.valueOf(KeyPair.random().getSecretSeed())); + + assertThatThrownBy(() -> provider.sign("nope", new byte[32])) + .isInstanceOf(UnknownKeyReferenceException.class) + .hasMessageContaining("nope"); + } + + /** + * The whole point of the {@link SigningProvider} abstraction is that a seed + * can't be read back out of it. This asserts the class shape, not just the + * behaviour: a getter added later would fail here. + */ + @Test + void exposesNoMethodThatCouldReturnKeyMaterial() { + assertThat(SigningProvider.class.getDeclaredMethods()) + .extracting(java.lang.reflect.Method::getName) + .containsExactlyInAnyOrder("providerId", "supports", "publicKey", "sign"); + } + + @Test + void neverRendersASeedInItsOwnToString() { + String seed = String.valueOf(KeyPair.random().getSecretSeed()); + LocalSigningProvider provider = providerFor("channel1", seed); + + assertThat(provider.toString()).contains("channel1").doesNotContain(seed); + assertThat(SecretRedactor.containsSecret(provider.toString())).isFalse(); + } + + /** + * strkey parsers habitually quote the input they choked on. Startup must + * fail — but the message that reaches the log has to name the reference + * only, and the cause has to be dropped along with it. + */ + @Test + void aBadSeedFailsStartupWithoutEchoingTheSeed() { + String almostASeed = "SBADSEEDSBADSEEDSBADSEEDSBADSEEDSBADSEEDSBADSEEDSBADSEED"; + SigningProperties properties = new SigningProperties(); + properties.getLocal().getKeys().put("channel1", almostASeed); + LocalSigningProvider provider = new LocalSigningProvider(properties); + + assertThatThrownBy(provider::loadKeys) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("channel1") + .hasMessageNotContaining(almostASeed) + .hasNoCause(); + } + + @Test + void anEmptySeedFailsStartup() { + SigningProperties properties = new SigningProperties(); + properties.getLocal().getKeys().put("channel1", " "); + LocalSigningProvider provider = new LocalSigningProvider(properties); + + assertThatThrownBy(provider::loadKeys) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("channel1"); + } + + /** Seeds live in the nested {@code Local} holder precisely so this stays true. */ + @Test + void propertiesDoNotRenderSeeds() { + String seed = String.valueOf(KeyPair.random().getSecretSeed()); + SigningProperties properties = new SigningProperties(); + properties.getLocal().getKeys().put("channel1", seed); + properties.getKms().setApiKey("super-secret-api-key"); + + assertThat(properties.getLocal().toString()).contains("channel1").doesNotContain(seed); + assertThat(properties.getKms().toString()).doesNotContain("super-secret-api-key"); + } +} diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/custody/SecretRedactorTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/custody/SecretRedactorTest.java new file mode 100644 index 0000000..5d4fab5 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/custody/SecretRedactorTest.java @@ -0,0 +1,66 @@ +package com.guildworkman.api.signing.custody; + +import org.junit.jupiter.api.Test; +import org.stellar.sdk.KeyPair; + +import static org.assertj.core.api.Assertions.assertThat; + +class SecretRedactorTest { + + private static String randomSeed() { + return String.valueOf(KeyPair.random().getSecretSeed()); + } + + @Test + void redactsASecretSeedAnywhereInTheText() { + String seed = randomSeed(); + String redacted = SecretRedactor.redact("KMS rejected the key " + seed + " — retry later"); + + assertThat(redacted).doesNotContain(seed).contains(SecretRedactor.MARKER); + } + + @Test + void redactsEverySeedInTheText() { + String first = randomSeed(); + String second = randomSeed(); + + String redacted = SecretRedactor.redact(first + " and " + second); + + assertThat(redacted).doesNotContain(first).doesNotContain(second); + assertThat(redacted.split(java.util.regex.Pattern.quote(SecretRedactor.MARKER), -1)).hasSize(3); + } + + /** + * Account ids share the seed's alphabet and length. Redacting them would + * blind operators to the identifiers they diagnose with, so the version + * character has to be doing real work here. + */ + @Test + void leavesAccountIdsIntact() { + String accountId = KeyPair.random().getAccountId(); + + assertThat(SecretRedactor.redact("source account " + accountId)) + .isEqualTo("source account " + accountId); + assertThat(SecretRedactor.containsSecret(accountId)).isFalse(); + } + + /** + * Base64 XDR is a long run of the same alphabet and will contain an + * interior {@code S} sooner or later; the lookarounds are what stop that + * from being mistaken for a key and mangling a diagnostic blob. + */ + @Test + void doesNotMatchInsideALongerBase32Run() { + String longRun = "S" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".repeat(4); + + assertThat(SecretRedactor.containsSecret(longRun)).isFalse(); + assertThat(SecretRedactor.redact(longRun)).isEqualTo(longRun); + } + + @Test + void passesThroughNullAndEmpty() { + assertThat(SecretRedactor.redact(null)).isNull(); + assertThat(SecretRedactor.redact("")).isEmpty(); + assertThat(SecretRedactor.containsSecret(null)).isFalse(); + } +} diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/service/FailureClassifierTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/service/FailureClassifierTest.java new file mode 100644 index 0000000..3769f63 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/service/FailureClassifierTest.java @@ -0,0 +1,61 @@ +package com.guildworkman.api.signing.service; + +import com.guildworkman.api.signing.StellarTestFixtures; +import com.guildworkman.api.signing.model.SubmissionFailureReason; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.stellar.sdk.xdr.TransactionResultCode; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The mapping this whole feature's recovery logic hangs off: a + * {@code txBAD_SEQ} that gets retried unchanged reproduces itself forever, and + * a {@code txINSUFFICIENT_FEE} that gets treated as fatal throws away a + * transaction a fee bump would have landed. + */ +class FailureClassifierTest { + + private final FailureClassifier classifier = new FailureClassifier(); + + @ParameterizedTest + @CsvSource({ + "txBAD_SEQ, BAD_SEQUENCE", + "txINSUFFICIENT_FEE, INSUFFICIENT_FEE", + "txTOO_LATE, TOO_LATE", + "txTOO_EARLY, TOO_LATE", + "txMALFORMED, MALFORMED", + "txMISSING_OPERATION, MALFORMED", + "txSOROBAN_INVALID, MALFORMED", + "txNOT_SUPPORTED, MALFORMED", + "txBAD_AUTH, BAD_AUTH", + "txBAD_AUTH_EXTRA, BAD_AUTH", + "txINSUFFICIENT_BALANCE,INSUFFICIENT_BALANCE", + "txNO_ACCOUNT, INSUFFICIENT_BALANCE", + "txFAILED, ON_CHAIN_FAILED", + "txINTERNAL_ERROR, UNKNOWN", + "txBAD_SPONSORSHIP, UNKNOWN" + }) + void classifiesEachResultCode(String code, SubmissionFailureReason expected) { + String resultXdr = StellarTestFixtures.transactionResultXdr(TransactionResultCode.valueOf(code)); + + assertThat(classifier.classify(resultXdr)).isEqualTo(expected); + assertThat(classifier.resultCodeName(resultXdr)).isEqualTo(code); + } + + /** + * An undecodable blob degrades to bounded retries rather than crashing a + * worker — a code from a future protocol version shouldn't take the + * pipeline down. + */ + @Test + void undecodableResultsAreUnknownRatherThanFatal() { + assertThat(classifier.classify(null)).isEqualTo(SubmissionFailureReason.UNKNOWN); + assertThat(classifier.classify("")).isEqualTo(SubmissionFailureReason.UNKNOWN); + assertThat(classifier.classify("not-valid-xdr")).isEqualTo(SubmissionFailureReason.UNKNOWN); + + assertThat(classifier.resultCodeName(null)).isNull(); + assertThat(classifier.resultCodeName("not-valid-xdr")).isNull(); + } +} diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/service/TransactionAssemblerTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/service/TransactionAssemblerTest.java new file mode 100644 index 0000000..db03967 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/service/TransactionAssemblerTest.java @@ -0,0 +1,161 @@ +package com.guildworkman.api.signing.service; + +import com.guildworkman.api.signing.SigningProperties; +import com.guildworkman.api.signing.StellarTestFixtures; +import org.junit.jupiter.api.Test; +import org.stellar.sdk.FeeBumpTransaction; +import org.stellar.sdk.KeyPair; +import org.stellar.sdk.Transaction; + +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TransactionAssemblerTest { + + private final SigningProperties properties = new SigningProperties(); + private final TransactionAssembler assembler = new TransactionAssembler(properties); + + private static SequenceLease lease(String accountId, long sequence) { + return new SequenceLease(1L, accountId, "channel1", sequence, Instant.now().plusSeconds(300)); + } + + // --- parse -------------------------------------------------------------- + + @Test + void parsesAWellFormedEnvelope() { + Transaction parsed = assembler.parse(StellarTestFixtures.unsignedEnvelope(2)); + + assertThat(parsed.getOperations()).hasSize(2); + } + + @Test + void rejectsGarbage() { + assertThatThrownBy(() -> assembler.parse("not-xdr")) + .isInstanceOf(TransactionAssemblyException.class) + .hasMessageContaining("Could not parse"); + } + + /** + * Accepting a fee bump would hand us an envelope whose fee we don't + * control, and whose inner transaction is already signed against someone + * else's sequence number. The service builds its own bumps. + */ + @Test + void rejectsAFeeBumpEnvelope() { + KeyPair signer = KeyPair.random(); + Transaction inner = StellarTestFixtures.unsignedTransaction(1); + inner.sign(signer); + FeeBumpTransaction feeBump = FeeBumpTransaction.createWithFee(signer.getAccountId(), 1000L, inner); + + assertThatThrownBy(() -> assembler.parse(feeBump.toEnvelopeXdrBase64())) + .isInstanceOf(TransactionAssemblyException.class) + .hasMessageContaining("Fee-bump envelopes are not accepted"); + } + + @Test + void rejectsAnEnvelopeWithTooManyOperations() { + String envelope = StellarTestFixtures.unsignedEnvelope(TransactionAssembler.MAX_OPERATIONS + 1); + + assertThatThrownBy(() -> assembler.parse(envelope)) + .isInstanceOf(TransactionAssemblyException.class) + .hasMessageContaining("the maximum is " + TransactionAssembler.MAX_OPERATIONS); + } + + // --- rebuild ------------------------------------------------------------ + + /** + * The sequence number is the reason this service exists, so it gets its + * own assertion: the built transaction must carry exactly the number the + * lease allocated, not the one the caller's placeholder envelope had. + */ + @Test + void rebuildsOntoTheLeasedAccountWithTheAllocatedSequence() { + Transaction source = assembler.parse(StellarTestFixtures.unsignedEnvelope(2)); + String channelAccount = KeyPair.random().getAccountId(); + + Transaction rebuilt = assembler.rebuild(source, lease(channelAccount, 4242L), 100L, null); + + assertThat(rebuilt.getSourceAccount()).isEqualTo(channelAccount); + assertThat(rebuilt.getSequenceNumber()).isEqualTo(4242L); + assertThat(rebuilt.getOperations()).hasSize(2); + assertThat(rebuilt.getFee()).isEqualTo(200L); // 100 stroops x 2 operations + } + + @Test + void rebuildAppliesTheConfiguredValidityWindow() { + properties.setTransactionTimeout(Duration.ofSeconds(90)); + Transaction source = assembler.parse(StellarTestFixtures.unsignedEnvelope()); + + Transaction rebuilt = assembler.rebuild(source, lease(KeyPair.random().getAccountId(), 7L), 100L, null); + + long maxTime = rebuilt.getTimeBounds().getMaxTime().longValue(); + assertThat(maxTime).isBetween(Instant.now().getEpochSecond() + 60, + Instant.now().getEpochSecond() + 120); + } + + /** Two rebuilds of the same operations onto different sequences are different transactions. */ + @Test + void rebuildingWithADifferentSequenceProducesADifferentHash() { + Transaction source = assembler.parse(StellarTestFixtures.unsignedEnvelope()); + String channelAccount = KeyPair.random().getAccountId(); + + String first = assembler.rebuild(source, lease(channelAccount, 1L), 100L, null).hashHex(); + String second = assembler.rebuild(source, lease(channelAccount, 2L), 100L, null).hashHex(); + + assertThat(first).isNotEqualTo(second); + } + + // --- feeBump ------------------------------------------------------------ + + @Test + void feeBumpKeepsTheInnerTransactionIntact() { + Transaction source = assembler.parse(StellarTestFixtures.unsignedEnvelope()); + KeyPair channel = KeyPair.random(); + Transaction inner = assembler.rebuild(source, lease(channel.getAccountId(), 99L), 100L, null); + inner.sign(channel); + + FeeBumpTransaction bumped = assembler.feeBump(inner, channel.getAccountId(), 5000L); + + assertThat(bumped.getFee()).isEqualTo(5000L); + assertThat(bumped.getInnerTransaction().getSequenceNumber()).isEqualTo(99L); + assertThat(bumped.getInnerTransaction().hashHex()).isEqualTo(inner.hashHex()); + // The bump is a different envelope with a different hash, which is why + // the poller has to watch both. + assertThat(bumped.hashHex()).isNotEqualTo(inner.hashHex()); + } + + /** + * {@code createWithFee} accepts any amount at all — including one lower + * than the inner transaction's own fee. That's why the ceiling in + * {@code TransactionSubmissionService} is enforced by us and not delegated + * to the SDK; if that ever changes, this test says so. + */ + @Test + void theSdkDoesNotPoliceFeeBumpAmounts() { + Transaction source = assembler.parse(StellarTestFixtures.unsignedEnvelope()); + KeyPair channel = KeyPair.random(); + Transaction inner = assembler.rebuild(source, lease(channel.getAccountId(), 1L), 100L, null); + inner.sign(channel); + + assertThat(assembler.feeBump(inner, channel.getAccountId(), 1L).getFee()).isEqualTo(1L); + } + + @Test + void feeBumpFailuresSurfaceAsAssemblyExceptions() { + Transaction source = assembler.parse(StellarTestFixtures.unsignedEnvelope()); + Transaction inner = assembler.rebuild(source, lease(KeyPair.random().getAccountId(), 1L), 100L, null); + + assertThatThrownBy(() -> assembler.feeBump(inner, null, 1000L)) + .isInstanceOf(TransactionAssemblyException.class) + .hasMessageContaining("Could not build a fee-bump transaction"); + } + + @Test + void networkMatchesTheConfiguredPassphrase() { + assertThat(assembler.network().getNetworkPassphrase()) + .isEqualTo(properties.getNetworkPassphrase()); + } +} From b5f4158e590366d61cc5ef47cf14e234b53a846f Mon Sep 17 00:00:00 2001 From: KRIS <307999362+kris-nana@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:35:11 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix(backend):=20address=20review=20on=20Ste?= =?UTF-8?q?llar=20signing=20=E2=80=94=20custody=20guards,=20metrics,=20run?= =?UTF-8?q?book?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #48. Security - Providers refuse to sign anything that is not a 32-byte transaction hash. An Ed25519 key that signs whatever it is handed is a signing oracle; the KMS provider checks before resolving the key, so an oversized payload never produces a request either. - KmsSigningProvider refuses to start against a plaintext or unauthenticated gateway (loopback excepted). In cleartext the bearer credential is readable and a response is modifiable — a substituted response is a substituted signature. - SigningProviderShapeTest pins the interface's method set and checks both implementations for public members that return key material or are merely named as though they might, for public instance fields, and for a missing toString() override. - Log-capture tests drive the whole custody lifecycle, including the failure paths, with a capturing appender on the root logger at TRACE, and assert the seed and API key appear nowhere. - Envelope/result columns are @JsonIgnore'd and both entities have hand-written toString()s rendering identifiers only. - 1,000 generated seeds against the DTO seed guard, plus 1,000 randomised legitimate aliases against the mirror-image risk of a guard too broad to use. Correctness - XDR columns are `text` rather than @Lob. Hibernate maps @Lob String onto a PostgreSQL oid; large objects are not removed when their row is deleted, so this table would leak one per submission, unbounded and unreclaimable by VACUUM — and reading one outside a transaction fails at runtime. - Retry-vs-terminal decisions move into FailureClassifier#recoveryFor, an exhaustive switch with no default arm, so a failure reason added later is a compile error rather than a silent inheritance of "retry it". - SigningProperties validates at startup. A fee ceiling below the base fee, or a bump multiplier of 1.0, now fails the boot rather than every transaction. Operability - stellar.signing.enabled pauses the workers without a deploy; submissions still queue and resume from their durable row state. - Micrometer counters for submissions, phase attempts, fee bumps, terminal states and lease events, at /actuator/prometheus behind authentication. Tag values are a closed set, asserted by test. - NO_CHANNEL_ACCOUNT drops to DEBUG (it is backpressure, not a fault); lease claims and releases log their status transition and sequence. Docs and tests - Mermaid sequence diagram of the pipeline marking the durability boundary, plus two tests pinning that the envelope is committed before anything is broadcast and that a crashed process asks before it resends. - Operator runbook: stuck leases, FEE_CEILING_REACHED, KMS outage, and a table of levers. - Lease recovery covered end to end: crash, sweep, resync, next allocation — and the negative, that a consumed release costs no round trip. 347 tests, 0 failures. --- backend-api/.env.example | 11 +- backend-api/README.md | 12 +- backend-api/docs/STELLAR_SIGNING.md | 367 +++++++++++++++++- backend-api/pom.xml | 15 + .../api/signing/SigningProperties.java | 74 ++++ .../signing/custody/KmsSigningProvider.java | 41 +- .../signing/custody/LocalSigningProvider.java | 1 + .../api/signing/custody/SigningMessages.java | 36 ++ .../api/signing/model/ChannelAccount.java | 15 + .../signing/model/TransactionSubmission.java | 65 +++- .../service/ChannelAccountLeaseService.java | 27 +- .../signing/service/FailureClassifier.java | 27 ++ .../api/signing/service/RecoveryAction.java | 40 ++ .../api/signing/service/SigningMetrics.java | 82 ++++ .../service/TransactionSubmissionService.java | 93 ++++- .../src/main/resources/application.properties | 18 + .../ChannelAccountLeaseIntegrationTest.java | 93 +++++ .../SigningPropertiesValidationTest.java | 165 ++++++++ .../TransactionSubmissionIntegrationTest.java | 190 +++++++++ .../api/SigningRequestValidationTest.java | 81 ++++ .../custody/KmsSigningProviderTest.java | 89 +++++ .../custody/LocalSigningProviderTest.java | 75 +++- .../api/signing/custody/LogCapture.java | 62 +++ .../custody/SigningProviderShapeTest.java | 115 ++++++ .../model/SubmissionEntityExposureTest.java | 109 ++++++ .../service/FailureClassifierTest.java | 99 +++++ 26 files changed, 1948 insertions(+), 54 deletions(-) create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/custody/SigningMessages.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/RecoveryAction.java create mode 100644 backend-api/src/main/java/com/guildworkman/api/signing/service/SigningMetrics.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/SigningPropertiesValidationTest.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/custody/LogCapture.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/custody/SigningProviderShapeTest.java create mode 100644 backend-api/src/test/java/com/guildworkman/api/signing/model/SubmissionEntityExposureTest.java diff --git a/backend-api/.env.example b/backend-api/.env.example index 7319f93..a5367d2 100644 --- a/backend-api/.env.example +++ b/backend-api/.env.example @@ -30,6 +30,12 @@ STELLAR_SIGNING_PROVIDER=local STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +# The rollback lever: `false` stops signing, broadcasting and polling without a +# deploy. Submissions are still accepted and queue as PENDING; nothing is lost, +# and turning it back on resumes exactly where it stopped. See the operator +# runbook in backend-api/docs/STELLAR_SIGNING.md. +STELLAR_SIGNING_ENABLED=true + # One entry per channel account, keyed by the reference used to register it: # STELLAR_LOCAL_KEYS_=S... -> stellar.signing.local.keys. # These are secret seeds. Keep them in .env (git-ignored), never in a commit, @@ -37,7 +43,10 @@ SOROBAN_RPC_URL=https://soroban-testnet.stellar.org STELLAR_LOCAL_KEYS_CHANNEL1= STELLAR_LOCAL_KEYS_CHANNEL2= -# KMS/HSM gateway, used when STELLAR_SIGNING_PROVIDER=kms. +# KMS/HSM gateway, used when STELLAR_SIGNING_PROVIDER=kms. The URL must be +# https (loopback excepted) and the API key must be set — both are checked at +# startup, so a misconfigured gateway fails the boot rather than the first +# signature. STELLAR_KMS_URL= STELLAR_KMS_API_KEY= diff --git a/backend-api/README.md b/backend-api/README.md index 3062cb8..eefe5e7 100644 --- a/backend-api/README.md +++ b/backend-api/README.md @@ -176,6 +176,7 @@ export the values into your shell, IDE run configuration, or `docker run --env-f | `paystack.verify.payment.url` | `PAYSTACK_VERIFY_URL` | `https://api.paystack.co/transaction/verify` | | `paystack.initiate.payment` | `PAYSTACK_INITIATE_URL` | `https://api.paystack.co/transaction/initialize` | | `spring.h2.console.enabled` | `H2_CONSOLE_ENABLED` | `false` | +| `stellar.signing.enabled` | `STELLAR_SIGNING_ENABLED` | `true` (set `false` to pause the submission workers) | | `stellar.signing.provider` | `STELLAR_SIGNING_PROVIDER` | `local` (use `kms` in production) | | `stellar.signing.network-passphrase` | `STELLAR_NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | | `stellar.signing.local.keys.` | `STELLAR_LOCAL_KEYS_` | *(empty — development seeds; never commit)* | @@ -184,7 +185,16 @@ export the values into your shell, IDE run configuration, or `docker run --env-f The remaining `stellar.signing.*` knobs (fee ceiling, lease TTL, retry/backoff, worker poll intervals) are listed in -[`docs/STELLAR_SIGNING.md`](docs/STELLAR_SIGNING.md#configuration). +[`docs/STELLAR_SIGNING.md`](docs/STELLAR_SIGNING.md#configuration), along with +an [operator runbook](docs/STELLAR_SIGNING.md#operator-runbook) for stuck +leases, fee-ceiling failures and KMS outages. All of them are validated at +startup — a fee ceiling below the base fee fails the boot rather than every +transaction. + +Micrometer counters for the signing pipeline are exposed at +`/actuator/prometheus`. Only `health`, `info` and `prometheus` are enabled, and +none of them is public, so a scraper needs a bearer token or an in-cluster +network policy; see [Metrics](docs/STELLAR_SIGNING.md#metrics). > **Security note:** this repository's git history (both the old `secret.properties` > committed file and, briefly, this repo's own earlier state) contains real diff --git a/backend-api/docs/STELLAR_SIGNING.md b/backend-api/docs/STELLAR_SIGNING.md index 1259bcc..88a79a9 100644 --- a/backend-api/docs/STELLAR_SIGNING.md +++ b/backend-api/docs/STELLAR_SIGNING.md @@ -67,6 +67,19 @@ and no-ops thereafter; there is no existing column or data they could conflict with. The known limitation of this approach — it cannot express a safe rename or type change — is unchanged and app-wide, not specific to this feature. +**The XDR columns are `text`, not `@Lob`.** Worth stating because the obvious +annotation is the wrong one here. Hibernate maps `@Lob String` onto a +PostgreSQL `oid`, a pointer into `pg_largeobject`, and large objects are *not* +removed when the row referencing them is deleted — a service submitting +transactions continuously would leak one per submission, unbounded and +unreclaimable by `VACUUM`. They also can only be read inside an open +transaction, so any read outside one fails at runtime with "Unable to access +lob stream". `text` is unbounded in PostgreSQL and has neither problem. +(`EscrowOrchestrationRequest.signedTransactionXdr` from #21 still uses `@Lob` +and has the same latent leak; changing it means altering a deployed column's +type, which `ddl-auto=update` can't do safely, so it belongs in its own PR — +noted under [Follow-ups](#follow-ups-out-of-scope-for-this-pr).) + ## Key material This is the part worth reading closely, since the failure mode is @@ -75,8 +88,23 @@ unrecoverable: a leaked Stellar secret seed is a drained account. - **`SigningProvider` has no method that can return a key.** `providerId`, `supports`, `publicKey`, `sign` — that's the whole interface. There is no `secretSeed()` to accidentally call, log, or serialize. - `LocalSigningProviderTest` asserts the method set by reflection, so adding - one later fails the build. + `SigningProviderShapeTest` pins that method set by reflection *and* checks + both implementations for any public member that returns key material or is + merely named as though it might (`secret`, `seed`, `private`, `keypair`), for + any public instance field, and for a missing `toString()` override. A getter + added in six months fails the build with a message saying why, rather than + passing review as an innocuous accessor. +- **A provider will only sign a 32-byte transaction hash.** An Ed25519 key that + signs whatever it is handed is a signing oracle; constraining the input + doesn't prove the bytes are *our* hash, but it removes the case where a whole + envelope, a concatenation, or an attacker-chosen payload reaches a key. The + KMS provider checks before it resolves the key, so an oversized payload never + produces a request either. +- **The KMS gateway must be `https` and authenticated,** enforced at startup + rather than left to deployment convention. In cleartext the bearer credential + is readable and — worse — a response is *modifiable*, and a substituted + response is a substituted signature. Loopback is the one exemption, for tests + and for a sidecar-terminated proxy where there is no network path to observe. - **In production the key is never in this process.** With `stellar.signing.provider=kms`, `KmsSigningProvider` sends the 32-byte transaction hash to a gateway and receives a signature. Nothing else crosses @@ -108,6 +136,21 @@ unrecoverable: a leaked Stellar secret seed is a drained account. - **No response body contains an envelope.** `TransactionSubmissionResponse` exposes public chain data (accounts, sequence, fee, hashes, ledger) and our own bookkeeping. The signed envelope, which carries signatures, is not in it. + Belt and braces on the entity behind it: `signed_envelope_xdr`, + `unsigned_transaction_xdr` and `result_xdr` are `@JsonIgnore`d and both + entities have hand-written `toString()`s rendering identifiers only — so the + day someone returns an entity straight from a controller, or interpolates one + into a log line, signatures don't ride along. (A Lombok `@ToString` would + have picked the envelope columns up silently; that's why these are written + out by hand.) +- **Nothing here is logged, at any level.** `LocalSigningProviderTest` and + `KmsSigningProviderTest` drive the whole lifecycle — load, resolve, sign, + render, and the *failure* paths, where an exception message is the likeliest + leak — with a capturing appender on the **root** logger at `TRACE`, then + assert the seed and the API key appear nowhere in the output. Root, not the + class's own logger, because a leak via a library or a stack trace is still a + leak; `TRACE`, because production log levels are a deployment setting, not a + security control. ## Architecture decisions @@ -200,6 +243,96 @@ unrecoverable: a leaked Stellar secret seed is a drained account. shape as `EscrowOrchestrationService`, which also naturally throttles how many requests hit an unhealthy RPC endpoint at once. +## The pipeline, and where it becomes durable + +The one ordering rule everything else rests on: **the envelope is on disk +before it is on the network.** Phase 1 ends by committing the signed envelope +and its hash; phase 2 begins, in a *separate* transaction, by asking the +network about that hash. Nothing in between. + +```mermaid +sequenceDiagram + autonumber + actor Caller + participant API as TransactionSigningController + participant DB as PostgreSQL + participant P1 as preparePending() + participant Lease as ChannelAccountLeaseService + participant Sign as SigningProvider (local / KMS) + participant RPC as Soroban RPC + participant P2 as broadcastSigned() + participant P3 as pollBroadcast() + + Caller->>API: POST /transactions (operations only) + API->>DB: INSERT … status=PENDING (unique idempotency_key) + API-->>Caller: 202 Accepted + X-Idempotent-Replay + + Note over P1,DB: --- phase 1: one transaction --- + P1->>DB: claim a PENDING row (FOR UPDATE) + P1->>Lease: acquire() + Lease->>DB: SELECT … FOR UPDATE SKIP LOCKED + opt account is NEEDS_RESYNC + Lease->>RPC: getLedgerEntries (account sequence) + end + Lease->>DB: COMMIT lease (REQUIRES_NEW), nextSequence += 1 + P1->>P1: rebuild onto leased account (source, sequence, fee, bounds) + opt Soroban invocation + P1->>RPC: simulateTransaction + RPC-->>P1: resourceFee + transactionData (or an error → terminal) + end + P1->>Sign: sign(keyRef, 32-byte hash) + Sign-->>P1: 64-byte signature (verified against the known public key) + rect rgb(230, 240, 255) + Note over P1,DB: THE DURABILITY BOUNDARY + P1->>DB: COMMIT status=SIGNED + signed_envelope_xdr + transaction_hash + end + + Note over P2,RPC: --- phase 2: a later, separate transaction --- + P2->>DB: claim a SIGNED row + P2->>RPC: getTransaction(hash) — "did a previous life already send this?" + alt already on-chain + RPC-->>P2: SUCCESS / FAILED + P2->>DB: COMMIT terminal state, release lease + else not found + P2->>RPC: sendTransaction(envelope) + P2->>DB: COMMIT status=BROADCAST + end + + Note over P3,RPC: --- phase 3: to a terminal state --- + loop until terminal + P3->>RPC: getTransaction(hash) + alt stalled past stall-after + P3->>Sign: sign a fee bump (≤ fee.max-total-stroops) + P3->>DB: COMMIT status=SIGNED, new hash — back to phase 2 + else past validUntil + P3->>DB: COMMIT status=PENDING (rebuild), release lease unconsumed + end + end + P3->>DB: COMMIT CONFIRMED / FAILED / DEAD_LETTER + P3->>Lease: release(consumed?) + Caller->>API: GET /transactions/{id} +``` + +Read the boundary backwards to see what it buys. A process that dies anywhere +in phase 2 comes back holding the exact hash it *may* have sent, and phase 2 +starts by asking about that hash — so "did we broadcast this?" is a question +with an answer rather than a guess. Invert the ordering and the same crash +produces a service with no record of a transaction the network has, which then +signs a second one on a second sequence number and executes the caller's +operations twice. + +A genuine resend is harmless on top of that: the same envelope has the same +hash, so Soroban RPC answers `DUPLICATE` rather than executing it again. The +pre-check isn't what makes resending safe — it's what makes the *accounting* +right, recognising a transaction that landed while we were away instead of +re-sending and re-polling it. + +Pinned by `TransactionSubmissionIntegrationTest` +`#theEnvelopeIsCommittedBeforeAnythingIsEverBroadcast` (the row is committed, +the RPC client is untouched during phase 1, and phase 2's first call is +`getTransaction`) and +`#aSignedRowLeftBehindByACrashedProcessIsAskedAboutBeforeItIsResent`. + ## Why this always terminates Worth stating explicitly, because "retry until it lands" is exactly how a @@ -231,12 +364,42 @@ transaction forever. Three bounds, and every path hits one of them: signed, the transaction hash — so one submission's whole life is greppable. Fee bumps log both hashes, since after a bump either may be the one that lands. -- **No metrics, no circuit breaker** — same reasoning as - `ESCROW_ORCHESTRATION.md`: there is no Micrometer or Resilience4j anywhere in - this codebase, and introducing either is a cross-cutting infrastructure - choice that deserves its own PR rather than being smuggled into a feature. - What bounds the damage meanwhile: per-call HTTP timeouts, bounded attempts - into `DEAD_LETTER`, and one-row-per-tick claiming. +- **Log levels follow expectedness, not severity of wording.** Terminal + failures are `WARN` (an operator wants to see them), rebuilds and retries are + `INFO`, and `NO_CHANNEL_ACCOUNT` is `DEBUG` — under load it is the pool doing + its job, and at `INFO` it would drown out everything else. Lease claims and + releases are `DEBUG` with the account, its status transition and its + sequence, which is the detail you want when reconstructing a `txBAD_SEQ` + after the fact. +- **No circuit breaker.** Same reasoning as `ESCROW_ORCHESTRATION.md`: there is + no Resilience4j in this codebase and adding one is a cross-cutting choice + that deserves its own PR. What bounds the damage meanwhile: per-call HTTP + timeouts, bounded attempts into `DEAD_LETTER`, and one-row-per-tick claiming. + +### Metrics + +Micrometer counters, scraped from `/actuator/prometheus` (this PR adds +`spring-boot-starter-actuator` and `micrometer-registry-prometheus`). They +exist because the interesting failures here are the quiet ones: a +dead-lettered submission and a stalled one look identical from outside — in +both cases, nothing happens. + +| Meter | Tags | What a change in it means | +|---|---|---| +| `stellar_signing_submissions_total` | `outcome=created\|replayed` | Offered load, and how much of it is duplicate | +| `stellar_signing_phase_attempts_total` | `phase=prepare\|broadcast\|poll` | Which phase the pipeline is spending itself on | +| `stellar_signing_fee_bumps_total` | — | Network congestion — and an early warning that the ceiling is about to bite | +| `stellar_signing_terminal_total` | `status`, `reason` | The alerting series. A rising `SIGNING_FAILED` is a custody outage; `NO_CHANNEL_ACCOUNT`, a pool that needs more accounts; `DEAD_LETTER`, work that needs a human | +| `stellar_signing_leases_total` | `event=ACQUIRED\|RELEASED_CONSUMED\|RELEASED_NEEDS_RESYNC\|RECLAIMED\|EXTENDED\|RESYNCED` | `RECLAIMED` above zero means processes are dying mid-submission | + +Two deliberate choices. **Tag values are a closed set** — enum names and a +fixed vocabulary of phases — so series cardinality is bounded by the code +rather than by traffic; nothing caller-supplied (idempotency key, reference, +account) is ever a tag, and a test asserts it. And **the actuator endpoints are +authenticated**: only `health`, `info` and `prometheus` are exposed, none is in +`SecurityConfig`'s `PUBLIC_ENDPOINTS`, so a scraper needs a bearer token or an +in-cluster network policy. The tags name failure reasons and pipeline phases, +which is more than an anonymous reader should get. ## API behavior @@ -292,6 +455,128 @@ transaction forever. Three bounds, and every path hits one of them: local seeds. The two providers are interchangeable behind `SigningProvider` precisely so this is not a code change. +## Operator runbook + +The pause lever first, since several of the procedures below use it: + +**`stellar.signing.enabled=false` stops the workers.** Signing, broadcasting +and polling all halt; the API keeps accepting submissions and they queue as +`PENDING`. This is safe at any point precisely because every phase transition +is already durable — pausing changes no state at all, and resuming picks up +from the rows exactly where they were. In-flight transactions keep their +channel accounts (the sweeper extends rather than reclaims a lease whose +submission is still live), so nothing is handed a sequence number that is +already in the mempool. Verified by +`TransactionSubmissionIntegrationTest#pausingStopsTheWorkersWithoutLosingOrDuplicatingWork`. + +### (a) A lease that won't clear + +**Looks like:** `503 no-channel-account-available` on submissions; +`GET /api/v1/stellar/channel-accounts` shows members `LEASED` for longer than +`lease-ttl`; `stellar_signing_leases_total{event="EXTENDED"}` climbing. + +**What's happening:** `sweepExpiredLeases` is deliberately conservative — it +*extends* rather than reclaims any lease whose submission is still +non-terminal, because that transaction may be in the mempool right now and +reusing its sequence number would race it. So a lease that never clears means +its **submission** is stuck, not the sweeper. + +1. Find the holder: `leased_by_submission_id` on the account row, then look up + that submission's `status`, `failure_reason` and `last_error`. +2. **`BROADCAST` and the transaction is genuinely unknown to the network** — + it will time out at `valid_until` on its own and rebuild. Wait rather than + intervene; the bound is `transaction-timeout` (default 120s). +3. **`PENDING`/`SIGNED` and not advancing** — the workers are stopped + (`stellar.signing.enabled=false`?) or the phase is failing. Check + `stellar_signing_phase_attempts_total` for movement. +4. **Genuinely orphaned** (no submission row at all — a process died between + committing the lease and committing the submission): the sweeper reclaims + it to `NEEDS_RESYNC` within `lease-ttl`. To reclaim it sooner, lower + `stellar.signing.lease-ttl`; the sweep runs every + `stellar.signing.lease-sweep-delay-ms`. +5. **Never** clear a lease by hand while its submission is non-terminal. The + next lease would reuse the sequence number and, if the original does land, + both transactions can't — one of them fails `txBAD_SEQ` and which one is a + race. + +**To buy headroom now:** register more channel accounts +(`POST /api/v1/stellar/channel-accounts`). Pool size *is* the concurrency +limit, and adding to it is online and reversible. + +### (b) `FEE_CEILING_REACHED` + +**Looks like:** submissions terminal with `failure_reason=FEE_CEILING_REACHED`; +`stellar_signing_fee_bumps_total` rising beforehand. + +This is the ceiling working, not a fault: the transaction stalled, each bump +doubled the fee, and the next one would have crossed +`stellar.signing.fee.max-total-stroops`. The service stopped instead of +spending more. + +1. **Decide whether the transaction still matters.** Its sequence number was + released unconsumed and the account resynced, so the pool is not stuck. The + operations were never executed. +2. **If the network is congested and the work is worth more than the ceiling:** + raise `stellar.signing.fee.max-total-stroops` and resubmit under a **new + idempotency key** (the old row is terminal by design). The ceiling is a + per-transaction cap, so raising it raises the worst case for every + transaction — treat it as a spending decision. +3. **If it fired on a Soroban invocation immediately, before any bump:** that + is a large *resource* fee from simulation, not congestion. The ceiling is + doing exactly its job — a footprint that big is usually the contract call, + not the network. +4. **Do not** work around it by disabling the bump. A transaction that can't + outbid the mempool doesn't land; it just occupies a channel account until + its time bounds lapse. + +Startup validation refuses a ceiling below `fee.base-stroops` outright — that +misconfiguration would fail every transaction before it was ever signed, which +looks like a Stellar outage rather than a config typo. + +### (c) KMS connectivity lost + +**Looks like:** submissions retrying with `failure_reason=SIGNING_FAILED`, +`503 signing-unavailable` on synchronous key-reference validation, and +`stellar_signing_terminal_total{reason="SIGNING_FAILED"}` rising as attempts +run out into `DEAD_LETTER`. + +The signing path is already fail-safe: no key material is cached, nothing is +signed with a stale key, and a submission that can't be signed is retried and +then dead-lettered rather than broadcast half-formed. + +1. **Bound the blast radius first:** `stellar.signing.enabled=false`. Work + queues as `PENDING` instead of burning its `retry.max-attempts` (default 5) + against a gateway that is down and landing in `DEAD_LETTER`, which needs a + human per row. This is the main reason the pause lever exists. +2. Check gateway reachability and the credential. Signing requests carry a + 32-byte hash and a bearer token; the provider refuses at startup to talk to + a non-`https` gateway (loopback excepted) or one with no API key, so a + *started* process was configured correctly at boot. +3. If the gateway is up but signatures are being rejected with "unexpected + key" or "failed verification", **a key rotation is mid-flight.** Public keys + are cached for the process's lifetime on purpose — silently picking up a new + public key would invalidate transactions already signed against the old one. + Complete the rotation, then restart the service. +4. Re-enable with `stellar.signing.enabled=true`. Queued rows resume from where + they stopped; nothing needs resubmitting. +5. **Emergency fallback to local custody is a real decision, not a quick fix.** + `provider=local` puts seeds in this process's environment. If you take it, + treat those seeds as compromised afterwards and rotate the channel accounts + out. + +### Levers, in one place + +| Symptom | Lever | Effect | +|---|---|---| +| Anything, urgently | `stellar.signing.enabled=false` | Workers stop; submissions queue; nothing is lost | +| Pool exhausted (`503`) | `POST /channel-accounts` | More concurrency, online | +| Load shedding needed | `stellar.signing.prepare-poll-delay-ms` ↑ | Slower intake per node | +| Orphaned leases held too long | `stellar.signing.lease-ttl` ↓ | Sweeper reclaims sooner (never reclaims a live submission) | +| Congestion, work worth more | `stellar.signing.fee.max-total-stroops` ↑ | Higher per-transaction ceiling | +| Bumping too early/late | `stellar.signing.stall-after` | Keep it comfortably under `transaction-timeout` | +| Gateway flapping | `stellar.signing.retry.max-attempts` ↑ | Fewer rows reach `DEAD_LETTER` | +| Sequence drift on one account | `POST /channel-accounts/{id}/resync` | Re-reads the chain (refused while leased) | + ## Endpoints | Method | Path | Auth | Description | @@ -313,6 +598,7 @@ annotations on the controllers. | Property | Default | Purpose | |---|---|---| +| `stellar.signing.enabled` | `true` | Master switch for the workers. `false` pauses signing/broadcast/polling; submissions still queue. See [Operator runbook](#operator-runbook) | | `stellar.signing.provider` | `local` | Active custody backend: `local` or `kms` | | `stellar.signing.network-passphrase` | `Test SDF Network ; September 2015` | Network the hash is signed over; must match `soroban.rpc.url`'s network | | `stellar.signing.fee-source-key-ref` | *(empty)* | Key that pays for fee bumps; empty means the channel account pays for its own | @@ -335,9 +621,21 @@ annotations on the controllers. | `stellar.signing.broadcast-poll-delay-ms` | `1000` | `broadcastSigned()` interval | | `stellar.signing.confirm-poll-delay-ms` | `1000` | `pollBroadcast()` interval | +| `management.endpoints.web.exposure.include` | `health,info,prometheus` | Actuator surface. Authenticated — see [Metrics](#metrics) | + Environment-variable names for all of these are in [`.env.example`](../.env.example). +**These are validated at startup** (`SigningProperties#validate`), and a bad +value fails the boot rather than every transaction. The fee bounds are the +reason: a `max-total-stroops` below `base-stroops` makes every transaction fail +`FEE_CEILING_REACHED` before it is even signed, and a `bump-multiplier` of +`1.0` turns fee bumps into a loop that burns attempts without outbidding +anything. Both are one typo away, and both would first surface as "Stellar is +broken", at the exact moment a transaction needed to go out. Also checked: +provider is `local`/`kms`, the network passphrase is set, `max-attempts ≥ 1`, +`max-delay ≥ base-delay`, `jitter ∈ [0, 1)`, and every duration is positive. + ## Tests `./mvnw verify` runs everything. The ones worth knowing about: @@ -345,22 +643,45 @@ Environment-variable names for all of these are in - **`ChannelAccountLeaseIntegrationTest`** — the acceptance criterion, against real PostgreSQL: 8 threads released simultaneously by a `CyclicBarrier` get 8 distinct accounts and 8 distinct sequence numbers; an exhausted pool refuses - the surplus rather than reusing an account; the consumed/unconsumed release - paths and the lease sweeper's conservative half are covered too. An in-memory - database would test nothing here — `SKIP LOCKED` is the mechanism under test. + the surplus rather than reusing an account. Then the recovery chain end to + end — a crashed holder → lease expiry → sweep to `NEEDS_RESYNC` → the next + lease **re-reading the chain before allocating**, asserted by moving the + chain's sequence between the two leases so a skipped resync fails on the + number rather than passing on a plausible one. Plus the negative + (`aConsumedReleaseCostsNoNetworkRoundTrip`: a landed transaction must *not* + cost a resync) and the sweeper's conservative half. An in-memory database + would test nothing here — `SKIP LOCKED` is the mechanism under test. - **`TransactionSubmissionIntegrationTest`** — the pipeline, phase by phase, with the RPC stubbed: the happy path (including verifying that the persisted envelope really carries the leased sequence and a valid signature by the - channel key), idempotent replay, restart safety, each failure classification, - stall→fee-bump, the ceiling loop terminating, expiry→rebuild, landing as the - pre-bump hash, and dead-lettering. + channel key), idempotent replay, each failure classification, stall→fee-bump, + the ceiling loop terminating, expiry→rebuild, landing as the pre-bump hash, + and dead-lettering. Restart safety gets two dedicated tests around + [the durability boundary](#the-pipeline-and-where-it-becomes-durable), plus + the pause lever and the metric counters. - **`KmsSigningProviderTest`** — the gateway contract over `MockWebServer`, including the two misconfiguration cases (wrong key, non-verifying signature) - that must fail here rather than at the network. + that must fail here rather than at the network, the transport requirements + (`https`, API key) refused at construction, and the oversized-payload guard. - **`SigningApiTest`** — status codes, the replay header, the problem+json contract, and the authorisation rules. -- **`LocalSigningProviderTest`, `SecretRedactorTest`, `SigningRequestValidationTest`** — - the key-material guarantees listed under [Key material](#key-material). +- **`SigningProviderShapeTest`, `LocalSigningProviderTest`, `SecretRedactorTest`, + `SubmissionEntityExposureTest`** — the key-material guarantees listed under + [Key material](#key-material): class shape, log capture, redaction, and what + can leave an entity. +- **`SigningRequestValidationTest`** — request validation, including 1,000 + freshly generated seeds against the seed guard (the pattern is a negative + lookahead over `[A-Z2-7]`, exactly the kind of expression that works on the + example it was written against and then lets one character through) and 1,000 + randomised legitimate aliases against the mirror-image risk of a guard too + broad to use. +- **`FailureClassifierTest`** — every result code, the four failure classes the + issue calls out one test each, and an exhaustive `RecoveryAction` table. + `recoveryFor` has no `default` arm, so a failure reason added later is a + compile error rather than a silent inheritance of "retry it" — which for a + transaction already on the network is the one answer that can do damage. +- **`SigningPropertiesValidationTest`** — the startup checks described under + [Configuration](#configuration). ## Follow-ups (out of scope for this PR) @@ -371,8 +692,16 @@ Environment-variable names for all of these are in surfaces as `INSUFFICIENT_BALANCE` and an operator refills it. - A persisted per-attempt audit trail, if grepping logs by submission id proves insufficient operationally. -- Micrometer metrics — pool utilisation and fee spend are the two that would - earn their keep — once Actuator/Micrometer exists app-wide. +- Gauges for pool utilisation and cumulative fee spend. This PR adds the + counters (see [Metrics](#metrics)); those two want a `Gauge` over live state + rather than a counter, which is a slightly different piece of work. +- **Migrating `EscrowOrchestrationRequest.signedTransactionXdr` off `@Lob`.** + It has the same PostgreSQL large-object leak described under + [Schema](#schema--migrations), but its column is already deployed as `oid` + and `ddl-auto=update` cannot change a column's type — so it needs a + hand-written `ALTER TABLE … USING convert_from(lo_get(…), 'UTF8')` plus a + `lo_unlink` sweep of the orphaned objects. Too much to attach to this PR, and + it touches a table this feature doesn't own. - Migrating `/api/v1/escrow/orchestrations` onto this service, so escrow operations stop requiring client-side signing. Deliberately not folded in here: it changes an existing public contract and deserves its own PR. diff --git a/backend-api/pom.xml b/backend-api/pom.xml index 70b6648..59a6fb0 100644 --- a/backend-api/pom.xml +++ b/backend-api/pom.xml @@ -45,6 +45,21 @@ spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + runtime + + org.postgresql postgresql diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/SigningProperties.java b/backend-api/src/main/java/com/guildworkman/api/signing/SigningProperties.java index ea48bcd..58e48db 100644 --- a/backend-api/src/main/java/com/guildworkman/api/signing/SigningProperties.java +++ b/backend-api/src/main/java/com/guildworkman/api/signing/SigningProperties.java @@ -1,5 +1,6 @@ package com.guildworkman.api.signing; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -26,6 +27,20 @@ @Setter public class SigningProperties { + /** + * Master switch for the submission workers. Set to {@code false} to stop + * signing, broadcasting and polling without a deploy — the rollback lever + * described in {@code docs/STELLAR_SIGNING.md} ("Operator runbook"). + * + *

Pausing is safe at any point because every phase transition is + * already durable: work queues up as {@code PENDING}/{@code SIGNED}/ + * {@code BROADCAST} rows and resumes exactly where it stopped. The API + * keeps accepting submissions while paused (they simply aren't signed + * yet), and the lease sweeper keeps running, so an in-flight transaction's + * channel account stays held rather than being handed to someone else. + */ + private boolean enabled = true; + /** Active custody backend: {@code local} or {@code kms}. */ private String provider = "local"; @@ -60,6 +75,65 @@ public class SigningProperties { private final Fee fee = new Fee(); private final Retry retry = new Retry(); + /** + * Refuses to start on a configuration that would misbehave in production + * rather than in a test. + * + *

The fee bounds are the reason this exists. A ceiling below the base + * fee makes every transaction fail {@code FEE_CEILING_REACHED} before it + * is ever signed — a silent, total outage that looks like a Stellar + * problem. A bump multiplier of 1.0 or less makes fee bumps a no-op loop + * that burns attempts without ever outbidding anything. Both are one typo + * away, and neither shows up until a transaction actually needs to go out, + * which in a signing service is the worst possible moment to find out. + * + * @throws IllegalStateException naming the offending property and what a valid value looks like + */ + @PostConstruct + void validate() { + require("local".equals(provider) || "kms".equals(provider), + "stellar.signing.provider must be 'local' or 'kms', got '" + provider + "'"); + require(networkPassphrase != null && !networkPassphrase.isBlank(), + "stellar.signing.network-passphrase must be set; signing for the wrong network " + + "produces signatures that are valid nowhere"); + + require(fee.baseStroops >= MIN_STROOPS_PER_OPERATION, + "stellar.signing.fee.base-stroops must be at least the network minimum of " + + MIN_STROOPS_PER_OPERATION + " stroops per operation, got " + fee.baseStroops); + require(fee.maxTotalStroops >= fee.baseStroops, + "stellar.signing.fee.max-total-stroops (" + fee.maxTotalStroops + ") must be at least " + + "stellar.signing.fee.base-stroops (" + fee.baseStroops + "); a lower ceiling refuses " + + "every transaction before it is signed"); + require(fee.bumpMultiplier > 1.0, + "stellar.signing.fee.bump-multiplier must be greater than 1.0, got " + fee.bumpMultiplier + + "; a bump that doesn't raise the fee cannot outbid anything"); + + require(retry.maxAttempts >= 1, + "stellar.signing.retry.max-attempts must be at least 1, got " + retry.maxAttempts); + require(isPositive(retry.baseDelay), "stellar.signing.retry.base-delay must be positive"); + require(isPositive(retry.maxDelay) && retry.maxDelay.compareTo(retry.baseDelay) >= 0, + "stellar.signing.retry.max-delay must be positive and at least the base delay"); + require(retry.jitter >= 0 && retry.jitter < 1, + "stellar.signing.retry.jitter must be in [0, 1), got " + retry.jitter); + + require(isPositive(transactionTimeout), "stellar.signing.transaction-timeout must be positive"); + require(isPositive(stallAfter), "stellar.signing.stall-after must be positive"); + require(isPositive(leaseTtl), "stellar.signing.lease-ttl must be positive"); + } + + /** The network's minimum inclusion fee per operation. Below this nothing is ever included. */ + private static final long MIN_STROOPS_PER_OPERATION = 100; + + private static boolean isPositive(Duration duration) { + return duration != null && !duration.isZero() && !duration.isNegative(); + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalStateException(message); + } + } + /** Development/test custody: seeds supplied by configuration. */ @Getter @Setter diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/custody/KmsSigningProvider.java b/backend-api/src/main/java/com/guildworkman/api/signing/custody/KmsSigningProvider.java index ea53170..c36d932 100644 --- a/backend-api/src/main/java/com/guildworkman/api/signing/custody/KmsSigningProvider.java +++ b/backend-api/src/main/java/com/guildworkman/api/signing/custody/KmsSigningProvider.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.guildworkman.api.signing.SigningProperties; +import okhttp3.HttpUrl; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -68,6 +69,7 @@ public class KmsSigningProvider implements SigningProvider { public KmsSigningProvider(OkHttpClient httpClient, ObjectMapper objectMapper, SigningProperties properties) { this.objectMapper = objectMapper; this.properties = properties; + requireSecureGateway(properties.getKms()); Duration timeout = properties.getKms().getRequestTimeout(); // Same treatment as SorobanRpcClient: the shared app-wide OkHttpClient // bean keeps its own defaults, this copy gets the KMS timeout budget so @@ -78,10 +80,44 @@ public KmsSigningProvider(OkHttpClient httpClient, ObjectMapper objectMapper, Si .readTimeout(timeout) .writeTimeout(timeout) .build(); - if (properties.getKms().getUrl() == null || properties.getKms().getUrl().isBlank()) { + } + + /** + * Refuses to start against a gateway this service can't talk to safely. + * + *

Signing requests carry a bearer credential and come back with + * signatures; in cleartext both are readable and, worse, modifiable by + * anything on the path — a substituted response is a substituted + * signature. TLS is therefore a startup requirement, not a deployment + * convention, and a missing API key is refused for the same reason: an + * unauthenticated signing gateway is one anybody who can reach it can use. + * + *

Loopback is the one exemption, for tests and for a sidecar-terminated + * mTLS proxy on the same host, where there is no network path to observe. + */ + private static void requireSecureGateway(SigningProperties.Kms kms) { + if (kms.getUrl() == null || kms.getUrl().isBlank()) { throw new IllegalStateException( "stellar.signing.provider=kms requires stellar.signing.kms.url to be set"); } + HttpUrl url = HttpUrl.parse(kms.getUrl()); + if (url == null) { + throw new IllegalStateException( + "stellar.signing.kms.url is not a valid http(s) URL"); + } + if (!url.isHttps() && !isLoopback(url.host())) { + throw new IllegalStateException("stellar.signing.kms.url must use https (got '" + url.scheme() + + "' to host '" + url.host() + "'); a signing gateway reached in cleartext exposes the API key " + + "and lets a network attacker substitute signatures"); + } + if (kms.getApiKey() == null || kms.getApiKey().isBlank()) { + throw new IllegalStateException("stellar.signing.provider=kms requires stellar.signing.kms.api-key " + + "to be set; an unauthenticated signing gateway will sign for anyone who can reach it"); + } + } + + private static boolean isLoopback(String host) { + return "localhost".equals(host) || "127.0.0.1".equals(host) || "::1".equals(host); } @Override @@ -132,6 +168,9 @@ public String publicKey(String keyRef) { @Override public byte[] sign(String keyRef, byte[] message) { + // Checked before the key is even resolved: an oversized payload never + // reaches the gateway, so it can never reach a key. + SigningMessages.requireTransactionHash(keyRef, message); String expectedPublicKey = publicKey(keyRef); ObjectNode payload = objectMapper.createObjectNode(); diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/custody/LocalSigningProvider.java b/backend-api/src/main/java/com/guildworkman/api/signing/custody/LocalSigningProvider.java index 587413b..4f59038 100644 --- a/backend-api/src/main/java/com/guildworkman/api/signing/custody/LocalSigningProvider.java +++ b/backend-api/src/main/java/com/guildworkman/api/signing/custody/LocalSigningProvider.java @@ -89,6 +89,7 @@ public String publicKey(String keyRef) { @Override public byte[] sign(String keyRef, byte[] message) { + SigningMessages.requireTransactionHash(keyRef, message); return require(keyRef).sign(message); } diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/custody/SigningMessages.java b/backend-api/src/main/java/com/guildworkman/api/signing/custody/SigningMessages.java new file mode 100644 index 0000000..376c925 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/custody/SigningMessages.java @@ -0,0 +1,36 @@ +package com.guildworkman.api.signing.custody; + +/** + * The single shape a signing request is ever allowed to take. + * + *

{@link SigningProvider#sign} is documented as taking a Stellar + * transaction hash, and every caller in this application passes exactly that. + * This class turns the documentation into a check, because the difference + * matters: an Ed25519 signing oracle that accepts arbitrary bytes will sign + * anything put in front of it, including a hash the caller obtained from + * somewhere else entirely. Constraining the input to 32 bytes doesn't prove + * the bytes are our transaction hash, but it removes the class of + * mistake where a raw envelope, a concatenation, or an attacker-chosen blob + * reaches a key. + * + *

Deliberately an {@link IllegalArgumentException} rather than a + * {@link SigningProviderException}: a wrong-sized message is a programming + * error in this codebase, not a custody outage, and laundering it into a + * retryable failure would hide it behind five identical retries. + */ +final class SigningMessages { + + /** SHA-256 of the signature base — see {@code AbstractTransaction.hash()}. */ + static final int TRANSACTION_HASH_LENGTH = 32; + + private SigningMessages() { + } + + static void requireTransactionHash(String keyRef, byte[] message) { + if (message == null || message.length != TRANSACTION_HASH_LENGTH) { + throw new IllegalArgumentException("Refusing to sign for keyRef='" + keyRef + "': a signing request must " + + "be a " + TRANSACTION_HASH_LENGTH + "-byte transaction hash, got " + + (message == null ? "null" : message.length + " bytes")); + } + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/model/ChannelAccount.java b/backend-api/src/main/java/com/guildworkman/api/signing/model/ChannelAccount.java index 70f568d..7014ed3 100644 --- a/backend-api/src/main/java/com/guildworkman/api/signing/model/ChannelAccount.java +++ b/backend-api/src/main/java/com/guildworkman/api/signing/model/ChannelAccount.java @@ -105,4 +105,19 @@ public class ChannelAccount { public boolean isLeasable() { return status == ChannelAccountStatus.AVAILABLE || status == ChannelAccountStatus.NEEDS_RESYNC; } + + /** + * Every field here is public information, but this is written out by hand + * anyway so that the property holds by construction rather than by + * inspection — the same rule {@code TransactionSubmission} follows. + */ + @Override + public String toString() { + return "ChannelAccount(id=" + id + + ", accountId=" + accountId + + ", keyRef=" + keyRef + + ", status=" + status + + ", nextSequence=" + nextSequence + + ", leasedBySubmissionId=" + leasedBySubmissionId + ")"; + } } diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/model/TransactionSubmission.java b/backend-api/src/main/java/com/guildworkman/api/signing/model/TransactionSubmission.java index 6511c53..90d16b5 100644 --- a/backend-api/src/main/java/com/guildworkman/api/signing/model/TransactionSubmission.java +++ b/backend-api/src/main/java/com/guildworkman/api/signing/model/TransactionSubmission.java @@ -1,5 +1,6 @@ package com.guildworkman.api.signing.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -8,7 +9,6 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Index; -import jakarta.persistence.Lob; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; import jakarta.persistence.Version; @@ -43,6 +43,16 @@ * which after a fee bump is the outer fee-bump transaction; * {@link #innerTransactionHash} keeps the original so a transaction that * landed on its own, just as we bumped it, is still recognised. + * + *

What must not escape this row. No key material is stored here — + * {@link #keyRef} is an alias and {@link #sourceAccount} a public key — but + * {@link #signedEnvelopeXdr} carries signatures, and it plus + * {@link #unsignedTransactionXdr} and {@link #resultXdr} carry the full + * contents of what the caller asked to execute. None of that belongs in a log + * line or an HTTP response, so all three are {@link JsonIgnore}d and + * {@link #toString()} renders identifiers only. The API answers from + * {@code TransactionSubmissionResponse}, which never reads them at all; these + * are the second lock, for the day someone returns an entity directly. */ @Entity @Table(name = "stellar_transaction_submissions", @@ -70,9 +80,21 @@ public class TransactionSubmission { @Column(length = 128) private String reference; - /** Base64 {@code TransactionEnvelope} XDR supplying the operations to execute. Never signed as-is. */ - @Lob - @Column(name = "unsigned_transaction_xdr", nullable = false, updatable = false) + /** + * Base64 {@code TransactionEnvelope} XDR supplying the operations to execute. Never signed as-is. + * + *

{@code text}, deliberately, rather than {@code @Lob}. Hibernate maps + * {@code @Lob String} onto a PostgreSQL {@code oid} — a pointer into + * {@code pg_largeobject} — which brings two problems this table cannot + * live with. Large objects are not removed when the row referencing + * them is deleted, so a service submitting transactions continuously would + * leak one per submission, unbounded and unreclaimable by {@code VACUUM}. + * And reading one requires an open transaction, so any read outside one + * fails at runtime with "Unable to access lob stream". PostgreSQL + * {@code text} is unbounded and none of that applies. + */ + @JsonIgnore + @Column(name = "unsigned_transaction_xdr", nullable = false, updatable = false, columnDefinition = "text") private String unsignedTransactionXdr; /** @@ -116,9 +138,13 @@ public class TransactionSubmission { @Column(name = "fee_bump_count", nullable = false) private int feeBumpCount; - /** Signed envelope currently in flight — after a fee bump, the fee-bump envelope. */ - @Lob - @Column(name = "signed_envelope_xdr") + /** + * Signed envelope currently in flight — after a fee bump, the fee-bump + * envelope. Carries signatures. {@code text} for the reasons on + * {@link #unsignedTransactionXdr}. + */ + @JsonIgnore + @Column(name = "signed_envelope_xdr", columnDefinition = "text") private String signedEnvelopeXdr; /** Hash of {@link #signedEnvelopeXdr}: what {@code getTransaction} is polled with. */ @@ -141,7 +167,8 @@ public class TransactionSubmission { @Column(name = "valid_until") private Instant validUntil; - /** Base64 {@code TransactionResult} XDR of a terminal outcome, for post-hoc diagnosis. */ + /** Base64 {@code TransactionResult} XDR of a terminal outcome, for post-hoc diagnosis. Echoes transaction contents. */ + @JsonIgnore @Column(name = "result_xdr", length = 2000) private String resultXdr; @@ -169,4 +196,26 @@ public class TransactionSubmission { @Version private long version; + + /** + * Identifiers and state only. Written out by hand rather than generated so + * that a field added later has to be added here deliberately — a Lombok + * {@code @ToString} would have picked up the envelope columns silently, + * and a submission entity interpolated into a log line is exactly how + * signatures end up in log aggregation. + */ + @Override + public String toString() { + return "TransactionSubmission(id=" + id + + ", idempotencyKey=" + idempotencyKey + + ", reference=" + reference + + ", status=" + status + + ", failureReason=" + failureReason + + ", sourceAccount=" + sourceAccount + + ", sequenceNumber=" + sequenceNumber + + ", feeStroops=" + feeStroops + + ", feeBumpCount=" + feeBumpCount + + ", transactionHash=" + transactionHash + + ", attempts=" + attempts + ")"; + } } diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountLeaseService.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountLeaseService.java index 417a3a3..01755af 100644 --- a/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountLeaseService.java +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountLeaseService.java @@ -66,6 +66,7 @@ public class ChannelAccountLeaseService { private final TransactionSubmissionRepository submissions; private final SorobanRpcClient sorobanRpcClient; private final SigningProperties properties; + private final SigningMetrics metrics; /** * Takes an exclusive lease on a free channel account and allocates its @@ -107,8 +108,10 @@ public SequenceLease acquire(Long submissionId) { account.setLeaseExpiresAt(expiresAt); channelAccounts.save(account); - log.debug("Channel account leased channelAccountId={} accountId={} sequence={} submissionId={}", - account.getId(), account.getAccountId(), sequenceNumber, submissionId); + metrics.lease(SigningMetrics.LeaseEvent.ACQUIRED); + log.debug("Channel account leased channelAccountId={} accountId={} sequence={} submissionId={} " + + "leaseExpiresAt={} previousStatus=leasable", + account.getId(), account.getAccountId(), sequenceNumber, submissionId, expiresAt); return new SequenceLease(account.getId(), account.getAccountId(), account.getKeyRef(), sequenceNumber, expiresAt); } @@ -133,6 +136,14 @@ public void release(Long channelAccountId, boolean sequenceConsumed) { // problem, so long as we don't overwrite a fresher lease. return; } + // NEEDS_RESYNC is not a diagnostic label — it is the mechanism. + // `acquire` re-reads the chain for exactly this status before it + // hands out a number (see the `if (NEEDS_RESYNC) resyncFromChain` + // above), so releasing unconsumed here is what guarantees the next + // lease on this account starts from what the network actually + // believes rather than from a counter that ran ahead of it. + // Proven end to end by ChannelAccountLeaseIntegrationTest + // #anUnconsumedSequenceForcesAResyncAndRewindsTheCounter. account.setStatus(sequenceConsumed ? ChannelAccountStatus.AVAILABLE : ChannelAccountStatus.NEEDS_RESYNC); @@ -140,8 +151,13 @@ public void release(Long channelAccountId, boolean sequenceConsumed) { account.setLeasedAt(null); account.setLeaseExpiresAt(null); channelAccounts.save(account); - log.debug("Channel account released channelAccountId={} status={} sequenceConsumed={}", - account.getId(), account.getStatus(), sequenceConsumed); + metrics.lease(sequenceConsumed + ? SigningMetrics.LeaseEvent.RELEASED_CONSUMED + : SigningMetrics.LeaseEvent.RELEASED_NEEDS_RESYNC); + log.debug("Channel account released channelAccountId={} accountId={} status={} sequenceConsumed={} " + + "nextSequence={}", + account.getId(), account.getAccountId(), account.getStatus(), sequenceConsumed, + account.getNextSequence()); }); } @@ -168,6 +184,7 @@ public void sweepExpiredLeases() { if (!active.isEmpty()) { account.setLeaseExpiresAt(now.plus(properties.getLeaseTtl())); channelAccounts.save(account); + metrics.lease(SigningMetrics.LeaseEvent.EXTENDED); log.debug("Channel account lease extended channelAccountId={} activeSubmissions={}", account.getId(), active.size()); continue; @@ -177,6 +194,7 @@ public void sweepExpiredLeases() { account.setLeasedAt(null); account.setLeaseExpiresAt(null); channelAccounts.save(account); + metrics.lease(SigningMetrics.LeaseEvent.RECLAIMED); log.warn("Reclaimed an orphaned channel-account lease channelAccountId={} accountId={}; " + "sequence will be resynced from the network before reuse", account.getId(), account.getAccountId()); @@ -219,6 +237,7 @@ private void resyncFromChain(ChannelAccount account) { } account.setNextSequence(onChainSequence + 1); account.setLastSyncedAt(Instant.now()); + metrics.lease(SigningMetrics.LeaseEvent.RESYNCED); log.info("Channel account resynced channelAccountId={} accountId={} nextSequence={}", account.getId(), account.getAccountId(), account.getNextSequence()); } diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/FailureClassifier.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/FailureClassifier.java index 06a3507..148b32d 100644 --- a/backend-api/src/main/java/com/guildworkman/api/signing/service/FailureClassifier.java +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/FailureClassifier.java @@ -50,6 +50,33 @@ public SubmissionFailureReason classify(String resultXdr) { }; } + /** + * Maps a failure to the recovery it needs. + * + *

Exhaustive on purpose — no {@code default} arm — so a failure reason + * added later is a compile error here instead of quietly inheriting + * "retry it". That default would be the dangerous one: the whole reason + * {@code txBAD_SEQ} gets its own constant is that retrying it unchanged + * reproduces it forever, and the reason {@code txINSUFFICIENT_FEE} gets + * one is that not retrying it abandons a transaction a fee bump + * would have landed. + * + *

Note that {@link RecoveryAction#REBUILD} for {@code BAD_SEQUENCE} is + * also what keeps it from double-submitting: a rebuild discards the + * envelope and releases the channel account unconsumed, so the next + * attempt re-reads the chain's sequence rather than re-sending the same + * transaction against a number the network has already told us is wrong. + */ + public RecoveryAction recoveryFor(SubmissionFailureReason reason) { + return switch (reason) { + case BAD_SEQUENCE, TOO_LATE -> RecoveryAction.REBUILD; + case INSUFFICIENT_FEE -> RecoveryAction.FEE_BUMP; + case SIMULATION_FAILED, FEE_CEILING_REACHED, MALFORMED, BAD_AUTH, INSUFFICIENT_BALANCE, ON_CHAIN_FAILED -> + RecoveryAction.TERMINAL; + case NONE, NO_CHANNEL_ACCOUNT, SIGNING_FAILED, RPC_ERROR, UNKNOWN -> RecoveryAction.RETRY; + }; + } + /** @return the raw result code name for diagnostics (e.g. {@code txBAD_SEQ}), or {@code null} if undecodable. */ public String resultCodeName(String resultXdr) { TransactionResultCode code = resultCode(resultXdr); diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/RecoveryAction.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/RecoveryAction.java new file mode 100644 index 0000000..440d8c1 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/RecoveryAction.java @@ -0,0 +1,40 @@ +package com.guildworkman.api.signing.service; + +import com.guildworkman.api.signing.model.SubmissionFailureReason; + +/** + * What to do about a failure, as opposed to what the failure + * was ({@link SubmissionFailureReason}). + * + *

Split out from the submission workers so the decision can be asserted + * directly, one reason at a time, instead of only through the pipeline that + * consumes it. The mapping lives in + * {@link FailureClassifier#recoveryFor(SubmissionFailureReason)} as an + * exhaustive switch with no {@code default}, so a failure reason added later + * fails compilation rather than silently falling into "retry it" — which for + * a transaction that is already on the network is the one answer that can do + * damage. + */ +public enum RecoveryAction { + + /** + * Throw the envelope away and start over: new channel account, new + * sequence number, new time bounds. Only ever correct for an envelope the + * network has definitively refused, or one whose validity window has + * closed — both cases where the old envelope can never be included later. + */ + REBUILD, + + /** + * Wrap the in-flight transaction in a fee bump paying more. Safe while the + * original may still be in the mempool: the inner transaction, and + * therefore the operations and the sequence number, are unchanged. + */ + FEE_BUMP, + + /** Nothing about resubmitting this transaction could change the outcome. Stop. */ + TERMINAL, + + /** A transient condition. Back off and try the same thing again, while attempts remain. */ + RETRY +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/SigningMetrics.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/SigningMetrics.java new file mode 100644 index 0000000..8a6e901 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/SigningMetrics.java @@ -0,0 +1,82 @@ +package com.guildworkman.api.signing.service; + +import com.guildworkman.api.signing.model.SubmissionFailureReason; +import com.guildworkman.api.signing.model.SubmissionStatus; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Micrometer counters for the submission pipeline, scraped at + * {@code /actuator/prometheus}. + * + *

These exist because the interesting failures here are the quiet ones. + * A dead-lettered submission and a stalled one look identical from outside: + * nothing happens. So the counters are deliberately keyed on the things an + * operator would want an alert on — the rate of fee bumps (the network is + * congested, or the ceiling is about to start biting), terminal states by + * reason (a rising {@code SIGNING_FAILED} is a custody outage, a rising + * {@code NO_CHANNEL_ACCOUNT} is a pool that needs more accounts), and lease + * reclaims (a process is dying mid-submission). + * + *

Tag values are all closed sets — enum names and a fixed vocabulary of + * phases and lease events — so the cardinality of these series is bounded by + * the code, not by traffic. Nothing caller-supplied (no idempotency key, no + * reference, no account) is ever a tag. + */ +@Component +@RequiredArgsConstructor +public class SigningMetrics { + + static final String SUBMISSIONS = "stellar.signing.submissions"; + static final String PHASE_ATTEMPTS = "stellar.signing.phase.attempts"; + static final String FEE_BUMPS = "stellar.signing.fee.bumps"; + static final String TERMINAL = "stellar.signing.terminal"; + static final String LEASES = "stellar.signing.leases"; + + /** Lease lifecycle events, {@link #LEASES}' {@code event} tag. */ + public enum LeaseEvent { + /** A submission took a channel account and a sequence number. */ + ACQUIRED, + /** Released after the transaction reached a ledger — the sequence number was spent. */ + RELEASED_CONSUMED, + /** Released without landing — the account's counter is ahead of the chain and needs a resync. */ + RELEASED_NEEDS_RESYNC, + /** The sweeper reclaimed a lease whose holder never came back. */ + RECLAIMED, + /** The sweeper found an expired lease whose submission is still in flight, and extended it. */ + EXTENDED, + /** An account's sequence number was re-read from the chain. */ + RESYNCED + } + + private final MeterRegistry registry; + + /** @param replayed whether this was an idempotent replay rather than a new submission. */ + public void submissionAccepted(boolean replayed) { + counter(SUBMISSIONS, "outcome", replayed ? "replayed" : "created").increment(); + } + + /** @param phase {@code prepare}, {@code broadcast} or {@code poll}. */ + public void phaseAttempt(String phase) { + counter(PHASE_ATTEMPTS, "phase", phase).increment(); + } + + public void feeBumped() { + registry.counter(FEE_BUMPS).increment(); + } + + /** A submission reached a state it will never leave on its own. */ + public void terminal(SubmissionStatus status, SubmissionFailureReason reason) { + registry.counter(TERMINAL, "status", status.name(), "reason", reason.name()).increment(); + } + + public void lease(LeaseEvent event) { + counter(LEASES, "event", event.name()).increment(); + } + + private Counter counter(String name, String tagKey, String tagValue) { + return registry.counter(name, tagKey, tagValue); + } +} diff --git a/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSubmissionService.java b/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSubmissionService.java index 918dbfd..ae3bb05 100644 --- a/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSubmissionService.java +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSubmissionService.java @@ -90,6 +90,7 @@ public class TransactionSubmissionService { private final FailureClassifier failureClassifier; private final SorobanRpcClient sorobanRpcClient; private final SigningProperties properties; + private final SigningMetrics metrics; // --- API surface -------------------------------------------------------- @@ -112,9 +113,11 @@ public SubmitOutcome submit(SubmitTransactionRequest request) { for (String keyRef : request.extraSignerKeyRefsOrEmpty()) { signer.publicKey(keyRef); } - return submissions.findByIdempotencyKey(request.idempotencyKey()) + SubmitOutcome outcome = submissions.findByIdempotencyKey(request.idempotencyKey()) .map(existing -> new SubmitOutcome(existing, true)) .orElseGet(() -> insertIdempotently(request)); + metrics.submissionAccepted(outcome.replayed()); + return outcome; } @Transactional(readOnly = true) @@ -148,8 +151,14 @@ private SubmitOutcome insertIdempotently(SubmitTransactionRequest request) { @Scheduled(fixedDelayString = "${stellar.signing.prepare-poll-delay-ms:1000}") @Transactional public void preparePending() { + if (paused()) { + return; + } submissions.claimNext(EnumSet.of(SubmissionStatus.PENDING), Instant.now(), PageRequest.of(0, 1)) - .stream().findFirst().ifPresent(this::prepareOne); + .stream().findFirst().ifPresent(entity -> { + metrics.phaseAttempt("prepare"); + prepareOne(entity); + }); } void prepareOne(TransactionSubmission entity) { @@ -226,6 +235,15 @@ void prepareOne(TransactionSubmission entity) { entity.setLastError(null); entity.setSignedAt(now); entity.setNextAttemptAt(now); + // THE DURABILITY BOUNDARY. This save is the last statement of the + // @Transactional `preparePending` tick, so the envelope and its + // hash are committed when this method returns — and `broadcastOne` + // runs in a *later*, separate transaction. Nothing in this class + // can call sendTransaction with an envelope that isn't already on + // disk, because the only caller of sendTransaction is in phase 2 + // and phase 2 only ever reads rows that are already SIGNED. + // Pinned by TransactionSubmissionIntegrationTest + // #theEnvelopeIsCommittedBeforeAnythingIsEverBroadcast. submissions.save(entity); log.info("Transaction signed id={} sourceAccount={} sequence={} fee={} hash={} provider={}", @@ -246,8 +264,14 @@ void prepareOne(TransactionSubmission entity) { @Scheduled(fixedDelayString = "${stellar.signing.broadcast-poll-delay-ms:1000}") @Transactional public void broadcastSigned() { + if (paused()) { + return; + } submissions.claimNext(EnumSet.of(SubmissionStatus.SIGNED), Instant.now(), PageRequest.of(0, 1)) - .stream().findFirst().ifPresent(this::broadcastOne); + .stream().findFirst().ifPresent(entity -> { + metrics.phaseAttempt("broadcast"); + broadcastOne(entity); + }); } void broadcastOne(TransactionSubmission entity) { @@ -302,17 +326,15 @@ private void handleSendFailure(TransactionSubmission entity, String errorResultX + (codeName == null ? "" : " (" + codeName + ")"); entity.setResultXdr(errorResultXdr); - switch (reason) { - case BAD_SEQUENCE, TOO_LATE -> - // The envelope is dead either way: its sequence number is - // wrong, or its window has closed. Rebuilding is the only move - // that can work — and for BAD_SEQUENCE, releasing the account - // for resync is what stops the next attempt reproducing it. - rebuild(entity, reason, detail); - case INSUFFICIENT_FEE -> feeBump(entity, detail); - case MALFORMED, BAD_AUTH, INSUFFICIENT_BALANCE, ON_CHAIN_FAILED -> - fail(entity, reason, detail, false); - default -> scheduleRetry(entity, reason, detail, null); + switch (failureClassifier.recoveryFor(reason)) { + // REBUILD: the envelope is dead either way — its sequence number is + // wrong, or its window has closed. Rebuilding is the only move that + // can work, and releasing the account for resync is what stops the + // next attempt reproducing a BAD_SEQUENCE. + case REBUILD -> rebuild(entity, reason, detail); + case FEE_BUMP -> feeBump(entity, detail); + case TERMINAL -> fail(entity, reason, detail, false); + case RETRY -> scheduleRetry(entity, reason, detail, null); } } @@ -321,8 +343,31 @@ private void handleSendFailure(TransactionSubmission entity, String errorResultX @Scheduled(fixedDelayString = "${stellar.signing.confirm-poll-delay-ms:1000}") @Transactional public void pollBroadcast() { + if (paused()) { + return; + } submissions.claimNext(EnumSet.of(SubmissionStatus.BROADCAST), Instant.now(), PageRequest.of(0, 1)) - .stream().findFirst().ifPresent(this::pollOne); + .stream().findFirst().ifPresent(entity -> { + metrics.phaseAttempt("poll"); + pollOne(entity); + }); + } + + /** + * The operator kill switch ({@code stellar.signing.enabled=false}). + * + *

Checked per tick rather than by conditioning the beans on the + * property, so it can be flipped by a config refresh without a restart — + * and so flipping it back resumes from the durable row states rather than + * from nothing. Submissions keep being accepted while paused; they simply + * queue as {@code PENDING}. + */ + private boolean paused() { + if (properties.isEnabled()) { + return false; + } + log.debug("Submission workers are paused (stellar.signing.enabled=false)"); + return true; } void pollOne(TransactionSubmission entity) { @@ -428,6 +473,7 @@ private void feeBump(TransactionSubmission entity, String cause) { entity.setLastError(SecretRedactor.redact(cause)); entity.setNextAttemptAt(Instant.now()); submissions.save(entity); + metrics.feeBumped(); log.info("Transaction fee-bumped id={} innerHash={} newHash={} fee={} bumps={} cause={}", entity.getId(), entity.getInnerTransactionHash(), entity.getTransactionHash(), @@ -449,6 +495,7 @@ private void confirm(TransactionSubmission entity, GetTransactionResult result) entity.setConfirmedAt(Instant.now()); entity.setLastError(null); submissions.save(entity); + metrics.terminal(SubmissionStatus.CONFIRMED, SubmissionFailureReason.NONE); // The transaction reached a ledger, so its sequence number is spent // exactly as the pool's counter assumes: the account can go straight // back to AVAILABLE without a resync. @@ -471,6 +518,7 @@ private void fail(TransactionSubmission entity, SubmissionFailureReason reason, entity.setFailureReason(reason); entity.setLastError(SecretRedactor.redact(error)); submissions.save(entity); + metrics.terminal(SubmissionStatus.FAILED, reason); releaseLease(entity.getChannelAccountId(), sequenceConsumed); log.warn("Transaction submission failed id={} reason={} hash={}: {}", entity.getId(), reason, entity.getTransactionHash(), entity.getLastError()); @@ -527,14 +575,25 @@ private void scheduleRetry(TransactionSubmission entity, SubmissionFailureReason if (channelAccountIdToRelease != null) { releaseLease(channelAccountIdToRelease, false); } - log.info("Transaction submission id={} retry scheduled attempts={} nextAttemptAt={} reason={}: {}", - entity.getId(), entity.getAttempts(), entity.getNextAttemptAt(), reason, entity.getLastError()); + // NO_CHANNEL_ACCOUNT is backpressure, not a fault: under load it is the + // pool doing its job, and at INFO it would drown the phase out. Every + // other retryable reason means something outside us broke, so it stays + // visible without turning on debug logging. The count is on + // `stellar.signing.terminal`/`phase.attempts` either way. + if (reason == SubmissionFailureReason.NO_CHANNEL_ACCOUNT) { + log.debug("Transaction submission id={} waiting for a free channel account attempts={} nextAttemptAt={}", + entity.getId(), entity.getAttempts(), entity.getNextAttemptAt()); + } else { + log.info("Transaction submission id={} retry scheduled attempts={} nextAttemptAt={} reason={}: {}", + entity.getId(), entity.getAttempts(), entity.getNextAttemptAt(), reason, entity.getLastError()); + } } private void deadLetter(TransactionSubmission entity, String error) { entity.setStatus(SubmissionStatus.DEAD_LETTER); entity.setLastError(SecretRedactor.redact(error)); submissions.save(entity); + metrics.terminal(SubmissionStatus.DEAD_LETTER, entity.getFailureReason()); releaseLease(entity.getChannelAccountId(), false); log.warn("Transaction submission id={} moved to DEAD_LETTER after {} attempts: {}", entity.getId(), entity.getAttempts(), entity.getLastError()); diff --git a/backend-api/src/main/resources/application.properties b/backend-api/src/main/resources/application.properties index 75ec321..4c156f6 100644 --- a/backend-api/src/main/resources/application.properties +++ b/backend-api/src/main/resources/application.properties @@ -97,6 +97,15 @@ booking.expiry-sweep-delay-ms=${BOOKING_EXPIRY_SWEEP_DELAY_MS:30000} # the wrong network yields signatures that are valid nowhere. # - fee-source-key-ref: key that pays for fee bumps. Empty means the channel # account pays for its own bump, which is always valid. +# - enabled: the rollback lever. `false` stops signing, broadcasting and +# polling; the API keeps accepting submissions and they queue as PENDING +# until it is turned back on. Every phase transition is already durable, so +# pausing and resuming is safe at any point. See docs/STELLAR_SIGNING.md +# ("Operator runbook"). +# These values are validated at startup (SigningProperties#validate) — a fee +# ceiling below the base fee, or a bump multiplier of 1.0, fails the boot +# rather than every transaction. +stellar.signing.enabled=${STELLAR_SIGNING_ENABLED:true} stellar.signing.provider=${STELLAR_SIGNING_PROVIDER:local} stellar.signing.network-passphrase=${STELLAR_NETWORK_PASSPHRASE:Test SDF Network ; September 2015} stellar.signing.fee-source-key-ref=${STELLAR_FEE_SOURCE_KEY_REF:} @@ -161,3 +170,12 @@ security.jwt.refresh-token-ttl=${JWT_REFRESH_TTL:P7D} # for preview deployments). Configured in config/WebConfig; the default covers # local development and the live frontend (guildworkman-web.vercel.app). cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,https://guildworkman-web.vercel.app} + +# --- Metrics --------------------------------------------------------------- +# Micrometer counters for the signing pipeline (see signing/service/SigningMetrics) +# are scraped from /actuator/prometheus. Only health, info and prometheus are +# exposed, and none of them is in SecurityConfig's PUBLIC_ENDPOINTS, so a +# scraper needs a bearer token or in-cluster network policy — deliberately, as +# the metric tags name failure reasons and pipeline phases. +management.endpoints.web.exposure.include=${MANAGEMENT_ENDPOINTS:health,info,prometheus} +management.endpoint.health.show-details=when-authorized diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/ChannelAccountLeaseIntegrationTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/ChannelAccountLeaseIntegrationTest.java index db63ff1..bb37a86 100644 --- a/backend-api/src/test/java/com/guildworkman/api/signing/ChannelAccountLeaseIntegrationTest.java +++ b/backend-api/src/test/java/com/guildworkman/api/signing/ChannelAccountLeaseIntegrationTest.java @@ -37,7 +37,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -307,6 +310,96 @@ void theSweeperExtendsALeaseWhoseSubmissionIsStillInFlight() { assertThat(swept.getLeaseExpiresAt()).isAfter(Instant.now()); } + /** + * The recovery chain end to end, from a crash to the next usable sequence + * number: a holder dies mid-submission → its lease expires → the sweeper + * reclaims it to {@code NEEDS_RESYNC} → the next lease re-reads the + * chain before allocating → the number handed out is the chain's, not + * the local counter's. + * + *

The last link is the one worth asserting explicitly, and it's the + * reason the chain's sequence is moved between the two leases here. If the + * resync were skipped the second lease would hand out the counter's own + * next value and the assertion would fail on a number that looks + * perfectly plausible — an off-by-one nobody notices until every + * transaction from this account starts coming back {@code txBAD_SEQ}. + */ + @Test + void aCrashedHolderIsSweptAndTheNextLeaseReReadsTheChainBeforeAllocating() { + ChannelAccount account = registerPool(1).get(0); + long onChainAtCrashTime = onChainSequences.get(account.getAccountId()); + + // A submission takes the account and the process dies: nothing ever + // releases the lease, and we can't know whether its transaction landed. + SequenceLease orphaned = leases.acquire(999L); + assertThat(orphaned.sequenceNumber()).isEqualTo(onChainAtCrashTime + 1); + + expireLease(account.getId()); + leases.sweepExpiredLeases(); + assertThat(channelAccounts.findById(account.getId()).orElseThrow().getStatus()) + .isEqualTo(ChannelAccountStatus.NEEDS_RESYNC); + + // While we were away the account moved on — the crashed transaction + // landed after all, and something else used the account too. + long onChainNow = onChainAtCrashTime + 5; + onChainSequences.put(account.getAccountId(), onChainNow); + + SequenceLease afterRecovery = leases.acquire(1000L); + + verify(sorobanRpcClient, atLeastOnce()).getAccountSequence(account.getAccountId()); + assertThat(afterRecovery.sequenceNumber()) + .withFailMessage("the lease after a resync used the local counter instead of the chain") + .isEqualTo(onChainNow + 1); + assertThat(channelAccounts.findById(account.getId()).orElseThrow().getLastSyncedAt()).isNotNull(); + } + + /** + * The same guarantee for the ordinary (non-crash) unconsumed release — + * a fee-ceiling failure, a dead-letter, an expired envelope. The chain is + * re-read, and the account's counter follows the network down as well as + * up. + */ + @Test + void anUnconsumedReleaseMakesTheNextLeaseReReadRatherThanTrustTheCounter() { + ChannelAccount account = registerPool(1).get(0); + + SequenceLease first = leases.acquire(1L); + leases.release(first.channelAccountId(), false); + reset(sorobanRpcClient); + when(sorobanRpcClient.getAccountSequence(anyString())) + .thenAnswer(invocation -> onChainSequences.get(invocation.getArgument(0, String.class))); + + // The chain is now *behind* where the local counter was left. + long rewound = onChainSequences.get(account.getAccountId()) - 3; + onChainSequences.put(account.getAccountId(), rewound); + + SequenceLease second = leases.acquire(2L); + + verify(sorobanRpcClient).getAccountSequence(account.getAccountId()); + assertThat(second.sequenceNumber()).isEqualTo(rewound + 1).isLessThan(first.sequenceNumber()); + } + + /** + * And the negative: a consumed release must not re-read. The + * transaction reached a ledger, so the local counter is right by + * construction, and an extra round trip per transaction on the hot path is + * a cost with nothing to buy. + */ + @Test + void aConsumedReleaseCostsNoNetworkRoundTrip() { + ChannelAccount account = registerPool(1).get(0); + + SequenceLease first = leases.acquire(1L); + leases.release(first.channelAccountId(), true); + reset(sorobanRpcClient); + + SequenceLease second = leases.acquire(2L); + + verify(sorobanRpcClient, never()).getAccountSequence(anyString()); + assertThat(second.sequenceNumber()).isEqualTo(first.sequenceNumber() + 1); + assertThat(account.getId()).isEqualTo(second.channelAccountId()); + } + // --- operator management ------------------------------------------------- @Test diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/SigningPropertiesValidationTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/SigningPropertiesValidationTest.java new file mode 100644 index 0000000..094bb78 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/SigningPropertiesValidationTest.java @@ -0,0 +1,165 @@ +package com.guildworkman.api.signing; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Configuration that would break signing has to break the boot instead. + * + *

The fee bounds are the reason this validation exists. Every value here is + * one typo away from a production outage that doesn't look like a + * configuration problem at all: a ceiling under the base fee makes every + * transaction fail {@code FEE_CEILING_REACHED} before it is even signed, and a + * bump multiplier of 1.0 turns fee bumps into a loop that burns attempts + * without ever outbidding anything. Both would first surface as "Stellar is + * broken", at the exact moment a transaction needed to go out. + */ +class SigningPropertiesValidationTest { + + private static SigningProperties valid() { + return new SigningProperties(); + } + + @Test + void theShippedDefaultsAreValid() { + assertThatCode(() -> valid().validate()).doesNotThrowAnyException(); + } + + @Test + void aCeilingBelowTheBaseFeeIsRefused() { + SigningProperties properties = valid(); + properties.getFee().setBaseStroops(100); + properties.getFee().setMaxTotalStroops(50); + + assertThatThrownBy(properties::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("max-total-stroops") + .hasMessageContaining("refuses every transaction"); + } + + /** A ceiling exactly at the base fee is legal: a single-operation transaction still fits. */ + @Test + void aCeilingEqualToTheBaseFeeIsAllowed() { + SigningProperties properties = valid(); + properties.getFee().setBaseStroops(100); + properties.getFee().setMaxTotalStroops(100); + + assertThatCode(properties::validate).doesNotThrowAnyException(); + } + + @Test + void aBaseFeeBelowTheNetworkMinimumIsRefused() { + SigningProperties properties = valid(); + properties.getFee().setBaseStroops(99); + + assertThatThrownBy(properties::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("network minimum of 100 stroops"); + } + + @Test + void aNegativeCeilingIsRefused() { + SigningProperties properties = valid(); + properties.getFee().setMaxTotalStroops(-1); + + assertThatThrownBy(properties::validate).isInstanceOf(IllegalStateException.class); + } + + @Test + void aBumpMultiplierThatCannotOutbidAnythingIsRefused() { + SigningProperties properties = valid(); + properties.getFee().setBumpMultiplier(1.0); + + assertThatThrownBy(properties::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("bump-multiplier") + .hasMessageContaining("cannot outbid"); + } + + @Test + void anUnknownProviderIsRefused() { + SigningProperties properties = valid(); + properties.setProvider("vault"); + + assertThatThrownBy(properties::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("must be 'local' or 'kms'"); + } + + @Test + void anEmptyNetworkPassphraseIsRefused() { + SigningProperties properties = valid(); + properties.setNetworkPassphrase(" "); + + assertThatThrownBy(properties::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("network-passphrase"); + } + + @Test + void aRetryPolicyThatWouldNeverRetryIsRefused() { + SigningProperties properties = valid(); + properties.getRetry().setMaxAttempts(0); + + assertThatThrownBy(properties::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("max-attempts"); + } + + @Test + void aMaxDelayBelowTheBaseDelayIsRefused() { + SigningProperties properties = valid(); + properties.getRetry().setBaseDelay(Duration.ofSeconds(10)); + properties.getRetry().setMaxDelay(Duration.ofSeconds(1)); + + assertThatThrownBy(properties::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("max-delay"); + } + + @Test + void jitterOutsideTheUnitIntervalIsRefused() { + SigningProperties properties = valid(); + properties.getRetry().setJitter(1.0); + + assertThatThrownBy(properties::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("jitter"); + } + + @Test + void nonPositiveDurationsAreRefused() { + SigningProperties zeroTimeout = valid(); + zeroTimeout.setTransactionTimeout(Duration.ZERO); + assertThatThrownBy(zeroTimeout::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("transaction-timeout"); + + SigningProperties negativeLease = valid(); + negativeLease.setLeaseTtl(Duration.ofSeconds(-1)); + assertThatThrownBy(negativeLease::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("lease-ttl"); + + SigningProperties zeroStall = valid(); + zeroStall.setStallAfter(Duration.ZERO); + assertThatThrownBy(zeroStall::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("stall-after"); + } + + /** The pause lever ships on, and toggling it is not itself a misconfiguration. */ + @Test + void signingIsEnabledByDefaultAndMayBeTurnedOff() { + SigningProperties properties = valid(); + assertThat(properties.isEnabled()).isTrue(); + + properties.setEnabled(false); + assertThatCode(properties::validate).doesNotThrowAnyException(); + } +} diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/TransactionSubmissionIntegrationTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/TransactionSubmissionIntegrationTest.java index 0895ccf..184b9c2 100644 --- a/backend-api/src/test/java/com/guildworkman/api/signing/TransactionSubmissionIntegrationTest.java +++ b/backend-api/src/test/java/com/guildworkman/api/signing/TransactionSubmissionIntegrationTest.java @@ -18,8 +18,10 @@ import com.guildworkman.api.signing.service.TransactionAssemblyException; import com.guildworkman.api.signing.service.TransactionSubmissionService; import com.guildworkman.api.signing.custody.UnknownKeyReferenceException; +import io.micrometer.core.instrument.MeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -41,6 +43,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.verify; @@ -102,6 +105,9 @@ static void localSigningKeys(DynamicPropertyRegistry registry) { @Autowired private SigningProperties properties; + @Autowired + private MeterRegistry meterRegistry; + @MockBean private SorobanRpcClient rpc; @@ -625,6 +631,190 @@ private TransactionSubmission broadcastOne() { return broadcast; } + // --- the durability boundary -------------------------------------------- + + /** + * The single ordering rule the whole restart story rests on: the + * envelope is on disk before it is on the network. + * + *

Asserted three ways, because "we save before we send" is easy to + * believe and easy to break. First, phase 1 leaves a committed row — + * re-read through a fresh {@code EntityManager}, so this is what another + * process would see, not what this thread has cached — carrying the + * envelope and the exact hash phase 2 will poll. Second, the RPC client is + * never touched at all during phase 1, so no send can have raced the + * commit. Third, phase 2's very first move against that hash is a + * {@code getTransaction}, not a {@code sendTransaction}. + * + *

Invert the ordering and this is the failure it prevents: a process + * that dies between broadcasting and committing comes back with no record + * of a transaction the network has, signs a second one on a second + * sequence number, and executes the caller's operations twice. + */ + @Test + void theEnvelopeIsCommittedBeforeAnythingIsEverBroadcast() { + Long id = service.submit(request(StellarTestFixtures.unsignedEnvelope())).submission().getId(); + + service.preparePending(); + + // What a *different* process would read after phase 1's transaction commits. + submissions.flush(); + TransactionSubmission committed = reload(id); + assertThat(committed.getStatus()).isEqualTo(SubmissionStatus.SIGNED); + assertThat(committed.getSignedEnvelopeXdr()).isNotBlank(); + assertThat(committed.getTransactionHash()).isNotBlank().hasSize(64); + assertThat(committed.getSignedAt()).isNotNull(); + + // Phase 1 never spoke to the network beyond reading the account sequence. + verify(rpc, never()).sendTransaction(anyString()); + verify(rpc, never()).getTransaction(anyString()); + + // The committed hash is exactly what a recovering process would ask about. + String hash = committed.getTransactionHash(); + assertThat(AbstractTransaction.fromEnvelopeXdr(committed.getSignedEnvelopeXdr(), Network.TESTNET).hashHex()) + .isEqualTo(hash); + + when(rpc.getTransaction(hash)).thenReturn(notFound()); + when(rpc.sendTransaction(anyString())).thenReturn(new SendTransactionResult(hash, "PENDING", null)); + service.broadcastSigned(); + + InOrder inOrder = inOrder(rpc); + inOrder.verify(rpc).getTransaction(hash); + inOrder.verify(rpc).sendTransaction(committed.getSignedEnvelopeXdr()); + } + + /** + * The same rule seen from recovery's side. A row left {@code SIGNED} by a + * process that died is picked up by a later one and asked about before + * anything is sent — which is what turns "we may have broadcast this" into + * a question with an answer. + */ + @Test + void aSignedRowLeftBehindByACrashedProcessIsAskedAboutBeforeItIsResent() { + TransactionSubmission prepared = submitAndPrepare(); + String hash = prepared.getTransactionHash(); + + // The crashed process had in fact broadcast it; the network says so. + when(rpc.getTransaction(hash)).thenReturn(success(4242L)); + + service.broadcastSigned(); + + TransactionSubmission recovered = reload(prepared.getId()); + assertThat(recovered.getStatus()).isEqualTo(SubmissionStatus.CONFIRMED); + assertThat(recovered.getLedgerSequence()).isEqualTo(4242L); + verify(rpc, never()).sendTransaction(anyString()); + assertThat(statusOf(recovered.getChannelAccountId())).isEqualTo(ChannelAccountStatus.AVAILABLE); + } + + // --- operator levers ---------------------------------------------------- + + /** + * The rollback switch. Pausing has to stop work moving without losing any, + * and resuming has to pick up from the durable row state rather than from + * the beginning — which it does for free, because pausing changes no state + * at all. + */ + @Test + void pausingStopsTheWorkersWithoutLosingOrDuplicatingWork() { + properties.setEnabled(false); + try { + Long id = service.submit(request(StellarTestFixtures.unsignedEnvelope())).submission().getId(); + + service.preparePending(); + service.broadcastSigned(); + service.pollBroadcast(); + + // Submissions are still accepted — they just queue. + TransactionSubmission paused = reload(id); + assertThat(paused.getStatus()).isEqualTo(SubmissionStatus.PENDING); + assertThat(paused.getSignedEnvelopeXdr()).isNull(); + assertThat(paused.getAttempts()).isZero(); + verify(rpc, never()).sendTransaction(anyString()); + assertThat(channelAccounts.findAll()) + .allMatch(account -> account.getStatus() != ChannelAccountStatus.LEASED); + + properties.setEnabled(true); + service.preparePending(); + + assertThat(reload(id).getStatus()).isEqualTo(SubmissionStatus.SIGNED); + } finally { + properties.setEnabled(true); + } + } + + // --- metrics ------------------------------------------------------------ + + /** + * The counters an operator would alert on. Asserted as deltas rather than + * absolutes: these are process-wide meters and other tests in this class + * share the registry. + */ + @Test + void thePipelineCountsWhatAnOperatorWouldAlertOn() { + double submissionsBefore = counter("stellar.signing.submissions", "outcome", "created"); + double replaysBefore = counter("stellar.signing.submissions", "outcome", "replayed"); + double prepareBefore = counter("stellar.signing.phase.attempts", "phase", "prepare"); + double leasesBefore = counter("stellar.signing.leases", "event", "ACQUIRED"); + double confirmedBefore = counter("stellar.signing.terminal", "status", "CONFIRMED"); + + SubmitTransactionRequest request = request(StellarTestFixtures.unsignedEnvelope()); + Long id = service.submit(request).submission().getId(); + service.submit(request); // the same idempotency key: a replay + service.preparePending(); + TransactionSubmission signed = reload(id); + when(rpc.getTransaction(signed.getTransactionHash())).thenReturn(success(99L)); + service.broadcastSigned(); + + assertThat(counter("stellar.signing.submissions", "outcome", "created")).isEqualTo(submissionsBefore + 1); + assertThat(counter("stellar.signing.submissions", "outcome", "replayed")).isEqualTo(replaysBefore + 1); + assertThat(counter("stellar.signing.phase.attempts", "phase", "prepare")).isEqualTo(prepareBefore + 1); + assertThat(counter("stellar.signing.leases", "event", "ACQUIRED")).isEqualTo(leasesBefore + 1); + assertThat(counter("stellar.signing.terminal", "status", "CONFIRMED")).isEqualTo(confirmedBefore + 1); + } + + /** A fee bump is the signal that the network is congested — or that the ceiling is about to bite. */ + @Test + void feeBumpsAndDeadLettersAreCounted() { + double bumpsBefore = counter("stellar.signing.fee.bumps"); + double ceilingBefore = counter("stellar.signing.terminal", "reason", "FEE_CEILING_REACHED"); + + TransactionSubmission broadcast = broadcastOne(); + when(rpc.getTransaction(anyString())).thenReturn(notFound()); + when(rpc.sendTransaction(anyString())) + .thenReturn(new SendTransactionResult(broadcast.getTransactionHash(), "PENDING", null)); + backdate(broadcast, Instant.now().minusSeconds(120), Instant.now().plusSeconds(600)); + service.pollBroadcast(); + + assertThat(counter("stellar.signing.fee.bumps")).isEqualTo(bumpsBefore + 1); + assertThat(counter("stellar.signing.terminal", "reason", "FEE_CEILING_REACHED")).isEqualTo(ceilingBefore); + } + + /** + * Tag cardinality is bounded by the code, not by traffic: nothing a caller + * supplies may become a tag value, or a busy day turns into a metrics + * cardinality incident. + */ + @Test + void noCallerSuppliedValueBecomesAMetricTag() { + String reference = "reference-" + UUID.randomUUID(); + service.submit(new SubmitTransactionRequest("idem-" + UUID.randomUUID(), reference, + StellarTestFixtures.unsignedEnvelope(), null)); + service.preparePending(); + + assertThat(meterRegistry.getMeters().stream() + .filter(meter -> meter.getId().getName().startsWith("stellar.signing")) + .flatMap(meter -> meter.getId().getTags().stream()) + .map(io.micrometer.core.instrument.Tag::getValue)) + .doesNotContain(reference) + .allSatisfy(value -> assertThat(value).doesNotContain("idem-")); + } + + private double counter(String name, String... tags) { + return meterRegistry.find(name).tags(tags).counters().stream() + .mapToDouble(io.micrometer.core.instrument.Counter::count) + .sum(); + } + /** Sanity: the pool this test class relies on really was registered. */ @Test void theTestPoolIsRegistered() { diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/api/SigningRequestValidationTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/api/SigningRequestValidationTest.java index 0e601ff..1ef3508 100644 --- a/backend-api/src/test/java/com/guildworkman/api/signing/api/SigningRequestValidationTest.java +++ b/backend-api/src/test/java/com/guildworkman/api/signing/api/SigningRequestValidationTest.java @@ -106,4 +106,85 @@ void rejectsPunctuationAndWhitespaceInAKeyReference() { assertThat(invalidFields(new RegisterChannelAccountRequest("channel 1"))).contains("keyRef"); assertThat(invalidFields(new RegisterChannelAccountRequest(""))).contains("keyRef"); } + + // --- fuzzing the seed guard -------------------------------------------- + + /** + * The two tests above prove the seed pattern rejects a seed. This + * proves it rejects every seed: a thousand freshly generated ones, + * each a different 56-character base32 string. The pattern is a negative + * lookahead over a character class, which is exactly the kind of expression + * that works on the example it was written against and then lets some + * character through — {@code [A-Z2-7]} excludes the digits 0, 1, 8 and 9, + * so a single unlucky seed would be enough to punch a hole in it. + * + *

Real key pairs rather than synthesised strings, deliberately: the + * question is whether the guard matches what the SDK actually produces, not + * whether it matches what this test thinks strkey looks like. + */ + @Test + void everySecretSeedTheSdkCanProduceIsRejected() { + for (int i = 0; i < 1_000; i++) { + String seed = String.valueOf(KeyPair.random().getSecretSeed()); + + assertThat(validator.validate(new RegisterChannelAccountRequest(seed))) + .withFailMessage("a generated secret seed passed validation at iteration %d " + + "(length %d) — the seed pattern has a hole in it", i, seed.length()) + .anyMatch(v -> v.getMessage().contains("must not be a secret seed")); + } + } + + /** + * The mirror image: the guard must not be so broad that it rejects the + * aliases operators will actually use. Randomised over the alias character + * set, with a fixed seed so a failure is reproducible from the message + * rather than only on the machine that saw it. + */ + @Test + void ordinaryAliasesSurviveTheSeedGuard() { + java.util.Random random = new java.util.Random(20260822L); + String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"; + + for (int i = 0; i < 1_000; i++) { + // Includes length 56 — an alias is allowed to be seed-*length*, + // it just isn't allowed to be seed-*shaped*. + int length = 1 + random.nextInt(64); + StringBuilder alias = new StringBuilder(length); + for (int c = 0; c < length; c++) { + alias.append(alphabet.charAt(random.nextInt(alphabet.length()))); + } + + assertThat(validator.validate(new RegisterChannelAccountRequest(alias.toString()))) + .withFailMessage("a legitimate alias was rejected: '%s'", alias) + .isEmpty(); + } + } + + /** + * Whatever arrives, validation has to answer rather than throw — a + * {@code RuntimeException} escaping a constraint validator becomes a 500 + * on a request that should have been a 400. + */ + @Test + void hostileInputIsAnswered_notThrownOn() { + List hostile = List.of( + "\u0000nul", "\uD83D\uDD11", "../../etc/passwd", + "'; DROP TABLE stellar_channel_accounts; --", + "S".repeat(56), "S" + "A".repeat(55), "\n", "\t\t", "%s%n", + "channel\u200B1", "S23456789012345678901234567890123456789012345678901234567", + "\uFDD0", "a".repeat(10_000)); + + for (String candidate : hostile) { + assertThat(validator.validate(new RegisterChannelAccountRequest(candidate))) + .withFailMessage("validation produced no verdict for %s", candidate) + .isNotNull(); + assertThat(validator.validate(submit(StellarTestFixtures.unsignedEnvelope(), List.of(candidate)))) + .isNotNull(); + } + + // "SAAA…" is a well-formed strkey shape even though it decodes to + // nothing useful, so the guard must still refuse it. + assertThat(validator.validate(new RegisterChannelAccountRequest("S" + "A".repeat(55)))) + .anyMatch(v -> v.getMessage().contains("must not be a secret seed")); + } } diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/custody/KmsSigningProviderTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/custody/KmsSigningProviderTest.java index 915a773..45db087 100644 --- a/backend-api/src/test/java/com/guildworkman/api/signing/custody/KmsSigningProviderTest.java +++ b/backend-api/src/test/java/com/guildworkman/api/signing/custody/KmsSigningProviderTest.java @@ -15,6 +15,7 @@ import java.util.Base64; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** @@ -170,11 +171,54 @@ void aNonBase64SignatureIsRejected() { .hasMessageContaining("non-base64"); } + /** + * The gateway is a signing oracle reachable over the network, so the size + * check happens before the key lookup: an oversized payload must + * not even produce a request, let alone reach a key. + */ + @Test + void refusesToSendAnythingLargerThanATransactionHash() { + assertThatThrownBy(() -> provider.sign("channel1", new byte[64])) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("32-byte transaction hash") + .hasMessageContaining("64 bytes"); + assertThatThrownBy(() -> provider.sign("channel1", new byte[33])) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> provider.sign("channel1", null)) + .isInstanceOf(IllegalArgumentException.class); + + assertThat(server.getRequestCount()) + .withFailMessage("an oversized payload reached the gateway") + .isZero(); + } + @Test void neverRendersTheApiKey() { assertThat(provider.toString()).doesNotContain("gateway-token"); } + /** + * The API key travels on every request and the signature comes back on + * every response. Neither belongs in a log — the credential for obvious + * reasons, the response body because a gateway is free to echo the request + * into an error. + */ + @Test + void neitherTheApiKeyNorTheGatewayBodyIsLogged() { + try (LogCapture logs = LogCapture.start()) { + enqueueKeyLookup(signingKey); + enqueueSignature(signingKey, signingKey); + provider.sign("channel1", MESSAGE); + + server.enqueue(new MockResponse().setResponseCode(500).setBody("gateway-token leaked in an error body")); + assertThatThrownBy(() -> provider.publicKey("channel2")).isInstanceOf(SigningProviderException.class); + + org.slf4j.LoggerFactory.getLogger(KmsSigningProviderTest.class).info("provider={}", provider); + + assertThat(logs.output()).doesNotContain("gateway-token"); + } + } + /** Misconfiguration should stop the context starting, not fail on the first signature. */ @Test void refusesToStartWithoutAGatewayUrl() { @@ -185,4 +229,49 @@ void refusesToStartWithoutAGatewayUrl() { .isInstanceOf(IllegalStateException.class) .hasMessageContaining("stellar.signing.kms.url"); } + + /** + * Cleartext to a signing gateway leaks the bearer credential and, worse, + * lets anything on the path substitute the signature that comes back. + * Loopback is exempt (this test's own MockWebServer, and the sidecar-proxy + * deployment shape) because there is no network path to observe. + */ + @Test + void refusesToStartAgainstAPlaintextGateway() { + SigningProperties insecure = new SigningProperties(); + insecure.setProvider("kms"); + insecure.getKms().setUrl("http://kms.internal/stellar"); + insecure.getKms().setApiKey("gateway-token"); + + assertThatThrownBy(() -> new KmsSigningProvider(new OkHttpClient(), new ObjectMapper(), insecure)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("must use https"); + + insecure.getKms().setUrl("https://kms.internal/stellar"); + assertThatCode(() -> new KmsSigningProvider(new OkHttpClient(), new ObjectMapper(), insecure)) + .doesNotThrowAnyException(); + } + + @Test + void refusesToStartAgainstAnUnauthenticatedGateway() { + SigningProperties noCredential = new SigningProperties(); + noCredential.setProvider("kms"); + noCredential.getKms().setUrl("https://kms.internal/stellar"); + + assertThatThrownBy(() -> new KmsSigningProvider(new OkHttpClient(), new ObjectMapper(), noCredential)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("stellar.signing.kms.api-key"); + } + + @Test + void refusesToStartAgainstAnUnparseableUrl() { + SigningProperties broken = new SigningProperties(); + broken.setProvider("kms"); + broken.getKms().setUrl("not a url"); + broken.getKms().setApiKey("gateway-token"); + + assertThatThrownBy(() -> new KmsSigningProvider(new OkHttpClient(), new ObjectMapper(), broken)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("valid http(s) URL"); + } } diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/custody/LocalSigningProviderTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/custody/LocalSigningProviderTest.java index 25dedd0..fd49bbf 100644 --- a/backend-api/src/test/java/com/guildworkman/api/signing/custody/LocalSigningProviderTest.java +++ b/backend-api/src/test/java/com/guildworkman/api/signing/custody/LocalSigningProviderTest.java @@ -19,17 +19,42 @@ private static LocalSigningProvider providerFor(String keyRef, String seed) { return provider; } + /** Exactly 32 bytes — the only length {@link SigningMessages} lets through. */ + private static final byte[] TRANSACTION_HASH = + "a thirty-two byte transaction ha".getBytes(StandardCharsets.UTF_8); + @Test void signsWithTheConfiguredKeyAndTheSignatureVerifies() { KeyPair keyPair = KeyPair.random(); LocalSigningProvider provider = providerFor("channel1", String.valueOf(keyPair.getSecretSeed())); - byte[] message = "a 32-byte-ish transaction hash".getBytes(StandardCharsets.UTF_8); - byte[] signature = provider.sign("channel1", message); + byte[] signature = provider.sign("channel1", TRANSACTION_HASH); assertThat(provider.providerId()).isEqualTo("local"); assertThat(provider.publicKey("channel1")).isEqualTo(keyPair.getAccountId()); - assertThat(KeyPair.fromAccountId(keyPair.getAccountId()).verify(message, signature)).isTrue(); + assertThat(KeyPair.fromAccountId(keyPair.getAccountId()).verify(TRANSACTION_HASH, signature)).isTrue(); + } + + /** + * An Ed25519 key that will sign whatever it is handed is a signing oracle. + * Constraining the input to a transaction hash doesn't prove the bytes are + * our hash, but it removes the case where a whole envelope, a + * concatenation, or an attacker-chosen payload reaches the key at all. + */ + @Test + void refusesToSignAnythingThatIsNotATransactionHash() { + LocalSigningProvider provider = providerFor("channel1", String.valueOf(KeyPair.random().getSecretSeed())); + + assertThatThrownBy(() -> provider.sign("channel1", new byte[64])) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("32-byte transaction hash") + .hasMessageContaining("64 bytes"); + assertThatThrownBy(() -> provider.sign("channel1", new byte[31])) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> provider.sign("channel1", new byte[0])) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> provider.sign("channel1", null)) + .isInstanceOf(IllegalArgumentException.class); } @Test @@ -102,6 +127,50 @@ void anEmptySeedFailsStartup() { .hasMessageContaining("channel1"); } + /** + * The one that matters most in practice. A seed can be kept out of API + * responses and out of the database and still end up in log aggregation, + * where it is retained, indexed and readable by everyone with a dashboard. + * + *

So this drives the whole lifecycle — load, resolve, sign, render — + * with the root logger capturing at TRACE, and asserts the seed appears + * nowhere in any of it. The public key is expected to appear: startup logs + * it on purpose, and it's public by definition. + */ + @Test + void nothingInTheLifecycleEverLogsTheSeed() { + KeyPair keyPair = KeyPair.random(); + String seed = String.valueOf(keyPair.getSecretSeed()); + + try (LogCapture logs = LogCapture.start()) { + LocalSigningProvider provider = providerFor("channel1", seed); + provider.publicKey("channel1"); + provider.sign("channel1", TRANSACTION_HASH); + provider.supports("channel1"); + org.slf4j.LoggerFactory.getLogger(LocalSigningProviderTest.class).info("provider={}", provider); + + assertThat(logs.output()) + .contains(keyPair.getAccountId()) + .doesNotContain(seed); + assertThat(SecretRedactor.containsSecret(logs.output())).isFalse(); + } + } + + /** The same guarantee on the failure path, where an exception message is the likeliest leak. */ + @Test + void aRejectedSeedIsNotLoggedEitherByUsOrByTheStrkeyParser() { + String almostASeed = "SBADSEEDSBADSEEDSBADSEEDSBADSEEDSBADSEEDSBADSEEDSBADSEED"; + SigningProperties properties = new SigningProperties(); + properties.getLocal().getKeys().put("channel1", almostASeed); + + try (LogCapture logs = LogCapture.start()) { + LocalSigningProvider provider = new LocalSigningProvider(properties); + assertThatThrownBy(provider::loadKeys).isInstanceOf(IllegalStateException.class); + + assertThat(logs.output()).doesNotContain(almostASeed); + } + } + /** Seeds live in the nested {@code Local} holder precisely so this stays true. */ @Test void propertiesDoNotRenderSeeds() { diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/custody/LogCapture.java b/backend-api/src/test/java/com/guildworkman/api/signing/custody/LogCapture.java new file mode 100644 index 0000000..d57012a --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/custody/LogCapture.java @@ -0,0 +1,62 @@ +package com.guildworkman.api.signing.custody; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.slf4j.LoggerFactory; + +import java.util.stream.Collectors; + +/** + * Captures everything the application logs while a block runs, so a test can + * assert on it. + * + *

Attached to the root logger at {@code TRACE}, deliberately: the + * point of these tests is that a secret doesn't reach the log by any + * route, and pinning the capture to one class's logger would miss a seed + * echoed by a library, a stack trace, or a class added later. Verbosity is + * raised for the duration too, since a leak at {@code DEBUG} is still a leak — + * production log levels are a deployment setting, not a security control. + */ +final class LogCapture implements AutoCloseable { + + private final ch.qos.logback.classic.Logger root; + private final ListAppender appender = new ListAppender<>(); + private final Level originalLevel; + + private LogCapture() { + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + this.root = context.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + this.originalLevel = root.getLevel(); + appender.setContext(context); + appender.start(); + root.setLevel(Level.TRACE); + root.addAppender(appender); + } + + static LogCapture start() { + return new LogCapture(); + } + + /** Everything logged so far: formatted messages, plus any throwable's message. */ + String output() { + return appender.list.stream() + .map(event -> { + String rendered = event.getFormattedMessage(); + if (event.getThrowableProxy() != null) { + rendered += " | " + event.getThrowableProxy().getClassName() + + ": " + event.getThrowableProxy().getMessage(); + } + return rendered; + }) + .collect(Collectors.joining("\n")); + } + + @Override + public void close() { + root.detachAppender(appender); + root.setLevel(originalLevel); + appender.stop(); + } +} diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/custody/SigningProviderShapeTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/custody/SigningProviderShapeTest.java new file mode 100644 index 0000000..2c224b9 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/custody/SigningProviderShapeTest.java @@ -0,0 +1,115 @@ +package com.guildworkman.api.signing.custody; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.stellar.sdk.KeyPair; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A build-time guard on the custody boundary's shape, not its + * behaviour. + * + *

Every other test here asserts that the providers don't leak a key today. + * These assert that they can't grow a way to leak one tomorrow: the interface + * is pinned to four methods, and no implementation may expose a public member + * that hands back key material or is even named as though it might. A getter + * added in six months for what looks like a good reason fails here, in a test + * whose failure message says why, rather than passing review as an innocuous + * accessor. + * + *

This is deliberately broader than {@code SigningProvider} itself, since + * the interface is only half the surface — nothing stops a caller holding a + * concrete {@link LocalSigningProvider} and calling a method the interface + * never declared. + */ +class SigningProviderShapeTest { + + /** + * Names that suggest a method hands back something private. Matched as + * substrings, case-insensitively, so {@code getSecretSeed}, + * {@code exportPrivateKey} and {@code keyPairFor} are all caught. + */ + private static final List FORBIDDEN_NAME_FRAGMENTS = + List.of("secret", "seed", "private", "keypair", "signingkey", "rawkey"); + + /** Types no public custody method may ever return. */ + private static final List> FORBIDDEN_RETURN_TYPES = List.of(KeyPair.class, char[].class); + + private static final List> IMPLEMENTATIONS = + List.of(LocalSigningProvider.class, KmsSigningProvider.class); + + /** + * The interface's method set, pinned exactly. Adding a fifth method is a + * decision that has to be made here first. + */ + @Test + void theInterfaceExposesExactlyTheFourMethodsItIsAllowedTo() { + assertThat(SigningProvider.class.getDeclaredMethods()) + .extracting(Method::getName) + .containsExactlyInAnyOrder("providerId", "supports", "publicKey", "sign"); + } + + @ParameterizedTest + @ValueSource(classes = {LocalSigningProvider.class, KmsSigningProvider.class}) + void noImplementationExposesAMethodShapedLikeAKeyGetter(Class implementation) { + List suspicious = Arrays.stream(implementation.getMethods()) + .filter(method -> method.getDeclaringClass() != Object.class) + .filter(SigningProviderShapeTest::looksLikeItReturnsKeyMaterial) + .toList(); + + assertThat(suspicious) + .withFailMessage("%s exposes %s. Nothing above the custody boundary may be able to read key " + + "material, or a value named as though it could — see SigningProvider's contract.", + implementation.getSimpleName(), suspicious) + .isEmpty(); + } + + /** + * A public field would bypass the method check entirely, and Lombok's + * {@code @Getter}/{@code @Setter} on a provider would generate one of each. + */ + @ParameterizedTest + @ValueSource(classes = {LocalSigningProvider.class, KmsSigningProvider.class}) + void noImplementationExposesAPublicOrProtectedField(Class implementation) { + assertThat(Arrays.stream(implementation.getDeclaredFields()) + .filter(field -> Modifier.isPublic(field.getModifiers()) || Modifier.isProtected(field.getModifiers())) + .filter(field -> !Modifier.isStatic(field.getModifiers())) + .toList()) + .withFailMessage("%s exposes instance state directly; custody state must stay private", + implementation.getSimpleName()) + .isEmpty(); + } + + /** + * The providers hold their keys (or their gateway credential) in instance + * state, so a generated {@code toString} would print them. Both override + * it; this asserts the override exists rather than trusting that the next + * person notices. + */ + @Test + void everyImplementationOverridesToString() { + for (Class implementation : IMPLEMENTATIONS) { + assertThat(Arrays.stream(implementation.getDeclaredMethods()) + .anyMatch(method -> "toString".equals(method.getName()) && method.getParameterCount() == 0)) + .withFailMessage("%s inherits Object.toString(); a custody class must control its own rendering", + implementation.getSimpleName()) + .isTrue(); + } + } + + private static boolean looksLikeItReturnsKeyMaterial(Method method) { + if (FORBIDDEN_RETURN_TYPES.contains(method.getReturnType())) { + return true; + } + String name = method.getName().toLowerCase(Locale.ROOT); + return FORBIDDEN_NAME_FRAGMENTS.stream().anyMatch(name::contains); + } +} diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/model/SubmissionEntityExposureTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/model/SubmissionEntityExposureTest.java new file mode 100644 index 0000000..9aa7169 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/model/SubmissionEntityExposureTest.java @@ -0,0 +1,109 @@ +package com.guildworkman.api.signing.model; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.guildworkman.api.signing.StellarTestFixtures; +import com.guildworkman.api.signing.api.TransactionSubmissionResponse; +import org.junit.jupiter.api.Test; +import org.stellar.sdk.KeyPair; +import org.stellar.sdk.Transaction; + +import java.lang.reflect.RecordComponent; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Belt and braces on what can leave a submission row. + * + *

The API answers from {@link TransactionSubmissionResponse} and never + * serializes an entity, so today none of this can happen. These tests exist + * for the day it can: someone returns an entity straight from a controller, + * or interpolates one into a log line. Signatures and envelope contents must + * not ride along when they do. + */ +class SubmissionEntityExposureTest { + + /** Modules discovered the way Spring Boot's own mapper discovers them, so this serializes like the API would. */ + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + + private static TransactionSubmission signedSubmission() { + Transaction transaction = StellarTestFixtures.unsignedTransaction(1); + KeyPair signer = StellarTestFixtures.randomKeyPair(); + transaction.sign(signer); + + TransactionSubmission submission = new TransactionSubmission(); + submission.setId(7L); + submission.setIdempotencyKey("key-7"); + submission.setReference("appointment-42"); + submission.setUnsignedTransactionXdr(StellarTestFixtures.unsignedEnvelope()); + submission.setSignedEnvelopeXdr(transaction.toEnvelopeXdrBase64()); + submission.setResultXdr(StellarTestFixtures.transactionResultXdr( + org.stellar.sdk.xdr.TransactionResultCode.txFAILED)); + submission.setTransactionHash(transaction.hashHex()); + submission.setSourceAccount(signer.getAccountId()); + submission.setSequenceNumber(99L); + submission.setStatus(SubmissionStatus.SIGNED); + return submission; + } + + @Test + void serializingASubmissionOmitsEveryEnvelopeAndResultBlob() throws Exception { + TransactionSubmission submission = signedSubmission(); + + String json = objectMapper.writeValueAsString(submission); + + assertThat(json) + .doesNotContain(submission.getSignedEnvelopeXdr()) + .doesNotContain(submission.getUnsignedTransactionXdr()) + .doesNotContain(submission.getResultXdr()) + .doesNotContain("signedEnvelopeXdr", "unsignedTransactionXdr", "resultXdr"); + // The bookkeeping a caller legitimately needs is still there. + assertThat(json).contains("\"transactionHash\"", "\"status\"", "\"sequenceNumber\""); + } + + @Test + void aSubmissionRendersOnlyIdentifiersInItsToString() { + TransactionSubmission submission = signedSubmission(); + + String rendered = submission.toString(); + + assertThat(rendered) + .doesNotContain(submission.getSignedEnvelopeXdr()) + .doesNotContain(submission.getUnsignedTransactionXdr()) + .doesNotContain(submission.getResultXdr()); + assertThat(rendered).contains("id=7", "status=SIGNED", submission.getTransactionHash()); + } + + @Test + void aChannelAccountRendersOnlyPublicInformation() { + ChannelAccount account = new ChannelAccount(); + account.setId(3L); + account.setAccountId(StellarTestFixtures.randomKeyPair().getAccountId()); + account.setKeyRef("channel1"); + account.setStatus(ChannelAccountStatus.LEASED); + account.setNextSequence(1234L); + + assertThat(account.toString()) + .contains("id=3", "keyRef=channel1", "status=LEASED", "nextSequence=1234") + .contains(account.getAccountId()); + } + + /** + * The public view is a record, so its component list is its + * contract — a component named after an envelope would put one on the + * wire. Checked by shape rather than by example so it holds for whatever + * the record grows into. + */ + @Test + void thePublicResponseHasNoComponentThatCouldCarryAnEnvelopeOrAKey() { + assertThat(Arrays.stream(TransactionSubmissionResponse.class.getRecordComponents()) + .map(RecordComponent::getName) + .filter(name -> { + String lower = name.toLowerCase(java.util.Locale.ROOT); + return lower.contains("xdr") || lower.contains("envelope") || lower.contains("signature") + || lower.contains("secret") || lower.contains("seed"); + }) + .toList()) + .isEmpty(); + } +} diff --git a/backend-api/src/test/java/com/guildworkman/api/signing/service/FailureClassifierTest.java b/backend-api/src/test/java/com/guildworkman/api/signing/service/FailureClassifierTest.java index 3769f63..51f794e 100644 --- a/backend-api/src/test/java/com/guildworkman/api/signing/service/FailureClassifierTest.java +++ b/backend-api/src/test/java/com/guildworkman/api/signing/service/FailureClassifierTest.java @@ -58,4 +58,103 @@ void undecodableResultsAreUnknownRatherThanFatal() { assertThat(classifier.resultCodeName(null)).isNull(); assertThat(classifier.resultCodeName("not-valid-xdr")).isNull(); } + + // --- the four failure classes the issue calls out, one test each -------- + + /** + * {@code tx_bad_seq} is the one that can double-submit if handled lazily. + * REBUILD is not just "try again": it discards the envelope, releases the + * channel account unconsumed (so the next lease re-reads the chain) and + * signs a new transaction with a new number. A plain RETRY would resend + * the identical envelope against the very number the network just rejected + * — and, if the original were in fact in the mempool, would leave two + * envelopes in flight for one intended operation. + */ + @Test + void aBadSequenceIsRebuilt_neverRetriedAsIs() { + SubmissionFailureReason reason = + classifier.classify(StellarTestFixtures.transactionResultXdr(TransactionResultCode.txBAD_SEQ)); + + assertThat(reason).isEqualTo(SubmissionFailureReason.BAD_SEQUENCE); + assertThat(classifier.recoveryFor(reason)) + .isEqualTo(RecoveryAction.REBUILD) + .isNotEqualTo(RecoveryAction.RETRY); + } + + /** {@code tx_insufficient_fee} is recoverable and only by paying more — never terminal. */ + @Test + void anInsufficientFeeIsFeeBumped() { + SubmissionFailureReason reason = classifier.classify( + StellarTestFixtures.transactionResultXdr(TransactionResultCode.txINSUFFICIENT_FEE)); + + assertThat(reason).isEqualTo(SubmissionFailureReason.INSUFFICIENT_FEE); + assertThat(classifier.recoveryFor(reason)) + .isEqualTo(RecoveryAction.FEE_BUMP) + .isNotEqualTo(RecoveryAction.TERMINAL); + } + + /** + * {@code tx_too_late} means the time bounds closed. The envelope can never + * be included now, so resending it is pointless — but the caller's + * operations are still perfectly valid, so giving up would be wrong too. + */ + @Test + void aTooLateTransactionIsRebuiltWithFreshTimeBounds() { + SubmissionFailureReason reason = + classifier.classify(StellarTestFixtures.transactionResultXdr(TransactionResultCode.txTOO_LATE)); + + assertThat(reason).isEqualTo(SubmissionFailureReason.TOO_LATE); + assertThat(classifier.recoveryFor(reason)).isEqualTo(RecoveryAction.REBUILD); + } + + /** + * A simulation failure has no result XDR — it never reached the network — + * so it is set directly rather than classified. It is terminal because the + * contract call itself cannot succeed: retrying spends a fee and a + * sequence number to be told what simulation said for free. + */ + @Test + void aFailedSimulationIsTerminal() { + assertThat(classifier.recoveryFor(SubmissionFailureReason.SIMULATION_FAILED)) + .isEqualTo(RecoveryAction.TERMINAL); + } + + /** + * Exhaustive, so the mapping is a specification rather than a description. + * The dangerous direction is a new reason silently defaulting to RETRY — + * for a transaction that may already be on the network, retrying is the + * one answer that can do damage — so {@code recoveryFor} has no + * {@code default} arm and this pins every constant's answer. + */ + @ParameterizedTest + @CsvSource({ + "NONE, RETRY", + "SIMULATION_FAILED, TERMINAL", + "BAD_SEQUENCE, REBUILD", + "INSUFFICIENT_FEE, FEE_BUMP", + "TOO_LATE, REBUILD", + "FEE_CEILING_REACHED, TERMINAL", + "MALFORMED, TERMINAL", + "BAD_AUTH, TERMINAL", + "INSUFFICIENT_BALANCE, TERMINAL", + "ON_CHAIN_FAILED, TERMINAL", + "NO_CHANNEL_ACCOUNT, RETRY", + "SIGNING_FAILED, RETRY", + "RPC_ERROR, RETRY", + "UNKNOWN, RETRY" + }) + void everyFailureReasonHasADeclaredRecovery(SubmissionFailureReason reason, RecoveryAction expected) { + assertThat(classifier.recoveryFor(reason)).isEqualTo(expected); + } + + /** Guards the table above against a reason added to the enum but not to the CsvSource. */ + @Test + void theRecoveryTableCoversEveryFailureReason() { + assertThat(SubmissionFailureReason.values()).hasSize(14); + for (SubmissionFailureReason reason : SubmissionFailureReason.values()) { + assertThat(classifier.recoveryFor(reason)) + .withFailMessage("no recovery declared for %s", reason) + .isNotNull(); + } + } }