Skip to content

Latest commit

 

History

History
435 lines (358 loc) · 41.3 KB

File metadata and controls

435 lines (358 loc) · 41.3 KB

Train Solver

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 the dtf-experiment branch. Stick with Temporal.

Repository Structure

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)

C# Project (csharp/)

Project Structure

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)

Commands

# 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

Tech Stack

  • .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

Conventions

  • 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 via network.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 async in implementation class (Temporal requirement)
  • String truncation: Use StringExtensions.Truncate(maxLength) instead of inline [..Math.Min()] patterns. Located in src/Util/Extensions/StringExtensions.cs.
  • Quote expiry enforcement: ValidateQuoteAsync checks QuoteExpiry < Workflow.UtcNow after 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.IsTestnet is set at creation time only (not editable via update). NetworkDto.IsTestnet propagated through all projections. AdminPanel defaults to hiding testnets with a toggle.
  • Network.Enabled: Controls whether NetworkRuntimeMonitor starts a runtime for the network. Disabling a network cascades to set all associated routes (source or destination) to RouteStatus.Inactive. All seeded networks default to Enabled = false.
  • Node entity: No ProviderName column — DB nodes are always manual. Provider-resolved nodes get ProviderName set at runtime. NodeDto.IsFromProvider is a computed property (ProviderName is not null). Unique index: (Url, NetworkId, Protocol). Delete by Id (not provider name).
  • WalletType: Managed (0, treasury-generated, status lifecycle) or Whitelisted (1, external address, always Active, deletable). Single Wallet entity — no separate TrustedWallet table. Managed at index 0 ensures backward compatibility with existing DB wallets. Rebalance: FromAddress must be Managed, ToAddress can be any type.

JS Project (js/)

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 build

Starknet (js/src/Blockchain/Blockchain.Starknet/)

Full 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 (in Workflow.Common) runs on core queue, dispatches RuntimeXxx activities remotely to starknet task queue via RuntimeActivities.ts wrappers
  • Transaction Processor: 3-queue pipeline (queued → pending → in-flight) with estimate-first nonce management. Supports all signal types: SubmitTransfer, SubmitLock, SubmitRedeem, SubmitRefund (only SubmitApprove/SubmitCustom rejected). 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 Starknet Call objects for all 6 HTLC functions using CallData.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 from starknet_getEvents RPC. Uses FeltReader for sequential felt consumption. Event selector matching via hash.getSelectorFromName(). Handles u256 (2 felts), ContractAddress (1 felt), ByteArray (variable felts → UTF-8), u64 (1 felt).
  • Event Listener: StarknetRPCLogEventListener (registered as rpc-log-event-listener) — polls starknet_getEvents in block batches. Template dispatch via emitEvent() (routingKey=hashlock → order-{hashlock}). Checkpoint to Redis every 3 iterations. Continue-as-new after 50 iterations. Catch-up mode via args.toBlock.
  • Dispatch Rules: StarknetConstants.tsStarknetDispatchRules maps all 6 event types to signal names and target workflow routing. composeStarknetSubscriptions() builds EventSubscription[] from dispatch rules + filter addresses. UserTokenLocked starts OrderWorkflow on core queue; all others signal-only.
  • GasStation: StarknetGasStation (registered as gas-station) — simplified version that tracks actual fees paid (no separate gas price fetching). Receives ReportGasUsage signal from TP on confirmed tx with txResponse.fee.amount. Exposes GetEstimatedFees query (returns EstimatedFeesDto with 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# QuoteServiceINetworkRuntimeService.GetEstimatedFeesAsync → Temporal typed IGasStation handle query.
  • RuntimeActivities wrapper: js/src/Blockchain/Blockchain.Starknet/Activities/RuntimeActivities.tscreateStarknetRuntimeActivities() registers RuntimeXxx-named wrapper functions that adapt C# parameter format to existing StarknetWorkflowActivities methods. Registered in StarknetWorker.ts.
  • AddressGenerator: StarknetAddressGenerator workflow (registered as address-generator). Generates address via treasury — no on-chain work. Uses networkSlug from GenerateAddressRequest (passed by C# WalletGenerator).
  • AddressActivator: StarknetAddressActivator workflow (registered as address-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# WalletActivation workflow for wallets with Inactive status.
  • Activities: StarknetWorkflowActivities — includes HTLC build methods (buildSolverLockTransaction, buildUserLockTransaction, buildSolverRedeemTransaction, buildUserRedeemTransaction, buildSolverRefundTransaction, buildUserRefundTransaction), event retrieval (getEvents using starknet_getEvents RPC with pagination), checkpoint persistence (getCheckpoint/saveCheckpoint via Redis), address generation, and granular TP pipeline: estimateFee, reserveNonce, unreserveNonce, composeSignedTransaction, publishTransaction, loadTransactionState, saveTransactionState.
  • Starknet-specific types: StarknetFee, PendingSignedTransaction, InFlightTransaction, TransactionProcessorSnapshot, PublishOutcome, PublishTransactionResult, NonceReservationResult in Models/StarknetTransactionProcessorTypes.ts. Shared types (QueuedTransactionRequest, TransactionCallback, etc.) stay in TrainSolver/Models/Workflow/TransactionProcessorTypes.ts.
  • Shared interfaces: ICoreActivities, NetworkWalletDto, StartWorkflowRequest in Models/ICoreActivities.ts — used by runtime, TP, and address generator.
  • Event type mapping (contract → internal): UserLockedUserTokenLocked, SolverLockedSolverTokenLocked, UserRedeemedSolverTokenRedeemed (solver redeems user's lock), SolverRedeemedUserTokenRedeemed (user redeems solver's lock), UserRefundedUserTokenRefunded, SolverRefundedSolverTokenRefunded
  • Starknet event format: keys[] (first key = event selector via sn_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' triggers signer.signDeployAccountTransaction() instead of invoke signing
  • Address & hash canonical format: 0x + 64 lowercase hex chars (e.g., 0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7). normalizeStarknetHex() in Helpers/StarknetAddressHelper.ts wraps addAddressPadding(hex).toLowerCase(). Applied at system entry points: runtime (wallet addresses, update handler inputs), TP init (walletAddress), address generator (treasury return), activities (toAddress, generateAddress return), and RPC client (tx hashes from trySendInvocation/sendDeployAccount). Activities also keep defensive normalization internally (belt-and-suspenders).
  • Same Dockerfile as Aztec: Differentiated by TrainSolver__NetworkType env var
  • AppHost: workflow-runner-starknet container with TrainSolver__NetworkSlug=starknet-sepolia

Solana HTLC Contract Reference

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, _sol suffix): 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_amount and reward_amount are u128 in instruction args and events. Serialized as 16-byte LE in Borsh. C# uses BigInteger for these fields.
  • Timelock: Solana stores timelockDelta (relative seconds). Converted to absolute Unix timestamp via blockTime + 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 HTLCLock cost on Solana = meta.fee + rent-exempt surcharge for the PDAs created by solver_lock (SolverLock, solver_vault, optional solver_reward_vault, solver_count). Single upper-bound constant (SolanaHelper.RentCosts.HTLCLockUpperBound ≈ 7.6M lamports) is added in SolanaTransactionProcessorWorker.ReportGasUsageAsync. Rent is a real sunk cost on the happy-path redeem (accounts are only closed on the refund path via close_solver_lock), so it must be baked into the quote's expense fee. Redeem/Refund/Transfer reports stay as raw meta.fee.

Treasury (treasury/)

Infisical-backed key management and signing service.

# Install (from treasury/)
npm ci

# Run (from treasury/)
npm start

gRPC Network Service

Each 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 by Network.Common and network gRPC servers.
  • Network.Grpc.Shared — Proto compilation (protos/blockchain.proto, solver.proto, etc.) + ProtoMapper (domain ↔ proto type conversion). Used by Core/Gateway gRPC and Network.Grpc.Client.
  • Network.Grpc.ClientGrpcNetworkRuntimeService, GrpcGasStationService, NetworkServiceRegistry (slug → endpoint routing)
  • Network.Common — Chain-agnostic infrastructure (WorkerRegistry, IKeyStore/InfisicalKeyStore, INetworkConfigProvider, EventListenerWorkerBase, ICheckpointRepository, IGasUsageHistoryRepository). References Network.Proto for proto types. No dependency on Shared.Models or Workflow.Abstractions.
  • Network.EVM.Shared — Shared EVM-compatible types (blockchain reader, tx builder, EventDecoder, FunctionMessages, EventModels). References Network.Common.
  • Network.EVM.Grpc — Self-contained EVM gRPC server. Owns ALL EVM blockchain logic
  • Network.Tron.Grpc — Self-contained Tron gRPC server. Uses Network.Common for chain-agnostic types and Network.EVM.Shared for shared EVM-compatible types
  • Network.Solana.Grpc — Self-contained Solana gRPC server. Uses SolNet SDK, Anchor/Borsh HTLC, Ed25519 signing via Infisical

Network config pattern (INetworkConfigProvider):

  • Singleton NetworkConfigProvider (implements IHostedService) fetches network config from ConfigurationService gRPC at startup, auto-refreshes every 60 seconds.
  • Network services inject INetworkConfigProvider and access Network property directly — no per-request HTTP calls.
  • Config env var: TrainSolver__CoreGrpcUrl (replaces old TrainSolver__AdminApiUrl).

Event dispatch pattern:

  • Event listeners emit HTLC events via EventCollectorService.EmitHTLCEvent gRPC call (not Temporal client).
  • Transaction processors report results via EventCollectorService.ReportTransactionResult gRPC call (not Temporal signal).
  • Core.Grpc receives these calls and internally uses Temporal to signal OrderWorkflow, start workflows, etc.
  • EventCollectorServiceClient is injected from Network.Proto generated 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. ProtoMapper handles all conversions. Use global::Grpc.Core.RpcException to avoid namespace collision with Train.Solver.Network.Grpc.
  • Network services receive config from INetworkConfigProvider (fetched from ConfigurationService gRPC) — callers only send network_slug.
  • NetworkServiceRegistry caches GrpcChannel per endpoint. Config: NetworkGrpc:DefaultEndpoint + NetworkGrpc:Endpoints dict (slug → URL).
  • To swap from Temporal to gRPC: replace WithCoreServices() with WithGrpcNetworkClient() 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: SubmitCustomRequest with CustomTransactionRequest (correlation_id, callback, sender, to_address, call_data, amount, token_contract).
  • EVM: Fully supported. FeeEstimationService.BuildAndEstimateTransactionAsync short-circuits the builder via CustomPreparedTransaction and runs eth_estimateGas for validation.
  • Solana/Tron/Starknet: Returns Unimplemented (EVM-only for now).
  • AdminAPI: POST /api/custom-transaction with { networkSlug, walletAddress, toAddress, callData?, amount, tokenContract, correlationId? }. CorrelationId defaults to custom-{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: CustomTransactionRequest in Shared.Models/Workflow/TransactionProcessorModels.cs.
  • Client wrapper: IGrpcNetworkService.SubmitCustomAsync in Infrastructure.Network.Grpc.

Network-scoped wallets (CAIP-2 identification):

  • Wallets have an optional NetworkId FK (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 from TrainSolver__NetworkType + TrainSolver__ChainId env vars. Replaces old TrainSolver__NetworkSlug.
  • ConfigurationService.GetWallets accepts CAIP-2 identifier, parses type + chainId, returns wallets where NetworkId IS NULL (universal) OR Network.ChainId == chainId.
  • ConfigurationService.GetNetwork also accepts CAIP-2 (resolved via existing EFNetworkRepository.GetAsync which supports CAIP-2 lookup).
  • NetworkConfigProvider builds CAIP-2 from env vars. Internal code still uses configProvider.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, writes tx-result:{correlationId} = pending to Redis, proxies to network gRPC SubmitCustomTransaction. Returns correlationId immediately.
  • GetTransactionStatus: reads tx-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 empty callback.WorkflowId). Temporal signaling only fires when WorkflowId is set.
  • Redis key: tx-result:{correlationId} → JSON { status, txHash, blockNumber, failureReason, networkSlug, timestamp }, TTL 24h.
  • Proto: ExecutionService in solver.proto, network_slug field added to ReportTransactionResultRequest in events.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, no virtual methods.
  • 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 to Workflow.EVM, Workflow.Abstractions, Shared.Models, or Infrastructure.Secret.Treasury.
  • Data layer: Separate EVMDbContext (not shared SolverDbContext). 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 uses pg_advisory_xact_lock instead of Redis Redlock.
  • Class names preserved: EVMReadActivities, EVMFeeActivities etc. keep their names for now (rename to *Service is cosmetic, done later). Interfaces IEVMReadActivities etc. also preserved — they live in Train.Solver.Network.EVM.Grpc.Blockchain namespace.
  • Workers use the blockchain interfaces directly (e.g., IEVMFeeActivities, IEVMNonceActivities) — no intermediate service layer.

Azure Key Vault signing (EVM — private keys never leave KV):

  • IEVMSigningService interface (Blockchain/IEVMSigningService.cs) — SignTransaction1559Async, SignLegacyTransactionAsync.
  • AzureKeyVaultSignerService (Blockchain/AzureKeyVaultSignerService.cs) — uses Nethereum.Signer.AzureKeyVault.AzureKeyVaultExternalSigner for in-vault signing. Caches signer instances per wallet address. Inner CachedAzureKeyVaultSigner overrides GetPublicKeyAsync() to cache public key (avoids extra KV call per sign). Resolves wallet address → slug (KV key name) from mapping populated by TransactionProcessorManager.
  • AzureKeyVaultAddressService (Blockchain/AzureKeyVaultAddressService.cs) — generates P-256K keys in KV or imports existing private keys. Uses wallet Slug as KV key name.
  • TransactionPublisherService uses IEVMSigningService (injected). Agnostic to signing backend.
  • NetworkGrpcService.GenerateAddress RPC wired to AzureKeyVaultAddressService. Receives wallet slug from GenerateAddressRequest.Slug.
  • Config: AzureKeyVault:VaultUri env var (e.g., https://prod-ts-evm.vault.azure.net/). Auth via DefaultAzureCredential (workload identity in k8s, az login locally).
  • Wallet slug: Auto-generated on wallet creation (RandomFriendlyNameGenerator), immutable, stored in Wallet.Slug. Used as Azure KV key name. Returned via ConfigurationService.GetWallets as WalletConfigDto { 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): EVMSigningService and EVMAddressGeneratorService still exist but are not registered in DI. Tron/Solana/Starknet still use InfisicalKeyStore from Network.Common.
  • No Infisical.Sdk dependency — removed from Network.EVM.Grpc.csproj. Replaced by Azure.Identity + Azure.Security.KeyVault.Keys + Nethereum.Signer.AzureKeyVault.

AdminAPI health/infrastructure endpoints use gRPC:

  • HealthEndpoints (/health/*) calls NetworkServiceRegistry.GetClient(slug).GetHealthAsync() to get GetHealthServiceResponse with EventListenerHealthInfo and TransactionProcessorHealthInfo. No ITemporalClient dependency.
  • 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 between Train.Solver.Proto.DetailedNetworkDto and Train.Solver.Shared.Models.DetailedNetworkDto.
  • NetworkServiceRegistry may 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: SolanaSigningService uses IKeyStore from Network.Common for key storage, but generates Ed25519 keypairs (not secp256k1). Does NOT call IKeyStore.GenerateKeyPairAsync().
  • Blockhash-based TP: No nonce reservation. Uses recentBlockhash + lastValidBlockHeight for tx expiry. Retries with 1.5x priority fee escalation (max 3 retries).
  • Solana-specific checkpoint: ISolanaCheckpointRepository stores { Slot, Signature } (not just block number)
  • Solana-specific gas history: ISolanaGasUsageHistoryRepository stores Dict<string, List<ulong>> in lamports (not BigInteger)
  • Event detection: getSignaturesForAddress(HTLCProgramId) + Anchor instruction discriminator parsing (not RPC log topic filtering)
  • AppHost: network-solana-grpc with TrainSolver__NetworkSlug=solana-devnet, Infisical config, TrainSolver__CoreGrpcUrl pointing to Core.Grpc, wired to Workflow.Swap via NetworkGrpc__Endpoints__solana-devnet

Planning Rule

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 → run dotnet test csharp/tests/Workflow.Tests/Workflow.Tests.csproj --filter "FullyQualifiedName~OrderWorkflow"
  • Changes to Data.Npgsql → run dotnet test csharp/tests/Data.Tests/Data.Tests.csproj
  • Changes to Util utilities → run dotnet test csharp/tests/Util.Tests/Util.Tests.csproj
  • Changes to TransactionProcessor → run dotnet 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

RebalanceWorkflow (Cross-Chain Transfer)

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: FromAddress must be a system wallet. ToAddress must be a system wallet OR a trusted wallet.
  • Key files: Interface src/Workflow.Abstractions/Workflows/IRebalanceWorkflow.cs, Impl src/Workflow.Swap/Workflows/RebalanceWorkflow.cs, Activities src/Workflow.Swap/Activities/RebalanceActivities.cs, Endpoint src/AdminAPI/Endpoints/RebalanceEndpoints.cs

Webhook Event Filtering

  • Central registry: WebhookEventTypes in src/Infrastructure.Abstractions/WebhookEventTypes.cs — constants + All list
  • Event types: order.created, order.status_changed, order.transaction_created, rebalance.completed, rebalance.failed
  • Per-subscriber filtering: WebhookSubscriber.EventTypes (List<string>, maps to PostgreSQL text[]). Empty list = receive all events (backward compatible).
  • Filter logic: WebhookNotificationService.NotifyAsync skips subscribers whose EventTypes list is non-empty and doesn't contain the event type
  • AdminAPI endpoint: GET /webhooks/event-types returns all available event types
  • AdminPanel: Create/edit panels have multi-select checkboxes for event types

StationAPI Configuration Source

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).

StationAPI Solver Address Resolution

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:

  1. solverAddress → in-memory lookup → solverId → route to solver
  2. Cache miss or no address → broadcast GetOrder to all solvers in parallel, first hit wins, cache result
  3. 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).

Configuration

  • appsettings.json / appsettings.Local.json for environment config
  • Infrastructure modules register via DI extensions
  • Secrets via Azure Key Vault or Treasury service

Development Environment (Aspire)

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