Skip to content
Open
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ secrets.properties

# Bridge API spec (obtain from Bridge dashboard, not committed)
bridge-spec-official.json
TODO*.txt
TODO*
84 changes: 75 additions & 9 deletions example-x402/src/app/wallet/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default function WalletPage() {
const [settleResult, setSettleResult] = useState<string | null>(null);
const [txHash, setTxHash] = useState<string | null>(null);
const [txStatus, setTxStatus] = useState<string | null>(null);
const [, forceUpdate] = useState(0); // Force re-render for transaction age timer

// Check for Phantom wallet on mount
useEffect(() => {
Expand All @@ -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) {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -662,10 +690,19 @@ export default function WalletPage() {
<h2>4️⃣ Verify Payment (POST /verify)</h2>
<p>Send the signed payload to the facilitator for verification.</p>

{/* Transaction age timer */}
{signedAt && (
<div className={`result ${isTransactionExpired() ? "error" : getTransactionAge() > 30 ? "warning" : "info"}`} style={{ marginBottom: "1rem" }}>
⏱️ Transaction age: <strong>{getTransactionAge()}s</strong> / ~60-90s max
{isTransactionExpired() && " ⚠️ EXPIRED - Go back and re-sign!"}
{!isTransactionExpired() && getTransactionAge() > 30 && " (Hurry! Getting old...)"}
</div>
)}

<button
className="button"
onClick={verifyPayment}
disabled={loading === "verify" || !signedPayload}
disabled={loading === "verify" || !signedPayload || isTransactionExpired()}
style={{ marginTop: "1rem" }}
>
{loading === "verify" ? "Verifying..." : "POST /verify - Verify Payment"}
Expand All @@ -684,19 +721,48 @@ export default function WalletPage() {
<h2>5️⃣ Settle Payment (POST /settle)</h2>
<p>Execute the payment on-chain.</p>

{/* Transaction age warning */}
{signedAt && (
<div className={`result ${isTransactionExpired() ? "error" : "info"}`} style={{ marginBottom: "1rem" }}>
{isTransactionExpired() ? (
<>
⏰ <strong>Transaction likely expired!</strong> Signed {getTransactionAge()} seconds ago.
Solana blockhashes expire in ~60-90 seconds. Please re-sign the transaction.
</>
) : (
<>
⏱️ Transaction signed {getTransactionAge()} seconds ago.
{getTransactionAge() > 30 && " (Getting old - settle soon!)"}
</>
)}
</div>
)}

<div className="result info" style={{ marginBottom: "1rem" }}>
⚠️ <strong>Note:</strong> Settlement requires the payer to have USDC tokens on {network}.
For devnet, you can get test USDC from faucets.
</div>

<button
className="button"
onClick={settlePayment}
disabled={loading === "settle" || !signedPayload}
style={{ marginTop: "1rem" }}
>
{loading === "settle" ? "Settling..." : "POST /settle - Settle On-Chain"}
</button>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
<button
className="button"
onClick={settlePayment}
disabled={loading === "settle" || !signedPayload || isTransactionExpired()}
style={{ marginTop: "1rem" }}
>
{loading === "settle" ? "Settling..." : "POST /settle - Settle On-Chain"}
</button>

{isTransactionExpired() && (
<button
className="button"
onClick={() => { setStep("sign"); }}
style={{ marginTop: "1rem", background: "#ff6b35" }}
>
↩️ Go Back to Re-sign
</button>
)}
</div>

{settleResult && (
<div className={`result ${settleResult.includes('"success":true') || settleResult.includes('"success": true') ? "success" : "error"}`} style={{ marginTop: "1rem" }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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<BridgeCustomer> 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<BridgeCustomer> 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;
}
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 + ")");
Expand Down
Loading