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..a5367d2 100644 --- a/backend-api/.env.example +++ b/backend-api/.env.example @@ -22,4 +22,32 @@ 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 + +# 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, +# and use throwaway testnet accounts. +STELLAR_LOCAL_KEYS_CHANNEL1= +STELLAR_LOCAL_KEYS_CHANNEL2= + +# 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= + H2_CONSOLE_ENABLED=false diff --git a/backend-api/README.md b/backend-api/README.md index 351ad90..eefe5e7 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,25 @@ 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)* | +| `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), 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 @@ -281,6 +305,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..88a79a9 --- /dev/null +++ b/backend-api/docs/STELLAR_SIGNING.md @@ -0,0 +1,707 @@ +# 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. + +**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 +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. + `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 + 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. + 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 + +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. + +## 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 +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. +- **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 + +- **`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. + +## 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 | +|---|---|---|---| +| `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.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 | +| `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 | + +| `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: + +- **`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. 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, 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, 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. +- **`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) + +- 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. +- 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 7452dc4..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 @@ -129,6 +144,19 @@ json-patch 1.13 + + + network.lightsail + stellar-sdk + 3.1.0 + junit junit @@ -191,6 +219,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..58e48db --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/SigningProperties.java @@ -0,0 +1,208 @@ +package com.guildworkman.api.signing; + +import jakarta.annotation.PostConstruct; +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 { + + /** + * 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"; + + /** + * 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(); + + /** + * 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 + 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..c36d932 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/custody/KmsSigningProvider.java @@ -0,0 +1,250 @@ +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.HttpUrl; +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; + 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 + // an unresponsive gateway can't pin a submission worker thread. + this.httpClient = httpClient.newBuilder() + .callTimeout(timeout) + .connectTimeout(timeout) + .readTimeout(timeout) + .writeTimeout(timeout) + .build(); + } + + /** + * 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 + 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) { + // 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(); + 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..4f59038 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/custody/LocalSigningProvider.java @@ -0,0 +1,114 @@ +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) { + SigningMessages.requireTransactionHash(keyRef, 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("(?{@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/custody/SigningProvider.java b/backend-api/src/main/java/com/guildworkman/api/signing/custody/SigningProvider.java new file mode 100644 index 0000000..c8a33e0 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/custody/SigningProvider.java @@ -0,0 +1,54 @@ +package com.guildworkman.api.signing.custody; + +/** + * Key custody boundary: the only way the rest of the application ever gets a + * signature. Implementations hold (or reach) Ed25519 key material and hand + * back raw signatures for a 32-byte message; 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..7014ed3 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/model/ChannelAccount.java @@ -0,0 +1,123 @@ +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; + } + + /** + * 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/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..90d16b5 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/model/TransactionSubmission.java @@ -0,0 +1,221 @@ +package com.guildworkman.api.signing.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +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; + +/** + * 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. + * + *

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", + 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. + * + *

{@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; + + /** + * 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. 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. */ + @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. Echoes transaction contents. */ + @JsonIgnore + @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; + + /** + * 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/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..01755af --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/ChannelAccountLeaseService.java @@ -0,0 +1,244 @@ +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; + private final SigningMetrics metrics; + + /** + * 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); + + 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); + } + + /** + * 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; + } + // 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); + account.setLeasedBySubmissionId(null); + account.setLeasedAt(null); + account.setLeaseExpiresAt(null); + channelAccounts.save(account); + 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()); + }); + } + + /** + * 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); + metrics.lease(SigningMetrics.LeaseEvent.EXTENDED); + 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); + 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()); + } + } + + /** + * 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()); + 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/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..148b32d --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/FailureClassifier.java @@ -0,0 +1,103 @@ +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; + }; + } + + /** + * 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); + 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/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/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/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/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..ae3bb05 --- /dev/null +++ b/backend-api/src/main/java/com/guildworkman/api/signing/service/TransactionSubmissionService.java @@ -0,0 +1,647 @@ +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; + private final SigningMetrics metrics; + + // --- 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); + } + SubmitOutcome outcome = submissions.findByIdempotencyKey(request.idempotencyKey()) + .map(existing -> new SubmitOutcome(existing, true)) + .orElseGet(() -> insertIdempotently(request)); + metrics.submissionAccepted(outcome.replayed()); + return outcome; + } + + @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() { + if (paused()) { + return; + } + submissions.claimNext(EnumSet.of(SubmissionStatus.PENDING), Instant.now(), PageRequest.of(0, 1)) + .stream().findFirst().ifPresent(entity -> { + metrics.phaseAttempt("prepare"); + prepareOne(entity); + }); + } + + 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); + // 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={}", + 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() { + if (paused()) { + return; + } + submissions.claimNext(EnumSet.of(SubmissionStatus.SIGNED), Instant.now(), PageRequest.of(0, 1)) + .stream().findFirst().ifPresent(entity -> { + metrics.phaseAttempt("broadcast"); + broadcastOne(entity); + }); + } + + 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 (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); + } + } + + // --- Phase 3: poll to a terminal state, fee-bumping a stall ------------- + + @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(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) { + 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); + metrics.feeBumped(); + + 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); + 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. + 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); + metrics.terminal(SubmissionStatus.FAILED, reason); + 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); + } + // 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()); + } + + 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..4c156f6 100644 --- a/backend-api/src/main/resources/application.properties +++ b/backend-api/src/main/resources/application.properties @@ -88,6 +88,73 @@ 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. +# - 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:} + +# 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 @@ -103,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/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..bb37a86 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/ChannelAccountLeaseIntegrationTest.java @@ -0,0 +1,454 @@ +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.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; + +/** + * 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()); + } + + /** + * 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 + 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/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/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..184b9c2 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/TransactionSubmissionIntegrationTest.java @@ -0,0 +1,826 @@ +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 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; +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.inOrder; +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; + + @Autowired + private MeterRegistry meterRegistry; + + @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; + } + + // --- 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() { + 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..1ef3508 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/api/SigningRequestValidationTest.java @@ -0,0 +1,190 @@ +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"); + } + + // --- 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 new file mode 100644 index 0000000..45db087 --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/custody/KmsSigningProviderTest.java @@ -0,0 +1,277 @@ +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.assertThatCode; +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"); + } + + /** + * 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() { + SigningProperties unconfigured = new SigningProperties(); + unconfigured.setProvider("kms"); + + assertThatThrownBy(() -> new KmsSigningProvider(new OkHttpClient(), new ObjectMapper(), unconfigured)) + .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 new file mode 100644 index 0000000..fd49bbf --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/custody/LocalSigningProviderTest.java @@ -0,0 +1,185 @@ +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; + } + + /** 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[] signature = provider.sign("channel1", TRANSACTION_HASH); + + assertThat(provider.providerId()).isEqualTo("local"); + assertThat(provider.publicKey("channel1")).isEqualTo(keyPair.getAccountId()); + 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 + 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"); + } + + /** + * 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() { + 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/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/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/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 new file mode 100644 index 0000000..51f794e --- /dev/null +++ b/backend-api/src/test/java/com/guildworkman/api/signing/service/FailureClassifierTest.java @@ -0,0 +1,160 @@ +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(); + } + + // --- 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(); + } + } +} 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()); + } +}