Skip to content

Commit a5d7631

Browse files
feat(s4): chain selection engine — multi-chain scoring (STA-136)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5b41f28 commit a5d7631

13 files changed

Lines changed: 1064 additions & 4 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.stablecoin.payments.custody.application.config;
2+
3+
import com.stablecoin.payments.custody.domain.model.ChainSelectionWeights;
4+
import org.springframework.boot.context.properties.ConfigurationProperties;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
8+
/**
9+
* Configuration for the chain selection engine.
10+
* Maps {@code app.custody.chain-selection.*} properties to {@link ChainSelectionWeights}.
11+
*/
12+
@Configuration
13+
public class ChainSelectionConfig {
14+
15+
@Bean
16+
@ConfigurationProperties(prefix = "app.custody.chain-selection")
17+
public ChainSelectionProperties chainSelectionProperties() {
18+
return new ChainSelectionProperties();
19+
}
20+
21+
@Bean
22+
public ChainSelectionWeights chainSelectionWeights(ChainSelectionProperties props) {
23+
return ChainSelectionWeights.builder()
24+
.costWeight(props.getCostWeight())
25+
.speedWeight(props.getSpeedWeight())
26+
.reliabilityWeight(props.getReliabilityWeight())
27+
.build();
28+
}
29+
30+
/**
31+
* Mutable properties bean for Spring Boot's {@code @ConfigurationProperties} binding.
32+
* Defaults match {@link ChainSelectionWeights#defaults()}.
33+
*/
34+
public static class ChainSelectionProperties {
35+
private double costWeight = ChainSelectionWeights.DEFAULT_COST_WEIGHT;
36+
private double speedWeight = ChainSelectionWeights.DEFAULT_SPEED_WEIGHT;
37+
private double reliabilityWeight = ChainSelectionWeights.DEFAULT_RELIABILITY_WEIGHT;
38+
39+
public double getCostWeight() {
40+
return costWeight;
41+
}
42+
43+
public void setCostWeight(double costWeight) {
44+
this.costWeight = costWeight;
45+
}
46+
47+
public double getSpeedWeight() {
48+
return speedWeight;
49+
}
50+
51+
public void setSpeedWeight(double speedWeight) {
52+
this.speedWeight = speedWeight;
53+
}
54+
55+
public double getReliabilityWeight() {
56+
return reliabilityWeight;
57+
}
58+
59+
public void setReliabilityWeight(double reliabilityWeight) {
60+
this.reliabilityWeight = reliabilityWeight;
61+
}
62+
}
63+
}
Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,50 @@
11
package com.stablecoin.payments.custody.config;
22

3+
import com.stablecoin.payments.custody.domain.model.ChainId;
4+
import com.stablecoin.payments.custody.domain.model.StablecoinTicker;
5+
import com.stablecoin.payments.custody.domain.port.ChainFeeProvider;
6+
import com.stablecoin.payments.custody.domain.port.ChainHealthProvider;
37
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
9+
import org.springframework.context.annotation.Bean;
410
import org.springframework.context.annotation.Configuration;
511

12+
import java.util.Map;
13+
614
/**
715
* Provides fallback (dev/test) implementations for external provider ports.
816
* Each bean uses {@code @ConditionalOnMissingBean} so that production adapters
917
* (activated via {@code @ConditionalOnProperty}) take precedence.
10-
*
11-
* <p>Domain ports (CustodyEngine, ChainRpcProvider) will be added here
12-
* once the domain model is scaffolded.</p>
1318
*/
1419
@Slf4j
1520
@Configuration
1621
public class FallbackAdaptersConfig {
17-
// Fallback beans will be added when domain ports are defined
22+
23+
private static final Map<String, Double> DEFAULT_FEES = Map.of(
24+
"base", 0.01,
25+
"ethereum", 2.50,
26+
"solana", 0.005
27+
);
28+
29+
/**
30+
* Fallback health provider that returns 1.0 (healthy) for all chains.
31+
*/
32+
@Bean
33+
@ConditionalOnMissingBean
34+
public ChainHealthProvider fallbackChainHealthProvider() {
35+
log.info("Using fallback ChainHealthProvider (all chains healthy)");
36+
return (ChainId chainId) -> 1.0;
37+
}
38+
39+
/**
40+
* Fallback fee provider with realistic defaults:
41+
* Base=0.01, Ethereum=2.50, Solana=0.005 USD.
42+
*/
43+
@Bean
44+
@ConditionalOnMissingBean
45+
public ChainFeeProvider fallbackChainFeeProvider() {
46+
log.info("Using fallback ChainFeeProvider (static fee estimates)");
47+
return (ChainId chainId, StablecoinTicker stablecoin) ->
48+
DEFAULT_FEES.getOrDefault(chainId.value(), 1.0);
49+
}
1850
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.stablecoin.payments.custody.domain.exception;
2+
3+
/**
4+
* Thrown when no healthy blockchain chain is available for a transfer.
5+
*/
6+
public class ChainUnavailableException extends RuntimeException {
7+
8+
public ChainUnavailableException(String message) {
9+
super(message);
10+
}
11+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.stablecoin.payments.custody.domain.model;
2+
3+
import lombok.Builder;
4+
5+
/**
6+
* A candidate chain evaluation result used during chain selection.
7+
* <p>
8+
* Each candidate records the fee estimate, finality time, health score,
9+
* the composite weighted score, and whether this candidate was ultimately selected.
10+
*/
11+
@Builder(toBuilder = true)
12+
public record ChainCandidate(
13+
ChainId chainId,
14+
double feeUsd,
15+
int finalitySeconds,
16+
double healthScore,
17+
double score,
18+
boolean selected
19+
) {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.stablecoin.payments.custody.domain.model;
2+
3+
import lombok.Builder;
4+
5+
import java.util.List;
6+
import java.util.UUID;
7+
8+
/**
9+
* The result of a chain selection evaluation, containing the selected chain,
10+
* all evaluated candidates with their scores, and the transfer ID.
11+
*/
12+
@Builder(toBuilder = true)
13+
public record ChainSelectionResult(
14+
ChainId selectedChain,
15+
List<ChainCandidate> candidates,
16+
UUID transferId
17+
) {
18+
19+
public ChainSelectionResult {
20+
if (selectedChain == null) {
21+
throw new IllegalArgumentException("selectedChain is required");
22+
}
23+
if (candidates == null || candidates.isEmpty()) {
24+
throw new IllegalArgumentException("candidates must not be empty");
25+
}
26+
if (transferId == null) {
27+
throw new IllegalArgumentException("transferId is required");
28+
}
29+
candidates = List.copyOf(candidates);
30+
}
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.stablecoin.payments.custody.domain.model;
2+
3+
import lombok.Builder;
4+
5+
/**
6+
* Immutable weights for the multi-criteria chain selection scoring model.
7+
* <p>
8+
* All weights must be non-negative and sum to approximately 1.0 (±0.001 tolerance).
9+
*/
10+
@Builder(toBuilder = true)
11+
public record ChainSelectionWeights(
12+
double costWeight,
13+
double speedWeight,
14+
double reliabilityWeight
15+
) {
16+
17+
public static final double DEFAULT_COST_WEIGHT = 0.4;
18+
public static final double DEFAULT_SPEED_WEIGHT = 0.35;
19+
public static final double DEFAULT_RELIABILITY_WEIGHT = 0.25;
20+
21+
private static final double SUM_TOLERANCE = 0.001;
22+
23+
public ChainSelectionWeights {
24+
if (costWeight < 0) {
25+
throw new IllegalArgumentException("costWeight must be non-negative");
26+
}
27+
if (speedWeight < 0) {
28+
throw new IllegalArgumentException("speedWeight must be non-negative");
29+
}
30+
if (reliabilityWeight < 0) {
31+
throw new IllegalArgumentException("reliabilityWeight must be non-negative");
32+
}
33+
double sum = costWeight + speedWeight + reliabilityWeight;
34+
if (Math.abs(sum - 1.0) > SUM_TOLERANCE) {
35+
throw new IllegalArgumentException(
36+
"Weights must sum to ~1.0, but got %s".formatted(sum));
37+
}
38+
}
39+
40+
/**
41+
* Returns the default weights (cost=0.4, speed=0.35, reliability=0.25).
42+
*/
43+
public static ChainSelectionWeights defaults() {
44+
return new ChainSelectionWeights(DEFAULT_COST_WEIGHT, DEFAULT_SPEED_WEIGHT, DEFAULT_RELIABILITY_WEIGHT);
45+
}
46+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.stablecoin.payments.custody.domain.port;
2+
3+
import com.stablecoin.payments.custody.domain.model.ChainId;
4+
import com.stablecoin.payments.custody.domain.model.StablecoinTicker;
5+
6+
/**
7+
* Port for estimating transaction fees on a given blockchain chain.
8+
*/
9+
public interface ChainFeeProvider {
10+
11+
/**
12+
* Estimates the fee in USD for transferring a stablecoin on the given chain.
13+
*
14+
* @param chainId the target chain
15+
* @param stablecoin the stablecoin to transfer
16+
* @return estimated fee in USD
17+
*/
18+
double estimateFeeUsd(ChainId chainId, StablecoinTicker stablecoin);
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.stablecoin.payments.custody.domain.port;
2+
3+
import com.stablecoin.payments.custody.domain.model.ChainId;
4+
5+
/**
6+
* Port for retrieving the current health score of a blockchain chain.
7+
* <p>
8+
* Returns 0.0 for unhealthy/down chains and 1.0 for healthy chains.
9+
*/
10+
public interface ChainHealthProvider {
11+
12+
/**
13+
* Returns the health score for the given chain.
14+
*
15+
* @param chainId the chain to evaluate
16+
* @return 0.0 (unhealthy) or 1.0 (healthy)
17+
*/
18+
double getHealthScore(ChainId chainId);
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.stablecoin.payments.custody.domain.port;
2+
3+
import com.stablecoin.payments.custody.domain.model.ChainSelectionResult;
4+
5+
import java.util.UUID;
6+
7+
/**
8+
* Port for persisting chain selection evaluation results.
9+
*/
10+
public interface ChainSelectionLogRepository {
11+
12+
/**
13+
* Saves the chain selection result for the given transfer.
14+
*
15+
* @param transferId the transfer for which chain was selected
16+
* @param result the selection result including all scored candidates
17+
*/
18+
void save(UUID transferId, ChainSelectionResult result);
19+
}

0 commit comments

Comments
 (0)