Skip to content

feat(backend): Stellar key custody & transaction signing service with KMS abstraction, sequence management & fee-bump retries - #48

Merged
meshackyaro merged 2 commits into
workman-labs:developmentfrom
kris-nana:feat/stellar-signing-service
Aug 22, 2026
Merged

meshackyaro merged 2 commits into
workman-labs:developmentfrom
kris-nana:feat/stellar-signing-service

Conversation

@kris-nana

Copy link
Copy Markdown
Contributor

Closes #40

Server-side signing and submission of Stellar/Soroban transactions. Callers hand over the operations to execute and nothing else — no source account, no sequence number, no fee, and no key — and the service sees the transaction through to a terminal state.

Design notes: backend-api/docs/STELLAR_SIGNING.md.

Acceptance criteria

Criterion Where
SigningProvider abstraction, local + externalized-KMS impls selected by profile signing/custody/@ConditionalOnProperty on stellar.signing.provider
No secret key ever logged, serialized or committed See Key material
Concurrency-safe sequence allocation via a DB-leased channel-account pool ChannelAccountLeaseService, SELECT … FOR UPDATE SKIP LOCKED
Submission polling to final status TransactionSubmissionService.pollBroadcast()
Fee-bump resubmission under a bounded fee ceiling feeBump(), stellar.signing.fee.max-total-stroops
Distinct handling of tx_bad_seq, tx_insufficient_fee, tx_too_late, simulation failures FailureClassifier + handleSendFailure
Every attempt persisted with hash + terminal status; restart resumes rather than double-submits stellar_transaction_submissions; envelope durable before broadcast
JPA entities, repositories, schema signing/model/, signing/repository/
Unit + integration tests incl. a concurrency test proving N parallel submissions → N distinct sequences ChannelAccountLeaseIntegrationTest
Maven dependency caching in CI already present (cache: maven); CI now runs ./mvnw -B verify
OpenAPI docs + consistent error contract springdoc annotations on both controllers; RFC 7807 via GlobalExceptionHandler

./mvnw clean verify285 tests, 0 failures.

The three properties worth reviewing closely

Keys. SigningProvider exposes providerId/supports/publicKey/sign and nothing else — there is no secretSeed() to accidentally call, and LocalSigningProviderTest asserts that method set by reflection so adding one later fails the build. With provider=kms the key never enters this process: only the 32-byte transaction hash crosses the wire, and every returned signature is verified against the reference's known public key, so a gateway signing with the wrong key fails immediately rather than as an opaque txBAD_AUTH that has already cost a sequence number. Accounts are registered by reference, never by key material — and since a Stellar seed is 56 characters of letters and digits, exactly the shape of a plausible alias, the DTOs carry an explicit pattern rejecting seed-shaped input rather than assuming it won't arrive.

Sequence numbers. Stellar validates a sequence number as exactly source + 1, so handing out n and n+1 on one account parallelises nothing — the network takes one and rejects the other. Concurrency has to come from more accounts, which is what the pool is; SKIP LOCKED makes claiming "any free account" contention-free. The subtle half is release: a number allocated to a transaction that never landed is skipped, and every later transaction from that account would then fail. So confirmation and on-chain failure both release to AVAILABLE (both spend the number), while anything that never landed releases to NEEDS_RESYNC and the next lease re-reads the chain first.

Termination. Every path is bounded: fee bumps stop at the ceiling (terminal FEE_CEILING_REACHED), a transaction past its time bounds is rebuilt rather than polled forever, rebuilds and retries are bounded by max-attempts into DEAD_LETTER, and a crashed holder's lease is swept — but conservatively, extending rather than reclaiming any lease whose submission is still in flight, since reusing its sequence number would race a transaction that may be in the mempool right now.

Reversal worth flagging

This adds network.lightsail:stellar-sdk:3.1.0, reversing decision 1 of ESCROW_ORCHESTRATION.md (#21), which found no Java Stellar SDK on Maven Central and therefore relayed pre-signed envelopes as opaque strings. That was right about the official SDK — SDF archived java-stellar-sdk — but this is its maintained successor. Setting a sequence number, fee-bumping, and telling txBAD_SEQ from txINSUFFICIENT_FEE all require real XDR, and hand-rolling that encoding is precisely what decision 1 refused to do. SorobanRpcClient still treats envelopes and results as opaque everywhere except getAccountSequence, where a ledger entry can only be addressed and read as XDR. Rationale in full: New dependencies.

Notes for reviewers

  • No migration file: this repo has no Flyway/Liquibase and manages schema via ddl-auto=update with entities as the source of truth, same as every existing table. Both new tables are new, so first deploy creates them and every deploy after is a no-op.
  • No new error contract — the new exceptions are wired into the existing GlobalExceptionHandler and render as application/problem+json. SigningProviderException deliberately answers with a fixed detail string so nothing from the signing path can reach a client body.
  • The existing /api/v1/escrow/orchestrations endpoints are untouched. Migrating them onto this service would change a public contract and belongs in its own PR.

Adds server-side signing and submission of Stellar/Soroban transactions.
Callers hand over the operations to execute and nothing else — no source
account, no sequence number, no fee, no key — and the service sees the
transaction through to a terminal state.

Custody sits behind a SigningProvider interface with no method that can
return a key. LocalSigningProvider parses configured seeds once at startup
and holds only KeyPairs; KmsSigningProvider delegates to an external gateway
so the secret key never enters this process, and verifies every returned
signature against the reference's known public key before use. Selection is
by profile property, so swapping one for the other is configuration rather
than code. SecretRedactor backstops every string that reaches a log or the
last_error column, and the request DTOs reject anything shaped like a secret
seed outright.

Sequence numbers come from a leased pool of channel accounts claimed with
SELECT ... FOR UPDATE SKIP LOCKED: N concurrent submissions get N distinct
accounts and N distinct sequence numbers with no coordination beyond the
database. A lease is only returned as AVAILABLE when the transaction
actually reached a ledger; anything that never landed forces a resync
against the chain, which is what stops one lost transaction from poisoning
an account permanently.

Submission runs as three claim-one-row workers. The signed envelope is
durable before it is broadcast, never after, and the broadcast phase begins
by asking the network about the hash it holds — so a restart resumes rather
than double-submits. Failures are classified from the decoded
TransactionResult and each gets the recovery it needs: tx_bad_seq resyncs
and rebuilds, tx_insufficient_fee fee-bumps, tx_too_late rebuilds with fresh
time bounds, and a failed simulation is terminal before anything is signed.
Fee bumps double the fee until the ceiling, which turns a stall into a
bounded state instead of unbounded spend.

Adds network.lightsail:stellar-sdk, the maintained successor to the archived
official Java SDK. This reverses decision 1 of ESCROW_ORCHESTRATION.md,
which found no such SDK on Maven Central; every acceptance criterion here
needs real XDR, and hand-rolling it was what that decision rightly refused
to do. SorobanRpcClient still treats envelopes and results as opaque strings
everywhere except getAccountSequence, where a ledger entry can only be
addressed and read as XDR.

Tests cover the concurrency criterion against real PostgreSQL (8 threads
released by a barrier, 8 distinct sequence numbers), the full pipeline phase
by phase, the KMS gateway contract over MockWebServer, the HTTP/problem+json
contract and ADMIN gating, and the key-material guarantees. CI now runs
./mvnw -B verify with the existing Maven cache.

Closes workman-labs#40
@meshackyaro

meshackyaro commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Closes #40

Server-side signing and submission of Stellar/Soroban transactions. Callers hand over the operations to execute and nothing else — no source account, no sequence number, no fee, and no key — and the service sees the transaction through to a terminal state.

Design notes: backend-api/docs/STELLAR_SIGNING.md.

Acceptance criteria

Criterion Where
SigningProvider abstraction, local + externalized-KMS impls selected by profile signing/custody/@ConditionalOnProperty on stellar.signing.provider
No secret key ever logged, serialized or committed See Key material
Concurrency-safe sequence allocation via a DB-leased channel-account pool ChannelAccountLeaseService, SELECT … FOR UPDATE SKIP LOCKED
Submission polling to final status TransactionSubmissionService.pollBroadcast()
Fee-bump resubmission under a bounded fee ceiling feeBump(), stellar.signing.fee.max-total-stroops
Distinct handling of tx_bad_seq, tx_insufficient_fee, tx_too_late, simulation failures FailureClassifier + handleSendFailure
Every attempt persisted with hash + terminal status; restart resumes rather than double-submits stellar_transaction_submissions; envelope durable before broadcast
JPA entities, repositories, schema signing/model/, signing/repository/
Unit + integration tests incl. a concurrency test proving N parallel submissions → N distinct sequences ChannelAccountLeaseIntegrationTest
Maven dependency caching in CI already present (cache: maven); CI now runs ./mvnw -B verify
OpenAPI docs + consistent error contract springdoc annotations on both controllers; RFC 7807 via GlobalExceptionHandler
./mvnw clean verify285 tests, 0 failures.

The three properties worth reviewing closely

Keys. SigningProvider exposes providerId/supports/publicKey/sign and nothing else — there is no secretSeed() to accidentally call, and LocalSigningProviderTest asserts that method set by reflection so adding one later fails the build. With provider=kms the key never enters this process: only the 32-byte transaction hash crosses the wire, and every returned signature is verified against the reference's known public key, so a gateway signing with the wrong key fails immediately rather than as an opaque txBAD_AUTH that has already cost a sequence number. Accounts are registered by reference, never by key material — and since a Stellar seed is 56 characters of letters and digits, exactly the shape of a plausible alias, the DTOs carry an explicit pattern rejecting seed-shaped input rather than assuming it won't arrive.

Sequence numbers. Stellar validates a sequence number as exactly source + 1, so handing out n and n+1 on one account parallelises nothing — the network takes one and rejects the other. Concurrency has to come from more accounts, which is what the pool is; SKIP LOCKED makes claiming "any free account" contention-free. The subtle half is release: a number allocated to a transaction that never landed is skipped, and every later transaction from that account would then fail. So confirmation and on-chain failure both release to AVAILABLE (both spend the number), while anything that never landed releases to NEEDS_RESYNC and the next lease re-reads the chain first.

Termination. Every path is bounded: fee bumps stop at the ceiling (terminal FEE_CEILING_REACHED), a transaction past its time bounds is rebuilt rather than polled forever, rebuilds and retries are bounded by max-attempts into DEAD_LETTER, and a crashed holder's lease is swept — but conservatively, extending rather than reclaiming any lease whose submission is still in flight, since reusing its sequence number would race a transaction that may be in the mempool right now.

Reversal worth flagging

This adds network.lightsail:stellar-sdk:3.1.0, reversing decision 1 of ESCROW_ORCHESTRATION.md (#21), which found no Java Stellar SDK on Maven Central and therefore relayed pre-signed envelopes as opaque strings. That was right about the official SDK — SDF archived java-stellar-sdk — but this is its maintained successor. Setting a sequence number, fee-bumping, and telling txBAD_SEQ from txINSUFFICIENT_FEE all require real XDR, and hand-rolling that encoding is precisely what decision 1 refused to do. SorobanRpcClient still treats envelopes and results as opaque everywhere except getAccountSequence, where a ledger entry can only be addressed and read as XDR. Rationale in full: New dependencies.

Notes for reviewers

  • No migration file: this repo has no Flyway/Liquibase and manages schema via ddl-auto=update with entities as the source of truth, same as every existing table. Both new tables are new, so first deploy creates them and every deploy after is a no-op.
  • No new error contract — the new exceptions are wired into the existing GlobalExceptionHandler and render as application/problem+json. SigningProviderException deliberately answers with a fixed detail string so nothing from the signing path can reach a client body.
  • The existing /api/v1/escrow/orchestrations endpoints are untouched. Migrating them onto this service would change a public contract and belongs in its own PR.

Overall recommendation

  • Request changes — this implements an important, high-risk feature (server-side custody & signing). I like the design, tests, and documentation, but please address the security, correctness and observability items below before merge.

Critical, diff-anchored review (must be fixed before merge)

  • signing/custody/SigningProvider.java — ensure the interface and implementations do not accidentally expose any method that could return secret key material; please confirm and add a unit test that reflection or accidental addition of a secret-returning method fails the build (the PR mentions a LocalSigningProviderTest but ensure it covers KMS-backed provider class shape as well).
  • signing/custody/LocalSigningProviderTest.java — add assertion that LocalSigningProvider never logs the seed or secret in any log output during tests (use a logging appender to capture log output).
  • signing/service/ChannelAccountLeaseService.java — in the code path that releases a lease when the submission never reaches the network (NEEDS_RESYNC), add a comment and a test that proves the subsequent lease first resyncs the sequence from the chain before handing out numbers; include a test simulating a crashed holder so we can see the "sweep" behavior.
  • signing/service/TransactionSubmissionService.java — the durable persistence of the envelope is critical to avoid double-submit; please confirm the commit that saves the envelope occurs inside a transaction that is committed before any network broadcast, and add an inline test or assertion (and a short comment pointing to the DB transaction boundary).
  • signing/repository/* (JPA entities) — ensure fields that might contain any secret or signature bytes are marked and never serialized to logs or REST responses; consider @JsonIgnore and toString() exclusions on sensitive entities.
  • backend-api/docs/STELLAR_SIGNING.md — add a short operator runbook section that documents how to recover from: (a) a stuck lease sweep, (b) a fee-ceiling reached error, and (c) when KMS connectivity is lost. Link relevant config keys and what to change to mitigate (e.g., reduce concurrency, pause submission).
  • signing/service/FailureClassifier.java — add explicit unit tests for the four highlighted failure classes (tx_bad_seq, tx_insufficient_fee, tx_too_late, simulation failure) and for the mapping to the retry vs terminal decisions; ensure tx_bad_seq handling cannot create silent double-submits.
  • application.properties / application.yml (config defaults) — ensure defaults for stellar.signing.fee.max-total-stroops and max-attempts are conservative and documented in docs; add validation at startup that max-total-stroops is non-negative and not less than single-transaction-minimum fee.
  • signing/SigningController (or equivalent controller) — make sure API responses never contain sequences, private material, or PKCS blobs; confirm that error payloads conform to RFC 7807 and do not leak internal state.

Other suggested improvements (actionable, diff-anchored)

  • signing/custody/KmsSigningProvider.java — add (or document) a test that verifies signatures returned by the KMS provider are validated locally against the known public key before being accepted. If not present, add signature verification step immediately after sign() returns.
  • signing/service/TransactionSubmissionService.java:pollBroadcast — add metric counters (Micrometer) for attempts, fee-bumps, terminal statuses (SUCCESS, DEAD_LETTER, FEE_CEILING_REACHED, NEEDS_RESYNC) and expose them via prometheus; add a unit/integration test that exercises the metric increments.
  • signing/service/ChannelAccountLeaseService.java — add detailed logging at DEBUG when a lease is claimed/released including lease id and current state transitions (but ensure no secrets are logged). Add a small integration test that asserts leases are SKIP-LOCKED under concurrent claims.
  • signing/model/stellar_transaction_submissions entity — add an indexed timestamp or status column for efficient polling queries; if not indexed, add migration / index to speed restart scans.
  • backend-api/docs/STELLAR_SIGNING.md — include a sequence diagram that shows: client calls service → service persists envelope → service requests signature from provider → broadcast → poll → terminal update. A diagram helps reviewers and operators reason about the durability boundary.
  1. Minor / stylistic suggestions (non-blocking)
  • Use more specific log levels for expected failure paths (e.g., tx_bad_seq at DEBUG/info vs unexpected exceptions at WARN/ERROR).
  • In tests where network submission is simulated, prefer deterministic mocking over sleeps; ensure the concurrency test has a deterministic assertion about sequences used.
  • Consider adding a feature flag or an "enable-signing" toggle so operators can safely roll back in production if needed.
  1. Security checklist (confirm these before merge)
  • No secret seeds or private material are persisted or written to logs.
  • Public keys only ever identify accounts by reference and not by seed-like strings — tests mention rejecting seed-shaped DTOs; include fuzz tests for DTO validation.
  • KMS provider only accepts the 32-byte transaction hash (per design) — add a unit test ensuring that a larger payload is rejected.
  • TLS + auth between service and KMS is enforced in default profiles and documented in README.

Final words

  • This is a well-thought-out, thorough implementation addressing many tricky edge cases. The major concerns are operational/observability and ensuring absolutely no secret exposure and correct durable commit ordering. If you address the critical items and add or confirm the suggested tests + metrics + runbook, I will be comfortable approving and merging.

…rics, runbook

Review follow-ups on workman-labs#48.

Security
- Providers refuse to sign anything that is not a 32-byte transaction hash.
  An Ed25519 key that signs whatever it is handed is a signing oracle; the
  KMS provider checks before resolving the key, so an oversized payload never
  produces a request either.
- KmsSigningProvider refuses to start against a plaintext or unauthenticated
  gateway (loopback excepted). In cleartext the bearer credential is readable
  and a response is modifiable — a substituted response is a substituted
  signature.
- SigningProviderShapeTest pins the interface's method set and checks both
  implementations for public members that return key material or are merely
  named as though they might, for public instance fields, and for a missing
  toString() override.
- Log-capture tests drive the whole custody lifecycle, including the failure
  paths, with a capturing appender on the root logger at TRACE, and assert the
  seed and API key appear nowhere.
- Envelope/result columns are @JsonIgnore'd and both entities have
  hand-written toString()s rendering identifiers only.
- 1,000 generated seeds against the DTO seed guard, plus 1,000 randomised
  legitimate aliases against the mirror-image risk of a guard too broad to use.

Correctness
- XDR columns are `text` rather than @lob. Hibernate maps @lob String onto a
  PostgreSQL oid; large objects are not removed when their row is deleted, so
  this table would leak one per submission, unbounded and unreclaimable by
  VACUUM — and reading one outside a transaction fails at runtime.
- Retry-vs-terminal decisions move into FailureClassifier#recoveryFor, an
  exhaustive switch with no default arm, so a failure reason added later is a
  compile error rather than a silent inheritance of "retry it".
- SigningProperties validates at startup. A fee ceiling below the base fee, or
  a bump multiplier of 1.0, now fails the boot rather than every transaction.

Operability
- stellar.signing.enabled pauses the workers without a deploy; submissions
  still queue and resume from their durable row state.
- Micrometer counters for submissions, phase attempts, fee bumps, terminal
  states and lease events, at /actuator/prometheus behind authentication. Tag
  values are a closed set, asserted by test.
- NO_CHANNEL_ACCOUNT drops to DEBUG (it is backpressure, not a fault); lease
  claims and releases log their status transition and sequence.

Docs and tests
- Mermaid sequence diagram of the pipeline marking the durability boundary,
  plus two tests pinning that the envelope is committed before anything is
  broadcast and that a crashed process asks before it resends.
- Operator runbook: stuck leases, FEE_CEILING_REACHED, KMS outage, and a
  table of levers.
- Lease recovery covered end to end: crash, sweep, resync, next allocation —
  and the negative, that a consumed release costs no round trip.

347 tests, 0 failures.
@kris-nana

Copy link
Copy Markdown
Contributor Author

Thanks — this was a genuinely useful review. All of it is addressed in b5f4158. The suite is now 347 tests, 0 failures (was 285); ./mvnw clean verify is green locally and CI is running on the push.

Two of your items turned up real defects rather than just missing tests, so I'll lead with those.


Two things this review actually caught

1. The XDR columns were leaking PostgreSQL large objects. Your entity item made me look at how @Lob String actually lands, and it maps onto a PostgreSQL oid — a pointer into pg_largeobject. Large objects are not removed when the row referencing them is deleted, so a signing service submitting transactions continuously would leak one per submission, unbounded and unreclaimable by VACUUM. They also can only be read inside an open transaction, which is a latent runtime failure on any read outside one. Both XDR columns are now columnDefinition = "text" (unbounded in PostgreSQL, neither problem). The table is new in this PR so there's nothing to migrate.

EscrowOrchestrationRequest.signedTransactionXdr from #21 has the same latent leak, but its column is already deployed as oid and ddl-auto=update can't change a column's type — it needs a hand-written ALTER TABLE … USING convert_from(lo_get(…), 'UTF8') plus a lo_unlink sweep. Flagged under Follow-ups rather than folded in here, since it touches a table this feature doesn't own. Happy to do it as a small separate PR if you'd like it soon.

2. Nothing constrained what a provider would sign. Your "KMS provider only accepts the 32-byte hash" item was framed as a test, but the check didn't exist — an Ed25519 key that signs whatever it is handed is a signing oracle. Both providers now enforce it, and the KMS one checks before resolving the key so an oversized payload never produces a request. It's an IllegalArgumentException, not a SigningProviderException, deliberately: a wrong-sized message is a bug in this codebase, and laundering it into a retryable failure would hide it behind five identical retries.


Critical items

SigningProvider shape, incl. KMS class shape — new SigningProviderShapeTest. Pins the interface's four methods, and checks both implementations for any public member returning key material or merely named as though it might (secret, seed, private, keypair), for public instance fields (a Lombok @Getter on a provider would generate one), and for a missing toString() override. The interface was only ever half the surface — nothing stopped a caller holding a concrete LocalSigningProvider.

Log-appender assertion that the seed is never logged — done, and broader than asked. The capture is on the root logger at TRACE, not the provider's own logger: a seed echoed by a library or a stack trace is still a seed, and production log levels are a deployment setting, not a security control. Covers load → resolve → sign → render, plus the failure path, where an exception message is the likeliest leak. Same for the KMS API key and gateway error bodies.

NEEDS_RESYNC comment + test, and a crashed-holder test — the comment now says the status is the mechanism rather than a diagnostic label, and points at the test. Three tests:

  • aCrashedHolderIsSweptAndTheNextLeaseReReadsTheChainBeforeAllocating — the full chain: crash → expiry → sweep → the next lease re-reading the chain. The chain's sequence is moved between the two leases on purpose; without that, a skipped resync would hand out a plausible number and the test would pass on an off-by-one nobody notices until every transaction from that account comes back txBAD_SEQ.
  • anUnconsumedReleaseMakesTheNextLeaseReReadRatherThanTrustTheCounter — with the chain moved backwards, so the counter has to follow the network down as well as up.
  • aConsumedReleaseCostsNoNetworkRoundTrip — the negative. A landed transaction must not cost a resync.

Durable commit before broadcast — confirm, comment, test — confirmed, and it holds structurally rather than by convention: phase 1 (preparePending) and phase 2 (broadcastSigned) are separate @Transactional scheduled methods, and the only call to sendTransaction is in phase 2, which only ever reads rows already SIGNED. There's now a marked comment at the boundary and theEnvelopeIsCommittedBeforeAnythingIsEverBroadcast, which asserts it three ways: the committed row carries the envelope and hash, the RPC client is never touched during phase 1, and phase 2's first call against that hash is getTransaction (via Mockito InOrder). Plus aSignedRowLeftBehindByACrashedProcessIsAskedAboutBeforeItIsResent from recovery's side. The doc now has a mermaid sequence diagram with the boundary marked — that was your diagram request too, and it turned out to be the right place for it.

@JsonIgnore / toString() on sensitive entities — done. signed_envelope_xdr, unsigned_transaction_xdr and result_xdr are @JsonIgnored; both entities have hand-written toString()s rendering identifiers only. Written by hand rather than @ToString(exclude=…) on purpose: a generated one would pick up a field added later, silently. SubmissionEntityExposureTest covers serialization, toString, and — by reflection — that the public response record has no component that could carry an envelope.

Operator runbook — new section covering exactly your three: (a) a lease that won't clear, (b) FEE_CEILING_REACHED, (c) KMS connectivity lost. Each has the symptom, what's actually happening, numbered steps, and what not to do (e.g. never clear a lease by hand while its submission is non-terminal — the next lease reuses the sequence number and races a transaction that may be in the mempool). Plus a levers table. Your "pause submission" suggestion became a real feature, below.

FailureClassifier tests + tx_bad_seq can't double-submit — the retry-vs-terminal decision was buried in a switch inside the submission service, so it could only be tested through the pipeline. It's now FailureClassifier#recoveryFor returning a RecoveryAction, an exhaustive switch with no default arm — 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. Tests: your four classes one test each (tx_bad_seq asserted as REBUILD and isNotEqualTo(RETRY)), an exhaustive 14-row table, and a guard that the table covers the enum.

On double-submits specifically: REBUILD isn't "try again" — it discards the envelope and releases the account unconsumed, so the next attempt re-reads the chain and signs a new transaction on a new number. A plain retry would resend the identical envelope against the number the network just rejected.

Conservative, documented, validated fee defaultsSigningProperties now validates at startup. Your instinct was right and the failure is worse than "unconservative": a ceiling below the base fee makes every transaction fail FEE_CEILING_REACHED before it is even signed — a total outage that looks like a Stellar problem, not a config typo. Refused at boot, along with a base fee under the network minimum, a bump multiplier ≤ 1.0 (fee bumps that can't outbid anything), max-attempts < 1, max-delay < base-delay, jitter outside [0,1), and non-positive durations. Defaults unchanged (ceiling 0.1 XLM, 5 attempts) and now documented as validated.

Controller responses / RFC 7807 — confirmed, already covered: SigningApiTest asserts the response body excludes signedEnvelopeXdr/unsignedTransactionXdr, and every error path renders application/problem+json through the existing GlobalExceptionHandler (no new contract). SigningProviderException answers with a fixed detail string so nothing from the signing path can reach a client body. One small note on your wording: sequence numbers are returned — they're public chain data, visible to anyone with the account id, and callers need them to correlate. Say the word if you'd rather they weren't.


Other suggested improvements

KMS signature verified locally — already there and now called out: sign() verifies every returned signature against the reference's known public key before returning it, and TransactionSigner verifies again before attaching. Two tests cover it (a gateway signing with the wrong key, and one lying about which key it used). This is the difference between failing here, loudly, and finding out seconds later as an opaque txBAD_AUTH that has already cost a sequence number.

Micrometer + Prometheus — added: spring-boot-starter-actuator and micrometer-registry-prometheus, with counters for submissions (created/replayed), phase attempts, fee bumps, terminal states (by status and reason) and lease events (incl. RECLAIMED, which above zero means processes are dying mid-submission). Two deliberate choices worth your review, since this is app-wide:

  • Endpoints are authenticated. Only health, info, prometheus are exposed and none is in PUBLIC_ENDPOINTS, so a scraper needs a bearer token or a network policy. The tags name failure reasons and pipeline phases — more than an anonymous reader should get. Easy to open up if your scrape path can't authenticate.
  • Tag values are a closed set (enum names, fixed phase vocabulary), so cardinality is bounded by the code rather than by traffic. A test asserts no caller-supplied value (idempotency key, reference) becomes a tag.

Tests assert the increments. Pool utilisation and cumulative fee spend want Gauges over live state rather than counters — noted as a follow-up.

Lease DEBUG logging + SKIP LOCKED concurrency test — claims and releases now log the account, its status transition and its sequence, which is what you want when reconstructing a txBAD_SEQ after the fact. The SKIP LOCKED concurrency test is the acceptance criterion and was already there: 8 threads released simultaneously by a CyclicBarrier, against real PostgreSQL, asserting 8 distinct accounts and 8 distinct sequence numbers.

Index for polling queries — already present: idx_stellar_submission_status on (status, next_attempt_at), matching the worker claim query exactly, plus indexes on transaction_hash and reference.

Sequence diagram — added (mermaid), covering client → persist → sign → broadcast → poll → terminal, with the durability boundary highlighted and phase transaction scopes marked.


Minor / stylistic

Log levelsNO_CHANNEL_ACCOUNT drops to DEBUG: under load it's the pool doing its job, and at INFO it drowns out the phase. Other retryable reasons stay INFO (something outside us broke); terminal failures stay WARN. Following your tx_bad_seq example: rebuilds were already INFO, which I think is right — it's an expected recovery, but one an operator wants to see the rate of.

Deterministic mocking, no sleeps — already the case: the workers are driven by hand, timestamps are backdated rather than waited on, and the schedulers are pinned to 1-hour delays in surefire. The concurrency test's assertion is fully deterministic (distinct accounts, distinct numbers, each equal to that account's on-chain sequence + 1).

Feature flag / rollback toggle — added as stellar.signing.enabled, and it's now the first thing the runbook reaches for. Signing, broadcast and polling stop; the API keeps accepting submissions and they queue as PENDING. Safe at any point precisely because every phase transition is already durable — pausing changes no state, resuming picks up where it stopped. Checked per tick rather than conditioning the beans, so a config refresh flips it without a restart. In-flight transactions keep their channel accounts (the sweeper extends rather than reclaims a live lease), so nothing is handed a sequence number that's already in the mempool. Covered by pausingStopsTheWorkersWithoutLosingOrDuplicatingWork.


Security checklist

  • No secrets persisted or logged — confirmed, now by root-logger capture at TRACE across the full lifecycle including failure paths, plus nothingPersistedLooksLikeKeyMaterial scanning every persisted column.
  • Accounts identified by reference; fuzz tests for DTO validation — added. 1,000 freshly generated seeds against the 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. Plus a hostile-input set (NUL, emoji, path traversal, SQL, format specifiers, zero-width space, 10 KB) asserting validation answers rather than throws — a RuntimeException escaping a validator turns a 400 into a 500.
  • KMS accepts only the 32-byte hash — now enforced, not just documented. See above.
  • TLS + auth to KMS enforced and documented — enforced at startup, refusing a non-https gateway (loopback excepted, for tests and sidecar-terminated proxies) or a blank API key. Documented in the README, .env.example and the design doc.

One thing worth flagging for your judgement: actuator is an app-wide dependency, not scoped to this feature. I've kept the surface minimal and authenticated, but if you'd rather metrics landed in their own PR I'm happy to pull them back out — say the word and I'll split it.

@meshackyaro meshackyaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent work on this signing service implementation

This is a textbook example of production-grade backend infrastructure. Every piece — security, durability, operability — is thoughtfully designed and rigorously tested.

What stands out:

  • Security by construction: The signing providers refuse arbitrary payloads; KMS startup checks enforce HTTPS and authentication; log-capture tests prove secrets never escape. You've eliminated whole categories of mistakes rather than just hoping they won't happen.

  • Durability principles: The three-phase pipeline with a hard durability boundary (envelope committed before broadcast) is the right answer for "what if we crash mid-submission." The tests prove it: a process that dies is asked about the hash before resending, not the other way around. That's the difference between idempotent recovery and double-submission.

  • Failure taxonomy: RecoveryAction with an exhaustive switch and no default arm is brilliant. A new failure reason added later is a compile error, not a silent "retry it" that could wreck a transaction already on the network. This is the kind of invariant that saves operators at 2 AM.

  • Operational readiness: The pause lever, startup validation, metrics with bounded cardinality, and the operator runbook show you've thought through how this gets deployed and debugged. Micrometer counters on the right signals (fee bumps, lease reclaims, terminal failures by reason) mean operators see problems in near real-time, not after the fact.

  • Test discipline: 347 tests, including the hard ones — concurrency against real PostgreSQL, restart safety with durable row state, log capture at TRACE to prove secrets don't leak by any route, 1,000 generated seeds fuzzed against the validation guard. The test suite is a specification for the safety guarantees.

The follow-up commit taking feedback on security, metrics, and runbook documentation shows the maturity of your iteration. This is ready.

Great job. Approved.

@meshackyaro
meshackyaro merged commit 87726bc into workman-labs:development Aug 22, 2026
1 check passed
daniella-techie pushed a commit to daniella-techie/guildworkman-core that referenced this pull request Aug 22, 2026
…nced with development for workman-labs#48) into local CI fix

# Conflicts:
#	backend-api/pom.xml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stellar Key Custody & Transaction Signing Service with KMS Abstraction, Sequence Management & Fee-Bump Retries

2 participants