Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .env.sandbox.template
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
Comment on lines +31 to +32

Copy link
Copy Markdown

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_ID should precede PERSONA_SANDBOX_API_KEY for 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
Verify each finding against the current code and only fix it if needed.

In @.env.sandbox.template around lines 31 - 32, Reorder the two related
environment variables so they are alphabetically sorted: place
PERSONA_INQUIRY_TEMPLATE_ID before PERSONA_SANDBOX_API_KEY in the .env template;
this satisfies dotenv-linter and keeps related keys consistent.


# --- 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Thumbs.db

# Env / secrets
.env
.env.sandbox
*.env.local
credentials.json

Expand Down
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:}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Empty default for JWT private key may cause unclear startup failures.

${JWT_PRIVATE_KEY_BASE64:} defaults to empty string. If the developer forgets to set this env var, token signing will fail at runtime with a potentially cryptic error. Consider defaulting to a well-known development-only key (clearly documented as insecure) or fail fast with a validation check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api-gateway-iam/api-gateway-iam/src/main/resources/application-sandbox.yml`
at line 12, The YAML default for JWT_PRIVATE_KEY_BASE64 is empty
(private-key-base64: ${JWT_PRIVATE_KEY_BASE64:}), which can lead to cryptic
runtime failures; either provide a documented development-only default value for
JWT_PRIVATE_KEY_BASE64 (clearly marked insecure) or add a fail-fast validation
in your JWT config loader (e.g., JwtProperties / JwtConfig /
loadJwtKey()/getPrivateKeyBase64()) that checks the resolved private-key-base64
and throws a clear exception on startup if it is missing or invalid. Ensure the
validation message references JWT_PRIVATE_KEY_BASE64/private-key-base64 so
developers know which env var to set.

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
Expand Up @@ -41,7 +41,7 @@ app:
read-timeout-ms: 30000
solana:
enabled: true
rpc-url: https://api.devnet.solana.com
rpc-url: https://solana-devnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}
usdc-mint-address: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"
commitment: confirmed
connect-timeout-ms: 10000
Expand All @@ -62,7 +62,7 @@ app:
solana:
min-confirmations: 1
avg-finality-s: 5
rpc-url: https://api.devnet.solana.com
rpc-url: https://solana-devnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}
token-contracts:
USDC: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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>
Expand Down Expand Up @@ -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;
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Exception assertions are likely incorrect because adapter fallbacks wrap 4xx into IllegalStateException.

These assertions expect raw HttpClientErrorException to escape, but the adapter’s resilience fallbacks appear to catch and rethrow as IllegalStateException, so both changed tests can fail for the wrong reason.

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
Verify each finding against the current code and only fix it if needed.

In
`@blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/fireblocks/FireblocksCustodyAdapterSandboxTest.java`
around lines 90 - 91, The test asserts that
adapter.getTransactionStatus(fabricatedTxId) throws
HttpClientErrorException.NotFound but the FireblocksCustodyAdapter fallbacks
wrap 4xx errors into IllegalStateException; update the failing tests to either
expect IllegalStateException (e.g.,
assertThatThrownBy(...).isInstanceOf(IllegalStateException.class)) or change the
adapter behavior in FireblocksCustodyAdapter (inspect
getTransactionStatusFallback / signAndSubmitFallback) to unwrap and rethrow the
original HttpClientErrorException if you want the raw 4xx to propagate; pick one
approach and make parallel changes for the other tests mentioned (lines 116-121)
so assertions match the actual fallback behavior.

}
}

Expand All @@ -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
Expand Up @@ -14,20 +14,19 @@
import static org.assertj.core.api.Assertions.assertThat;

/**
* Sandbox integration tests that hit the real Solana Devnet public RPC endpoint.
* Sandbox integration tests that hit the Solana Devnet via Alchemy RPC.
*
* <p>Guarded by {@code SOLANA_SANDBOX_ENABLED=true} env var to avoid
* hitting public Devnet endpoints during every CI build. Solana Devnet
* has rate limits and can be unstable.
* <p>Guarded by {@code ALCHEMY_API_KEY} env var — uses the same Alchemy key
* as the EVM sandbox tests.
*
* <p>Run manually:
* <pre>
* SOLANA_SANDBOX_ENABLED=true ./gradlew :blockchain-custody:blockchain-custody:test --tests '*SolanaRpcAdapterSandboxTest*'
* ALCHEMY_API_KEY=xxx ./gradlew :blockchain-custody:blockchain-custody:test --tests '*SolanaRpcAdapterSandboxTest*'
* </pre>
*/
@Tag("sandbox")
@EnabledIfEnvironmentVariable(named = "SOLANA_SANDBOX_ENABLED", matches = "true")
@DisplayName("SolanaRpcAdapter — Devnet Sandbox")
@EnabledIfEnvironmentVariable(named = "ALCHEMY_API_KEY", matches = ".+")
@DisplayName("SolanaRpcAdapter — Alchemy Devnet Sandbox")
class SolanaRpcAdapterSandboxTest {

private static final String SOLANA_DEVNET_USDC_MINT = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
Expand All @@ -37,9 +36,10 @@ class SolanaRpcAdapterSandboxTest {

@BeforeAll
static void setUp() {
var alchemyKey = System.getenv("ALCHEMY_API_KEY");
var properties = new SolanaChainProperties(
true,
"https://api.devnet.solana.com",
"https://solana-devnet.g.alchemy.com/v2/" + alchemyKey,
SOLANA_DEVNET_USDC_MINT,
"confirmed",
10000,
Expand All @@ -55,10 +55,8 @@ class SolanaDevnetSlot {
@Test
@DisplayName("should return positive slot number from Solana Devnet")
void shouldReturnPositiveSlotNumber() {
// when
var slot = adapter.getLatestBlockNumber(SOLANA_CHAIN);

// then — Solana Devnet has been running for years, slot numbers are in the billions
assertThat(slot).isGreaterThan(0L);
}
}
Expand All @@ -70,13 +68,10 @@ class SolanaDevnetTokenBalance {
@Test
@DisplayName("should return non-negative USDC balance for any address")
void shouldReturnNonNegativeBalance() {
// given — a random address unlikely to have USDC token accounts
var randomAddress = "BPFLoaderUpgradeab1e11111111111111111111111";

// when
var balance = adapter.getTokenBalance(SOLANA_CHAIN, randomAddress, SOLANA_DEVNET_USDC_MINT);

// then — balance should be non-negative (likely zero for this address)
assertThat(balance).isGreaterThanOrEqualTo(BigDecimal.ZERO);
}
}
Expand All @@ -88,14 +83,10 @@ class SolanaDevnetTransactionReceipt {
@Test
@DisplayName("should return null for non-existent transaction signature")
void shouldReturnNullForNonExistentTransaction() {
// given — a valid base58-encoded signature that does not exist on-chain
// Solana signatures are 64 bytes = 88 base58 characters
var fakeSignature = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQU";

// when
TransactionReceipt receipt = adapter.getTransactionReceipt(SOLANA_CHAIN, fakeSignature);

// then — receipt should be null since the transaction does not exist
assertThat(receipt).isNull();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 SEPA_CREDIT_TRANSFER for SEPA and uses SCAN as the destination type for Faster Payments. The updated docs still use SEPA_CREDIT/FPS examples, which no longer match what this DTO represents on the wire.

Also applies to: 29-37

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPaymentRequest.java`
around lines 13 - 18, Update the Javadoc on ModulrPaymentRequest and its
parameter docs to match the current wire contract: replace the SEPA example
token SEPA_CREDIT with SEPA_CREDIT_TRANSFER and replace the FPS destination
example with SCAN for Faster Payments; also review and update the related param
descriptions for currency, reference, externalReference and destination (lines
covering the parameter block, e.g., the Javadoc for ModulrPaymentRequest) so the
documented enums/strings and destination type examples exactly match the
adapter’s emitted values.

record ModulrPaymentRequest(
String sourceAccountId,
Expand All @@ -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);
}
}
}
Loading
Loading