From 294d5433874662da7189d094fa279a3c9002543c Mon Sep 17 00:00:00 2001 From: ifsantana Date: Wed, 8 Jul 2026 20:37:41 -0300 Subject: [PATCH 1/3] feat: replace stub READMEs with real, runnable examples (#1) - settlement/PendingSettlementExample.kt: registers a settlement expectation, posts the matching on-chain transaction, forces the match via reconcileBatch, confirms SETTLED, and demonstrates the CANCELLED path. - mcp/McpAgentWorkflowExample.kt: a real MCP client (official io.modelcontextprotocol.sdk:mcp) over SSE, mints an agent-scoped API key + policy rule, and drives postTransaction -> reconcileBatch -> rollbackWorkflow -> getAgentAuditLog against the live server. - ExampleAccounts.kt / ReconciliationExample.kt now call the real IdemClient.createAccount / reconcileBatch SDK methods (idem-finance/idem#234) instead of raw httpClient workarounds. - Bumped idem-sdk.version to 0.0.12-test; added the MCP client dependency. All 5 examples verified live against docker-compose (real transaction IDs, real SETTLED/CANCELLED settlement transitions, real HMAC-signed audit trail). Closes #1. --- README.md | 23 ++- pom.xml | 13 +- .../examples/mcp/McpAgentWorkflowExample.kt | 160 ++++++++++++++++++ .../examples/mcp/McpAgentWorkflowReadme.md | 76 --------- .../reconciliation/ReconciliationExample.kt | 31 +--- .../settlement/PendingSettlementExample.kt | 134 +++++++++++++++ .../settlement/PendingSettlementReadme.md | 56 ------ .../idem/examples/support/ExampleAccounts.kt | 44 ++--- .../idem/examples/support/ExampleAdmin.kt | 54 ++++++ 9 files changed, 397 insertions(+), 194 deletions(-) create mode 100644 src/main/kotlin/finance/idem/examples/mcp/McpAgentWorkflowExample.kt delete mode 100644 src/main/kotlin/finance/idem/examples/mcp/McpAgentWorkflowReadme.md create mode 100644 src/main/kotlin/finance/idem/examples/settlement/PendingSettlementExample.kt delete mode 100644 src/main/kotlin/finance/idem/examples/settlement/PendingSettlementReadme.md create mode 100644 src/main/kotlin/finance/idem/examples/support/ExampleAdmin.kt diff --git a/README.md b/README.md index 0b3036a..3e108ba 100644 --- a/README.md +++ b/README.md @@ -41,14 +41,13 @@ docker compose run --rm -e SPRING_PROFILES_ACTIVE=dev,seed app |---|---|---| | 01 | [`basic/BasicTransactionExample.kt`](src/main/kotlin/finance/idem/examples/basic/BasicTransactionExample.kt) | A simple fiat double-entry transaction — debit/credit, `postTransaction`, `getBalance` | | 02 | [`onchain/StablecoinOnChainExample.kt`](src/main/kotlin/finance/idem/examples/onchain/StablecoinOnChainExample.kt) | A cross-border stablecoin transaction mixing on-chain entries in one transaction, with an explicit idempotency key | -| 03 | [`settlement/PendingSettlementReadme.md`](src/main/kotlin/finance/idem/examples/settlement/PendingSettlementReadme.md) | Conceptual doc — how server-side settlement actually works today (chain-reader/webhook-driven); not yet exposed via the SDK, so there's no runnable code here | -| 04 | [`reconciliation/ReconciliationExample.kt`](src/main/kotlin/finance/idem/examples/reconciliation/ReconciliationExample.kt) | Posting via the SDK, then reconciling via a direct REST call (no SDK method for this yet) | -| 05 | [`mcp/McpAgentWorkflowReadme.md`](src/main/kotlin/finance/idem/examples/mcp/McpAgentWorkflowReadme.md) | Connecting Claude Code to Idem's MCP server and driving the ledger with natural-language prompts instead of code | +| 03 | [`settlement/PendingSettlementExample.kt`](src/main/kotlin/finance/idem/examples/settlement/PendingSettlementExample.kt) | The settlement lifecycle — `registerSettlement`, forcing a match via `reconcileBatch`, `getSettlement` showing `SETTLED`, and `cancelSettlement` showing `CANCELLED` | +| 04 | [`reconciliation/ReconciliationExample.kt`](src/main/kotlin/finance/idem/examples/reconciliation/ReconciliationExample.kt) | Posting via the SDK, then reconciling via `IdemClient.reconcileBatch` | +| 05 | [`mcp/McpAgentWorkflowExample.kt`](src/main/kotlin/finance/idem/examples/mcp/McpAgentWorkflowExample.kt) | A real MCP client (official `io.modelcontextprotocol.sdk:mcp`) driving `postTransaction` -> `reconcileBatch` -> `rollbackWorkflow` -> `getAgentAuditLog` over SSE — the same tools you can also drive via natural-language prompts in Claude Code/Desktop | -Every code example creates its own accounts on first run — `idem-sdk-kotlin` -doesn't expose account creation, so each one bootstraps what it needs via -`support/ExampleAccounts.kt`, a small helper shared across examples 01, 02, -and 04. +Every code example creates its own accounts on first run via +`support/ExampleAccounts.kt`, a small helper around `IdemClient.createAccount` +shared across examples 01, 02, 03, 04, and 05. ## Running a specific example @@ -58,7 +57,9 @@ Run any of them via `exec:java`: ```bash ./mvnw compile exec:java -Dexec.mainClass=finance.idem.examples.basic.BasicTransactionExampleKt ./mvnw compile exec:java -Dexec.mainClass=finance.idem.examples.onchain.StablecoinOnChainExampleKt +./mvnw compile exec:java -Dexec.mainClass=finance.idem.examples.settlement.PendingSettlementExampleKt ./mvnw compile exec:java -Dexec.mainClass=finance.idem.examples.reconciliation.ReconciliationExampleKt +./mvnw compile exec:java -Dexec.mainClass=finance.idem.examples.mcp.McpAgentWorkflowExampleKt ``` (Kotlin compiles a top-level `main()` in `Foo.kt` to a class named `FooKt`.) @@ -69,16 +70,20 @@ Run any of them via `exec:java`: finance.idem idem-sdk-kotlin - 0.0.11-test + 0.0.12-test ``` -`idem-sdk-kotlin` hasn't had a stable release yet — `0.0.11-test` is the +`idem-sdk-kotlin` hasn't had a stable release yet — `0.0.12-test` is the latest pre-release build published to Maven Central while the `idem` release pipeline is under active development ([idem-finance/idem#233](https://github.com/idem-finance/idem/issues/233)). Update this version once a real `0.x`/`1.x` release ships. +Example 05 (`mcp/McpAgentWorkflowExample.kt`) also depends on the official +`io.modelcontextprotocol.sdk:mcp` client, pinned to the same version the main +repo's MCP server pulls in via `spring-ai-bom`. + ## Links - Main repo: [github.com/idem-finance/idem](https://github.com/idem-finance/idem) diff --git a/pom.xml b/pom.xml index 8cc7b96..5e7a0c9 100644 --- a/pom.xml +++ b/pom.xml @@ -30,12 +30,16 @@ 21 1.9.25 - 0.0.11-test + 0.0.12-test + + 0.10.0 @@ -44,6 +48,11 @@ idem-sdk-kotlin ${idem-sdk.version} + + io.modelcontextprotocol.sdk + mcp + ${mcp-sdk.version} + org.springframework.boot spring-boot-starter diff --git a/src/main/kotlin/finance/idem/examples/mcp/McpAgentWorkflowExample.kt b/src/main/kotlin/finance/idem/examples/mcp/McpAgentWorkflowExample.kt new file mode 100644 index 0000000..86c8e6a --- /dev/null +++ b/src/main/kotlin/finance/idem/examples/mcp/McpAgentWorkflowExample.kt @@ -0,0 +1,160 @@ +package finance.idem.examples.mcp + +import com.fasterxml.jackson.databind.ObjectMapper +import finance.idem.examples.support.allowAgentMaxDebitPerSession +import finance.idem.examples.support.createAccount +import finance.idem.examples.support.mintAgentApiKey +import finance.idem.sdk.IdemClient +import io.modelcontextprotocol.client.McpClient +import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport +import io.modelcontextprotocol.spec.McpSchema +import kotlinx.coroutines.runBlocking +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.UUID + +/** + * Example 05 — a real MCP client driving Idem's agent tools, mirroring the + * "demo scenario" covered by the main repo's `McpServerIntegrationTest`: + * post -> reconcile -> rollback -> audit log. + * + * Unlike every other example here, this one does NOT go through + * `idem-sdk-kotlin` for the ledger operations — the MCP server + * (`IdemMcpServer` in the main repo's `mcp` module) is a separate protocol + * surface (SSE/JSON-RPC), reached with the official MCP Java SDK + * (`io.modelcontextprotocol.sdk:mcp`) instead of an HTTP client. `IdemClient` + * is only used here for one-time setup: bootstrapping accounts and minting + * the agent-scoped API key. + * + * Every `postTransaction` call is evaluated by `PolicyGuard` before it + * commits, and the default policy is deny-all — so this example configures a + * permissive `MAX_DEBIT_PER_SESSION` rule for the minted agent key first. + * + * You can drive the exact same 4 tools through natural-language prompts in + * Claude Code/Desktop instead of this Kotlin client — see the connection + * instructions in the main repo's `docs/mcp-server.md`. + * + * Run with: + * ./mvnw compile exec:java -Dexec.mainClass=finance.idem.examples.mcp.McpAgentWorkflowExampleKt + */ +fun main() = + runBlocking { + val baseUrl = System.getenv("IDEM_BASE_URL") ?: error("IDEM_BASE_URL is not set — see .env.example") + val apiKey = System.getenv("IDEM_API_KEY") ?: error("IDEM_API_KEY is not set — see .env.example") + val objectMapper = ObjectMapper() + + val client = IdemClient(baseUrl = baseUrl, apiKey = apiKey) + val (fiatAccountId, usdcAccountId, agentApiKey) = + client.use { + val fiatAccountId = client.createAccount(name = "MCP Agent Fiat", currency = "USD", type = "ASSET") + val usdcAccountId = client.createAccount(name = "MCP Agent USDC", currency = "USD", type = "ASSET") + + val agentApiKey = client.mintAgentApiKey(listOf("AGENTS_EXECUTE", "AGENTS_ROLLBACK", "AGENTS_AUDIT_READ")) + println("Minted agent API key with prefix ${agentApiKey.prefix}") + + client.allowAgentMaxDebitPerSession(agentApiKey.prefix, amount = "100000.00") + println("Configured a permissive MAX_DEBIT_PER_SESSION policy rule for ${agentApiKey.prefix}") + + Triple(fiatAccountId, usdcAccountId, agentApiKey) + } + + val sessionId = UUID.randomUUID().toString() + val agentId = "idem-examples-mcp-demo" + + val transport = + HttpClientSseClientTransport + .builder(baseUrl) + .sseEndpoint("/sse") + .customizeRequest { it.header("X-API-Key", agentApiKey.rawKey) } + .build() + + val mcpClient = McpClient.sync(transport).requestTimeout(Duration.ofSeconds(30)).build() + try { + mcpClient.initialize() + println("Connected to Idem MCP server — ${mcpClient.listTools().tools().size} tools available") + + val postResult = + mcpClient.callTool( + McpSchema.CallToolRequest( + "postTransaction", + mapOf( + "entries" to + listOf( + mapOf( + "accountId" to fiatAccountId.toString(), + "entryType" to "DEBIT", + "monetaryEntryType" to "FIAT", + "amount" to "300.00", + "currency" to "USD", + "rail" to "WIRE", + ), + mapOf( + "accountId" to usdcAccountId.toString(), + "entryType" to "CREDIT", + "monetaryEntryType" to "FIAT", + "amount" to "300.00", + "currency" to "USD", + "rail" to "WIRE", + ), + ), + "idempotencyKey" to UUID.randomUUID().toString(), + "intentDescription" to "idem-examples MCP agent workflow demo", + "agentId" to agentId, + "sessionId" to sessionId, + ), + ), + ) + val workflowPlanId = printToolResult("postTransaction", postResult, objectMapper).get("workflowPlanId").asText() + + val reconcileResult = + mcpClient.callTool( + McpSchema.CallToolRequest( + "reconcileBatch", + mapOf( + "accountId" to usdcAccountId.toString(), + "from" to Instant.now().minus(1, ChronoUnit.DAYS).toString(), + "to" to Instant.now().toString(), + ), + ), + ) + printToolResult("reconcileBatch", reconcileResult, objectMapper) + + val rollbackResult = + mcpClient.callTool( + McpSchema.CallToolRequest( + "rollbackWorkflow", + mapOf( + "workflowPlanId" to workflowPlanId, + "reason" to "idem-examples MCP agent workflow demo — compensating the demo transaction", + "agentId" to agentId, + "sessionId" to sessionId, + ), + ), + ) + printToolResult("rollbackWorkflow", rollbackResult, objectMapper) + + val auditResult = + mcpClient.callTool( + McpSchema.CallToolRequest( + "getAgentAuditLog", + mapOf("sessionId" to sessionId, "limit" to 10), + ), + ) + val auditJson = printToolResult("getAgentAuditLog", auditResult, objectMapper) + println("Audit trail has ${auditJson.get("total").asInt()} event(s)") + } finally { + mcpClient.closeGracefully() + } + } + +private fun printToolResult( + toolName: String, + result: McpSchema.CallToolResult, + objectMapper: ObjectMapper, +): com.fasterxml.jackson.databind.JsonNode { + val text = (result.content().first() as McpSchema.TextContent).text() + check(result.isError != true) { "$toolName returned an error: $text" } + println("$toolName -> $text") + return objectMapper.readTree(text) +} \ No newline at end of file diff --git a/src/main/kotlin/finance/idem/examples/mcp/McpAgentWorkflowReadme.md b/src/main/kotlin/finance/idem/examples/mcp/McpAgentWorkflowReadme.md deleted file mode 100644 index 32f1e2f..0000000 --- a/src/main/kotlin/finance/idem/examples/mcp/McpAgentWorkflowReadme.md +++ /dev/null @@ -1,76 +0,0 @@ -# Example 05 — MCP agent workflow (conceptual, not runnable Kotlin) - -This is a setup guide + prompt reference, not code — the point is to drive -Idem through Claude Code (or any MCP client) directly, not through -`idem-sdk-kotlin`. It documents the MCP server exposed by the main `idem` -repo's `mcp` module (`IdemMcpServer.kt`). - -## The 7 tools, as actually registered - -Spring AI's `@Tool` registers MCP tools under their **Kotlin method name**, -which is camelCase — not the snake_case (`post_transaction`, etc.) used in -some of the main repo's older docs. Use the names below; they're read directly -from `mcp/src/main/kotlin/finance/idem/mcp/IdemMcpServer.kt`. - -| Tool (real, registered name) | Required scope | Key parameters | -|---|---|---| -| `postTransaction` | `AGENTS_EXECUTE` | `entries` (journal lines), `idempotencyKey`, `intentDescription`, `agentId`, `sessionId` | -| `getBalance` | `AGENTS_EXECUTE` | `accountId`, `asOf?` | -| `listEntries` | `AGENTS_EXECUTE` | `accountId`, `from?`, `to?`, `limit?`, `cursor?` | -| `describeAccount` | `AGENTS_EXECUTE` | `accountId` | -| `rollbackWorkflow` | `AGENTS_ROLLBACK` | `workflowPlanId`, `reason`, `agentId`, `sessionId` | -| `reconcileBatch` | `AGENTS_EXECUTE` | `accountId?`, `from`, `to`, `tolerancePercent?` | -| `getAgentAuditLog` | `AGENTS_AUDIT_READ` | `sessionId?`, `from?`, `to?`, `limit?` | - -`AGENTS_ROLLBACK` is intentionally a separate scope from `AGENTS_EXECUTE` — an -agent key that can post transactions cannot roll them back unless explicitly -granted this too. - -Every `postTransaction` call is evaluated by `PolicyGuard` **before** it -commits — by default, a tenant with no configured policy rules gets a -deny-all rule (`MaxDebitPerSession(ZERO)`), so agent debits will be rejected -until you configure a permissive rule for your dev tenant. - -## Connecting Claude Code - -The server speaks the SSE transport (`GET /sse` + `POST /mcp/messages`, -Spring AI's `WebMvcSseServerTransportProvider`) — use `--transport sse`, not -`http`. If you're running the stack locally per this repo's README, expose it -first (e.g. `ngrok http 8081`), then: - -```bash -claude mcp add --transport sse idem https://.ngrok.io/sse \ - --header "X-API-Key: " -``` - -Verify with `claude mcp list` or `/mcp` inside a session — confirm `idem` -shows connected and all 7 tools are visible. - -Use an agent-scoped key (`sk_agent_...`) with only the scopes you need for the -demo, e.g. `AGENTS_EXECUTE` + `AGENTS_ROLLBACK` + `AGENTS_AUDIT_READ` — not the -ADMIN dev-seed key from the rest of this repo's examples. - -## Example prompts - -Once connected, natural-language prompts to Claude Code exercise the tools -directly — no code required. A demo flow mirroring the one covered by the -main repo's `McpServerIntegrationTest` "demo scenario" test: - -1. *"Post a transaction debiting account `` and crediting - `` for 300 USD over WIRE, with idempotency key - `demo-exec-001`."* → calls `postTransaction`, returns a `workflowPlanId`. -2. *"Reconcile account `` for the last 24 hours."* → calls - `reconcileBatch`, returns `matched`/`unmatched`/`exceptions`. -3. *"Roll back workflow `` — reason: compliance - review."* → calls `rollbackWorkflow`, returns the compensating - transaction(s) and a `ROLLED_BACK` status. -4. *"Show me the agent audit log for this session."* → calls - `getAgentAuditLog`, returns HMAC-signed events — note the `PENDING` - event written *before* execution and the `COMPLETED`/`FAILED` event - written after, per Idem's audit-before-execution rule. - -## Further reading - -- `docs/mcp-server.md` in the main `idem` repo for the full connection guide. -- `mcp/src/test/kotlin/finance/idem/mcp/McpServerIntegrationTest.kt` for the - canonical end-to-end scenario this doc mirrors. diff --git a/src/main/kotlin/finance/idem/examples/reconciliation/ReconciliationExample.kt b/src/main/kotlin/finance/idem/examples/reconciliation/ReconciliationExample.kt index 317db9a..3528ea0 100644 --- a/src/main/kotlin/finance/idem/examples/reconciliation/ReconciliationExample.kt +++ b/src/main/kotlin/finance/idem/examples/reconciliation/ReconciliationExample.kt @@ -8,30 +8,19 @@ import finance.idem.sdk.model.JournalLineRequest import finance.idem.sdk.model.OnChainEntryRequest import finance.idem.sdk.model.PostTransactionRequest import finance.idem.sdk.model.StablecoinToken -import io.ktor.client.call.body -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.contentType import kotlinx.coroutines.runBlocking import java.math.BigDecimal import java.util.UUID /** - * Example 04 — reconciliation, called directly against the REST API. + * Example 04 — reconciliation via the real SDK method. * - * `IdemClient`'s entire public surface is postTransaction/getBalance/ - * listEntries/getStatement — there is no `reconcileEntries()` or - * `rollbackWorkflow()` on the SDK today. Reconciliation IS real and reachable - * at `POST /api/v1/reconciliation/batch` (requires RECONCILIATION_WRITE), so - * this example posts through the SDK as usual and then reconciles with a - * plain call through the SDK client's underlying HTTP client — the same - * pattern used for account creation in `support/ExampleAccounts.kt`. + * `IdemClient.reconcileBatch` wraps `POST /api/v1/reconciliation/batch` + * (requires RECONCILIATION_WRITE) directly — no raw HTTP workaround needed. * * Rollback has NO REST or SDK path at all — it's exposed exclusively as the * MCP tool `rollbackWorkflow` (AGENTS_ROLLBACK scope). See - * `mcp/McpAgentWorkflowReadme.md` for how to trigger it from Claude Code. + * `mcp/McpAgentWorkflowExample.kt` for how to trigger it as a real MCP client. * * Run with: * ./mvnw compile exec:java -Dexec.mainClass=finance.idem.examples.reconciliation.ReconciliationExampleKt @@ -88,21 +77,15 @@ fun main() = ) println("Posted on-chain transaction with no PENDING match: ${unmatchedTx.transactionId}") - val results: List> = - client.httpClient - .post("$baseUrl/api/v1/reconciliation/batch") { - header("X-API-Key", apiKey) - contentType(ContentType.Application.Json) - setBody(mapOf("transactionIds" to listOf(unmatchedTx.transactionId))) - }.body() + val results = client.reconcileBatch(listOf(unmatchedTx.transactionId)) results.forEach { item -> - println("Reconciliation outcome for ${item["transactionId"]}: ${item["outcome"]}") + println("Reconciliation outcome for ${item.transactionId}: ${item.outcome}") } println( "An UNMATCHED result is a reconciliation exception, not an automatic rollback - " + "resolving/rolling back the underlying workflow is only available via the MCP " + - "rollbackWorkflow tool today (no REST/SDK path). See mcp/McpAgentWorkflowReadme.md.", + "rollbackWorkflow tool today (no REST/SDK path). See mcp/McpAgentWorkflowExample.kt.", ) } } diff --git a/src/main/kotlin/finance/idem/examples/settlement/PendingSettlementExample.kt b/src/main/kotlin/finance/idem/examples/settlement/PendingSettlementExample.kt new file mode 100644 index 0000000..bcbbef0 --- /dev/null +++ b/src/main/kotlin/finance/idem/examples/settlement/PendingSettlementExample.kt @@ -0,0 +1,134 @@ +package finance.idem.examples.settlement + +import finance.idem.examples.support.createAccount +import finance.idem.sdk.IdemClient +import finance.idem.sdk.model.ChainId +import finance.idem.sdk.model.EntryType +import finance.idem.sdk.model.JournalLineRequest +import finance.idem.sdk.model.OnChainEntryRequest +import finance.idem.sdk.model.PostTransactionRequest +import finance.idem.sdk.model.RegisterSettlementRequest +import finance.idem.sdk.model.StablecoinToken +import kotlinx.coroutines.runBlocking +import java.math.BigDecimal +import java.util.UUID + +/** + * Example 03 — pending settlement lifecycle: register an expectation, watch + * it settle, and cancel one that never arrives. + * + * A `Settlement` is a `PENDING` expectation registered ahead of time + * (`registerSettlement`) that Idem watches for a matching on-chain transfer. + * In production that match happens automatically via the chain-reader/webhook + * pipeline (see the main repo's `docs/domain-model.md`); locally, with no live + * chain, `reconcileBatch` triggers the same matching logic on demand once the + * matching transaction has been posted. + * + * Run with: + * ./mvnw compile exec:java -Dexec.mainClass=finance.idem.examples.settlement.PendingSettlementExampleKt + */ +fun main() = + runBlocking { + val baseUrl = System.getenv("IDEM_BASE_URL") ?: error("IDEM_BASE_URL is not set — see .env.example") + val apiKey = System.getenv("IDEM_API_KEY") ?: error("IDEM_API_KEY is not set — see .env.example") + + val client = IdemClient(baseUrl = baseUrl, apiKey = apiKey) + client.use { + val treasuryAccountId = client.createAccount(name = "Settlement Treasury", currency = "USD", type = "ASSET") + val walletAddress = "0x5555555555555555555555555555555555555c" + val amount = BigDecimal("2500.00") + + // --- PENDING -> SETTLED --- + + val settlement = + client.registerSettlement( + RegisterSettlementRequest( + accountId = treasuryAccountId, + expectedToken = StablecoinToken.USDC, + expectedAmount = amount, + expectedWalletAddress = walletAddress, + expectedChainId = ChainId.EVM, + ), + idempotencyKey = UUID.randomUUID().toString(), + ) + println("Registered settlement ${settlement.settlementId}: status=${settlement.status}, expiresAt=${settlement.expiresAt}") + + // Post the matching on-chain transaction — same amount/token/chain/wallet + // as the registered expectation, so reconciliation can match it. + val counterpartyAccountId = client.createAccount(name = "Settlement Counterparty", currency = "USD", type = "LIABILITY") + val txHash = "0x" + "ef".repeat(32) + // BasicReconciliationService matches settlements using the FIRST journal + // line's OnChainEntry, so the CREDIT line into the treasury account (the + // side carrying the registered wallet address) must come first. + val postedTx = + client.postTransaction( + PostTransactionRequest( + lines = + listOf( + JournalLineRequest( + accountId = treasuryAccountId, + entryType = EntryType.CREDIT, + monetaryEntry = + OnChainEntryRequest( + amount = amount, + token = StablecoinToken.USDC, + chainId = ChainId.EVM, + txHash = txHash, + blockNumber = 21_000_200L, + walletAddress = walletAddress, + tokenContract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + ), + ), + JournalLineRequest( + accountId = counterpartyAccountId, + entryType = EntryType.DEBIT, + monetaryEntry = + OnChainEntryRequest( + amount = amount, + token = StablecoinToken.USDC, + chainId = ChainId.EVM, + txHash = txHash, + blockNumber = 21_000_200L, + walletAddress = "0x6666666666666666666666666666666666666d", + tokenContract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + ), + ), + ), + ), + idempotencyKey = UUID.randomUUID().toString(), + ) + println("Posted matching on-chain transaction: ${postedTx.transactionId}") + + // No live chain locally, so force the match rather than waiting on a + // webhook/chain-reader sweep that will never fire in this environment. + client.reconcileBatch(listOf(postedTx.transactionId)) + + val settledView = client.getSettlement(settlement.settlementId) + println( + "Settlement ${settledView.settlementId} is now ${settledView.status} " + + "(matchedTransactionId=${settledView.matchedTransactionId}, txHash=${settledView.txHash}, confirmedAt=${settledView.confirmedAt})", + ) + + // --- PENDING -> CANCELLED --- + // A second expectation is registered and cancelled before anything ever + // arrives to match it — the other terminal path besides SETTLED. Reaching + // a genuine UNMATCHED terminal status instead would require waiting out + // the real settlement matching-window expiry (24h by default), which + // isn't practical for a short-lived example run. + val abandoned = + client.registerSettlement( + RegisterSettlementRequest( + accountId = treasuryAccountId, + expectedToken = StablecoinToken.USDC, + expectedAmount = BigDecimal("100.00"), + expectedWalletAddress = "0x7777777777777777777777777777777777777e", + expectedChainId = ChainId.EVM, + ), + idempotencyKey = UUID.randomUUID().toString(), + ) + println("Registered a second settlement ${abandoned.settlementId}: status=${abandoned.status}") + + val cancelled = client.cancelSettlement(abandoned.settlementId) + println("Cancelled settlement ${cancelled.settlementId}: status=${cancelled.status}") + } + } \ No newline at end of file diff --git a/src/main/kotlin/finance/idem/examples/settlement/PendingSettlementReadme.md b/src/main/kotlin/finance/idem/examples/settlement/PendingSettlementReadme.md deleted file mode 100644 index a95bfe9..0000000 --- a/src/main/kotlin/finance/idem/examples/settlement/PendingSettlementReadme.md +++ /dev/null @@ -1,56 +0,0 @@ -# Example 03 — Pending settlement (conceptual, not runnable) - -This example is documentation only — there's no `PendingSettlementExample.kt` -in this directory. A search of the whole `idem` codebase (core, application, -infrastructure, `idem-sdk-kotlin`) confirms there is no `PendingSettlement` -class and no `WATCHING` state anywhere; it's not implemented, and neither -`idem-sdk-kotlin` nor the REST API expose a settlement lifecycle a client can -observe directly. This doc describes how settlement actually works today — -entirely server-side — so it's clear what's real versus aspirational. - -## How settlement actually works - -Idem's ledger has an internal `EntryStatus` on settlements: -`PENDING → SETTLED` or `PENDING → UNMATCHED`. There is no `WATCHING` state. - -The flow, end to end: - -1. A fiat leg of a cross-border transaction is posted (e.g. via - [`BasicTransactionExample`](../basic/BasicTransactionExample.kt)), and - separately an expected on-chain settlement is registered server-side as a - `Settlement` record with status `PENDING`. -2. Idem watches the relevant chain for the matching on-chain transfer through - one of three mechanisms, depending on the network: - - **EVM** (Ethereum, Base, Polygon): `AlchemyWebhookReceiver` — Alchemy - Notify pushes matching ERC-20 `Transfer` events (primary), with - `EvmChainReader` (Web3j `getLogs`) as a startup-recovery fallback. - - **Solana**: `QuickNodeWebhookService` — QuickNode Streams pushes matching - transfers (primary), with `SolanaChainReader` (raw JSON-RPC) as a - startup-recovery fallback. - - **Tron**: `TronChainReader` polls the Tronscan REST API on a schedule - (primary and only mechanism — Tron has no webhook support). -3. When a matching on-chain transfer arrives, `BasicReconciliationService` - attempts to match it against `PENDING` settlement candidates (by amount, - with sender-address confirmation preferred over FIFO — see - `BasicReconciliationService.findMatch` in the main repo). A match flips the - settlement to `SETTLED` and fires a `transaction.settled` webhook event via - the transactional outbox. No match flips it to `UNMATCHED` instead — see - [`ReconciliationExample`](../reconciliation/ReconciliationExample.kt) for - how that exception case looks from the client side. - -## What you can observe today - -- `idem-sdk-kotlin` doesn't expose `EntryStatus` or a settlement resource at - all — `BalanceResponse`, `JournalLineResponse`, and `StatementResponse` - carry no settlement-state field. -- The closest thing to visibility today is the `transaction.settled` / - reconciliation-related webhook events delivered via the tenant's configured - webhook endpoint (`webhook_outbox` → `WebhookOutboxPoller`), or manually - triggering `POST /api/v1/reconciliation/batch` for a known transaction ID - (see example 04). -- If you need to watch a specific transaction settle in real time today, - configure a webhook receiver rather than polling — there is no client-side - polling primitive for this in the SDK or REST API. - -If a future `idem-sdk-kotlin` release adds a settlement-status field or -polling endpoint, this doc should be replaced with a real, runnable example. diff --git a/src/main/kotlin/finance/idem/examples/support/ExampleAccounts.kt b/src/main/kotlin/finance/idem/examples/support/ExampleAccounts.kt index 9d008c6..11540eb 100644 --- a/src/main/kotlin/finance/idem/examples/support/ExampleAccounts.kt +++ b/src/main/kotlin/finance/idem/examples/support/ExampleAccounts.kt @@ -1,24 +1,16 @@ package finance.idem.examples.support import finance.idem.sdk.IdemClient -import io.ktor.client.call.body -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.client.statement.HttpResponse -import io.ktor.http.ContentType -import io.ktor.http.contentType -import io.ktor.http.isSuccess +import finance.idem.sdk.model.AccountType +import finance.idem.sdk.model.CreateAccountRequest +import finance.idem.sdk.model.FiatCurrency import java.util.UUID /** - * idem-sdk-kotlin does not expose account creation (POST /api/v1/accounts) — - * its public surface is limited to posting transactions and read queries - * (postTransaction/getBalance/listEntries/getStatement). Accounts must exist - * before a transaction can reference them (PostTransactionService rejects - * unknown account IDs), so every example bootstraps its own accounts via a - * direct call through the SDK client's already-configured Ktor HttpClient, - * reusing its content negotiation and X-API-Key header pattern. + * Accounts aren't implicitly opened on first use — the ledger requires them + * to exist before a transaction can reference them. This wraps the real + * `IdemClient.createAccount` with String params and a bare UUID return, since + * that's what every example's call site expects. * * Requires the ACCOUNTS_WRITE scope — the dev-seeded key from the README has * every scope, so this works out of the box against a local stack. @@ -28,15 +20,13 @@ suspend fun IdemClient.createAccount( currency: String, type: String, ): UUID { - val response: HttpResponse = - httpClient.post("$baseUrl/api/v1/accounts") { - header("X-API-Key", apiKey) - contentType(ContentType.Application.Json) - setBody(mapOf("name" to name, "currency" to currency, "type" to type)) - } - check(response.status.isSuccess()) { "Failed to create account '$name': HTTP ${response.status}" } - // Deserialize as a loose map rather than a full response DTO — we only need - // the generated id, and the SDK deliberately has no CreateAccountResponse model. - val body: Map = response.body() - return UUID.fromString(body["id"] as String) -} + val response = + createAccount( + CreateAccountRequest( + name = name, + currency = FiatCurrency.valueOf(currency.uppercase()), + type = AccountType.valueOf(type.uppercase()), + ), + ) + return response.id +} \ No newline at end of file diff --git a/src/main/kotlin/finance/idem/examples/support/ExampleAdmin.kt b/src/main/kotlin/finance/idem/examples/support/ExampleAdmin.kt new file mode 100644 index 0000000..1c639c8 --- /dev/null +++ b/src/main/kotlin/finance/idem/examples/support/ExampleAdmin.kt @@ -0,0 +1,54 @@ +package finance.idem.examples.support + +import finance.idem.sdk.IdemClient +import io.ktor.client.call.body +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +/** + * API-key and policy-rule creation are one-time ADMIN-scope tenant setup + * actions, not ledger data-plane operations — `idem-sdk-kotlin` deliberately + * doesn't expose them, so these go through the SDK client's underlying HTTP + * client directly, the same pattern used for account creation before + * `createAccount` was added to the SDK. + */ +data class MintedApiKey( + val rawKey: String, + val prefix: String, +) + +suspend fun IdemClient.mintAgentApiKey(scopes: List): MintedApiKey { + val response: HttpResponse = + httpClient.post("$baseUrl/api/v1/api-keys") { + header("X-API-Key", apiKey) + contentType(ContentType.Application.Json) + setBody(mapOf("scopes" to scopes)) + } + check(response.status.isSuccess()) { "Failed to mint agent API key: HTTP ${response.status}" } + val body: Map = response.body() + return MintedApiKey(rawKey = body["rawKey"] as String, prefix = body["prefix"] as String) +} + +suspend fun IdemClient.allowAgentMaxDebitPerSession( + agentKeyPrefix: String, + amount: String, +) { + val response: HttpResponse = + httpClient.post("$baseUrl/api/v1/admin/policy-rules") { + header("X-API-Key", apiKey) + contentType(ContentType.Application.Json) + setBody( + mapOf( + "type" to "MAX_DEBIT_PER_SESSION", + "agentKeyPrefix" to agentKeyPrefix, + "amount" to amount, + ), + ) + } + check(response.status.isSuccess()) { "Failed to create policy rule: HTTP ${response.status}" } +} \ No newline at end of file From 112b4482ff7fcf16023b22b2d104a3e925906d30 Mon Sep 17 00:00:00 2001 From: ifsantana Date: Wed, 8 Jul 2026 20:42:40 -0300 Subject: [PATCH 2/3] ci: add minimal build-and-test GitHub Actions pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No lint/coverage gates yet — this repo has no test source set today, just compileable examples. Runs ./mvnw verify on push/PR to main so it's ready to catch regressions as soon as tests get added. --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1ad374d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: temurin + cache: maven + + - name: Build and test + run: ./mvnw verify --no-transfer-progress From 694f2716bc247de44398a119d3a6701f29675243 Mon Sep 17 00:00:00 2001 From: ifsantana Date: Wed, 8 Jul 2026 22:49:33 -0300 Subject: [PATCH 3/3] ci: fix mvnw missing executable bit, breaking CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mvnw was tracked as mode 100644 — fine on Windows (where Git's fileMode tracking is typically off and the file still runs via its .cmd counterpart or an IDE's own Maven wrapper handling), but GitHub Actions' ubuntu-latest runners need the executable bit set to invoke ./mvnw directly. --- mvnw | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 mvnw diff --git a/mvnw b/mvnw old mode 100644 new mode 100755