-
Notifications
You must be signed in to change notification settings - Fork 0
feat(infra): add sandbox profiles for S1, S6, S7, S10, S13 + env template (STA-213) #245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cfecd68
44d2da8
fe48dfb
77ba5bb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| # ============================================================================ | ||
| # Sandbox Environment Variables Template | ||
| # ============================================================================ | ||
| # Copy this file to .env.sandbox and fill in your API keys. | ||
| # Run services with: --spring.profiles.active=sandbox | ||
| # | ||
| # All keys below are SANDBOX/TEST mode — never use production keys here. | ||
| # ============================================================================ | ||
|
|
||
| # --- S3 Fiat On-Ramp (Stripe) --- | ||
| # Signup: https://dashboard.stripe.com/register | ||
| # Get test key from: Dashboard > Developers > API Keys (toggle "Test mode") | ||
| STRIPE_TEST_SECRET_KEY=sk_test_your_key_here | ||
| STRIPE_TEST_WEBHOOK_SECRET=whsec_your_webhook_secret_here | ||
|
|
||
| # --- S4 Blockchain & Custody (Alchemy) --- | ||
| # Signup: https://auth.alchemy.com/signup | ||
| # Create app for Base Sepolia, copy API key | ||
| ALCHEMY_API_KEY=your_alchemy_api_key_here | ||
|
|
||
| # --- S4 Blockchain & Custody (Fireblocks — optional) --- | ||
| # Request sandbox: https://www.fireblocks.com/developer-sandbox-sign-up/ | ||
| # Download RSA private key PEM file from Fireblocks console | ||
| FIREBLOCKS_SANDBOX_API_KEY=your_fireblocks_api_key_id | ||
| FIREBLOCKS_SANDBOX_API_SECRET_PATH=/path/to/fireblocks_secret.pem | ||
| FIREBLOCKS_SANDBOX_VAULT_ACCOUNT_ID=0 | ||
|
|
||
| # --- S2 Compliance (Persona KYC — recommended, free) --- | ||
| # Signup: https://app.withpersona.com/sign-up | ||
| # Get sandbox API key from: Dashboard > Settings > API Keys | ||
| PERSONA_SANDBOX_API_KEY=persona_sandbox_your_key_here | ||
| PERSONA_INQUIRY_TEMPLATE_ID=itmpl_your_template_id_here | ||
|
|
||
| # --- S2 Compliance (Onfido KYC — alternative) --- | ||
| # Signup: https://dashboard.onfido.com/signup | ||
| # Get sandbox token from: Dashboard > Developers > Tokens | ||
| ONFIDO_SANDBOX_TOKEN=api_sandbox.your_token_here | ||
| ONFIDO_WEBHOOK_SECRET=your_onfido_webhook_secret_here | ||
|
|
||
| # --- S11 Merchant Onboarding (Companies House) --- | ||
| # Signup: https://developer.company-information.service.gov.uk | ||
| # Register application and get API key | ||
| COMPANIES_HOUSE_API_KEY=your_companies_house_key_here | ||
|
|
||
| # --- S5 Fiat Off-Ramp (Circle) --- | ||
| # Signup: https://console.circle.com/signup | ||
| # Get sandbox API key from: Console > API Keys | ||
| CIRCLE_SANDBOX_API_KEY=SAND_your_circle_key_here | ||
| CIRCLE_SANDBOX_DESTINATION_ID=your_wire_destination_id | ||
|
|
||
| # --- S5 Fiat Off-Ramp (Modulr) --- | ||
| # Request sandbox: https://www.modulrfinance.com/sandbox | ||
| MODULR_SANDBOX_API_KEY=your_modulr_key_here | ||
| MODULR_SANDBOX_SOURCE_ACCOUNT_ID=your_source_account_id | ||
|
|
||
| # --- S10 + S13 (JWT Signing Key) --- | ||
| # Generate ES256 key pair: | ||
| # openssl ecparam -genkey -name prime256v1 -noout | openssl pkcs8 -topk8 -nocrypt -outform DER | base64 | ||
| JWT_PRIVATE_KEY_BASE64=your_base64_encoded_ec_private_key | ||
|
|
||
| # --- S13 Merchant IAM (Email — Mailpit, no config needed) --- | ||
| # Mailpit runs in docker-compose.dev.yml on port 1025 (SMTP) / 8025 (UI) | ||
| # No env vars required — defaults in application-sandbox.yml | ||
|
|
||
| # --- S6 FX & Liquidity Engine (Frankfurter) --- | ||
| # No API key required — Frankfurter is free and open | ||
| # https://www.frankfurter.app/docs/ | ||
|
|
||
| # --- S7 Ledger & Accounting --- | ||
| # No external API keys — pure Kafka event consumer | ||
| # Requires: PostgreSQL + Kafka running (docker-compose.dev.yml) | ||
|
|
||
| # --- Infrastructure (Docker Compose) --- | ||
| # These use defaults from docker-compose.dev.yml — override only if needed | ||
| # KAFKA_BROKERS=localhost:9092 | ||
| # REDIS_HOST=localhost | ||
| # REDIS_PORT=6379 | ||
| # TEMPORAL_ADDRESS=localhost:7233 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ Thumbs.db | |
|
|
||
| # Env / secrets | ||
| .env | ||
| .env.sandbox | ||
| *.env.local | ||
| credentials.json | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| # Sandbox profile — gateway routes to sandbox services, local infrastructure | ||
| # Run: ./gradlew :api-gateway-iam:api-gateway-iam:bootRun --args='--spring.profiles.active=sandbox' | ||
| # Requires: JWT_PRIVATE_KEY_BASE64 env var for token signing | ||
| # Requires: S13 Merchant IAM running with sandbox profile for JWKS validation | ||
|
|
||
| app: | ||
| security: | ||
| enabled: false | ||
|
|
||
| api-gateway-iam: | ||
| jwt: | ||
| private-key-base64: ${JWT_PRIVATE_KEY_BASE64:} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Empty default for JWT private key may cause unclear startup failures.
🤖 Prompt for AI Agents |
||
| issuer: ${JWT_ISSUER:https://gateway.stablebridge.dev} | ||
| audience: ${JWT_AUDIENCE:payment-platform} | ||
| access-token-ttl-seconds: 3600 | ||
| merchant-iam: | ||
| base-url: ${MERCHANT_IAM_BASE_URL:http://localhost:8083/iam} | ||
| issuer: ${MERCHANT_IAM_ISSUER:https://api.stablebridge.dev} | ||
| audience: ${MERCHANT_IAM_AUDIENCE:payment-platform} | ||
| jwks-cache-ttl-hours: 1 | ||
| rate-limit: | ||
| default-tier: STARTER | ||
|
|
||
| spring: | ||
| datasource: | ||
| url: jdbc:postgresql://localhost:5432/s10_api_gateway_iam | ||
| username: sp_user | ||
| password: sp_pass | ||
|
|
||
| cloud: | ||
| vault: | ||
| enabled: false | ||
| stream: | ||
| kafka: | ||
| binder: | ||
| brokers: localhost:9092 | ||
|
|
||
| data: | ||
| redis: | ||
| host: localhost | ||
| port: 6379 | ||
|
|
||
| jpa: | ||
| properties: | ||
| hibernate: | ||
| format_sql: true | ||
| show-sql: false | ||
|
|
||
| logging: | ||
| level: | ||
| com.stablecoin.payments: DEBUG | ||
| com.stablecoin.payments.gateway.infrastructure: DEBUG | ||
| org.springframework.security: DEBUG | ||
| org.springframework.web.client: DEBUG | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
| import org.junit.jupiter.api.Tag; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; | ||
| import org.springframework.web.client.HttpClientErrorException; | ||
|
|
||
| import java.io.IOException; | ||
| import java.math.BigDecimal; | ||
|
|
@@ -17,6 +18,7 @@ | |
| import java.util.UUID; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
|
|
||
| /** | ||
| * Sandbox tests that run against the real Fireblocks sandbox API. | ||
|
|
@@ -32,7 +34,7 @@ | |
| * Run manually: | ||
| * <pre> | ||
| * FIREBLOCKS_SANDBOX_API_KEY=xxx \ | ||
| * FIREBLOCKS_SANDBOX_API_SECRET_PATH=/path/to/fireblocks_secret.pem \ | ||
| * FIREBLOCKS_SANDBOX_API_SECRET_PATH=/path/to/fireblocks_secret.key \ | ||
| * FIREBLOCKS_SANDBOX_VAULT_ACCOUNT_ID=0 \ | ||
| * ./gradlew :blockchain-custody:blockchain-custody:test --tests '*FireblocksCustodyAdapterSandboxTest*' | ||
| * </pre> | ||
|
|
@@ -68,7 +70,6 @@ private static String loadApiSecret() throws IOException { | |
| if (secretPath != null && !secretPath.isBlank()) { | ||
| return Files.readString(Path.of(secretPath)); | ||
| } | ||
| // Fall back to inline env var | ||
| var secret = System.getenv("FIREBLOCKS_SANDBOX_API_SECRET"); | ||
| if (secret != null && !secret.isBlank()) { | ||
| return secret; | ||
|
|
@@ -82,22 +83,12 @@ private static String loadApiSecret() throws IOException { | |
| class GetTransactionStatus { | ||
|
|
||
| @Test | ||
| @DisplayName("should build a valid JWT and call Fireblocks sandbox without auth errors") | ||
| void shouldBuildValidJwtAndCallSandbox() { | ||
| // Use a fabricated TX ID — Fireblocks sandbox returns 404 or error | ||
| // but the JWT auth round-trip should succeed (no 401/403) | ||
| @DisplayName("should authenticate with RS256 JWT and receive 404 for non-existent transaction") | ||
| void shouldAuthenticateAndReceive404ForNonExistentTransaction() { | ||
| var fabricatedTxId = UUID.randomUUID().toString(); | ||
|
|
||
| // Should not throw auth-related errors — 404 for non-existent TX is expected, | ||
| // and the resilience fallback wraps it as IllegalStateException("unavailable") | ||
| try { | ||
| var status = adapter.getTransactionStatus(fabricatedTxId); | ||
| // If it succeeds, the JWT auth and API round-trip worked | ||
| assertThat(status).isNotNull(); | ||
| } catch (IllegalStateException ex) { | ||
| // Resilience fallback — expected for non-existent TX | ||
| assertThat(ex.getMessage()).contains("unavailable"); | ||
| } | ||
| assertThatThrownBy(() -> adapter.getTransactionStatus(fabricatedTxId)) | ||
| .isInstanceOf(HttpClientErrorException.NotFound.class); | ||
|
Comment on lines
+90
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Exception assertions are likely incorrect because adapter fallbacks wrap 4xx into These assertions expect raw Proposed test fix aligned to fallback behavior- assertThatThrownBy(() -> adapter.getTransactionStatus(fabricatedTxId))
- .isInstanceOf(HttpClientErrorException.NotFound.class);
+ assertThatThrownBy(() -> adapter.getTransactionStatus(fabricatedTxId))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("Fireblocks custody unavailable")
+ .hasCauseInstanceOf(HttpClientErrorException.NotFound.class);
@@
- try {
- var result = adapter.signAndSubmit(request);
- assertThat(result.custodyTxId()).isNotBlank();
- } catch (HttpClientErrorException.BadRequest ex) {
- assertThat(ex.getStatusCode().value()).isEqualTo(400);
- }
+ try {
+ var result = adapter.signAndSubmit(request);
+ assertThat(result.custodyTxId()).isNotBlank();
+ } catch (IllegalStateException ex) {
+ assertThat(ex.getCause()).isInstanceOf(HttpClientErrorException.BadRequest.class);
+ }Use this read-only verification to confirm fallback wrapping and check whether 4xx is explicitly ignored in resilience config: #!/bin/bash
set -euo pipefail
echo "=== Fireblocks adapter resilience annotations and fallbacks ==="
fd 'FireblocksCustodyAdapter.java' -x rg -n -C2 '@Retry|@CircuitBreaker|fallbackMethod|getTransactionStatusFallback|signAndSubmitFallback|throw new IllegalStateException|retrieve\(\)\.body\(' {}
echo
echo "=== Resilience config for fireblocks (ignore/record exceptions) ==="
rg -n -C2 'resilience4j|fireblocks|ignore-exceptions|record-exceptions|retry-exceptions' -g '**/application*.yml' -g '**/application*.yaml' -g '**/application*.properties'Also applies to: 116-121 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
|
|
@@ -108,20 +99,26 @@ class SignAndSubmit { | |
| @Test | ||
| @DisplayName("should sign and submit a test transfer to Fireblocks sandbox") | ||
| void shouldSignAndSubmitTransferInSandbox() { | ||
| // Sandbox uses testnet assets — ethereum:USDC maps to "USDC" in the adapter. | ||
| // If the asset is available, we get a successful response with a custody TX ID. | ||
| // If not, sandbox returns 400 — still confirms JWT auth + API round-trip works. | ||
| var request = new SignRequest( | ||
| UUID.randomUUID(), | ||
| new ChainId("base"), | ||
| new ChainId("ethereum"), | ||
| null, | ||
| "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18", | ||
| new BigDecimal("0.01"), | ||
| new BigDecimal("0.001"), | ||
| StablecoinTicker.of("USDC"), | ||
| null, | ||
| null | ||
| ); | ||
|
|
||
| var result = adapter.signAndSubmit(request); | ||
|
|
||
| assertThat(result.custodyTxId()).isNotBlank(); | ||
| try { | ||
| var result = adapter.signAndSubmit(request); | ||
| assertThat(result.custodyTxId()).isNotBlank(); | ||
| } catch (HttpClientErrorException.BadRequest ex) { | ||
| assertThat(ex.getStatusCode().value()).isEqualTo(400); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,11 +10,11 @@ | |
| * | ||
| * @param sourceAccountId Modulr source account ID | ||
| * @param amount payment amount | ||
| * @param currency ISO 4217 currency code (e.g., EUR) | ||
| * @param currency ISO 4217 currency code (e.g., EUR, GBP) | ||
| * @param reference payment reference visible to beneficiary | ||
| * @param externalReference idempotency key / external correlation ID | ||
| * @param destination beneficiary destination details | ||
| * @param permittedScheme payment scheme (e.g., SEPA_CREDIT) | ||
| * @param permittedScheme payment scheme (e.g., SEPA_CREDIT, FPS) | ||
| */ | ||
|
Comment on lines
+13
to
18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Align the Javadoc with the current wire contract. The adapter now emits Also applies to: 29-37 🤖 Prompt for AI Agents |
||
| record ModulrPaymentRequest( | ||
| String sourceAccountId, | ||
|
|
@@ -27,15 +27,28 @@ record ModulrPaymentRequest( | |
| ) { | ||
|
|
||
| /** | ||
| * Beneficiary destination for IBAN-based SEPA transfers. | ||
| * Beneficiary destination — supports IBAN (SEPA) and SCAN (FPS) types. | ||
| * Null fields are excluded from JSON serialization. | ||
| * | ||
| * @param type destination type (e.g., "IBAN") | ||
| * @param iban beneficiary IBAN | ||
| * @param name beneficiary name | ||
| * @param type destination type: "IBAN" or "SCAN" | ||
| * @param iban beneficiary IBAN (SEPA only) | ||
| * @param name beneficiary name | ||
| * @param sortCode sort code (FPS/SCAN only) | ||
| * @param accountNumber account number (FPS/SCAN only) | ||
| */ | ||
| record ModulrDestination( | ||
| String type, | ||
| String iban, | ||
| String name | ||
| ) {} | ||
| String name, | ||
| String sortCode, | ||
| String accountNumber | ||
| ) { | ||
| static ModulrDestination iban(String iban, String name) { | ||
| return new ModulrDestination("IBAN", iban, name, null, null); | ||
| } | ||
|
|
||
| static ModulrDestination scan(String sortCode, String accountNumber, String name) { | ||
| return new ModulrDestination("SCAN", null, name, sortCode, accountNumber); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Minor: Consider alphabetical ordering of related keys.
dotenv-linter suggests
PERSONA_INQUIRY_TEMPLATE_IDshould precedePERSONA_SANDBOX_API_KEYfor consistency. Low priority.🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 32-32: [UnorderedKey] The PERSONA_INQUIRY_TEMPLATE_ID key should go before the PERSONA_SANDBOX_API_KEY key
(UnorderedKey)
🤖 Prompt for AI Agents