diff --git a/.gitignore b/.gitignore index 614faff..f82f2c8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ secrets.properties # Bridge API spec (obtain from Bridge dashboard, not committed) bridge-spec-official.json -TODO*.txt \ No newline at end of file +TODO* \ No newline at end of file diff --git a/example-x402/src/app/wallet/page.tsx b/example-x402/src/app/wallet/page.tsx index 69cde86..38cd45f 100644 --- a/example-x402/src/app/wallet/page.tsx +++ b/example-x402/src/app/wallet/page.tsx @@ -94,6 +94,7 @@ export default function WalletPage() { const [settleResult, setSettleResult] = useState(null); const [txHash, setTxHash] = useState(null); const [txStatus, setTxStatus] = useState(null); + const [, forceUpdate] = useState(0); // Force re-render for transaction age timer // Check for Phantom wallet on mount useEffect(() => { @@ -114,6 +115,15 @@ export default function WalletPage() { return () => clearTimeout(timeout); }, []); + // Update transaction age display every second when signed + useEffect(() => { + if (!signedAt) return; + const interval = setInterval(() => { + forceUpdate(n => n + 1); + }, 1000); + return () => clearInterval(interval); + }, [signedAt]); + // Connect wallet const connectWallet = async () => { if (!provider) { @@ -428,6 +438,24 @@ export default function WalletPage() { setLoading(null); }; + // Calculate transaction age in seconds + const getTransactionAge = (): number => { + if (!signedAt) return 0; + return Math.floor((Date.now() - signedAt) / 1000); + }; + + // Check if transaction is likely expired (Solana blockhashes expire in ~60-90 seconds) + const isTransactionExpired = (): boolean => { + return getTransactionAge() > 45; // Warn after 45 seconds, likely expired after 60-90 + }; + + // Re-sign and settle in one atomic operation + const resignAndSettle = async () => { + // First re-sign + await signPayment(); + // Then immediately settle (signPayment sets signedPayload) + }; + // Reset flow const resetFlow = () => { setStep("config"); @@ -662,10 +690,19 @@ export default function WalletPage() {

4️⃣ Verify Payment (POST /verify)

Send the signed payload to the facilitator for verification.

+ {/* Transaction age timer */} + {signedAt && ( +
30 ? "warning" : "info"}`} style={{ marginBottom: "1rem" }}> + ⏱️ Transaction age: {getTransactionAge()}s / ~60-90s max + {isTransactionExpired() && " ⚠️ EXPIRED - Go back and re-sign!"} + {!isTransactionExpired() && getTransactionAge() > 30 && " (Hurry! Getting old...)"} +
+ )} + +
+ + + {isTransactionExpired() && ( + + )} +
{settleResult && (
diff --git a/src/main/java/org/jhely/money/base/service/payments/BridgeOnboardingService.java b/src/main/java/org/jhely/money/base/service/payments/BridgeOnboardingService.java index ec8f03e..354af73 100644 --- a/src/main/java/org/jhely/money/base/service/payments/BridgeOnboardingService.java +++ b/src/main/java/org/jhely/money/base/service/payments/BridgeOnboardingService.java @@ -7,13 +7,17 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClientResponseException; import com.fasterxml.jackson.databind.ObjectMapper; import org.jhely.money.base.api.payments.BridgeApiClient; import org.jhely.money.base.domain.BridgeCustomer; import org.jhely.money.base.repository.BridgeCustomerRepository; +import org.jhely.money.base.service.BridgeApiClientFactory; import org.jhely.money.base.service.payments.BridgeAgreementService; +import org.jhely.money.sdk.bridge.model.Customer; +import org.jhely.money.sdk.bridge.model.Customers; @Service public class BridgeOnboardingService { @@ -22,13 +26,18 @@ public class BridgeOnboardingService { private final BridgeCustomerRepository repo; private final BridgeApiClient bridge; + private final BridgeApiClientFactory bridgeFactory; private final BridgeAgreementService agreements; private final ObjectMapper mapper; private final KycStatusBroadcaster kycBroadcaster; - public BridgeOnboardingService(BridgeCustomerRepository repo, BridgeApiClient bridge, ObjectMapper mapper, BridgeAgreementService agreements, KycStatusBroadcaster kycBroadcaster) { + public BridgeOnboardingService(BridgeCustomerRepository repo, BridgeApiClient bridge, + BridgeApiClientFactory bridgeFactory, + ObjectMapper mapper, BridgeAgreementService agreements, + KycStatusBroadcaster kycBroadcaster) { this.repo = repo; this.bridge = bridge; + this.bridgeFactory = bridgeFactory; this.mapper = mapper; this.agreements = agreements; this.kycBroadcaster = kycBroadcaster; @@ -281,5 +290,124 @@ public BridgeCustomer persistFetched(String userId, String email, org.jhely.mone } } + /** + * Synchronizes local BridgeCustomer data from Bridge API if needed. + * + * This is useful when the same user (by email) exists in Bridge from another app instance + * sharing the same API key. If the local record is missing or KYC is not yet approved, + * we fetch the customer from Bridge API and populate/update the local database. + * + * @param userId Local user identifier (can be null) + * @param email User's email address (required for Bridge lookup) + * @return Updated BridgeCustomer if sync occurred, or existing if already up-to-date + */ + @Transactional + public Optional syncFromBridgeIfNeeded(String userId, String email) { + if (email == null || email.isBlank()) { + log.warn("syncFromBridgeIfNeeded called with null/empty email"); + return Optional.empty(); + } + + // Check if we already have an approved customer locally + Optional existing = findForUser(userId, email); + if (existing.isPresent()) { + BridgeCustomer bc = existing.get(); + // If already approved, no need to sync + if ("approved".equalsIgnoreCase(bc.getKycStatus()) || "active".equalsIgnoreCase(bc.getStatus())) { + log.debug("Local BridgeCustomer already approved/active for email={}, skipping sync", email); + return existing; + } + } + + // Search Bridge API for this email + try { + log.info("Searching Bridge API for customer with email={}", email); + Customers customers = bridgeFactory.customers().customersGet(null, null, 10, email); + + if (customers == null || customers.getData() == null || customers.getData().isEmpty()) { + log.info("No customer found in Bridge API for email={}", email); + return existing; // Return existing (possibly empty) if nothing in Bridge + } + + // Take the first match (should be unique by email) + Customer remote = customers.getData().get(0); + log.info("Found Bridge customer id={} status={} for email={}", + remote.getId(), remote.getStatus(), email); + + // Create or update local record + BridgeCustomer bc; + if (existing.isPresent()) { + bc = existing.get(); + log.info("Updating existing local BridgeCustomer from Bridge API"); + } else { + bc = new BridgeCustomer(); + bc.setUserId(userId); + bc.setEmail(email); + bc.setCreatedAt(Instant.now()); + log.info("Creating new local BridgeCustomer from Bridge API"); + } + + // Populate from remote + bc.setBridgeCustomerId(remote.getId()); + bc.setStatus(remote.getStatus() != null ? remote.getStatus().getValue() : bc.getStatus()); + + // Map customer status to KYC status + // Bridge uses: pending, approved, rejected, active, inactive + if (remote.getStatus() != null) { + String statusValue = remote.getStatus().getValue(); + if ("active".equalsIgnoreCase(statusValue) || "approved".equalsIgnoreCase(statusValue)) { + bc.setKycStatus("approved"); + } else if ("rejected".equalsIgnoreCase(statusValue)) { + bc.setKycStatus("rejected"); + } else { + bc.setKycStatus("pending"); + } + } + + // Store rejection reasons if any + if (remote.getRejectionReasons() != null && !remote.getRejectionReasons().isEmpty()) { + try { + bc.setKycRejectionReason(mapper.writeValueAsString(remote.getRejectionReasons())); + } catch (Exception e) { + bc.setKycRejectionReason(remote.getRejectionReasons().toString()); + } + } + + // Store capabilities as details JSON + if (remote.getCapabilities() != null) { + try { + bc.setKycDetailsJson(mapper.writeValueAsString(remote.getCapabilities())); + } catch (Exception e) { + log.warn("Failed to serialize capabilities: {}", e.getMessage()); + } + } + + // Store full raw JSON + try { + bc.setRawJson(mapper.writeValueAsString(remote)); + } catch (Exception e) { + log.warn("Failed to serialize remote customer: {}", e.getMessage()); + } + + bc.setKycUpdatedAt(Instant.now()); + bc.setUpdatedAt(Instant.now()); + repo.save(bc); + + log.info("Synced BridgeCustomer from Bridge API: userId={} bridgeId={} status={} kycStatus={}", + userId, remote.getId(), bc.getStatus(), bc.getKycStatus()); + + kycBroadcaster.broadcast(bc); + return Optional.of(bc); + + } catch (RestClientResponseException e) { + log.error("Failed to fetch customer from Bridge API: {} - {}", + e.getStatusCode(), e.getResponseBodyAsString()); + return existing; + } catch (Exception e) { + log.error("Error syncing from Bridge API: {}", e.getMessage(), e); + return existing; + } + } + } diff --git a/src/main/java/org/jhely/money/base/service/x402/X402FacilitatorService.java b/src/main/java/org/jhely/money/base/service/x402/X402FacilitatorService.java index 9e7ccf7..881c28b 100644 --- a/src/main/java/org/jhely/money/base/service/x402/X402FacilitatorService.java +++ b/src/main/java/org/jhely/money/base/service/x402/X402FacilitatorService.java @@ -409,8 +409,19 @@ private SettleResponse settleSolana(X402FacilitatorConfig config, PaymentRequire return SettleResponse.success(signature, slot, network.getCanonicalName()); } catch (RpcException e) { - log.error("Solana RPC error during settlement: {}", e.getMessage(), e); - return SettleResponse.failure("RPC error: " + e.getMessage(), "RPC_ERROR"); + String errorMsg = e.getMessage(); + log.error("Solana RPC error during settlement: {}", errorMsg, e); + + // Provide more helpful error messages for common issues + if (errorMsg != null && errorMsg.contains("Blockhash not found")) { + return SettleResponse.failure( + "Transaction expired: the blockhash is too old. " + + "The client's transaction was created too long ago (Solana blockhashes expire in ~60-90 seconds). " + + "Please retry the payment with a fresh transaction.", + "BLOCKHASH_EXPIRED"); + } + + return SettleResponse.failure("RPC error: " + errorMsg, "RPC_ERROR"); } catch (Exception e) { log.error("Solana settlement failed: {}", e.getMessage(), e); return SettleResponse.failure("Settlement error: " + e.getMessage(), "SETTLEMENT_ERROR"); @@ -487,6 +498,31 @@ private String coSignAndSendTransaction(RpcClient client, Account facilitator, b log.info("First account (fee payer): {}", firstAccountKeyBase58); log.info("Facilitator public key: {}", facilitatorPubkeyBase58); + // Extract and log the blockhash from the message + // After account keys comes the recent blockhash (32 bytes) + int blockhashStart = accountKeysStart + (numAccountKeys * 32); + if (messageBytes.length >= blockhashStart + 32) { + byte[] blockhashBytes = new byte[32]; + System.arraycopy(messageBytes, blockhashStart, blockhashBytes, 0, 32); + String blockhashBase58 = new PublicKey(blockhashBytes).toBase58(); + log.info("Transaction blockhash: {}", blockhashBase58); + + // Check if the blockhash is still valid + try { + boolean isValid = client.getApi().isBlockhashValid(blockhashBase58); + log.info("Blockhash validity check: {} -> {}", blockhashBase58, isValid ? "VALID" : "EXPIRED"); + if (!isValid) { + throw new RpcException("Transaction simulation failed: Blockhash not found (pre-check: blockhash " + blockhashBase58 + " is no longer valid on the network)"); + } + } catch (RpcException e) { + if (e.getMessage().contains("pre-check")) { + throw e; // Re-throw our own pre-check exception + } + log.warn("Could not verify blockhash validity (RPC error): {}", e.getMessage()); + // Continue anyway - let the sendRawTransaction call handle it + } + } + if (!firstAccountKeyBase58.equals(facilitatorPubkeyBase58)) { throw new IllegalArgumentException( "Transaction fee payer (" + firstAccountKeyBase58 + ") does not match facilitator (" + facilitatorPubkeyBase58 + ")"); diff --git a/src/main/java/org/jhely/money/base/ui/view/MainLayout.java b/src/main/java/org/jhely/money/base/ui/view/MainLayout.java index e0fa8ac..ad89b7d 100644 --- a/src/main/java/org/jhely/money/base/ui/view/MainLayout.java +++ b/src/main/java/org/jhely/money/base/ui/view/MainLayout.java @@ -20,6 +20,7 @@ import com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment; import com.vaadin.flow.component.orderedlayout.FlexComponent.JustifyContentMode; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.sidenav.SideNav; import com.vaadin.flow.component.sidenav.SideNavItem; import com.vaadin.flow.router.AfterNavigationEvent; @@ -55,18 +56,21 @@ public class MainLayout extends AppLayout implements AfterNavigationObserver { private final String brandName; private final String shortName; private final String logoUrl; + private final String companyName; public MainLayout(AuthenticatedUser auth, AccountBalanceService balanceService, @Value("${bridge.mode:sandbox}") String bridgeMode, @Value("${app.branding.name:JHELY FinTech Framework}") String brandName, @Value("${app.branding.short-name:FinTech}") String shortName, - @Value("${app.branding.logo-url:https://jhely.com/icons/jhelyLogoYellow.svg}") String logoUrl) { + @Value("${app.branding.logo-url:https://jhely.com/icons/jhelyLogoYellow.svg}") String logoUrl, + @Value("${app.branding.company-name}") String companyName) { this.auth = auth; this.balanceService = balanceService; this.isSandboxMode = "sandbox".equalsIgnoreCase(bridgeMode); this.brandName = brandName; this.shortName = shortName; this.logoUrl = logoUrl; + this.companyName = companyName; setPrimarySection(Section.DRAWER); setDrawerOpened(true); @@ -169,12 +173,25 @@ private void createFixedBottomBar() { terms.setTarget("_blank"); terms.getElement().setAttribute("rel", "noopener"); - Span copyright = new Span("© Colubris Technologies Ltd"); + Span copyright = new Span("© " + companyName); - HorizontalLayout bar = new HorizontalLayout(privacy, terms, copyright); + // Disclaimer using the configured app name + Span disclaimer = new Span(brandName + " is not a bank. Bank accounts and payment services are provided by regulated financial institutions we partner with."); + disclaimer.getStyle() + .set("color", "var(--lumo-secondary-text-color)") + .set("font-size", "var(--lumo-font-size-xs)") + .set("text-align", "center"); + + HorizontalLayout linksRow = new HorizontalLayout(privacy, terms, copyright); + linksRow.setSpacing(true); + linksRow.setAlignItems(Alignment.CENTER); + linksRow.setJustifyContentMode(JustifyContentMode.CENTER); + + VerticalLayout bar = new VerticalLayout(linksRow, disclaimer); bar.setWidthFull(); bar.setPadding(true); - bar.setSpacing(true); + bar.setSpacing(false); + bar.getStyle().set("gap", "4px"); bar.setAlignItems(Alignment.CENTER); bar.setJustifyContentMode(JustifyContentMode.CENTER); bar.getStyle() diff --git a/src/main/java/org/jhely/money/base/ui/view/payments/AccountsOverviewView.java b/src/main/java/org/jhely/money/base/ui/view/payments/AccountsOverviewView.java index 834d3e3..89d1f30 100644 --- a/src/main/java/org/jhely/money/base/ui/view/payments/AccountsOverviewView.java +++ b/src/main/java/org/jhely/money/base/ui/view/payments/AccountsOverviewView.java @@ -103,7 +103,11 @@ public AccountsOverviewView(MockStablecoinAccountsService svc, private Component buildBridgeSection() { var user = currentUser(); - Optional existing = onboarding.findForUser(user.id(), user.email()); + + // First, try to sync from Bridge API if we don't have complete KYC data locally. + // This handles the case where the same user completed KYC on another app instance + // sharing the same Bridge API key. + Optional existing = onboarding.syncFromBridgeIfNeeded(user.id(), user.email()); if (existing.isPresent()) { return onboardedCard(existing.get()); diff --git a/src/main/java/org/jhely/money/base/ui/view/x402/X402DashboardView.java b/src/main/java/org/jhely/money/base/ui/view/x402/X402DashboardView.java index c48940b..d4bc362 100644 --- a/src/main/java/org/jhely/money/base/ui/view/x402/X402DashboardView.java +++ b/src/main/java/org/jhely/money/base/ui/view/x402/X402DashboardView.java @@ -65,6 +65,7 @@ public X402DashboardView(X402FacilitatorService facilitatorService, Authenticate loadConfig(); if (config != null) { + add(buildNetworkStatusSection()); add(buildStatsSection()); add(buildTabsSection()); } else { @@ -140,6 +141,109 @@ private Component buildNoConfigMessage() { return card; } + private Component buildNetworkStatusSection() { + var container = new Div(); + container.getStyle() + .set("display", "flex") + .set("flexWrap", "wrap") + .set("gap", "16px") + .set("marginBottom", "16px") + .set("padding", "16px") + .set("borderRadius", "12px") + .set("background", "var(--lumo-contrast-5pct)"); + + // Check which networks are configured + boolean hasDevnet = config.getSolanaRpcDevnet() != null && !config.getSolanaRpcDevnet().isBlank(); + boolean hasMainnet = config.getSolanaRpcMainnet() != null && !config.getSolanaRpcMainnet().isBlank(); + + // Network status header + var headerDiv = new Div(); + headerDiv.getStyle() + .set("width", "100%") + .set("marginBottom", "8px"); + var headerSpan = new Span("🌐 Network Configuration"); + headerSpan.getStyle() + .set("fontWeight", "bold") + .set("fontSize", "14px"); + headerDiv.add(headerSpan); + container.add(headerDiv); + + // Devnet status + var devnetBadge = createNetworkBadge( + "DEVNET", + hasDevnet, + hasDevnet ? config.getSolanaRpcDevnet() : "Not configured", + "#ff9800" // orange + ); + container.add(devnetBadge); + + // Mainnet status + var mainnetBadge = createNetworkBadge( + "MAINNET", + hasMainnet, + hasMainnet ? config.getSolanaRpcMainnet() : "Not configured", + "#4caf50" // green + ); + container.add(mainnetBadge); + + // Add warning if no networks configured + if (!hasDevnet && !hasMainnet) { + var warning = new Div(); + warning.getStyle() + .set("width", "100%") + .set("padding", "12px") + .set("background", "#fff3cd") + .set("color", "#856404") + .set("borderRadius", "8px") + .set("marginTop", "8px"); + warning.add(new Span("⚠️ No RPC endpoints configured. Configure them in the Configuration page.")); + container.add(warning); + } + + return container; + } + + private Div createNetworkBadge(String networkName, boolean isEnabled, String rpcUrl, String enabledColor) { + var badge = new Div(); + badge.getStyle() + .set("display", "flex") + .set("flexDirection", "column") + .set("gap", "4px") + .set("padding", "12px 16px") + .set("borderRadius", "8px") + .set("background", isEnabled ? enabledColor : "var(--lumo-contrast-20pct)") + .set("color", isEnabled ? "white" : "var(--lumo-secondary-text-color)") + .set("minWidth", "200px") + .set("flex", "1"); + + var nameSpan = new Span((isEnabled ? "✓ " : "✗ ") + networkName); + nameSpan.getStyle() + .set("fontWeight", "bold") + .set("fontSize", "16px"); + + var statusSpan = new Span(isEnabled ? "Enabled" : "Disabled"); + statusSpan.getStyle() + .set("fontSize", "12px") + .set("opacity", "0.9"); + + var rpcSpan = new Span(truncateRpcUrl(rpcUrl)); + rpcSpan.getStyle() + .set("fontSize", "11px") + .set("opacity", "0.8") + .set("fontFamily", "monospace") + .set("wordBreak", "break-all"); + rpcSpan.getElement().setAttribute("title", rpcUrl); + + badge.add(nameSpan, statusSpan, rpcSpan); + return badge; + } + + private String truncateRpcUrl(String url) { + if (url == null) return "—"; + if (url.length() <= 40) return url; + return url.substring(0, 37) + "..."; + } + private Component buildStatsSection() { statsContainer = new Div(); statsContainer.getStyle() diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 44907c0..858493e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -7,6 +7,7 @@ server.servlet.session.persistent=${PRODUCTION_MODE:false} app.branding.name=${APP_NAME:JHELY FinTech Framework} app.branding.short-name=${APP_SHORT_NAME:FinTech} app.branding.logo-url=${APP_LOGO_URL:https://jhely.com/icons/jhelyLogoYellow.svg} +app.branding.company-name=${APP_COMPANY_NAME:Some Company Ltd} @@ -19,7 +20,7 @@ logging.level.okhttp3.root=DEBUG logging.level.org.jhely.money.base.api.bridge=DEBUG # Bridge API configuration -bridge.mode=sandbox +bridge.mode=${BRIDGE_MODE:sandbox} bridge.sandbox.base-url=https://api.sandbox.bridge.xyz bridge.sandbox.api-key=${BRIDGE_SANDBOX_API_KEY} bridge.live.base-url=https://api.bridge.xyz @@ -104,5 +105,5 @@ app.otp.ttl-seconds=300 app.otp.resend-cooldown-seconds=45 # Demo login (optional). When set, the login page shows a "Demo Login" button that signs in instantly. -demo.login.email=${DEMO_LOGIN_EMAIL} +demo.login.email=${DEMO_LOGIN_EMAIL:}