Getting Started
Prerequisites
- .NET 8 SDK (8.0+)
- Docker (for PostgreSQL)
- Node.js 20+ (for client-side tools, optional)
- ngrok (to expose the API to mobile/webapp over HTTPS)
Running Locally
# 1. Start PostgreSQL
cd backend
docker-compose up -d
cd Redatium.Api
# 2. Initialize the database
psql -U postgres -h localhost -p 5432 -d SOLIDUS -f Database/recreate_database.sql
psql -U postgres -h localhost -p 5432 -d SOLIDUS -f Database/seed_database.sql
# 3. Run the API
dotnet run
# Listening on http://0.0.0.0:5032
# 4. Expose via ngrok (required for mobile app)
ngrok http 5032
# Copy the HTTPS URL to mobile/.env (REACT_APP_API_BASE_URL) and webapp/.env (VITE_API_BASE_URL)
Swagger
In development mode, Swagger UI is available at http://localhost:5032/swagger.
Authentication Flow
Wallet-Based Auth (SIWE)
- Client checks wallet registration via
GET /user/wallet/check/{walletAddress}. - Client builds a SIWE (Sign-In With Ethereum) message and signs it with the user's wallet (MetaMask removed in the webapp; WalletConnect / ethers signer in webapp and mobile).
- Client POSTs the
(message, signature)pair toPOST /api/auth/siwe-verify(anonymous). The backend (AuthService) re-parses the SIWE message, verifies the signature server-side, and issues a JWT bound to the recovered wallet address. - Client sends the JWT as
Authorization: Bearer <token>on subsequent calls; JWT bearer auth validates the token and populatesHttpContext.User. Note:CurrentUserServicecurrently resolves the caller from theX-User-Idrequest header (wallet address or user GUID), not from the JWT claim — migrating it to read the JWTwallet/subclaim is pending.
Note: A handful of endpoints are intentionally anonymous ([AllowAnonymous]): the SIWE verify itself, the wallet-registration check, the public earnings read, /events/since, and /health/indexer. State-channel endpoints (/api/statechannel/*) are not [Authorize]-gated at all — wallet identity is taken from URL/body params and matched against the channel record server-side; tightening this to the JWT claim is on the auth-hardening backlog.
Base URL and Conventions
Development base URL: https://<ngrok-subdomain>.ngrok-free.dev (changes on each ngrok restart)
Request Format
All POST endpoints accept:
{
"header": {
"userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
},
"payload": {
// endpoint-specific data
}
}
Response Format
All endpoints return:
{
"payload": { /* result data */ },
"exception": null
}
On error:
{
"payload": null,
"exception": {
"code": "DP-404",
"description": "Resource not found"
}
}
Error codes follow a prefix convention:
DP-4xx: Domain/business errorsTE-5xx: Technical/infrastructure errorsSC-4xx: State channel errors
Auth
/api/authC# Code
public sealed class SiweVerifyRequest
{
public string Message { get; set; } // The unsigned SIWE message (EIP-4361 text)
public string Signature { get; set; } // 0x-prefixed signature hex
}
public sealed class SiweVerifyResponse
{
public string Token { get; set; } // JWT — send as Bearer on subsequent requests
public string WalletAddress { get; set; } // Lowercase recovered wallet address
public DateTimeOffset ExpiresAt { get; set; } // JWT expiry
}POST/api/authSIWE Verify
The backend:
1. Parses the SIWE message (domain, address, nonce, chain ID).
2. Re-derives the EIP-191 personal_sign hash and recovers the signer.
3. Asserts the recovered address matches the address field in the message.
4. Issues a JWT with claims for the wallet address; lifetime per JwtSettings.
Failures surface as a Response.Exception with category Business (e.g. invalid signature, mismatched address, expired or stale Issued At).
---
Users
/userC# Types: UserWalletDto
// DTOs
public class UserWalletDto
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string WalletAddress { get; set; }
public decimal Balance { get; set; }
}GET/user/walletGet Wallet
Response UserWalletDto
| Field | Type | Description |
|---|---|---|
id | uuid | Wallet record ID |
userId | uuid | Owner user ID |
walletAddress | string | Ethereum address |
balance | decimal | Current balance |
---
GET/user/earnings/{walletAddress}Get Earnings
Returns the wallet's lifetime "Earned via Redatium" total. Public — no auth required. Returns zero for wallets that have never earned.
The total is the running sum of FundsWithdrawn event amounts indexed off the DataTransferAdjudicator contract, credited idempotently per (blockNumber, logIndex) into the user_earnings table.
Response UserEarningsDto
| Field | Type | Description |
|---|---|---|
walletAddress | string | Lowercased address echoed back |
totalEarnedWei | string | Cumulative wei. **String** (not number) — JS would lose precision at 2^53. |
totalEarnedEth | decimal | totalEarnedWei / 1e18, convenience field |
updatedAt | datetime? | Last credit timestamp (null if never credited) |
Live updates also push via SignalR EarningsUpdated on the wallet group whenever a new FundsWithdrawn is indexed — see §6.
---
GET/user/wallet/check/{walletAddress}Check Wallet Registration
No authentication required.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
walletAddress | string | Ethereum address to check |
Response
---
Datasets
/datasetC# Types: DatasetDto, CreateDatasetDto, BrowseDatasetRequestDto, BrowseDatasetDto
public class DatasetDto
{
public Guid Id { get; set; }
public Guid OwnerId { get; set; }
public string Name { get; set; }
public string? Description { get; set; }
public Guid? DatasetTypeId { get; set; }
public string? Type { get; set; } // Joined from DatasetType
public string? TypeMachineName { get; set; }
public string? SourceInfo { get; set; } // JSON metadata
public decimal? QualityScore { get; set; }
// Owner-reported availability — see /dataset/owner-unavailable
public string AvailabilityState { get; set; } // "available" | "unavailable_owner_deleted"
public DateTime? AvailabilityCheckedAt { get; set; }
public string? AvailabilityReason { get; set; }
public DateTime Created { get; set; }
public DateTime? Changed { get; set; }
}
public class CreateDatasetDto
{
public Guid OwnerId { get; set; }
public string Name { get; set; }
public string? Description { get; set; }
public Guid? DatasetTypeId { get; set; }
public string? SourceInfo { get; set; }
public decimal? QualityScore { get; set; }
}
public class BrowseDatasetRequestDto
{
public int PageLimit { get; set; } = 20;
public int PageOffset { get; set; } = 0;
public Guid? DatasetTypeId { get; set; }
public int? MinRating { get; set; }
public DateTime? CreatedFrom { get; set; }
public DateTime? CreatedTo { get; set; }
public string? SearchTerm { get; set; }
public string SortField { get; set; } = "created";
public string SortOrder { get; set; } = "desc";
}
public class BrowseDatasetDto
{
public Guid Id { get; set; }
public Guid OwnerId { get; set; }
public string? OwnerUsername { get; set; }
public string Name { get; set; }
public string? Description { get; set; }
public string? Type { get; set; }
public string? TypeMachineName { get; set; }
public DateTime Created { get; set; }
public decimal? QualityScore { get; set; }
public decimal? AverageRating { get; set; }
public decimal? Price { get; set; }
public int TotalSales { get; set; }
// Owner-reported availability
public string AvailabilityState { get; set; } // "available" | "unavailable_owner_deleted"
public string? AvailabilityReason { get; set; }
public DateTime? AvailabilityCheckedAt { get; set; }
}GET/dataset/listList My Datasets
Returns all datasets owned by the authenticated user.
Response ReturnListDatasetDto
---
GET/dataset/{id}Get Dataset
Path Parameters
| Parameter | Type |
|---|---|
id | uuid |
Response DatasetDto
---
POST/dataset/createCreate Dataset
Request Body CreateDatasetDto
| Field | Type | Required | Description |
|---|---|---|---|
ownerId | uuid | Yes | Owner user ID |
name | string | Yes | Dataset name |
description | string | No | Description |
datasetTypeId | uuid | No | Category type ID |
sourceInfo | string | No | JSON with source metadata |
qualityScore | decimal | No | Quality score 0-5 |
Response DatasetDto
---
POST/dataset/updateUpdate Dataset
Request Body UpdateDatasetDto
| Field | Type | Required |
|---|---|---|
id | uuid | Yes |
ownerId | uuid | Yes |
name | string | No |
description | string | No |
datasetTypeId | uuid | No |
sourceInfo | string | No |
qualityScore | decimal | No |
---
POST/dataset/deleteDelete Dataset
Request Body DeleteDatasetDto
| Field | Type | Required |
|---|---|---|
datasetId | uuid | Yes |
---
POST/dataset/browseBrowse Marketplace
The main marketplace search endpoint. Returns datasets from all users.
Request Body BrowseDatasetRequestDto
| Field | Type | Default | Description |
|---|---|---|---|
pageLimit | int | 20 | Max results |
pageOffset | int | 0 | Skip count |
datasetTypeId | uuid | null | Filter by type |
minRating | int | null | Min average rating |
createdFrom | datetime | null | Created after |
createdTo | datetime | null | Created before |
searchTerm | string | null | Free text search |
sortField | string | "created" | Sort column |
sortOrder | string | "desc" | asc or desc |
Response List
---
GET/dataset/typesList Dataset Types
Response List
---
Dataset Feedback
/dataset-feedbackC# Types: EligibilityRequestDto, EligibilityResponseDto, DatasetEligibilityDto
public class EligibilityRequestDto
{
public List<Guid> DatasetIds { get; set; }
}
public class EligibilityResponseDto
{
public List<DatasetEligibilityDto> Items { get; set; }
}
public class DatasetEligibilityDto
{
public Guid DatasetId { get; set; }
public bool Eligible { get; set; } // true ⇔ DataSharingRequests.Status='completed' for this buyer
public int? CurrentRating { get; set; } // 1-5 if previously rated, null otherwise
}POST/dataset-feedback/eligibilityBatch Eligibility Check
Batched: pass every datasetId visible on the page, get one row back per id. Eligibility is satisfied by a single completed purchase — re-rating an already-rated dataset is allowed (the on-chain contract overwrites). The UI should render an interactive star input where eligible: true and a read-only badge otherwise.
Request Body EligibilityRequestDto
| Field | Type | Required | Description |
|---|---|---|---|
datasetIds | uuid[] | Yes | Datasets to check (no hard upper bound — the backend resolves with a single SQL query) |
Response EligibilityResponseDto
---
Data Sharing Requests
/requestC# Types: DataSharingRequestDto, CreateDataSharingRequestDto, ListDataSharingRequestDto
public class DataSharingRequestDto
{
public Guid Id { get; set; }
public Guid RequestorId { get; set; }
public Guid OwnerId { get; set; }
public Guid DatasetId { get; set; }
public string Status { get; set; } // "pending", "approved", "declined", "completed"
public string? Purpose { get; set; }
public string? DatasetName { get; set; }
public string? DatasetType { get; set; }
public decimal? Price { get; set; }
public string? OwnerUsername { get; set; }
public string? RequestorUsername { get; set; }
public string? MagnetUri { get; set; }
public string? TransferStatus { get; set; } // "seeding", "downloading", "completed"
public DateTime? SeedingStartedAt { get; set; }
public DateTime? DownloadCompletedAt { get; set; }
public string? HashLock { get; set; }
public string? EscrowTxHash { get; set; }
public string? EscrowSecret { get; set; }
public string? EscrowState { get; set; }
public DateTime? EscrowExpiry { get; set; }
public DateTime? BuyerConfirmedAt { get; set; }
public string? ClaimTxHash { get; set; }
public DateTime Created { get; set; }
}
public class CreateDataSharingRequestDto
{
public Guid? RequestorId { get; set; } // Auto-set from auth if null
public Guid OwnerId { get; set; }
public Guid DatasetId { get; set; }
public string? Purpose { get; set; }
}
public class ListDataSharingRequestDto
{
public int PageLimit { get; set; } = 10000;
public int PageOffset { get; set; } = 0;
public string? SortField { get; set; }
public string? SortOrder { get; set; }
public string? Status { get; set; }
public Guid? RequestorId { get; set; }
public Guid? OwnerId { get; set; }
}GET/request/{id}Get Request
Response DataSharingRequestDto
---
POST/request/listList Requests
Request Body ListDataSharingRequestDto
| Field | Type | Default | Description |
|---|---|---|---|
pageLimit | int | 10000 | Max results |
pageOffset | int | 0 | Skip count |
sortField | string | "Created" | Sort column |
sortOrder | string | "desc" | asc or desc |
status | string | null | Filter: pending, approved, declined, completed |
requestorId | uuid | null | Filter by requestor |
ownerId | uuid | null | Filter by dataset owner |
Response ReturnListDataSharingRequestDto — { data: DataSharingRequestDto[], metadata }
---
POST/request/createCreate Request
Request Body CreateDataSharingRequestDto
| Field | Type | Required | Description |
|---|---|---|---|
requestorId | uuid | No | Auto-set from auth if null |
ownerId | uuid | Yes | Dataset owner |
datasetId | uuid | Yes | Target dataset |
purpose | string | No | Reason for request |
---
POST/request/respondRespond to Request
Request Body RespondDataSharingRequestDto
| Field | Type | Required | Description |
|---|---|---|---|
id | uuid | Yes | Request ID |
status | string | Yes | approved or declined |
---
POST/request/create-bulkBulk Create Requests
Request Body CreateBulkDataSharingRequestDto
| Field | Type |
|---|---|
requests | CreateDataSharingRequestDto[] |
Response BulkRequestResultDto — { successful: DataSharingRequestDto[], failed: FailedRequestDto[] }
---
POST/request/{id}/start-seedingStart Seeding
Called by the seller after creating a torrent.
Request Body StartSeedingDto
| Field | Type | Required |
|---|---|---|
magnetUri | string | Yes |
---
GET/request/{id}/magnet-uriGet Magnet URI
Response { magnetUri: string }
---
POST/request/{id}/complete-downloadComplete Download
Marks the transfer as completed by the buyer. No request body.
---
POST/request/{id}/cancel-downloadCancel Download
Cancels an in-progress download. No request body.
---
POST/request/{id}/set-priceSet Price
Request Body SetPriceDto
| Field | Type | Required |
|---|---|---|
price | decimal | Yes |
---
Transactions
/transactionGET/transaction/listList Transactions
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
pageLimit | int | 100 | Max results |
pageOffset | int | 0 | Skip count |
sortField | string | null | Sort column |
sortOrder | string | null | asc or desc |
status | string | null | Filter by status |
datasetId | uuid | null | Filter by dataset |
Response ReturnListDataTransactionDto — { data: DataTransactionDto[], metadata }
DataTransactionDto: { id, buyerId, sellerId, datasetId, datasetName, datasetType, amount, status, blockchainTx, created, buyerUsername, sellerUsername }
---
POST/transaction/createCreate Transaction
Request Body CreateDataTransactionDto
| Field | Type | Required |
|---|---|---|
buyerId | uuid | Yes |
sellerId | uuid | Yes |
datasetId | uuid | Yes |
amount | decimal | Yes |
---
POST/transaction/create-bulkBulk Create Transactions
Request Body CreateBulkDataTransactionDto — { transactions: CreateDataTransactionDto[] }
Response BulkTransactionResultDto — { successful: [], failed: [] }
---
GET/transaction/status?transactionId={uuid}Get Transaction Status
Response DataTransactionDto
---
GET/transaction/tokenList Token Transactions
Query Parameters
| Parameter | Type | Description |
|---|---|---|
pageLimit | int | Max results |
pageOffset | int | Skip count |
transactionType | string | Filter by type |
Response ReturnListTokenTransactionDto
TokenTransactionDto: { id, userId, amount, type, relatedTxId, created }
---
GET/transaction/recent-activityRecent Activity
Returns the top 20 most recent transactions for the dashboard.
---
Notifications
/notificationPOST/notification/listList Notifications
Request Body NotificationListRequestDto
| Field | Type | Default |
|---|---|---|
pageLimit | int | 100 |
pageOffset | int | 0 |
isRead | bool | null |
Response ReturnListNotificationDto
NotificationDto: { id, userId, type, message, status, created }
---
POST/notification/readMark as Read
Request Body NotificationReadRequestDto
| Field | Type | Required |
|---|---|---|
notificationId | uuid | Yes |
---
POST/notification/clear-allClear All Notifications
Deletes every notification row for the current user. Drives the "Clear" button on the mobile notifications screen. Returns the count of rows deleted.
Request Body: none.
Response
---
POST/notification/device-tokensRegister Device Token (Push Notifications)
Registers an FCM (Firebase Cloud Messaging) token for a wallet so the backend can send push notifications when relevant on-chain events occur. Idempotent: re-registering the same (walletAddress, fcmToken) pair updates the existing row's timestamp instead of creating a duplicate.
Request Body RegisterDeviceTokenRequestDto
| Field | Type | Required | Description |
|---|---|---|---|
walletAddress | string | Yes | Lowercase wallet address (e.g. 0xabc...) |
fcmToken | string | Yes | FCM registration token from messaging().getToken() |
platform | string | No | "android" or "ios" (default: "android") |
The mobile app calls this automatically from WalletProvider whenever a wallet connects or switches.
---
DELETE/notification/device-tokensUnregister Device Token
Removes a single (walletAddress, fcmToken) row. Called on wallet disconnect or when the FCM SDK reports that the token has been invalidated. The IPushNotificationService also auto-deletes rows when FCM responds with UNREGISTERED or INVALID_ARGUMENT during a push send, so manual unregistration is mostly defensive.
Request Body UnregisterDeviceTokenRequestDto
| Field | Type | Required |
|---|---|---|
walletAddress | string | Yes |
fcmToken | string | Yes |
(No platform field — only the wallet/token pair is needed to delete the row.)
---
POST/notification/admin/push-testAdmin Push Test (Dev only)
Smoke-test endpoint that fires an arbitrary push to a wallet. Compiled into Debug builds only (#if DEBUG); returns 404 if hit on a Release build because the controller method does not exist.
| Header | Required | Description |
|---|---|---|
X-Dev-Auth | Yes | Must equal Notifications:AdminPushTestSecret from appsettings.Development.json |
Request Body AdminPushTestRequestDto
| Field | Type | Required |
|---|---|---|
walletAddress | string | Yes |
message | string | No (default: "Test message from /admin/push-test") |
If no FCM service-account credentials are configured, the underlying IPushNotificationService is a silent no-op and the endpoint returns 200 even though no push fires. Check the backend logs for [Push] No Firebase credentials configured.
---
Push Notification Trigger (internal, not an endpoint)
The backend never exposes "send push" as a public endpoint. Pushes are emitted internally from three call sites — all fire-and-forget, all wrapped so failures never roll back the underlying DB write:
| Call site | When | Why two API paths plus the indexer |
|---|---|---|
DataSharingRequestService.CreateAsync (declared at DataSharingRequestService.cs:154, push call at line 270) | Buyer hits POST /request/create | Push fires the moment the request row is inserted from the API path — no need to wait for chain confirmation |
DataSharingRequestService.CreateBulkAsync (declared at DataSharingRequestService.cs:407, push call at line 519) | Buyer hits POST /request/create-bulk | Same as above, per-request inside the bulk loop |
BlockchainEventListenerService | Indexer decodes a RedatiumStored event with JSON type: "data-request-ask" | Catches requests created on-chain (out-of-band of the API path), or replays missed pushes if the API call's push attempt failed |
Note on contract events: there is no Solidity event called DataSharingRequested. The on-chain event is RedatiumStored; the indexer dispatches on the inner JSON type field — "data-request-ask" becomes a DataSharingRequest row.
Payload format:
The notification block makes Android draw the system tray automatically when the app is backgrounded or killed. The data block drives in-app routing (mobile reads data.type and navigates to Requests with the highlight ID).
---
State Channels
/api/statechannelC# Types: StateChannelDto, SignedStateDto, VirtualEscrowDto, OpenChannelRequest, SubmitSignedStateRequest
public class StateChannelDto
{
public Guid Id { get; set; }
public string ChannelId { get; set; } // bytes32 hex
public string Participant0 { get; set; } // Seller address
public string Participant1 { get; set; } // Buyer address
public long ChannelNonce { get; set; }
public string Allocation0 { get; set; } = "0"; // Seller allocation (wei)
public string Allocation1 { get; set; } = "0"; // Buyer allocation (wei)
public long LatestTurnNum { get; set; }
public string Status { get; set; } // "Open", "Closed", "Disputed"
public string ChannelType { get; set; } // "DataTransfer"
public string? OpenTxHash { get; set; }
public string? CloseTxHash { get; set; }
public DateTime Created { get; set; }
}
public class SignedStateDto
{
public Guid Id { get; set; }
public string ChannelId { get; set; }
public long TurnNum { get; set; }
public decimal Allocation0 { get; set; }
public decimal Allocation1 { get; set; }
public bool IsFinal { get; set; }
public string? AppDataJson { get; set; }
public string? Signature0 { get; set; }
public string? Signature1 { get; set; }
public int ChunkIndex { get; set; }
public string? ChunkHash { get; set; }
public string StateType { get; set; } = "GAME"; // SETUP, GAME, CONCLUDE
public DateTime Created { get; set; }
}
public class VirtualEscrowDto
{
public Guid Id { get; set; }
public string PurchaseId { get; set; }
public string BuyerChannelId { get; set; }
public string SellerChannelId { get; set; }
public Guid? DatasetId { get; set; }
public string? DatasetName { get; set; }
public string BuyerAddress { get; set; }
public string SellerAddress { get; set; }
public decimal Price { get; set; }
public string HashLock { get; set; }
public string? SecretPreimage { get; set; }
public string Status { get; set; }
public string? MagnetUri { get; set; }
public DateTime? TimeoutAt { get; set; }
public DateTime Created { get; set; }
}
public class OpenChannelRequest
{
public string CounterpartyAddress { get; set; }
public decimal DepositAmountEth { get; set; }
public string ChannelType { get; set; }
}
public class SubmitSignedStateRequest
{
public string ChannelId { get; set; }
public long TurnNum { get; set; }
public string Allocation0 { get; set; } = "0";
public string Allocation1 { get; set; } = "0";
public bool IsFinal { get; set; }
public string? AppDataJson { get; set; }
public string Signature0 { get; set; }
public string Signature1 { get; set; }
public int ChunkIndex { get; set; }
public string? ChunkHash { get; set; }
public string StateType { get; set; } = "GAME";
}GET/api/statechannel/stuck-balances/{walletAddress}Get Stuck Balances
Returns balances locked in channels that have been finalized on-chain but not yet withdrawn by the wallet — used by the mobile / webapp "stuck funds" recovery UI.
If the blockchain RPC is unreachable, the endpoint returns HTTP 503 with a bestEffort: true response payload built from cached database state, so the UI can still surface stuck channels even during chain outages.
Path Parameters
| Field | Type | Description |
|---|---|---|
walletAddress | string | Lowercase wallet address |
Response Response — one entry per channel with non-zero withdrawable allocation.>
---
GET/api/statechannel/{channelId}Get Channel
Response StateChannelDto
---
GET/api/statechannel/user/{walletAddress}Get User Channels
Returns all channels where this wallet is a participant.
Response List
---
POST/api/statechannel/closeClose Channel
Request Body CooperativeCloseRequest
| Field | Type | Required |
|---|---|---|
channelId | string | Yes |
finalAllocation0 | decimal | Yes |
finalAllocation1 | decimal | Yes |
signature0 | string | Yes |
signature1 | string | Yes |
---
GET/api/statechannel/{channelId}/statesGet Signed States
Response List
SignedStateDto: { id, channelId, turnNum, allocation0, allocation1, isFinal, appDataJson, signature0, signature1, chunkIndex, chunkHash, stateType, created }
---
POST/api/statechannel/{channelId}/statesSubmit Signed State
Request Body SubmitSignedStateRequest
| Field | Type | Default | Description |
|---|---|---|---|
channelId | string | — | Channel ID |
turnNum | long | — | Turn number |
allocation0 | string | "0" | Seller allocation (wei) |
allocation1 | string | "0" | Buyer allocation (wei) |
isFinal | bool | — | Is this a final state |
appDataJson | string | null | App-specific JSON |
signature0 | string | — | Seller signature |
signature1 | string | — | Buyer signature |
chunkIndex | int | — | Chunk index |
chunkHash | string | null | Hash of delivered chunk |
stateType | string | "GAME" | SETUP, GAME, CONCLUDE |
---
GET/api/statechannel/purchases/seller/{address}Get Seller Purchases
Response List
VirtualEscrowDto: { id, purchaseId, buyerChannelId, sellerChannelId, datasetId, datasetName, buyerAddress, sellerAddress, price, hashLock, secretPreimage, status, magnetUri, timeoutAt, created }
---
GET/api/statechannel/purchases/buyer/{address}Get Buyer Purchases
Response List
---
POST/api/statechannel/purchase/proposePropose Purchase
Request Body ProposePurchaseRequest
| Field | Type | Required |
|---|---|---|
buyerChannelId | string | Yes |
datasetId | uuid | Yes |
sellerAddress | string | Yes |
ownerId | uuid | No |
price | decimal | Yes |
priceEth | string | Yes |
hashLock | string | Yes |
---
POST/api/statechannel/purchase/acceptAccept Purchase
Request Body AcceptPurchaseRequest
| Field | Type | Required |
|---|---|---|
purchaseId | string | Yes |
hashLock | string | Yes |
magnetUri | string | Yes |
---
POST/api/statechannel/settle-purchaseSettle Purchase
Request Body SettlePurchaseRequest
| Field | Type | Default |
|---|---|---|
channelId | string | — |
buyerPayout | string | "0" |
sellerPayout | string | "0" |
---
GET/api/statechannel/purchase/{purchaseId}Get Purchase
Response VirtualEscrowDto
---
PSD2 Banking
/adapters/psd2POST/adapters/psd2/consents/initiateInitiate Consent
Starts the NBG PSD2 OAuth2 consent flow.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
expirationDate | datetime | Optional consent expiry |
Response InitiateConsentResponseDto
---
POST/adapters/psd2/tokenExchange Token
Request Body ExchangeTokenRequestDto
| Field | Type | Required |
|---|---|---|
code | string | Yes |
Response TokenResponseDto
GET /events/since?wallet=0x...&sinceTimestamp=2026-05-18T09:00:00Z
GET /health/indexer
{
"payload": {
"lastProcessedBlock": 12534218,
"headBlock": 12534221,
"lagBlocks": 3,
"lastUpdatedAt": "2026-05-18T09:42:11Z",
"perContract": [
{ "contractType": "RedatiumRegistry", "lastProcessedBlock": 12534218, "lastConfirmedBlock": 12534216, "lastUpdated": "2026-05-18T09:42:11Z" },
{ "contractType": "DataTransferAdjudicator", "lastProcessedBlock": 12534218, "lastConfirmedBlock": 12534216, "lastUpdated": "2026-05-18T09:42:11Z" },
{ "contractType": "DatasetRating", "lastProcessedBlock": 12534215, "lastConfirmedBlock": 12534213, "lastUpdated": "2026-05-18T09:42:10Z" }
]
},
"exception": null
}
public sealed class TransakSignUrlRequest {
public string ProductsAvailed { get; set; } // "BUY" or "SELL"
public string WalletAddress { get; set; } // Authenticated user's EIP-55 address
public decimal? CryptoAmount { get; set; } // Optional preset
public decimal? FiatAmount { get; set; } // Optional preset
public string? DefaultFiatCurrency { get; set; } // "EUR" | "USD" | "GBP"
public string? PartnerOrderId { get; set; } // Optional idempotency id
}
public sealed class TransakSignUrlResponse {
public string WidgetUrl { get; set; } // Single-use widget URL (5-min TTL)
public DateTimeOffset ExpiresAt { get; set; } // Server-translated expiry
}
POST /api/transak/sign-url
Authorization: Bearer
{
"header": { ... },
"payload": { "productsAvailed": "BUY", "walletAddress": "0x...", "defaultFiatCurrency": "EUR" }
}
{
"payload": {
"widgetUrl": "https://global-stg.transak.com?apiKey=...&sessionId=...",
"expiresAt": "2026-05-21T09:47:11Z"
}
}
{
"Blockchain": {
"Enabled": true,
"Network": "Base",
"ChainId": 8453,
"RpcUrl": "https://base-mainnet.g.alchemy.com/v2/
"WebSocketRpcUrl": "wss://base-mainnet.g.alchemy.com/v2/
"EnableWebSocket": true,
"ContractAddress": "0xabE1d17925519290c68c68eBDdbF3B96329EE5d6",
"RegistryContractAddress": "0xabE1d17925519290c68c68eBDdbF3B96329EE5d6",
"TransactionContractAddress": "0xc0E4605CC9bD6768B785121c214B039946922082",
"DataTransferAdjudicatorAddress": "0x66345886cD27fD1a88F65a5704899F68af59cbAb",
"DatasetRatingContractAddress": "0xA0DBB5504cA81163d937C9c2374d778A4fD7a7bF",
"EventPollingIntervalSeconds": 10,
"StartFromBlock": 10532939,
"BatchSize": 10,
"MaxBlockRetryThreshold": 100000,
"RequiredConfirmations": 2,
"MaxCatchupBlocks": 10000,
"ContractPollingDelayMs": 1000
}
}
var response = await SafeExecutor.ExecuteAsync(async () =>
{
return await _service.DoSomething(request.Payload!);
});
return Ok(response);
SELECT * FROM "BlockchainSyncState";
SELECT * FROM chain_events WHERE channel_id = '0x...' ORDER BY block_number;
SignalR Hub Reference
BlockchainHub (/hubs/blockchain)
Connection: new HubConnectionBuilder().withUrl("https://<api>/hubs/blockchain").build()
| Client Method | Parameters | Description |
|---|---|---|
SubscribeToWallet |
walletAddress: string |
Join wallet group for targeted events |
UnsubscribeFromWallet |
walletAddress: string |
Leave wallet group |
ProposeState |
channelId: string, signedStateJson: string |
Broadcast state to channel participants |
| Server Event | Payload | Source | Description |
|---|---|---|---|
StateProposed |
{ channelId, signedStateJson } |
Peer relay via ProposeState |
State broadcast to channel group |
RequestStatusChanged |
{ requestId, status, datasetName, ... } |
DataSharingRequestService.RespondAsync / StartSeedingAsync |
Drives webapp toast + bell + Requests auto-refresh |
UserRegistered |
{ walletAddress, ... } |
Indexer | On-chain user-registration event indexed |
ChannelFunded |
{ channelId, ... } |
Indexer | On-chain ChannelFunded event indexed |
ChallengeDetected |
{ channelId, turnNum, expiry } |
Indexer | On-chain ChallengePlayed event. Known bug: Clients listen for ChallengeRaised but backend emits ChallengeDetected — event never received; verify on next client review |
ChallengeRefuted |
{ channelId, turnNum } |
Indexer | On-chain ChallengeRefuted event indexed |
ChallengeResponded |
{ channelId, turnNum } |
Indexer | On-chain ChallengeResponded event indexed |
ChannelConcluded |
{ channelId, sellerBalance, buyerBalance } |
Indexer | On-chain ChannelConcluded event. Known bug: Clients listen for ChannelFinalized but backend emits ChannelConcluded — event never received |
EarningsUpdated |
{ walletAddress, totalEarnedWei, updatedAt } |
Indexer — on FundsWithdrawn after UserEarningsRepository.CreditAsync succeeds |
Live update for the seller's lifetime "Earned via Redatium" total. Fires only when the credit is idempotently new (skipped on replay). |
All wallet-scoped on-chain events are emitted to BlockchainHub wallet groups via IHubContext<BlockchainHub>. The legacy NotifyChallengeRaised / NotifyChannelFinalized static helpers on TransferHub exist but are never called.
TransferHub (/hubs/transfer)
Connection: new HubConnectionBuilder().withUrl("https://<api>/hubs/transfer").build()
| Client Method | Parameters | Description |
|---|---|---|
JoinChannel |
channelId: string |
Join channel group for P2P signaling |
LeaveChannel |
channelId: string |
Leave channel group |
RelaySignaling |
channelId: string, signalingData: string |
WebRTC SDP/ICE relay |
| Server Event | Payload | Description |
|---|---|---|
PeerJoined |
connectionId |
New peer in channel |
PeerLeft |
connectionId |
Peer left channel |
SignalingMessage |
connectionId, signalingData |
WebRTC signaling data |
(Dispute / channel-lifecycle events are NOT on TransferHub — see BlockchainHub above.)
Blockchain Event Indexing Configuration
File: backend/Redatium.Api/appsettings.Development.json section Blockchain
{
"Blockchain": {
"Enabled": true,
"Network": "Base",
"ChainId": 8453,
"RpcUrl": "https://base-mainnet.g.alchemy.com/v2/<key>",
"WebSocketRpcUrl": "wss://base-mainnet.g.alchemy.com/v2/<key>",
"EnableWebSocket": true,
"ContractAddress": "0xabE1d17925519290c68c68eBDdbF3B96329EE5d6",
"RegistryContractAddress": "0xabE1d17925519290c68c68eBDdbF3B96329EE5d6",
"TransactionContractAddress": "0xc0E4605CC9bD6768B785121c214B039946922082",
"DataTransferAdjudicatorAddress": "0x66345886cD27fD1a88F65a5704899F68af59cbAb",
"DatasetRatingContractAddress": "0xA0DBB5504cA81163d937C9c2374d778A4fD7a7bF",
"EventPollingIntervalSeconds": 10,
"StartFromBlock": 10532939,
"BatchSize": 10,
"MaxBlockRetryThreshold": 100000,
"RequiredConfirmations": 2,
"MaxCatchupBlocks": 10000,
"ContractPollingDelayMs": 1000
}
}
Set Enabled: false to disable blockchain polling entirely (useful when working on UI-only features).
Polling strategy (current): the indexer collapses all tracked contracts into a single getLogs call per cycle rather than per-contract polling, then dispatches to per-event handlers. After 10 consecutive empty polls the loop backs off to a 60-second cycle (instead of EventPollingIntervalSeconds) and snaps back to the configured cadence on the first non-empty cycle. This dramatically reduces RPC compute-unit usage during quiet periods without sacrificing responsiveness.
Tracked contracts (current):
| Contract | Address | Events indexed |
|---|---|---|
RedatiumRegistry |
0xabE1d17925519290c68c68eBDdbF3B96329EE5d6 |
RedatiumStored (user registrations, data-request-ask) |
SolidusDataMarketplace |
0xc0E4605CC9bD6768B785121c214B039946922082 |
DataTransactionRecorded |
DataTransferAdjudicator |
0x66345886cD27fD1a88F65a5704899F68af59cbAb |
ChannelFunded, ChannelConcluded, ChallengePlayed, ChallengeRefuted, ChallengeResponded, FundsWithdrawn |
DatasetRating |
0xA0DBB5504cA81163d937C9c2374d778A4fD7a7bF |
DatasetRated (upserts DatasetFeedbacks) |
Configuration Reference
appsettings.Development.json
| Section | Key | Description |
|---|---|---|
ConnectionStrings.DefaultConnection |
Host=localhost;Port=5432;Database=SOLIDUS;Username=postgres;Password=postgres |
PostgreSQL connection |
Cors.AllowedOrigins |
["*"] |
CORS origins (dev: all) |
JwtSettings.SecretKey |
(in appsettings.json) | JWT signing key |
JwtSettings.Issuer |
JWT issuer | |
JwtSettings.Audience |
JWT audience | |
Blockchain.* |
(see section 5) | Event indexer config |
Psd2Settings.* |
NBG sandbox OAuth credentials |
Database Scripts
Scripts are in backend/Redatium.Api/Database/Scripts/, numbered 01_Users.sql through 33_UserDeviceTokens.sql. To add a new table, create a new numbered script and add it to recreate_database.sql.
Common Patterns
SafeExecutor
All controller actions wrap service calls in SafeExecutor.ExecuteAsync():
var response = await SafeExecutor.ExecuteAsync(async () =>
{
return await _service.DoSomething(request.Payload!);
});
return Ok(response);
This catches BusinessException (4xx), TechnicalException (5xx), and generic exceptions, mapping them to the standard Response<T> format.
Adding a New Endpoint
- Create DTO in
DTOs/{Feature}/ - Define interface in
Interfaces/I{Feature}Service.cs - Implement in
Services/{Feature}Service.cs - Register in
Program.cs:builder.Services.AddScoped<IFeatureService, FeatureService>() - Create
Controllers/{Feature}Controller.cs
Debugging Blockchain Events
Check the BlockchainSyncState table to see where the indexer is:
SELECT * FROM "BlockchainSyncState";
Check indexed chain events:
SELECT * FROM chain_events WHERE channel_id = '0x...' ORDER BY block_number;
Troubleshooting
| Problem | Solution |
|---|---|
| API not reachable from mobile | Ensure ngrok is running and URL is updated in mobile .env |
| Blockchain polling not starting | Check Blockchain:Enabled is true and RPC URL is valid |
| Events being missed | Check RequiredConfirmations (increase if chain is congested) |
| Database connection errors | Verify Docker container is running: docker ps |
| CORS errors from webapp | In development, all origins are allowed. Check that AllowCredentials is set. |
| SignalR connection fails | Ensure the API is running on 0.0.0.0:5032 (not localhost only) |