Cross-chain atomic swap orchestration platform built with .NET 9 and Temporal workflows.
Note: An alternative orchestration engine based on DTFx (LLL.DurableTask) was evaluated and removed in April 2026 due to silent failure modes (zero-handler registration when
[Orchestration]/[Activity]attributes are missing, start-then-signal race in EFCore provider, and misleading "started successfully" logs even when the dispatcher had nothing to dispatch). Full experimental state preserved on thedtf-experimentbranch. Stick with Temporal.
csharp/ # .NET 9 backend (Aspire, Temporal, APIs, workflows)
├── src/ # Source projects
├── tests/ # xUnit tests
└── tools/ # AzureKVTool
js/ # TypeScript workers (Aztec) and gRPC network services (Starknet)
treasury/ # Infisical-backed key management service (JS)
protos/blockchain.proto # Language-agnostic network worker contract schema
protos/solver.proto # Solver API gRPC contract (routes, quotes, orders)
protos/events.proto # Event Collector gRPC contract (HTLC events, TP callbacks)
protos/configuration.proto # Configuration gRPC contract (network config, wallets)
src/
├── Gateway.Grpc/ # gRPC public API consumed by StationAPI (routes, quotes, orders, reveal-secret)
├── Core.Grpc/ # gRPC internal API consumed by network services (EventCollector, Configuration) + webhook receiver endpoint
├── AdminAPI/ # Admin operations API
├── AdminPanel/ # Blazor WebAssembly UI
├── AppHost/ # Aspire orchestration host
├── Client/ # API client library
├── Workflow.Swap/ # Atomic swap coordination + workflow contracts (OrderWorkflow, WalletGenerator, RouteMonitor, RefundMonitor)
├── Data.Abstractions/ # Data layer interfaces
├── Data.Npgsql/ # PostgreSQL implementation
├── StationAPI/ # Solver aggregator API (multi-solver, SSE, webhooks)
├── Infrastructure*/ # Pluggable infrastructure modules
├── Shared/Util/ # Utilities and extensions
├── SmartNodeInvoker/ # RPC invocation with caching
├── Network.Common/ # Chain-agnostic infrastructure (IKeyStore, InfisicalKeyStore, INetworkConfigProvider, NetworkConfigProvider, WorkerRegistry, EventListenerWorkerBase, ICheckpointRepository, IGasUsageHistoryRepository)
├── Network.EVM.Shared/ # Shared EVM-compatible types (FunctionMessages, EventModels, EventDecoder, ISharedBlockchainReaderService, ITransactionBuilderService, Web3ClientExtensions, GasPriceResponse)
├── Network.Proto/ # Lean proto compilation for network services (blockchain.proto, events.proto, configuration.proto)
├── Network.Grpc.Shared/ # Proto compilation + domain↔proto mapper (for Core/Gateway gRPC)
├── Infrastructure.Network.Grpc/ # gRPC client implementations of INetworkRuntimeService/IGasStationService + tx submission
├── Network.EVM.Grpc/ # EVM gRPC server (self-contained, owns all EVM blockchain logic)
├── Network.Tron.Grpc/ # Tron gRPC server (self-contained, Tron-specific tx pipeline + HTLC via shared EVM ABI)
└── Network.Solana.Grpc/ # Solana gRPC server (self-contained, Anchor/Borsh HTLC, Ed25519 signing via Infisical)
tests/
├── Util.Tests/ # xUnit tests for shared utilities
├── Data.Tests/ # Repository integration tests (Testcontainers PostgreSQL)
└── Workflow.Tests/ # Temporal workflow integration tests
tools/
├── DataSeeder/ # Database seeding
└── AzureKVTool/ # Azure Key Vault key management CLI (create/import/sign)
# Build (from csharp/)
dotnet build TrainSolver.slnx
# Run with Aspire (from csharp/)
dotnet run --project src/AppHost/AppHost.csproj
# Test (from csharp/)
dotnet test tests/Util.Tests/Util.Tests.csproj
dotnet test tests/Data.Tests/Data.Tests.csproj # Requires Docker
dotnet test tests/Workflow.Tests/Workflow.Tests.csproj- .NET 9 with Aspire for orchestration
- Temporal for durable workflow execution
- PostgreSQL + Entity Framework Core 9
- Redis for caching and distributed locking
- Nethereum for EVM chains
- Blazor WebAssembly for AdminPanel
- Namespaces:
Train.Solver.<Module>(e.g.,Train.Solver.Workflow.EVM) - File-scoped namespaces:
namespace X; - Records for events and DTOs (immutable)
- Async/await with CancellationToken support
- FluentValidation for request validation
- Extension methods for DI registration (
AddNetworkActivities(),WithEVMWorkflows()) - BigInteger for all token amounts (via
System.Numerics); serialized as strings in DTOs - Token contract addresses: Always non-null. Native tokens use a null address (
0x0000000000000000000000000000000000000000). Access vianetwork.Type.NativeTokenAddress. - AdminAPI: Uses plain
Results.Ok(),Results.NotFound("message")- no wrapper - Public API (Gateway.Grpc): gRPC service, no REST wrapper
- Activity methods: Must be
public virtual asyncin implementation class (Temporal requirement) - String truncation: Use
StringExtensions.Truncate(maxLength)instead of inline[..Math.Min()]patterns. Located insrc/Util/Extensions/StringExtensions.cs. - Quote expiry enforcement:
ValidateQuoteAsyncchecksQuoteExpiry < Workflow.UtcNowafter HMAC signature validation to prevent replay of stale signed quotes - Wallet.NetworkId: Optional FK to Network.
null= universal (runs on all networks of this type). Set at creation time only (not editable). AdminPanel shows "Universal" or the network slug badge. - IsTestnet:
Network.IsTestnetis set at creation time only (not editable via update).NetworkDto.IsTestnetpropagated through all projections. AdminPanel defaults to hiding testnets with a toggle. - Network.Enabled: Controls whether
NetworkRuntimeMonitorstarts a runtime for the network. Disabling a network cascades to set all associated routes (source or destination) toRouteStatus.Inactive. All seeded networks default toEnabled = false. - Node entity: No
ProviderNamecolumn — DB nodes are always manual. Provider-resolved nodes getProviderNameset at runtime.NodeDto.IsFromProvideris a computed property (ProviderName is not null). Unique index:(Url, NetworkId, Protocol). Delete byId(not provider name). - WalletType:
Managed(0, treasury-generated, status lifecycle) orWhitelisted(1, external address, always Active, deletable). SingleWalletentity — no separateTrustedWallettable.Managedat index 0 ensures backward compatibility with existing DB wallets. Rebalance:FromAddressmust be Managed,ToAddresscan be any type.
TypeScript workers and gRPC network services. Aztec uses a Temporal worker (activities and child workflows dispatched remotely from C# NetworkRuntime). Starknet has been migrated to a standalone gRPC service (js/src/Network/Network.Starknet.Grpc/) that communicates with Core.Grpc for config and event dispatch.
# Install (from js/)
npm ci
# Build (from js/)
npm run buildFull HTLC integration — supports all 6 contract functions (lock/redeem/refund for both user and solver).
- NetworkType:
Starknet, task queue:starknet - Train contract:
0x056d5aab86196192bbdb571116b69de5169453eaf3f164300de2616c184fd697(Starknet Sepolia). 6-function split matching EVM:user_lock,solver_lock,redeem_user,redeem_solver,refund_user,refund_solver. - Runtime: Generic C#
NetworkRuntime(inWorkflow.Common) runs oncorequeue, dispatchesRuntimeXxxactivities remotely tostarknettask queue viaRuntimeActivities.tswrappers - Transaction Processor: 3-queue pipeline (queued → pending → in-flight) with estimate-first nonce management. Supports all signal types:
SubmitTransfer,SubmitLock,SubmitRedeem,SubmitRefund(onlySubmitApprove/SubmitCustomrejected). Lock transactions use multicall ([approveCall, lockCall]). Nonces reserved in Redis (nonce:reservation:{slug}:{address}hash, keyed by correlationId for idempotency, 1-hour TTL). State persisted to Redis (starknet:tp-state:{processorId}) for crash resilience. Orphaned monitoring after 10-min timeout (no tx replacement on Starknet).maxInFlight=1. Dirty flag skips Redis save when idle. - Transaction Builder:
StarknetTransactionBuilder(Activities/Helpers/StarknetTransactionBuilder.ts) — builds StarknetCallobjects for all 6 HTLC functions usingCallData.compile()with Train ABI. Lock txs return multicall[approveCall, lockCall]as JSON array. Handles reward token same/different token approval logic for solver lock. - Event Decoder:
StarknetEventDecoder(Activities/Helpers/StarknetEventDecoder.ts) — decodes events fromstarknet_getEventsRPC. UsesFeltReaderfor sequential felt consumption. Event selector matching viahash.getSelectorFromName(). Handles u256 (2 felts), ContractAddress (1 felt), ByteArray (variable felts → UTF-8), u64 (1 felt). - Event Listener:
StarknetRPCLogEventListener(registered asrpc-log-event-listener) — pollsstarknet_getEventsin block batches. Template dispatch viaemitEvent()(routingKey=hashlock →order-{hashlock}). Checkpoint to Redis every 3 iterations. Continue-as-new after 50 iterations. Catch-up mode viaargs.toBlock. - Dispatch Rules:
StarknetConstants.ts—StarknetDispatchRulesmaps all 6 event types to signal names and target workflow routing.composeStarknetSubscriptions()buildsEventSubscription[]from dispatch rules + filter addresses.UserTokenLockedstarts OrderWorkflow oncorequeue; all others signal-only. - GasStation:
StarknetGasStation(registered asgas-station) — simplified version that tracks actual fees paid (no separate gas price fetching). ReceivesReportGasUsagesignal from TP on confirmed tx withtxResponse.fee.amount. ExposesGetEstimatedFeesquery (returnsEstimatedFeesDtowith averaged fees per tx type). TX type mapping: TP lowercase (lock,redeem) → C# PascalCase (HTLCLock,HTLCRedeem). State persisted to Redis ({slug}:gas-station:usage-history, 7-day TTL). Continue-as-new after 200 operations. Consumed by C#QuoteService→INetworkRuntimeService.GetEstimatedFeesAsync→ Temporal typedIGasStationhandle query. - RuntimeActivities wrapper:
js/src/Blockchain/Blockchain.Starknet/Activities/RuntimeActivities.ts—createStarknetRuntimeActivities()registersRuntimeXxx-named wrapper functions that adapt C# parameter format to existingStarknetWorkflowActivitiesmethods. Registered inStarknetWorker.ts. - AddressGenerator:
StarknetAddressGeneratorworkflow (registered asaddress-generator). Generates address via treasury — no on-chain work. UsesnetworkSlugfromGenerateAddressRequest(passed by C# WalletGenerator). - AddressActivator:
StarknetAddressActivatorworkflow (registered asaddress-activator). On-chain activation: waits for funding (30-min timeout) → gets public key from treasury → deploys ArgentX account contract → waits for confirmation. Dispatched by C#WalletActivationworkflow for wallets withInactivestatus. - Activities:
StarknetWorkflowActivities— includes HTLC build methods (buildSolverLockTransaction,buildUserLockTransaction,buildSolverRedeemTransaction,buildUserRedeemTransaction,buildSolverRefundTransaction,buildUserRefundTransaction), event retrieval (getEventsusingstarknet_getEventsRPC with pagination), checkpoint persistence (getCheckpoint/saveCheckpointvia Redis), address generation, and granular TP pipeline:estimateFee,reserveNonce,unreserveNonce,composeSignedTransaction,publishTransaction,loadTransactionState,saveTransactionState. - Starknet-specific types:
StarknetFee,PendingSignedTransaction,InFlightTransaction,TransactionProcessorSnapshot,PublishOutcome,PublishTransactionResult,NonceReservationResultinModels/StarknetTransactionProcessorTypes.ts. Shared types (QueuedTransactionRequest,TransactionCallback, etc.) stay inTrainSolver/Models/Workflow/TransactionProcessorTypes.ts. - Shared interfaces:
ICoreActivities,NetworkWalletDto,StartWorkflowRequestinModels/ICoreActivities.ts— used by runtime, TP, and address generator. - Event type mapping (contract → internal):
UserLocked→UserTokenLocked,SolverLocked→SolverTokenLocked,UserRedeemed→SolverTokenRedeemed(solver redeems user's lock),SolverRedeemed→UserTokenRedeemed(user redeems solver's lock),UserRefunded→UserTokenRefunded,SolverRefunded→SolverTokenRefunded - Starknet event format:
keys[](first key = event selector viasn_keccak, remaining = indexed fields) +data[](non-indexed fields). u256 encoded as 2 felts (low, high). ByteArray:[data_len, ...bytes31_chunks, pending_word, pending_word_len]. - Treasury deploy signing:
StarknetSignRequest.type = 'deploy'triggerssigner.signDeployAccountTransaction()instead of invoke signing - Address & hash canonical format:
0x+ 64 lowercase hex chars (e.g.,0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7).normalizeStarknetHex()inHelpers/StarknetAddressHelper.tswrapsaddAddressPadding(hex).toLowerCase(). Applied at system entry points: runtime (wallet addresses, update handler inputs), TP init (walletAddress), address generator (treasury return), activities (toAddress,generateAddressreturn), and RPC client (tx hashes fromtrySendInvocation/sendDeployAccount). Activities also keep defensive normalization internally (belt-and-suspenders). - Same Dockerfile as Aztec: Differentiated by
TrainSolver__NetworkTypeenv var - AppHost:
workflow-runner-starknetcontainer withTrainSolver__NetworkSlug=starknet-sepolia
Solana integration is now in Network.Solana.Grpc (self-contained gRPC server). See key files in .claude/rules/key-files.md.
HTLC Contract (2cQYFAiud2LBg3r6MxKPJ1oS83yyrRwDsgxQSwhL97LJ):
- Anchor program on Solana devnet
- SOL-native functions (6,
_solsuffix):user_lock_sol,solver_lock_sol,redeem_user_sol,redeem_solver_sol,refund_user_sol,refund_solver_sol - SPL token functions (same reward):
solver_lock_token,redeem_solver_token,refund_solver_token,user_lock_token,redeem_user_token,refund_user_token - SPL token functions (diff reward):
solver_lock_token_diff_reward,redeem_solver_token_diff_reward,refund_solver_token_diff_reward - Read functions:
get_solver_lock,get_user_lock,get_solver_lock_count - Cleanup function:
close_solver_lock(2 accts) — reclaims PDA rent after solver lock refund - SPL token variants use identical Borsh instruction data as SOL variants — only account metas differ
- Anchor events: 6 events with discriminators (
SolverLocked,SolverRedeemed,SolverRefunded,UserLocked,UserRedeemed,UserRefunded) - IDL:
csharp/contracts/Train.solana.idl.json - PDA seeds: UserLock
["user_lock", hashlock], SolverLock["solver_lock", hashlock, index_u64_le], SolverCounter["solver_count", hashlock] - Vault PDAs:
solver_vault["solver_vault", hashlock, index],solver_reward_vault["solver_reward_vault", hashlock, index],user_vault["user_vault", hashlock] - PDA account layout (SolverLock):
disc(8) + secret(32) + amount(u64) + reward(u64) + sender(32) + timelock(u64) + reward_timelock(u64) + recipient(32) + status(u8) + reward_recipient(32) + token_mint(32) + reward_token_mint(32). Status: 0=committed, 1=locked, 2=refunded, 3=redeemed. - PDA account layout (UserLock):
disc(8) + secret(32) + amount(u64) + sender(32) + timelock(u64) + status(u8) + recipient(32) + token_mint(32) - u128 fields:
dst_amountandreward_amountareu128in instruction args and events. Serialized as 16-byte LE in Borsh. C# usesBigIntegerfor these fields. - Timelock: Solana stores
timelockDelta(relative seconds). Converted to absolute Unix timestamp viablockTime + timelockDelta. - Native token address:
11111111111111111111111111111111(System Program). Also accepts EVM-style null address. - SPL token support: Full SPL token support for all HTLC operations.
SolanaHelper.IsSplToken(tokenContract)determines branching. - Fee reporting includes PDA rent: GasStation's
HTLCLockcost on Solana =meta.fee+ rent-exempt surcharge for the PDAs created bysolver_lock(SolverLock,solver_vault, optionalsolver_reward_vault,solver_count). Single upper-bound constant (SolanaHelper.RentCosts.HTLCLockUpperBound≈ 7.6M lamports) is added inSolanaTransactionProcessorWorker.ReportGasUsageAsync. Rent is a real sunk cost on the happy-path redeem (accounts are only closed on the refund path viaclose_solver_lock), so it must be baked into the quote's expense fee. Redeem/Refund/Transfer reports stay as rawmeta.fee.
Infisical-backed key management and signing service.
# Install (from treasury/)
npm ci
# Run (from treasury/)
npm startEach network hosts a gRPC NetworkService server. The core layer calls it via a gRPC client. Network services obtain their configuration and dispatch events via Core.Grpc (not AdminAPI HTTP or Temporal directly).
Architecture:
Core (AdminAPI, QuoteService)
└─► INetworkRuntimeService / IGasStationService (existing interfaces)
└─► GrpcNetworkRuntimeService / GrpcGasStationService (gRPC client impl)
└─► gRPC ──► NetworkGrpcService (per-network server)
└─► Blockchain services (self-contained logic)
Network services (EVM, Tron, Solana, Starknet)
└─► Core.Grpc ConfigurationService (gRPC) for network config, wallets, contracts
└─► Core.Grpc EventCollectorService (gRPC) for HTLC event dispatch + TP callbacks
└─► Core.Grpc internally uses Temporal to signal OrderWorkflow, start workflows, etc.
Key change from previous architecture: Network services no longer depend on AdminAPI HTTP or Temporal directly. All config comes from ConfigurationService gRPC, all event dispatch goes through EventCollectorService gRPC. Temporal is fully contained within Core.Grpc.
Projects:
Network.Proto— Lean proto compilation for network services (blockchain.proto,events.proto,configuration.proto). No domain types, no mapper — just generated proto stubs. Referenced byNetwork.Commonand network gRPC servers.Network.Grpc.Shared— Proto compilation (protos/blockchain.proto,solver.proto, etc.) +ProtoMapper(domain ↔ proto type conversion). Used by Core/Gateway gRPC andNetwork.Grpc.Client.Network.Grpc.Client—GrpcNetworkRuntimeService,GrpcGasStationService,NetworkServiceRegistry(slug → endpoint routing)Network.Common— Chain-agnostic infrastructure (WorkerRegistry, IKeyStore/InfisicalKeyStore, INetworkConfigProvider, EventListenerWorkerBase, ICheckpointRepository, IGasUsageHistoryRepository). ReferencesNetwork.Protofor proto types. No dependency onShared.ModelsorWorkflow.Abstractions.Network.EVM.Shared— Shared EVM-compatible types (blockchain reader, tx builder, EventDecoder, FunctionMessages, EventModels). ReferencesNetwork.Common.Network.EVM.Grpc— Self-contained EVM gRPC server. Owns ALL EVM blockchain logicNetwork.Tron.Grpc— Self-contained Tron gRPC server. UsesNetwork.Commonfor chain-agnostic types andNetwork.EVM.Sharedfor shared EVM-compatible typesNetwork.Solana.Grpc— Self-contained Solana gRPC server. Uses SolNet SDK, Anchor/Borsh HTLC, Ed25519 signing via Infisical
Network config pattern (INetworkConfigProvider):
- Singleton
NetworkConfigProvider(implementsIHostedService) fetches network config fromConfigurationServicegRPC at startup, auto-refreshes every 60 seconds. - Network services inject
INetworkConfigProviderand accessNetworkproperty directly — no per-request HTTP calls. - Config env var:
TrainSolver__CoreGrpcUrl(replaces oldTrainSolver__AdminApiUrl).
Event dispatch pattern:
- Event listeners emit HTLC events via
EventCollectorService.EmitHTLCEventgRPC call (not Temporal client). - Transaction processors report results via
EventCollectorService.ReportTransactionResultgRPC call (not Temporal signal). Core.Grpcreceives these calls and internally uses Temporal to signalOrderWorkflow, start workflows, etc.EventCollectorServiceClientis injected fromNetwork.Protogenerated stubs.
Key patterns:
- Proto types (
Train.Solver.Proto.*) and domain types (Train.Solver.Shared.Models.*) have the same field names but are separate type systems.ProtoMapperhandles all conversions. Useglobal::Grpc.Core.RpcExceptionto avoid namespace collision withTrain.Solver.Network.Grpc. - Network services receive config from
INetworkConfigProvider(fetched fromConfigurationServicegRPC) — callers only sendnetwork_slug. NetworkServiceRegistrycachesGrpcChannelper endpoint. Config:NetworkGrpc:DefaultEndpoint+NetworkGrpc:Endpointsdict (slug → URL).- To swap from Temporal to gRPC: replace
WithCoreServices()withWithGrpcNetworkClient()in the consuming project's DI.
Custom transaction submission (SubmitCustomTransaction):
- Accepts pre-built transaction data (to_address, calldata, amount) — skips the HTLC builder, goes directly through fee estimation → nonce → sign → publish → monitor pipeline.
- Proto:
SubmitCustomRequestwithCustomTransactionRequest(correlation_id, callback, sender, to_address, call_data, amount, token_contract). - EVM: Fully supported.
FeeEstimationService.BuildAndEstimateTransactionAsyncshort-circuits the builder viaCustomPreparedTransactionand runseth_estimateGasfor validation. - Solana/Tron/Starknet: Returns
Unimplemented(EVM-only for now). - AdminAPI:
POST /api/custom-transactionwith{ networkSlug, walletAddress, toAddress, callData?, amount, tokenContract, correlationId? }. CorrelationId defaults tocustom-{guid}if not provided. - Idempotency: Same 5-layer dedup as all other transaction types, keyed by
correlationId. Caller controls idempotency by setting a deterministic correlationId. - Domain model:
CustomTransactionRequestinShared.Models/Workflow/TransactionProcessorModels.cs. - Client wrapper:
IGrpcNetworkService.SubmitCustomAsyncinInfrastructure.Network.Grpc.
Network-scoped wallets (CAIP-2 identification):
- Wallets have an optional
NetworkIdFK (nullable) scoping them to a specific network.null= universal (available on all networks of this type). - Network services identify themselves via CAIP-2 (
{type}:{chainId}, e.g.eip155:11155111) built fromTrainSolver__NetworkType+TrainSolver__ChainIdenv vars. Replaces oldTrainSolver__NetworkSlug. ConfigurationService.GetWalletsaccepts CAIP-2 identifier, parses type + chainId, returns wallets whereNetworkId IS NULL(universal) ORNetwork.ChainId == chainId.ConfigurationService.GetNetworkalso accepts CAIP-2 (resolved via existingEFNetworkRepository.GetAsyncwhich supports CAIP-2 lookup).NetworkConfigProviderbuilds CAIP-2 from env vars. Internal code still usesconfigProvider.Network.Slug(resolved server-side from config response) for worker IDs, Redis keys, logging.- AdminPanel wallet creation has optional "Network Scope" dropdown (filtered by selected NetworkType). Scope is immutable after creation.
- Wallet unique constraint: universal wallets unique per
(Address, NetworkTypeId) WHERE NetworkId IS NULL, network-scoped wallets unique per(Address, NetworkTypeId, NetworkId) WHERE NetworkId IS NOT NULL. Same address can be duplicated to different network scopes.
ExecutionService (programmatic transaction submission + status polling):
- gRPC service on Gateway.Grpc for external/institutional callers to submit custom transactions and poll results.
SubmitTransaction: validates input, writestx-result:{correlationId} = pendingto Redis, proxies to network gRPCSubmitCustomTransaction. Returns correlationId immediately.GetTransactionStatus: readstx-result:{correlationId}from Redis. Returns status (pending,confirmed,failed,rejected,unknown), txHash, blockNumber, failureReason.- Result persistence:
EventCollectorGrpcService.ReportTransactionResult(Core.Grpc) writes ALL transaction results to Redis (tx-result:{correlationId}) with 24h TTL. Works for HTLC, rebalance, and custom transactions uniformly. - Network TPs always report results to
EventCollectorService(removed the early-return guard on emptycallback.WorkflowId). Temporal signaling only fires whenWorkflowIdis set. - Redis key:
tx-result:{correlationId}→ JSON{ status, txHash, blockNumber, failureReason, networkSlug, timestamp }, TTL 24h. - Proto:
ExecutionServiceinsolver.proto,network_slugfield added toReportTransactionResultRequestinevents.proto.
Network.EVM.Grpc project structure:
Network.EVM.Grpc/
├── Blockchain/ # All EVM business logic (was Activities in Temporal world)
│ ├── FunctionMessages/ # Nethereum ABI types (HTLC, ERC20, Multicall3)
│ ├── EventModels/ # Decoded event types (Locked, Redeemed, Refunded, Transfer)
│ ├── FeeEstimation/ # Fee estimator hierarchy (IFeeEstimator, EIP1559, Legacy, L2)
│ ├── Helpers/ # EventDecoder, RPCErrorClassifier, StuckTransactionHandler, ListenerCapabilities
│ ├── Models/ # Fee, SignedTransaction, TP types, request/response DTOs
│ ├── BlockchainReaderService # Balance, tx queries, gas price (was EVMReadActivities)
│ ├── FeeEstimationService # Fee estimation + BuildAndEstimate (was EVMFeeActivities)
│ ├── NonceService # Nonce reserve/unreserve (was EVMNonceActivities)
│ ├── TransactionPublisherService # Sign + publish (was EVMTransactionActivities)
│ ├── TransactionBuilderService # HTLC calldata construction (was EVMTransactionBuilderActivities)
│ ├── EventReaderService # RPC log event retrieval (was EventListenerReadActivities)
│ ├── NetworkOperationService # Validation, HTLC build delegation (was EVMNetworkRuntimeActivities)
│ ├── WebhookProcessorService # Webhook HMAC verify + parse (was EVMWebhookActivities)
│ ├── SubgraphReaderService # Subgraph GraphQL queries (was EVMSubgraphActivities)
│ └── CheckpointService # Block checkpoint persistence (was CheckpointActivities)
├── Data/ # EF Core data layer (separate EVMDbContext)
│ ├── Entities/ # TransactionQueueEntry, TransactionInFlightEntry, ProcessedTransaction, NonceReservation, TransactionAttemptEntry
│ ├── Migrations/ # EF migrations (user-managed)
│ ├── EVMDbContext.cs # DbContext for TP state (queue, inflight, nonce, attempts)
│ └── EVMDbContextDesignFactory # Design-time factory for `dotnet ef migrations add`
├── Repositories/ # State persistence (Npgsql for TP, Redis for checkpoint/gas)
│ └── Npgsql/ # PostgreSQL implementations (queue, nonce, attempts)
├── Services/ # gRPC service implementation
├── Workers/ # Background services (TP manager, event listeners)
└── Core/ # WorkerRegistry
- No adapters: Services ARE the implementations. No wrapper layer, no
[Activity]attributes, novirtualmethods. - Dependencies:
Network.Common(INetworkConfigProvider, EventListenerWorkerBase, WorkerRegistry, IKeyStore),Network.EVM.Shared(shared EVM types, EventDecoder, blockchain reader, tx builder),Network.Proto(proto stubs),SmartNodeInvoker,Util(BigIntegerConverter for JSON serialization). ZERO references toWorkflow.EVM,Workflow.Abstractions,Shared.Models, orInfrastructure.Secret.Treasury. - Data layer: Separate
EVMDbContext(not sharedSolverDbContext). TP queue, inflight, nonce reservations, and attempt audit trail stored in PostgreSQL. Checkpoints and gas history remain in Redis. Migration:dotnet ef migrations add <Name> -p src/Network.EVM.Grpc -s src/Network.EVM.Grpc. Nonce locking usespg_advisory_xact_lockinstead of Redis Redlock. - Class names preserved:
EVMReadActivities,EVMFeeActivitiesetc. keep their names for now (rename to*Serviceis cosmetic, done later). InterfacesIEVMReadActivitiesetc. also preserved — they live inTrain.Solver.Network.EVM.Grpc.Blockchainnamespace. - Workers use the blockchain interfaces directly (e.g.,
IEVMFeeActivities,IEVMNonceActivities) — no intermediate service layer.
Azure Key Vault signing (EVM — private keys never leave KV):
IEVMSigningServiceinterface (Blockchain/IEVMSigningService.cs) —SignTransaction1559Async,SignLegacyTransactionAsync.AzureKeyVaultSignerService(Blockchain/AzureKeyVaultSignerService.cs) — usesNethereum.Signer.AzureKeyVault.AzureKeyVaultExternalSignerfor in-vault signing. Caches signer instances per wallet address. InnerCachedAzureKeyVaultSigneroverridesGetPublicKeyAsync()to cache public key (avoids extra KV call per sign). Resolves wallet address → slug (KV key name) from mapping populated byTransactionProcessorManager.AzureKeyVaultAddressService(Blockchain/AzureKeyVaultAddressService.cs) — generates P-256K keys in KV or imports existing private keys. Uses walletSlugas KV key name.TransactionPublisherServiceusesIEVMSigningService(injected). Agnostic to signing backend.NetworkGrpcService.GenerateAddressRPC wired toAzureKeyVaultAddressService. Receives wallet slug fromGenerateAddressRequest.Slug.- Config:
AzureKeyVault:VaultUrienv var (e.g.,https://prod-ts-evm.vault.azure.net/). Auth viaDefaultAzureCredential(workload identity in k8s,az loginlocally). - Wallet slug: Auto-generated on wallet creation (
RandomFriendlyNameGenerator), immutable, stored inWallet.Slug. Used as Azure KV key name. Returned viaConfigurationService.GetWalletsasWalletConfigDto { Address, Slug }. - AzureKVTool (
tools/AzureKVTool/): CLI for create-key, import-key, sign, get-address. For manual key management and migration. - Migration path: For existing wallets, generate slug via AdminAPI, then import private key via
AzureKVTool import-key --vault <url> --name <slug> --private-key <hex>. - Legacy (Infisical):
EVMSigningServiceandEVMAddressGeneratorServicestill exist but are not registered in DI. Tron/Solana/Starknet still useInfisicalKeyStorefromNetwork.Common. - No
Infisical.Sdkdependency — removed fromNetwork.EVM.Grpc.csproj. Replaced byAzure.Identity+Azure.Security.KeyVault.Keys+Nethereum.Signer.AzureKeyVault.
AdminAPI health/infrastructure endpoints use gRPC:
HealthEndpoints(/health/*) callsNetworkServiceRegistry.GetClient(slug).GetHealthAsync()to getGetHealthServiceResponsewithEventListenerHealthInfoandTransactionProcessorHealthInfo. NoITemporalClientdependency.InfrastructureEndpoints(/infrastructure/*) uses the same gRPC health call for TP listing and status. Removed: soft/hard restart endpoints, approve-spender endpoint (no Temporal runtime to signal — workers self-manage).- Proto namespace collision: Use
using Proto = Train.Solver.Proto;alias in AdminAPI endpoints to avoid conflict betweenTrain.Solver.Proto.DetailedNetworkDtoandTrain.Solver.Shared.Models.DetailedNetworkDto. NetworkServiceRegistrymay be null (injected as?) — endpoints return "Unknown" or empty results when null.
Network.Solana.Grpc project structure:
Network.Solana.Grpc/
├── Blockchain/
│ ├── Helpers/ # SolanaHelper, SolanaAnchorHelper, SolanaListenerCapabilities
│ ├── Models/ # TP pipeline types, HTLC models, request/response DTOs
│ ├── BlockchainReaderService # Balance, slot, blockhash, priority fee, signatures (SolNet)
│ ├── TransactionBuilderService # Transfer + all HTLC Anchor instruction building
│ ├── SolanaSigningService # Ed25519 signing via IKeyStore (Infisical)
│ ├── TransactionPublisherService # Sign + publish via SolNet RPC
│ ├── EventReaderService # getSignaturesForAddress + Anchor discriminator parsing
│ └── NetworkOperationService # HTLC build orchestration (blockhash + build)
├── Repositories/ # Solana-specific Redis repos (checkpoint, gas history, tx queue)
├── Services/ # NetworkGrpcService (proto method implementations)
└── Workers/ # TP manager + worker, RPC event listener
- No Nethereum: Uses SolNet SDK for all blockchain operations
- Ed25519 signing:
SolanaSigningServiceusesIKeyStorefromNetwork.Commonfor key storage, but generates Ed25519 keypairs (not secp256k1). Does NOT callIKeyStore.GenerateKeyPairAsync(). - Blockhash-based TP: No nonce reservation. Uses
recentBlockhash+lastValidBlockHeightfor tx expiry. Retries with 1.5x priority fee escalation (max 3 retries). - Solana-specific checkpoint:
ISolanaCheckpointRepositorystores{ Slot, Signature }(not just block number) - Solana-specific gas history:
ISolanaGasUsageHistoryRepositorystoresDict<string, List<ulong>>in lamports (notBigInteger) - Event detection:
getSignaturesForAddress(HTLCProgramId)+ Anchor instruction discriminator parsing (not RPC log topic filtering) - AppHost:
network-solana-grpcwithTrainSolver__NetworkSlug=solana-devnet, Infisical config,TrainSolver__CoreGrpcUrlpointing to Core.Grpc, wired to Workflow.Swap viaNetworkGrpc__Endpoints__solana-devnet
Every implementation plan MUST include a final step: "Update CLAUDE.md". After completing the task, review what was learned and add any notable context to the relevant rules file — new patterns, gotchas, architectural decisions, key file locations, or corrections to existing documentation.
If a plan affects integration-related behavior (workflow pipelines, event flows, protocol changes, cross-component communication, or architecture), the plan MUST also include a step to update the relevant docs in docs/ (ARCHITECTURE.md, HTLC-PROTOCOL.md, NETWORK-INTEGRATION-GUIDE.md).
Plans MUST NOT include EF Core migration steps (e.g., dotnet ef migrations add). The user handles migrations separately.
During build and test phases, run only the tests affected by the changes — do NOT run the entire test suite. Use dotnet test --filter to target specific test classes or namespaces. For example:
- Changes to
OrderWorkflow→ rundotnet test csharp/tests/Workflow.Tests/Workflow.Tests.csproj --filter "FullyQualifiedName~OrderWorkflow" - Changes to
Data.Npgsql→ rundotnet test csharp/tests/Data.Tests/Data.Tests.csproj - Changes to
Utilutilities → rundotnet test csharp/tests/Util.Tests/Util.Tests.csproj - Changes to
TransactionProcessor→ rundotnet test csharp/tests/Workflow.Tests/Workflow.Tests.csproj --filter "FullyQualifiedName~TransactionProcessor" - Changes to shared abstractions (e.g.,
Workflow.Abstractions) → run tests for all consumers that use the changed types
Short-lived Temporal workflow for admin-triggered token transfers via the solver's existing TransactionProcessor infrastructure.
- Workflow name:
rebalance-workflow, task queue:core - Workflow ID pattern:
rebalance-{guid} - AdminAPI endpoints:
GET /rebalance(list recent),POST /rebalance(start transfer) - Webhook events:
rebalance.completed,rebalance.failed - Flow: AdminAPI validates wallets → starts RebalanceWorkflow → signals TP with
SubmitTransfer→ waits for callback (10min timeout) → sends webhook notification → returns result - Wallet validation:
FromAddressmust be a system wallet.ToAddressmust be a system wallet OR a trusted wallet. - Key files: Interface
src/Workflow.Abstractions/Workflows/IRebalanceWorkflow.cs, Implsrc/Workflow.Swap/Workflows/RebalanceWorkflow.cs, Activitiessrc/Workflow.Swap/Activities/RebalanceActivities.cs, Endpointsrc/AdminAPI/Endpoints/RebalanceEndpoints.cs
- Central registry:
WebhookEventTypesinsrc/Infrastructure.Abstractions/WebhookEventTypes.cs— constants +Alllist - Event types:
order.created,order.status_changed,order.transaction_created,rebalance.completed,rebalance.failed - Per-subscriber filtering:
WebhookSubscriber.EventTypes(List<string>, maps to PostgreSQLtext[]). Empty list = receive all events (backward compatible). - Filter logic:
WebhookNotificationService.NotifyAsyncskips subscribers whoseEventTypeslist is non-empty and doesn't contain the event type - AdminAPI endpoint:
GET /webhooks/event-typesreturns all available event types - AdminPanel: Create/edit panels have multi-select checkboxes for event types
StationAPI config (station-config.json) can be loaded from a local file or a remote URL (e.g., a GitHub-hosted JSON file):
StationConfig:Source:"local"(default) or"remote"StationConfig:LocalPath: Relative path to local JSON file (default:station-config.json)StationConfig:RemoteUrl: Raw URL to fetch config from (e.g.,https://raw.githubusercontent.com/org/repo/main/station-config.json)StationConfig:RemoteAuthToken: Bearer token for private repos (GitHub PAT)StationConfig:PollingIntervalSeconds: How often to poll remote for changes (optional — omit to disable polling, config fetched once at startup only)
Live reload: When Source=remote and PollingIntervalSeconds is set, a background service polls the URL and updates the in-memory config on change. If omitted, config is loaded once at startup and never refreshed. All consumers use IStationConfigProvider.Current which returns the latest config. Solver HttpClient URLs are resolved dynamically per-request (no DI re-registration needed).
Key types: IStationConfigProvider (DI interface), StationConfigProvider (singleton holder with OnConfigChanged event), RemoteConfigBackgroundService (polls remote URL with ETag/content-hash change detection).
Order endpoints use solver wallet address (not solver name) for routing. The UI only needs the hashlock and solver address (both available on-chain).
Endpoints (replaced solverId-based URLs):
GET /api/v1/orders/{hashlock}?solverAddress=0x...POST /api/v1/orders/{hashlock}/reveal-secret?solverAddress=0x...GET /api/v1/orders/{hashlock}/stream?solverAddress=0x...
solverAddress is optional. If omitted, StationAPI broadcasts to all configured solvers.
Resolution flow:
solverAddress→ in-memory lookup →solverId→ route to solver- Cache miss or no address → broadcast
GetOrderto all solvers in parallel, first hit wins, cache result - Cache populated from quote responses (
source_solver_address,destination_solver_address→ solverId)
Cache: In-memory ConcurrentDictionary (no Redis dependency). Lost on restart — broadcast fallback rebuilds it.
Key types: ISolverAddressIndex (in-memory cache), ISolverResolver (address lookup + broadcast fallback), SolverResolver (implementation).
appsettings.json/appsettings.Local.jsonfor environment config- Infrastructure modules register via DI extensions
- Secrets via Azure Key Vault or Treasury service
Source of truth: csharp/src/AppHost/AppHost.cs. Run via dotnet run --project csharp/src/AppHost/AppHost.csproj.
| Service | Local URL / Port | Details |
|---|---|---|
| AdminAPI | https://localhost:7236 |
Swagger: /swagger |
| AdminPanel (Blazor) | http://localhost:5068 |
Connects to AdminAPI |
| StationAPI | http://localhost:9690 |
Solver aggregator, Swagger: /swagger |
| Aspire Dashboard | https://localhost:17070 |
Resource management & logs |
| PostgreSQL | localhost:5432 |
User: postgres, Password: postgres, persistent volume |
| Temporal | localhost:7233 (gRPC) |
PostgreSQL-backed, persistent |
| Temporal UI | localhost:8233 |
Web UI for workflow inspection |
| Redis | localhost:6379 |
No TLS locally, persistent volume |
| Infisical | http://localhost:8080 | Secrets store, see init steps in AppHost.cs |
| Treasury | http://localhost:3000 | Dockerized JS app, connects to local Infisical |
Optional Grafana stack (when UseGrafanaStack=true):
| Service | Port |
|---|---|
| Grafana | localhost:3001 |
| Loki | localhost:3100 |
| Tempo | localhost:3200 |
| Prometheus | localhost:9090 |
| Alloy (OTLP) | localhost:3300 |