From 2a8c076a8a90c50aafcfa174654ffdfd4439ffa1 Mon Sep 17 00:00:00 2001 From: ifsantana Date: Fri, 14 Aug 2026 12:24:16 -0300 Subject: [PATCH] feat: per-token on-chain balance breakdown on GET /balance (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accounts are chart-of-accounts buckets, not wallets — nothing prevents a single account receiving both FiatEntry and OnChainEntry postings, and the README's own pitch assumes it ("a single transaction can contain a PIX debit and a USDC credit on Base"). GET /balance previously reported 0 for the on-chain side with no indication anything was missing. Adds a per-token on-chain breakdown alongside the existing fiat amount: - core: BalanceCalculator.computeOnChain() nets debits/credits per StablecoinToken across all chains, mirroring the existing fiat compute() - application/infrastructure: Balance.onChainBalances, wired through GetBalanceService - api/sdk-kotlin: additive OnChainBalanceResponse field on BalanceResponse - mcp: getBalance tool result carries the same breakdown for agent callers No single combined total — fiat and on-chain amounts are never summed; they are not fungible units and nothing else in the codebase treats a stablecoin as fiat-equivalent outside one hardcoded travel-rule threshold table. Additive API change: existing BalanceResponse fields are untouched. Verified live: an account watched by the Alchemy webhook holding a 2.5 USDC OnChainEntry now returns amount=0 (fiat, unchanged) alongside onChainBalances=[{token: USDC, amount: 2.5}]. Signed-off-by: ifsantana --- README.md | 15 +++ .../idem/api/ledger/BalanceResponse.kt | 3 + .../idem/api/ledger/OnChainBalanceResponse.kt | 21 ++++ .../idem/api/ledger/AccountControllerTest.kt | 38 ++++-- .../idem/api/ledger/BalanceResponseTest.kt | 73 +++++++++++ .../idem/application/ledger/Balance.kt | 2 + .../ledger/GetBalanceModelsTest.kt | 12 +- .../idem/core/ledger/BalanceCalculator.kt | 36 ++++++ .../idem/core/ledger/OnChainBalance.kt | 9 ++ .../idem/core/ledger/BalanceCalculatorTest.kt | 119 ++++++++++++++++-- .../idem/core/ledger/OnChainBalanceTest.kt | 32 +++++ docs/mcp-server.md | 4 +- .../service/GetBalanceService.kt | 2 + .../service/GetBalanceServiceTest.kt | 32 +++-- .../kotlin/finance/idem/mcp/BalanceResult.kt | 6 + .../kotlin/finance/idem/mcp/IdemMcpServer.kt | 7 +- .../finance/idem/mcp/BalanceResultTest.kt | 31 +++++ .../finance/idem/mcp/IdemMcpServerTest.kt | 22 ++++ .../finance/idem/sdk/model/BalanceResponse.kt | 1 + .../idem/sdk/model/OnChainBalanceResponse.kt | 8 ++ .../kotlin/finance/idem/sdk/IdemClientTest.kt | 25 ++++ 21 files changed, 471 insertions(+), 27 deletions(-) create mode 100644 api/src/main/kotlin/finance/idem/api/ledger/OnChainBalanceResponse.kt create mode 100644 api/src/test/kotlin/finance/idem/api/ledger/BalanceResponseTest.kt create mode 100644 core/src/main/kotlin/finance/idem/core/ledger/OnChainBalance.kt create mode 100644 core/src/test/kotlin/finance/idem/core/ledger/OnChainBalanceTest.kt create mode 100644 mcp/src/test/kotlin/finance/idem/mcp/BalanceResultTest.kt create mode 100644 sdk-kotlin/src/main/kotlin/finance/idem/sdk/model/OnChainBalanceResponse.kt diff --git a/README.md b/README.md index ce2f3839..2b42eb59 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,23 @@ curl http://localhost:8081/api/v1/accounts//balance \ -H "X-API-Key: sk_test_devkey00000000000000000000" ``` +```json +{ + "accountId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "currency": "USD", + "amount": 1000.00, + "normalBalance": "DEBIT", + "computedAt": "2025-01-01T00:00:00Z", + "onChainBalances": [ + { "token": "USDC", "amount": 2.50 } + ] +} +``` + Point-in-time balance: append `?asOf=2025-01-01T00:00:00Z`. +`amount` is the fiat balance in the account's declared currency — it only sums `FiatEntry` lines. On-chain (stablecoin) entries posted to the same account are reported separately in `onChainBalances`, one entry per token net across all chains, and are never combined with the fiat `amount`: a token amount and a fiat amount are not fungible units. An account only receiving fiat entries returns `onChainBalances: []`. + ### Kotlin SDK ```kotlin diff --git a/api/src/main/kotlin/finance/idem/api/ledger/BalanceResponse.kt b/api/src/main/kotlin/finance/idem/api/ledger/BalanceResponse.kt index c74e3cca..225e4f2e 100644 --- a/api/src/main/kotlin/finance/idem/api/ledger/BalanceResponse.kt +++ b/api/src/main/kotlin/finance/idem/api/ledger/BalanceResponse.kt @@ -19,6 +19,8 @@ data class BalanceResponse( val normalBalance: EntryType, @Schema(description = "Timestamp when the balance was computed") val computedAt: Instant, + @Schema(description = "Net on-chain balance per stablecoin token, summed across all chains — never combined with the fiat amount above") + val onChainBalances: List = emptyList(), ) { companion object { fun from(balance: Balance) = @@ -28,6 +30,7 @@ data class BalanceResponse( amount = balance.amount.value, normalBalance = balance.normalBalance, computedAt = balance.computedAt, + onChainBalances = balance.onChainBalances.map(OnChainBalanceResponse::from), ) } } diff --git a/api/src/main/kotlin/finance/idem/api/ledger/OnChainBalanceResponse.kt b/api/src/main/kotlin/finance/idem/api/ledger/OnChainBalanceResponse.kt new file mode 100644 index 00000000..3f8bb6f7 --- /dev/null +++ b/api/src/main/kotlin/finance/idem/api/ledger/OnChainBalanceResponse.kt @@ -0,0 +1,21 @@ +package finance.idem.api.ledger + +import finance.idem.core.StablecoinToken +import finance.idem.core.ledger.OnChainBalance +import io.swagger.v3.oas.annotations.media.Schema +import java.math.BigDecimal + +data class OnChainBalanceResponse( + @Schema(description = "Stablecoin token") + val token: StablecoinToken, + @Schema(description = "Net on-chain balance for this token, summed across all chains") + val amount: BigDecimal, +) { + companion object { + fun from(balance: OnChainBalance) = + OnChainBalanceResponse( + token = balance.token, + amount = balance.amount.value, + ) + } +} diff --git a/api/src/test/kotlin/finance/idem/api/ledger/AccountControllerTest.kt b/api/src/test/kotlin/finance/idem/api/ledger/AccountControllerTest.kt index e81e9239..25bd1d85 100644 --- a/api/src/test/kotlin/finance/idem/api/ledger/AccountControllerTest.kt +++ b/api/src/test/kotlin/finance/idem/api/ledger/AccountControllerTest.kt @@ -20,11 +20,13 @@ import finance.idem.core.EntryType import finance.idem.core.FiatCurrency import finance.idem.core.MonetaryAmount import finance.idem.core.PaymentRail +import finance.idem.core.StablecoinToken import finance.idem.core.TenantId import finance.idem.core.TransactionId import finance.idem.core.ledger.Account import finance.idem.core.ledger.AccountType import finance.idem.core.ledger.JournalLine +import finance.idem.core.ledger.OnChainBalance import finance.idem.core.monetary.FiatEntry import org.junit.jupiter.api.Test import org.mockito.kotlin.any @@ -82,14 +84,17 @@ class AccountControllerTest { createdBy = "test-key", ) - private fun balanceFor(accountId: UUID) = - Balance( - accountId = AccountId(accountId), - currency = FiatCurrency.BRL, - amount = MonetaryAmount.of("350.00"), - normalBalance = EntryType.DEBIT, - computedAt = Instant.parse("2026-05-28T12:00:00Z"), - ) + private fun balanceFor( + accountId: UUID, + onChainBalances: List = emptyList(), + ) = Balance( + accountId = AccountId(accountId), + currency = FiatCurrency.BRL, + amount = MonetaryAmount.of("350.00"), + normalBalance = EntryType.DEBIT, + computedAt = Instant.parse("2026-05-28T12:00:00Z"), + onChainBalances = onChainBalances, + ) private fun lineFor( accountId: UUID, @@ -140,6 +145,23 @@ class AccountControllerTest { jsonPath("$.currency") { value("BRL") } jsonPath("$.amount") { value(350.00) } jsonPath("$.normalBalance") { value("DEBIT") } + jsonPath("$.onChainBalances") { isEmpty() } + } + } + + @Test + fun `balance with on-chain entries returns per-token breakdown`() { + val onChainBalances = listOf(OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("2.50"))) + whenever(getBalanceUseCase.execute(any())).thenReturn(Result.success(balanceFor(accountId, onChainBalances))) + + mockMvc + .get("/api/v1/accounts/$accountId/balance") { + with(SecurityMockMvcRequestPostProcessors.authentication(mockAuth("ACCOUNTS_READ"))) + }.andExpect { + status { isOk() } + jsonPath("$.amount") { value(350.00) } + jsonPath("$.onChainBalances[0].token") { value("USDC") } + jsonPath("$.onChainBalances[0].amount") { value(2.50) } } } diff --git a/api/src/test/kotlin/finance/idem/api/ledger/BalanceResponseTest.kt b/api/src/test/kotlin/finance/idem/api/ledger/BalanceResponseTest.kt new file mode 100644 index 00000000..b68185f7 --- /dev/null +++ b/api/src/test/kotlin/finance/idem/api/ledger/BalanceResponseTest.kt @@ -0,0 +1,73 @@ +package finance.idem.api.ledger + +import finance.idem.application.ledger.Balance +import finance.idem.core.AccountId +import finance.idem.core.EntryType +import finance.idem.core.FiatCurrency +import finance.idem.core.MonetaryAmount +import finance.idem.core.StablecoinToken +import finance.idem.core.ledger.OnChainBalance +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.time.Instant +import java.util.UUID +import kotlin.test.assertEquals + +class BalanceResponseTest { + @Test + fun `from produces correct dto including on-chain breakdown`() { + val accountId = AccountId(UUID.randomUUID()) + val computedAt = Instant.parse("2026-05-28T12:00:00Z") + val balance = + Balance( + accountId = accountId, + currency = FiatCurrency.BRL, + amount = MonetaryAmount.of("350.00"), + normalBalance = EntryType.DEBIT, + computedAt = computedAt, + onChainBalances = listOf(OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("2.50"))), + ) + + val dto = BalanceResponse.from(balance) + + assertEquals(accountId.value, dto.accountId) + assertEquals(FiatCurrency.BRL, dto.currency) + assertEquals(BigDecimal("350.00"), dto.amount) + assertEquals(EntryType.DEBIT, dto.normalBalance) + assertEquals(computedAt, dto.computedAt) + assertEquals(listOf(OnChainBalanceResponse(StablecoinToken.USDC, BigDecimal("2.50"))), dto.onChainBalances) + + val fullCopy = dto.copy() + val partialCopy = dto.copy(amount = BigDecimal("999.00")) + assertEquals(dto, fullCopy) + assert(dto != partialCopy) + assert(dto != null) + assert(dto.toString().contains("BRL")) + assertEquals(dto.hashCode(), fullCopy.hashCode()) + + val (respAccountId, currency, amount) = dto + assertEquals(accountId.value, respAccountId) + assertEquals(FiatCurrency.BRL, currency) + assertEquals(BigDecimal("350.00"), amount) + } + + @Test + fun `from defaults onChainBalances to empty list when balance has none`() { + val balance = + Balance( + accountId = AccountId(UUID.randomUUID()), + currency = FiatCurrency.USD, + amount = MonetaryAmount.of("0"), + normalBalance = EntryType.DEBIT, + computedAt = Instant.now(), + ) + + assertEquals(emptyList(), BalanceResponse.from(balance).onChainBalances) + } + + @Test + fun `onChainBalances defaults to empty list when omitted from the constructor`() { + val dto = BalanceResponse(UUID.randomUUID(), FiatCurrency.USD, BigDecimal("0"), EntryType.DEBIT, Instant.now()) + assertEquals(emptyList(), dto.onChainBalances) + } +} diff --git a/application/src/main/kotlin/finance/idem/application/ledger/Balance.kt b/application/src/main/kotlin/finance/idem/application/ledger/Balance.kt index 271d6928..9f4469e9 100644 --- a/application/src/main/kotlin/finance/idem/application/ledger/Balance.kt +++ b/application/src/main/kotlin/finance/idem/application/ledger/Balance.kt @@ -4,6 +4,7 @@ import finance.idem.core.AccountId import finance.idem.core.EntryType import finance.idem.core.FiatCurrency import finance.idem.core.MonetaryAmount +import finance.idem.core.ledger.OnChainBalance import java.time.Instant data class Balance( @@ -12,4 +13,5 @@ data class Balance( val amount: MonetaryAmount, val normalBalance: EntryType, val computedAt: Instant, + val onChainBalances: List = emptyList(), ) diff --git a/application/src/test/kotlin/finance/idem/application/ledger/GetBalanceModelsTest.kt b/application/src/test/kotlin/finance/idem/application/ledger/GetBalanceModelsTest.kt index 6d25ab9f..cb6230d5 100644 --- a/application/src/test/kotlin/finance/idem/application/ledger/GetBalanceModelsTest.kt +++ b/application/src/test/kotlin/finance/idem/application/ledger/GetBalanceModelsTest.kt @@ -4,7 +4,9 @@ import finance.idem.core.AccountId import finance.idem.core.EntryType import finance.idem.core.FiatCurrency import finance.idem.core.MonetaryAmount +import finance.idem.core.StablecoinToken import finance.idem.core.TenantId +import finance.idem.core.ledger.OnChainBalance import org.junit.jupiter.api.Test import java.time.Instant import kotlin.test.assertEquals @@ -32,15 +34,23 @@ class GetBalanceModelsTest { @Test fun `Balance holds all fields`() { - val balance = Balance(accountId, FiatCurrency.BRL, MonetaryAmount.of("500"), EntryType.DEBIT, now) + val onChainBalances = listOf(OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("125"))) + val balance = Balance(accountId, FiatCurrency.BRL, MonetaryAmount.of("500"), EntryType.DEBIT, now, onChainBalances) assertEquals(accountId, balance.accountId) assertEquals(FiatCurrency.BRL, balance.currency) assertEquals(MonetaryAmount.of("500"), balance.amount) assertEquals(EntryType.DEBIT, balance.normalBalance) assertEquals(now, balance.computedAt) + assertEquals(onChainBalances, balance.onChainBalances) assertEquals(balance, balance.copy()) } + @Test + fun `Balance onChainBalances defaults to empty list`() { + val balance = Balance(accountId, FiatCurrency.BRL, MonetaryAmount.of("500"), EntryType.DEBIT, now) + assertEquals(emptyList(), balance.onChainBalances) + } + @Test fun `GetBalanceError AccountNotFound carries accountId and message`() { val error = BalanceAccountNotFound(accountId) diff --git a/core/src/main/kotlin/finance/idem/core/ledger/BalanceCalculator.kt b/core/src/main/kotlin/finance/idem/core/ledger/BalanceCalculator.kt index 1417bc71..712acdbf 100644 --- a/core/src/main/kotlin/finance/idem/core/ledger/BalanceCalculator.kt +++ b/core/src/main/kotlin/finance/idem/core/ledger/BalanceCalculator.kt @@ -2,7 +2,9 @@ package finance.idem.core.ledger import finance.idem.core.EntryType import finance.idem.core.MonetaryAmount +import finance.idem.core.StablecoinToken import finance.idem.core.monetary.FiatEntry +import finance.idem.core.monetary.OnChainEntry object BalanceCalculator { fun compute( @@ -29,4 +31,38 @@ object BalanceCalculator { EntryType.CREDIT -> credits - debits } } + + // Net per StablecoinToken, across all chains — never combined with the fiat balance + // above, since a token amount and a fiat amount are not fungible units. + fun computeOnChain( + account: Account, + transactions: List, + ): List { + val debits = mutableMapOf() + val credits = mutableMapOf() + + for (tx in transactions) { + for (line in tx.lines) { + if (line.accountId != account.id) continue + val entry = line.monetaryEntry + if (entry !is OnChainEntry) continue + val byToken = if (line.entryType == EntryType.DEBIT) debits else credits + byToken[entry.token] = (byToken[entry.token] ?: MonetaryAmount.ZERO) + entry.amount + } + } + + return (debits.keys + credits.keys) + .distinct() + .sortedBy { it.name } + .map { token -> + val d = debits[token] ?: MonetaryAmount.ZERO + val c = credits[token] ?: MonetaryAmount.ZERO + val net = + when (account.normalBalance) { + EntryType.DEBIT -> d - c + EntryType.CREDIT -> c - d + } + OnChainBalance(token, net) + } + } } diff --git a/core/src/main/kotlin/finance/idem/core/ledger/OnChainBalance.kt b/core/src/main/kotlin/finance/idem/core/ledger/OnChainBalance.kt new file mode 100644 index 00000000..b31aa4b5 --- /dev/null +++ b/core/src/main/kotlin/finance/idem/core/ledger/OnChainBalance.kt @@ -0,0 +1,9 @@ +package finance.idem.core.ledger + +import finance.idem.core.MonetaryAmount +import finance.idem.core.StablecoinToken + +data class OnChainBalance( + val token: StablecoinToken, + val amount: MonetaryAmount, +) diff --git a/core/src/test/kotlin/finance/idem/core/ledger/BalanceCalculatorTest.kt b/core/src/test/kotlin/finance/idem/core/ledger/BalanceCalculatorTest.kt index d34b5a1f..9c42a2ab 100644 --- a/core/src/test/kotlin/finance/idem/core/ledger/BalanceCalculatorTest.kt +++ b/core/src/test/kotlin/finance/idem/core/ledger/BalanceCalculatorTest.kt @@ -60,15 +60,28 @@ class BalanceCalculatorTest { rail = PaymentRail.WIRE, ) - private fun usdcOnChain(amount: String) = + private fun usdcOnChain( + amount: String, + chainId: ChainId = ChainId.EVM, + ) = OnChainEntry( + amount = MonetaryAmount.of(amount), + token = StablecoinToken.USDC, + chainId = chainId, + txHash = "0xabc", + blockNumber = 19_000_000L, + walletAddress = "0xWallet", + tokenContract = "0xContract", + ) + + private fun usdtOnChain(amount: String) = OnChainEntry( amount = MonetaryAmount.of(amount), - token = StablecoinToken.USDC, - chainId = ChainId.EVM, - txHash = "0xabc", - blockNumber = 19_000_000L, - walletAddress = "0xWallet", - tokenContract = "0xContract", + token = StablecoinToken.USDT, + chainId = ChainId.TRON, + txHash = "0xdef", + blockNumber = 19_000_001L, + walletAddress = "TWallet", + tokenContract = "TContract", ) private fun line( @@ -144,4 +157,96 @@ class BalanceCalculatorTest { assertTrue(BalanceCalculator.compute(assetAccount(), transactions).isZero()) } + + // -- computeOnChain -- + + @Test + fun `computeOnChain returns empty list when there are no on-chain entries`() { + val transactions = + listOf(tx(listOf(line(EntryType.DEBIT, brlFiat("1000")), line(EntryType.CREDIT, brlFiat("1000"), otherAccountId)))) + + assertTrue(BalanceCalculator.computeOnChain(assetAccount(), transactions).isEmpty()) + } + + @Test + fun `computeOnChain nets debits minus credits per token for a debit-normal account`() { + val transactions = + listOf( + tx(listOf(line(EntryType.DEBIT, usdcOnChain("500")), line(EntryType.CREDIT, usdcOnChain("500"), otherAccountId))), + tx(listOf(line(EntryType.CREDIT, usdcOnChain("120")), line(EntryType.DEBIT, usdcOnChain("120"), otherAccountId))), + ) + + val result = BalanceCalculator.computeOnChain(assetAccount(), transactions) + + assertEquals(listOf(OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("380"))), result) + } + + @Test + fun `computeOnChain sums the same token across different chains`() { + val transactions = + listOf( + tx( + listOf( + line(EntryType.DEBIT, usdcOnChain("100", ChainId.EVM)), + line(EntryType.CREDIT, usdcOnChain("100", ChainId.EVM), otherAccountId), + ), + ), + tx( + listOf( + line(EntryType.DEBIT, usdcOnChain("50", ChainId.SOLANA)), + line(EntryType.CREDIT, usdcOnChain("50", ChainId.SOLANA), otherAccountId), + ), + ), + ) + + val result = BalanceCalculator.computeOnChain(assetAccount(), transactions) + + assertEquals(listOf(OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("150"))), result) + } + + @Test + fun `computeOnChain returns separate lines for different tokens, sorted by name`() { + val transactions = + listOf( + tx(listOf(line(EntryType.DEBIT, usdtOnChain("75")), line(EntryType.CREDIT, usdtOnChain("75"), otherAccountId))), + tx(listOf(line(EntryType.DEBIT, usdcOnChain("25")), line(EntryType.CREDIT, usdcOnChain("25"), otherAccountId))), + ) + + val result = BalanceCalculator.computeOnChain(assetAccount(), transactions) + + assertEquals( + listOf( + OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("25")), + OnChainBalance(StablecoinToken.USDT, MonetaryAmount.of("75")), + ), + result, + ) + } + + @Test + fun `computeOnChain nets credits minus debits for a credit-normal account`() { + val transactions = + listOf(tx(listOf(line(EntryType.CREDIT, usdcOnChain("300")), line(EntryType.DEBIT, usdcOnChain("300"), otherAccountId)))) + + val result = BalanceCalculator.computeOnChain(liabilityAccount(), transactions) + + assertEquals(listOf(OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("300"))), result) + } + + @Test + fun `computeOnChain excludes fiat entries and lines for other accounts`() { + val transactions = + listOf( + tx( + listOf( + line(EntryType.DEBIT, brlFiat("1000")), + line(EntryType.CREDIT, brlFiat("1000"), otherAccountId), + line(EntryType.DEBIT, usdcOnChain("40"), otherAccountId), + line(EntryType.CREDIT, usdcOnChain("40"), otherAccountId), + ), + ), + ) + + assertTrue(BalanceCalculator.computeOnChain(assetAccount(), transactions).isEmpty()) + } } diff --git a/core/src/test/kotlin/finance/idem/core/ledger/OnChainBalanceTest.kt b/core/src/test/kotlin/finance/idem/core/ledger/OnChainBalanceTest.kt new file mode 100644 index 00000000..ce8c0ad0 --- /dev/null +++ b/core/src/test/kotlin/finance/idem/core/ledger/OnChainBalanceTest.kt @@ -0,0 +1,32 @@ +package finance.idem.core.ledger + +import finance.idem.core.MonetaryAmount +import finance.idem.core.StablecoinToken +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class OnChainBalanceTest { + @Test + fun `equality is based on all fields`() { + val a = OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("100")) + val b = OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("100")) + assertEquals(a, b) + } + + @Test + fun `different tokens are not equal`() { + assertNotEquals( + OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("100")), + OnChainBalance(StablecoinToken.USDT, MonetaryAmount.of("100")), + ) + } + + @Test + fun `copy with updated amount reflects new value`() { + val original = OnChainBalance(StablecoinToken.BRZ, MonetaryAmount.of("10")) + val updated = original.copy(amount = MonetaryAmount.of("25")) + assertEquals(StablecoinToken.BRZ, updated.token) + assertEquals(MonetaryAmount.of("25"), updated.amount) + } +} diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 0e40fd2c..8b890178 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -119,9 +119,11 @@ Returns the current balance for an account. Accepts an optional ISO-8601 instant getBalance( accountId: String, // account UUID asOf: String?, // optional ISO-8601 instant, e.g. "2025-12-31T23:59:59Z" -) → BalanceResult(accountId, currency, amount, computedAt) +) → BalanceResult(accountId, currency, amount, computedAt, onChainBalances) ``` +`onChainBalances` is a per-token breakdown (`[{token, amount}]`) of any on-chain entries posted to the account, net across all chains for that token. It is never combined with the fiat `amount` above — a token amount and a fiat amount are not fungible units. + --- ### `listEntries` diff --git a/infrastructure/src/main/kotlin/finance/idem/infrastructure/service/GetBalanceService.kt b/infrastructure/src/main/kotlin/finance/idem/infrastructure/service/GetBalanceService.kt index b9ad2531..d333aca5 100644 --- a/infrastructure/src/main/kotlin/finance/idem/infrastructure/service/GetBalanceService.kt +++ b/infrastructure/src/main/kotlin/finance/idem/infrastructure/service/GetBalanceService.kt @@ -33,6 +33,7 @@ class GetBalanceService( } val net = BalanceCalculator.compute(account, transactions) + val onChain = BalanceCalculator.computeOnChain(account, transactions) return Result.success( Balance( @@ -41,6 +42,7 @@ class GetBalanceService( amount = net, normalBalance = account.normalBalance, computedAt = Instant.now(clock), + onChainBalances = onChain, ), ) } diff --git a/infrastructure/src/test/kotlin/finance/idem/infrastructure/service/GetBalanceServiceTest.kt b/infrastructure/src/test/kotlin/finance/idem/infrastructure/service/GetBalanceServiceTest.kt index 4f89b174..f49c2ae2 100644 --- a/infrastructure/src/test/kotlin/finance/idem/infrastructure/service/GetBalanceServiceTest.kt +++ b/infrastructure/src/test/kotlin/finance/idem/infrastructure/service/GetBalanceServiceTest.kt @@ -3,16 +3,19 @@ package finance.idem.infrastructure.service import finance.idem.application.ledger.BalanceAccountNotFound import finance.idem.application.ledger.GetBalanceQuery import finance.idem.core.AccountId +import finance.idem.core.ChainId import finance.idem.core.EntryType import finance.idem.core.FiatCurrency import finance.idem.core.MonetaryAmount import finance.idem.core.PaymentRail +import finance.idem.core.StablecoinToken import finance.idem.core.TenantId import finance.idem.core.TransactionId import finance.idem.core.ledger.Account import finance.idem.core.ledger.AccountRepository import finance.idem.core.ledger.AccountType import finance.idem.core.ledger.JournalLine +import finance.idem.core.ledger.OnChainBalance import finance.idem.core.ledger.Transaction import finance.idem.core.ledger.TransactionRepository import finance.idem.core.monetary.FiatEntry @@ -207,13 +210,13 @@ class GetBalanceServiceTest { } @Test - fun `on-chain entries are excluded from fiat balance`() { + fun `on-chain entries are excluded from fiat balance but reported in onChainBalances`() { val other = otherAccountId() val onChainEntry = OnChainEntry( amount = MonetaryAmount.of("180.00"), - token = finance.idem.core.StablecoinToken.USDC, - chainId = finance.idem.core.ChainId.EVM, + token = StablecoinToken.USDC, + chainId = ChainId.EVM, txHash = "0xabc", blockNumber = 19_000_000L, walletAddress = "0xWallet", @@ -230,13 +233,24 @@ class GetBalanceServiceTest { }), ), ) - assertTrue( - service - .execute(GetBalanceQuery(accountId, tenantId)) - .getOrThrow() - .amount - .isZero(), + val balance = service.execute(GetBalanceQuery(accountId, tenantId)).getOrThrow() + assertTrue(balance.amount.isZero()) + assertEquals( + listOf(OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("180.00"))), + balance.onChainBalances, + ) + } + + @Test + fun `account with only fiat entries has empty onChainBalances`() { + val other = otherAccountId() + whenever(accountRepository.findById(accountId, tenantId)).thenReturn(assetAccount()) + whenever(transactionRepository.findByAccountId(accountId, tenantId)).thenReturn( + listOf( + tx({ id -> listOf(line(id, EntryType.DEBIT, "1000", accountId), line(id, EntryType.CREDIT, "1000", other)) }), + ), ) + assertEquals(emptyList(), service.execute(GetBalanceQuery(accountId, tenantId)).getOrThrow().onChainBalances) } @Test diff --git a/mcp/src/main/kotlin/finance/idem/mcp/BalanceResult.kt b/mcp/src/main/kotlin/finance/idem/mcp/BalanceResult.kt index 4b21ae67..0f369d85 100644 --- a/mcp/src/main/kotlin/finance/idem/mcp/BalanceResult.kt +++ b/mcp/src/main/kotlin/finance/idem/mcp/BalanceResult.kt @@ -5,4 +5,10 @@ data class BalanceResult( val currency: String, val amount: String, val computedAt: String, + val onChainBalances: List = emptyList(), +) + +data class OnChainTokenBalance( + val token: String, + val amount: String, ) diff --git a/mcp/src/main/kotlin/finance/idem/mcp/IdemMcpServer.kt b/mcp/src/main/kotlin/finance/idem/mcp/IdemMcpServer.kt index 6d0014ba..e17a4298 100644 --- a/mcp/src/main/kotlin/finance/idem/mcp/IdemMcpServer.kt +++ b/mcp/src/main/kotlin/finance/idem/mcp/IdemMcpServer.kt @@ -103,7 +103,8 @@ class IdemMcpServer( @Tool( description = "Get the current balance for an account. Optionally pass asOf (ISO-8601 instant) " + - "to compute a historical balance. Requires AGENTS_EXECUTE scope.", + "to compute a historical balance. Also returns a per-token on-chain balance breakdown " + + "(never combined with the fiat amount). Requires AGENTS_EXECUTE scope.", ) fun getBalance( @ToolParam(description = "Account UUID") accountId: String, @@ -122,6 +123,10 @@ class IdemMcpServer( currency = balance.currency.name, amount = balance.amount.value.toPlainString(), computedAt = balance.computedAt.toString(), + onChainBalances = + balance.onChainBalances.map { + OnChainTokenBalance(token = it.token.name, amount = it.amount.value.toPlainString()) + }, ) }, onFailure = { handleFailure(it) }, diff --git a/mcp/src/test/kotlin/finance/idem/mcp/BalanceResultTest.kt b/mcp/src/test/kotlin/finance/idem/mcp/BalanceResultTest.kt new file mode 100644 index 00000000..60cbfe64 --- /dev/null +++ b/mcp/src/test/kotlin/finance/idem/mcp/BalanceResultTest.kt @@ -0,0 +1,31 @@ +package finance.idem.mcp + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class BalanceResultTest { + @Test + fun `onChainBalances defaults to empty list when omitted from the constructor`() { + val result = BalanceResult(accountId = "acc-1", currency = "USD", amount = "0", computedAt = "2026-01-01T00:00:00Z") + assertEquals(emptyList(), result.onChainBalances) + } + + @Test + fun `holds all fields including on-chain breakdown`() { + val tokenBalance = OnChainTokenBalance(token = "USDC", amount = "2.50") + val result = + BalanceResult( + accountId = "acc-1", + currency = "USD", + amount = "0", + computedAt = "2026-01-01T00:00:00Z", + onChainBalances = listOf(tokenBalance), + ) + + assertEquals("acc-1", result.accountId) + assertEquals(listOf(tokenBalance), result.onChainBalances) + assertEquals("USDC", tokenBalance.token) + assertEquals("2.50", tokenBalance.amount) + assertEquals(tokenBalance, tokenBalance.copy()) + } +} diff --git a/mcp/src/test/kotlin/finance/idem/mcp/IdemMcpServerTest.kt b/mcp/src/test/kotlin/finance/idem/mcp/IdemMcpServerTest.kt index feac4d5a..a51f9d2c 100644 --- a/mcp/src/test/kotlin/finance/idem/mcp/IdemMcpServerTest.kt +++ b/mcp/src/test/kotlin/finance/idem/mcp/IdemMcpServerTest.kt @@ -34,6 +34,7 @@ import finance.idem.core.TransactionId import finance.idem.core.WorkflowPlanId import finance.idem.core.agentic.PolicyViolationException import finance.idem.core.ledger.JournalLine +import finance.idem.core.ledger.OnChainBalance import finance.idem.core.monetary.FiatEntry import finance.idem.core.monetary.OnChainEntry import org.junit.jupiter.api.AfterEach @@ -201,6 +202,7 @@ class IdemMcpServerTest { assertEquals(accountId.value.toString(), result.accountId) assertEquals("USD", result.currency) assertEquals("500.00", result.amount) + assertEquals(emptyList(), result.onChainBalances) val captor = argumentCaptor() verify(getBalanceUseCase).execute(captor.capture()) @@ -208,6 +210,26 @@ class IdemMcpServerTest { assertEquals(tenantId, captor.firstValue.tenantId) } + @Test + fun `getBalance maps on-chain balance breakdown`() { + val balance = + Balance( + accountId = accountId, + currency = FiatCurrency.USD, + amount = MonetaryAmount.of("0"), + normalBalance = EntryType.DEBIT, + computedAt = Instant.now(), + onChainBalances = listOf(OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("2.50"))), + ) + whenever(getBalanceUseCase.execute(any())).thenReturn(Result.success(balance)) + + val result = server.getBalance(accountId = accountId.value.toString(), asOf = null) + + assertEquals(1, result.onChainBalances.size) + assertEquals("USDC", result.onChainBalances[0].token) + assertEquals("2.50", result.onChainBalances[0].amount) + } + @Test fun `getBalance parses asOf instant correctly`() { val asOf = Instant.parse("2025-01-15T10:00:00Z") diff --git a/sdk-kotlin/src/main/kotlin/finance/idem/sdk/model/BalanceResponse.kt b/sdk-kotlin/src/main/kotlin/finance/idem/sdk/model/BalanceResponse.kt index 58a8cb7b..d821f6df 100644 --- a/sdk-kotlin/src/main/kotlin/finance/idem/sdk/model/BalanceResponse.kt +++ b/sdk-kotlin/src/main/kotlin/finance/idem/sdk/model/BalanceResponse.kt @@ -10,4 +10,5 @@ data class BalanceResponse( val amount: BigDecimal, val normalBalance: EntryType, val computedAt: Instant, + val onChainBalances: List = emptyList(), ) diff --git a/sdk-kotlin/src/main/kotlin/finance/idem/sdk/model/OnChainBalanceResponse.kt b/sdk-kotlin/src/main/kotlin/finance/idem/sdk/model/OnChainBalanceResponse.kt new file mode 100644 index 00000000..fe8ff891 --- /dev/null +++ b/sdk-kotlin/src/main/kotlin/finance/idem/sdk/model/OnChainBalanceResponse.kt @@ -0,0 +1,8 @@ +package finance.idem.sdk.model + +import java.math.BigDecimal + +data class OnChainBalanceResponse( + val token: StablecoinToken, + val amount: BigDecimal, +) diff --git a/sdk-kotlin/src/test/kotlin/finance/idem/sdk/IdemClientTest.kt b/sdk-kotlin/src/test/kotlin/finance/idem/sdk/IdemClientTest.kt index f8ad0999..b485636a 100644 --- a/sdk-kotlin/src/test/kotlin/finance/idem/sdk/IdemClientTest.kt +++ b/sdk-kotlin/src/test/kotlin/finance/idem/sdk/IdemClientTest.kt @@ -431,9 +431,34 @@ class IdemClientTest { assertEquals(accountId, response.accountId) assertEquals(BigDecimal("100.00"), response.amount) + assertEquals(emptyList(), response.onChainBalances) assertNull(captured!!.url.parameters["asOf"]) } + @Test + fun `getBalance deserializes onChainBalances when present`() = + runTest { + val accountId = UUID.randomUUID() + val client = + clientWith { + respond( + content = + ByteReadChannel( + """{"accountId":"$accountId","currency":"USD","amount":0,"normalBalance":"DEBIT",""" + + """"computedAt":"2024-01-01T00:00:00Z","onChainBalances":[{"token":"USDC","amount":2.50}]}""", + ), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + val response = client.getBalance(accountId.toString()) + + assertEquals(1, response.onChainBalances.size) + assertEquals(StablecoinToken.USDC, response.onChainBalances[0].token) + assertEquals(BigDecimal("2.50"), response.onChainBalances[0].amount) + } + @Test fun `getBalance includes asOf query parameter when provided`() = runTest {