Technical companion to the README.
+----------------------------------------------------------------------+
| Server (single host: YOUR_SERVER_IP) |
| |
| +--------------------+ +------------------------------------+ |
| | Browser | | Canton 3.5.1 Validator | |
| | (role-switcher |--->| (Splice 0.6.5, pre-existing) | |
| | demo UI) | | JSON Ledger API v2 :7575 | |
| +--------------------+ | + Participant + ANS | |
| | +------------------------------------+ |
| v ^ ^ |
| +--------------------+ | | |
| | Next.js 16 |---------------+ | |
| | (App Router) | party-filtered reads | |
| | Port 3100 | submit-and-wait POSTs | |
| +--------------------+ | |
| | |
| +--------------------+ | |
| | Node/TS Agent |--------------------------+ |
| | (poller, agent/) | exercises choices as agent party |
| | LLM (Groq def.) | |
| +--------------------+ |
| |
| Host Caddy (wildcard *.unitynodes.com Cloudflare Origin cert) |
| finhive.unitynodes.com -> 127.0.0.1:3100 |
+----------------------------------------------------------------------+
Components NOT present in this deployment: PQS, separate app Postgres, Keycloak, Daml trigger container, Caddy container.
All calls to the JSON Ledger API v2 require a Bearer token. The web app and agent fetch one at startup (and refresh on 401) using the Auth0 M2M client_credentials flow:
POST https://YOUR_TENANT.us.auth0.com/oauth/token
{
"grant_type": "client_credentials",
"client_id": "<AUTH0_M2M_CLIENT_ID>",
"client_secret": "<AUTH0_M2M_CLIENT_SECRET>",
"audience": "https://canton.network.global"
}
The resulting JWT is forwarded as Authorization: Bearer <token> to the Ledger API. The LEDGER_API_USER (<client-id>@clients) is the Daml ledger user that the validator maps to party rights.
The UI is a demo role-switcher (party-id stored in a cookie). There is no end-user SSO - switching roles in the header changes which party's ACS is queried. Production would bind end-user identity to party ids via Auth0 user tokens.
- Browser on
/vendorsubmits form. - Next.js server action POSTs to JSON Ledger API v2
v2/commands/submit-and-wait:
{
"commands": [{
"CreateCommand": {
"template_id": "#finhive:FinHive.Invoice:Invoice",
"create_arguments": {
"operator": "<operator-party-id>",
"vendor": "<vendor-party-id>",
"apClerk": "<ap-party-id>",
"invoiceId": "...",
"lineItems": [...],
"totalAmount": "5000.00",
"currency": "USD",
"status": { "tag": "Pending", "value": {} }
}
}
}],
"act_as": ["<vendor-party-id>"],
"read_as": ["<vendor-party-id>"]
}Note: numeric and Decimal fields are encoded as strings in the v2 JSON API.
Result: Invoice contract on ledger with signatory [vendor, operator], observer [apClerk]. CEO is not an observer - the synchronizer will never send this contract to a party querying as CEO.
- Agent polls
v2/state/active-contractsfiltered toInvoicetemplate,act_as / read_asas agent party, every 30 seconds. - For each Pending invoice: reads
PaymentPolicyandAgentSpendingLimit. - Calls the configured OpenAI-compatible chat completions endpoint (default Groq, model
llama-3.3-70b-versatile); falls back to deterministic rule (amount < autoApproveBelow) when the LLM is unavailable. - If
AUTO_APPROVEand amount withinAgentSpendingLimit: exercisesApproveInvoicechoice via submit-and-wait. TheConsumeBudgetchoice onAgentSpendingLimitasserts the cap on-ledger - the contract fails if the limit is exceeded, regardless of what the LLM said. - Otherwise: exercises
ProposeActionchoice, creating aProposedActioncontract observable byapClerk.
- Browser loads
/ap. - Next.js fetches
v2/state/active-contractsforInvoiceandProposedActiontemplates,act_as / read_asas AP party. - Renders list of invoices (full line items visible) and AI proposals with accept/override buttons.
- Click "Approve" -> submit-and-wait exercise of
ApproveInvoicechoice on the Invoice contract. - Choice creates
SettlementandFeaturedAppActivityMarkerin the same transaction (operator is co-signatory, enabling marker creation).
- Browser loads
/ceo. - Next.js fetches
v2/state/active-contractsforBudgetViewtemplate,act_as / read_asas CEO party. - CEO receives
BudgetViewcontracts (total amount + vendor name, no line items) created by the AP_Clerk viaGetBudgetView. Invoicecontracts never appear in the CEO's query - CEO is not a stakeholder. This is enforced by Canton's stakeholder model, not by a UI filter.- Dashboard shows a privacy banner: "You see aggregates. Line items are not visible - enforced by Canton."
Privacy note: this MVP co-hosts all parties on one Canton participant. The privacy guarantee demonstrated is stakeholder-based: a non-stakeholder party's ACS query returns no contracts for that template, as enforced by the Canton participant. Multi-participant isolation (data never leaving a separate node) is a Phase 1 deployment topology, not demonstrated here.
Daml SDK 3.4.11, LF target 2.1. Package name: finhive. Template ids use the form #finhive:FinHive.Module:Template.
-- daml/FinHive/Company.daml
template Company
with
operator : Party
companyName : Text
where
signatory operator
template Role
with
company : ContractId Company
operator : Party
holder : Party
roleType : RoleType
where
signatory operator
observer holder
data RoleType = CEO | AP_Clerk | Vendor | HR | Employee-- daml/FinHive/Invoice.daml
template Invoice
with
operator : Party
vendor : Party
apClerk : Party
invoiceId : Text
lineItems : [LineItem]
totalAmount : Decimal
currency : Text
status : InvoiceStatus
where
signatory vendor, operator
observer apClerk
-- CEO is NOT an observer. BudgetView is the aggregate-only channel.
nonconsuming choice GetBudgetView : ContractId BudgetView
with ceo : Party
controller apClerk
do create BudgetView with ...
choice ApproveInvoice : (ContractId Settlement, ContractId FeaturedAppActivityMarker)
controller apClerk
do
create FeaturedAppActivityMarker with provider = operator ...
create Settlement with ...
choice RejectInvoice : ()
controller apClerk
do pure ()
template BudgetView
with
operator : Party
apClerk : Party
ceo : Party
vendorName : Text
totalAmount : Decimal
currency : Text
invoiceId : Text
where
signatory apClerk
observer ceo, operator
template Settlement
with
operator : Party
vendor : Party
apClerk : Party
invoiceId : Text
totalAmount : Decimal
currency : Text
marker : ContractId FeaturedAppActivityMarker
where
signatory apClerk
observer vendor, operator
template ProposedAction
with
operator : Party
apClerk : Party
agentParty : Party
invoiceId : Text
action : AgentAction
reasoning : Text
where
signatory agentParty
observer apClerk, operator-- daml/FinHive/PaymentPolicy.daml
template PaymentPolicy
with
ceo : Party
apClerk : Party
agentParty : Party
company : ContractId Company
maxPerInvoice : Decimal
maxPerVendorPerMonth : Decimal
vendorAllowlist : [Party]
autoApproveBelow : Decimal
where
signatory ceo
observer apClerk, agentParty-- daml/FinHive/AgentSpendingLimit.daml
template AgentSpendingLimit
with
ceo : Party
agentParty : Party
company : ContractId Company
dailyCapUSD : Decimal
spentTodayUSD : Decimal
where
signatory ceo
observer agentParty
-- choices: ConsumeBudget (asserts cap), ResetDaily-- daml/FinHive/RecurringPayment.daml
template RecurringPayment
with
operator : Party
customer : Party
vendor : Party
apClerk : Party
amount : Decimal
currency : Text
frequency : Frequency
nextCharge : Time
where
signatory customer, vendor, operator
observer apClerk
choice Charge : (ContractId RecurringPayment, ContractId FeaturedAppActivityMarker)
with newNextCharge : Time
controller vendor
do
create FeaturedAppActivityMarker with provider = operator ...
create this with nextCharge = newNextChargeFull source in ../daml/FinHive/.
- Next.js 16 App Router
- React 19
- TypeScript strict
- Tailwind CSS 3
- Hand-rolled shadcn-style components (no @shadcn/ui package dependency)
- JSON Ledger API v2 called directly from server components and server actions (no @daml/ledger, no PQS)
Pages: / (landing), /vendor (create/list invoices), /ap (inbox: approve/reject/publish-budget-view + policy editor), /ceo (budget dashboard + privacy banner + agent limit), /hr (payroll), /ar (subscriptions).
Auth: demo role-switcher. Active party stored in a cookie. Each server fetch uses the M2M token but with act_as / read_as set to the selected party. Production would bind each user to their own party and token.
Located in agent/. Node 20 / TypeScript.
- Polls
v2/state/active-contractson a 30-second interval. - Provider-agnostic OpenAI-compatible LLM client. Defaults to Groq (
llama-3.3-70b-versatile) and is pluggable to OpenRouter, OpenAI, or any Anthropic-compatible endpoint by changing env (LLM_BASE_URL,LLM_API_KEY,LLM_MODEL). - Calls the chat completions endpoint with a structured prompt (policy + invoice + limit as JSON).
- Deterministic fallback: if the LLM is unavailable, or if
totalAmount <= autoApproveBelowand amount within daily cap, decide without calling the LLM. - Submits choices via
v2/commands/submit-and-wait.
This is the primary agent implementation. A cron-driven variant was considered as a fallback but is not deployed separately - the Docker Compose service runs the Node poller directly.
infra/docker-compose.yml starts:
web- Next.js production build (port 3100)agent- Node/TS poller
Both containers join the existing Splice validator Docker network (splice-validator_splice_validator) so they can reach the validator's JSON Ledger API at http://splice-validator-participant-1:7575.
There is no app Postgres - nothing in FinHive uses a database. All state lives on the ledger.
TLS and routing are handled by the pre-existing host Caddy instance using a wildcard Cloudflare Origin certificate for *.unitynodes.com. The snippet in infra/caddy-finhive.snippet routes finhive.unitynodes.com to 127.0.0.1:3100. Caddy is not containerized.
See .env.example. Required variables:
LEDGER_API_BASE- JSON Ledger API v2 base URL (host-to-container reachable)LEDGER_API_BASE_DOCKER- same, for containers on the splice networkSPLICE_NETWORK- Docker network name of the validator compose stackAUTH0_TOKEN_URL,AUTH0_AUDIENCE,AUTH0_SCOPE- Auth0 M2M token endpointAUTH0_M2M_CLIENT_ID,AUTH0_M2M_CLIENT_SECRET- M2M application credentialsLEDGER_API_USER- Daml ledger user (<client-id>@clients)LLM_BASE_URL(defaulthttps://api.groq.com/openai/v1),LLM_API_KEY,LLM_MODEL(defaultllama-3.3-70b-versatile) - OpenAI-compatible LLM provider; pluggable to OpenRouter / OpenAI / Anthropic-compatible endpoints- Party ids:
DAML_OPERATOR_PARTY,AGENT_PARTY,CEO_PARTY,AP_PARTY,VENDOR_PARTY,CUSTOMER_PARTY,HR_PARTY FINHIVE_PACKAGE_NAME,FINHIVE_PACKAGE_ID- set after DAR upload
- Logs: docker-compose logs to stdout.
- Metrics: validator already exposes Splice metrics; scrape from Prometheus on the same host.
- Uptime: ping
https://finhive.unitynodes.com/api/health.
Loki/Prometheus/Sentry are post-grant additions.
- The Ledger API is not directly exposed to the internet - it is reachable only on the internal Docker network and via the host's private IP. Only the Next.js app and agent communicate with it.
- Auth tokens (M2M JWT) are short-lived and refreshed in memory. The client secret is in
.env- never commit.env, restrict file permissions. - No KMS for participant keys on docker-compose validator - acceptable for DevNet, not for MainNet.
- No app database - no session storage risk for this MVP.
- TLS terminated at host Caddy using a Cloudflare Origin certificate. Internal traffic between Caddy and the Next.js container is plaintext on localhost (acceptable on a single host).
- The demo role-switcher (cookie-based party selection) has no authentication - any visitor can switch roles. This is intentional for the demo; production would use Auth0 user tokens bound to party ids.
- Multi-participant topology (all parties on one validator; stakeholder-based privacy is proven, not multi-node isolation).
- PQS / read replica (all reads are live ACS queries via v2 API).
- KMS migration (deferred to TestNet/MainNet onboarding).
- Featured-App-Activity reward collection automation (deferred to Phase 1).
- BSA/AML pipeline (out of scope for this MVP).
- ANS sub-domain provisioning per tenant (static
finhive.unitynodes.comfor MVP). - SOC 2 controls (Phase 1).
- README - overview and quick start
- hackathon-submission.md - submission writeup