Loans
Periodic / amortizing loan deals — the sibling of the one-shot rental deal. Deploy a transferable loan-note class, mint the creditor position to the borrower, disburse principal, then service repayments period-by-period (interest/principal split paid to the lender), and sell the note to re-route collection.
Where this sits in the alignment economy. A loan is a specific shape of the Structure stage — a confirmed joint Intent compiled into an on-chain artifact and then driven over time. Where a repo locks an asset for a single repurchase and a one-shot rental settles once, a loan is the canonical periodic deal: principal disbursed up front, then a repayment stream split into interest and principal, period after period, until the balance clears. Read the DMS guide for the lifecycle machinery this rides on; this page is the loan-specific mechanic.
A loan in YieldFabric is a periodic DMS deal — the
sibling of the one-shot rental deal. It is not a bespoke backend
entity and not a raw obligation mint. It is a Deal: a
counter-signed agreement carrying a DealPlan (a small DAG of typed
action nodes) plus a cashflow engine and a per-period sub-DAG.
You author the plan, the borrower signs it, the lender activates it —
and the DMS compiles the plan and submits the underlying on-chain work
(deploy a loan-note class, mint the creditor position, disburse the
principal, accept the obligation) on the parties' behalf. Then the
platform services the loan one period at a time.
This page covers:
- What makes a loan periodic, and how it differs from a one-shot deal.
- The parties — lender and borrower (no third managing-agent role).
- The deal shape: the 4-node setup DAG, the
amortizing_loancashflow engine, and the per-period repayment sub-DAG. - The lifecycle: Compose → Sign-off → Live → Complete, with the exact GraphQL mutations.
- Periodic servicing: why a scheduler must advance periods, manual Repay vs armed auto-pay.
- The transferable note — selling the loan re-routes collection.
- A worked amortization example and the common pitfalls.
The app-built loan deal described here is what the Loans UX creates: see
buildLoanDraftin the first-party app (yieldfabric-app/src/components/loans/loanModel.ts) and the sample app (examples/yieldfabric-rent/src/lib/loanModel.ts). The DMS also ships server-side origination loan templates (loan,transferable_loan,syndicated_debt) — those are a different artifact set, covered briefly at the end as alternative shapes.
One-shot vs periodic
A rental deal runs a single setup phase and then it's done — the rent obligation is minted, the tenant accepts, settlement is one-shot. A loan adds exactly two things on top of that machinery:
- A cashflow engine. The plan carries
cashflow_ref: "amortizing_loan"plus adeal_termsblock. Each repayment is split into interest (accrued on the outstanding balance) and principal (which pays the balance down). Theamortizing_loanengine does this split authoritatively at runtime. - A periodic phase. The plan carries a
periodic_template— a per-period sub-DAG the platform runs once per repayment period, advancing one period at a time.
In DMS terms, a loan is a deal with a non-empty periodic_template
and a loan/mortgage cashflow_ref. (The app's isLoanDeal predicate
is precisely "has a periodic_template AND a cashflow_ref matching
/loan|mortgage/i".)
The parties
A loan has exactly two roles:
There is no facility-agent / managing-agent role in the app loan
flow. (Rentals add a third managing-agent role plus a balance-gated
send policy; loans drop both — the lender disburses and collects
directly.) In the DealPlan the only listed party is the borrower;
the lender is the deal's proposer.
The deal shape
The loan DealPlan has three parts: a one-shot setup DAG (4
nodes), the cashflow engine (cashflow_ref + deal_terms), and a
periodic template (the per-period repayment sub-DAG).
Setup DAG — four nodes
deploy_loan_classdeploy_contractsymbol: "LOAN", open_minting: false, and transferable set from the "Sellable" checkbox.mint_loancreate_composed_contractcounterpart and obligor are the borrower entity id. obligation_address links from $step.deploy_loan_class.obligation_address, so the note is minted into the just-deployed class.disburseinstant_sendaccept_loanaccept_obligationcontract_id from $step.mint_loan.composed_contract_id.The edges run strictly deploy_loan_class → mint_loan → disburse → accept_loan, each gated on_success.
Cashflow engine — cashflow_ref + deal_terms
The plan sets cashflow_ref: "amortizing_loan" and seeds the engine
with a deal_terms block:
loan_balanceinterest_pctexpected_payment_amountarrearsThe first-party app additionally sets total_terms (the period
count) in deal_terms — its cashflow projection returns nothing
without it. The server-side syndicated_debt template seeds only the
four fields above. When you author your own plan, include
total_terms if you rely on the client-side cashflow projection.
Periodic template — the per-period repayment sub-DAG
periodic_template.schedule is an interval schedule
(kind: "interval", start_at, interval_days: 30, count: <term>).
Each period runs a three-node sub-DAG:
service__collectwait_for_payment_completionpayment_id from $period.payment_id.service__evaluateevaluate_cashflowamortizing_loan engine for the period — splits the repayment into interest + principal and emits $cashflow.* outputs. Reads period_index from $period.index.service__pay_lenderinstant_send$cashflow.principal_transfer) to the lender. Assigned to the borrower.The per-period edges run service__collect → service__evaluate → service__pay_lender.
Reference resolution recap. Inside the plan,
$step.X.Yreads an earlier step's output,$cashflow.Xreads a field the cashflow engine emitted, and$period.Xreads a field from the current period. See the DMS guide for the full placeholder table.
Annotated plan sketch
A trimmed DealPlan for a periodic loan (JSON — the shape
saveDealDraft accepts). Comments are annotations, not literal JSON.
{
"entry_step_ids": ["deploy_loan_class"],
"cashflow_ref": "amortizing_loan",
"deal_terms": {
"loan_balance": 100000,
"interest_pct": 8,
"expected_payment_amount": 4522.73,
"arrears": 0,
"total_terms": 24
},
"nodes": [
{
"step_id": "deploy_loan_class",
"task_name": "deploy_contract",
"inputs": {
"name": "Acme term loan note class",
"symbol": "LOAN",
"open_minting": false,
"transferable": true
}
},
{
"step_id": "mint_loan",
"task_name": "create_composed_contract",
"inputs": {
"name": "Acme term loan",
"counterpart": "ENT-borrower-bob",
"obligation_address": "$step.deploy_loan_class.obligation_address",
"obligations": [
{
"name": "24-period term loan",
"denomination": "aud-token-asset",
"obligor": "ENT-borrower-bob",
"expiry": "2028-06-16T00:00:00Z",
"payment_schedule": {
"amount": 4522.73,
"legs": [
{ "due_date": "2026-07-16T00:00:00Z", "linear_vesting": false }
]
}
}
]
}
},
{
"step_id": "disburse",
"task_name": "instant_send",
"inputs": {
"asset_id": "aud-token-asset",
"amount": 100000,
"destination_id": "ENT-borrower-bob"
},
"assignee_override": { "kind": "proposer" }
},
{
"step_id": "accept_loan",
"task_name": "accept_obligation",
"inputs": { "contract_id": "$step.mint_loan.composed_contract_id" },
"assignee_override": { "kind": "party", "role": "borrower" }
}
],
"edges": [
{ "from": "deploy_loan_class", "to": "mint_loan", "condition": { "kind": "on_success" } },
{ "from": "mint_loan", "to": "disburse", "condition": { "kind": "on_success" } },
{ "from": "disburse", "to": "accept_loan", "condition": { "kind": "on_success" } }
],
"periodic_template": {
"schedule": { "kind": "interval", "start_at": "2026-07-16T00:00:00Z", "interval_days": 30, "count": 24 },
"per_period_entry_step_ids": ["service__collect"],
"per_period_actions": [
{
"step_id": "service__collect",
"task_name": "wait_for_payment_completion",
"inputs": { "payment_id": "$period.payment_id" }
},
{
"step_id": "service__evaluate",
"task_name": "evaluate_cashflow",
"inputs": {
"cashflow_ref": "amortizing_loan",
"snapshot": { "loan_balance": "100000", "arrears": "0", "interest_pct": "8" },
"inputs": { "payment_amount": "4522.73", "expected_payment_amount": "4522.73" },
"period_index": "$period.index"
}
},
{
"step_id": "service__pay_lender",
"task_name": "instant_send",
"inputs": {
"asset_id": "aud-token-asset",
"amount": "$cashflow.principal_transfer",
"destination_id": "ENT-lender-alice"
},
"assignee_override": { "kind": "party", "role": "borrower" }
}
],
"per_period_edges": [
{ "from": "service__collect", "to": "service__evaluate" },
{ "from": "service__evaluate", "to": "service__pay_lender" }
]
}
}
The snapshot / inputs on service__evaluate are non-empty seeds
(start-readiness validation rejects empty objects); the periodic
pre-pass re-derives the real per-period values from prior outputs at
runtime, so the values here are starting placeholders, not the final
per-period numbers.
The lifecycle
The UI drives four stages — Compose → Sign-off → Live → Complete —
each mapped to a dealFlow mutation on the federated gateway
(api.yieldfabric.com/graphql). These calls ride the dealFlow namespace served by
the agents subgraph, so every field nests under data.dealFlow.<op>.
Compose Sign-off Live Complete
─────── ──────── ──── ────────
saveDealDraft ─► proposeDraft ─► signDeal ─► activateDeal ─► processDealPeriod ─► (all periods clean)
(DRAFT) (PROPOSED, (borrower) (lender; (per period, by COMPLETED
author the plan lender: ACTIVE → a scheduler or
in the wizard "Send for deploy note, a manual Repay)
signing") mint, DISBURSE
principal,
borrower accept)
Compose — saveDealDraft
The wizard saves the loan DealPlan as a DRAFT. Drafts skip strict
validation, so partial authoring works.
mutation SaveDealDraft($input: SaveDealDraftInput!) {
dealFlow {
saveDealDraft(input: $input) {
id
status
plan
parties { entityId role }
}
}
}
draftId: null creates a new draft; the response carries the
generated DEAL-<uuid>. Pass that id back to append a new version.
Sign-off — proposeDraft then signDeal
The lender proposes the stored draft to the borrower ("Send for signing", DRAFT → PROPOSED):
mutation ProposeDraft($input: ProposeDraftInput!) {
dealFlow {
proposeDraft(input: $input) { # input: { draftId, revisionId }
success
deal { id status }
}
}
}
The borrower reviews the principal, rate, and term, then signs:
mutation SignDeal($input: SignDealInput!) {
dealFlow {
signDeal(input: $input) { # input: { dealId }
success
deal { status } # → ACCEPTED once every party has signed
}
}
}
Live — activateDeal
The lender activates the signed loan (PROPOSED/ACCEPTED → ACTIVE). The DMS compiles the plan and runs the one-shot setup phase: deploy the note class, mint the loan, disburse the principal, and the borrower accepts.
mutation ActivateDeal($input: ActivateDealInput!) {
dealFlow {
activateDeal(input: $input) { # input: { dealId }
success
deal { status } # → ACTIVE
}
}
}
The lender must hold the principal to disburse it. Some setup steps
are assigned to a party (e.g. accept_loan is the borrower's) — those
surface as pending actions the party executes with completePartyAction.
Complete — periodic servicing then terminal
Once the setup phase finishes, the loan stays ACTIVE and services period by period. After the last period settles cleanly the deal transitions to COMPLETED.
Periodic servicing & repayment
The platform does not auto-advance periods. A deal with a periodic
phase requires an application or backend scheduler to call
processDealPeriod for each due period; if nothing advances it, the
deal silently goes Defaulted past its grace period.
The reference scheduler is the agents deal-period scheduler (see
yieldfabric-agents/src/jobs/deal_period_scheduler.rs) — it finds
periods whose due time has arrived and fires processDealPeriod for
each, executing the resolver in-process under the payer's identity.
Advancing one period — processDealPeriod
mutation ProcessDealPeriod($input: ProcessDealPeriodInput!) {
dealFlow {
processDealPeriod(input: $input) { # input: { dealId, periodIndex }
success
dealId
periodIndex
status
message
}
}
}
This is idempotent on (dealId, periodIndex) — a status check in the
period ledger short-circuits already-processed periods, so retries are
safe. Each call runs the per-period sub-DAG: wait for the repayment to
settle, split it into interest + principal via the cashflow engine, and
pay the principal portion to the lender.
Inspect the runtime ledger with dealPeriods:
query DealPeriods($dealId: String!) {
dealFlow {
dealPeriods(dealId: $dealId) {
periodIndex
status # SCHEDULED → PROCESSING → COMPLETED
dueAt
workflowId
startedAt
completedAt
hasRealised
}
}
}
Manual Repay vs armed auto-pay
There are two ways the borrower's repayment fires:
- Manual Repay. The borrower clicks "Repay" on a due period, which
calls
processDealPeriodfor that period index directly. - Armed auto-pay. The borrower stores a scoped automation
credential (a
yf_api_…key) against the deal; the scheduler then fires the borrower's side unattended each period. Arm and disarm with:
mutation SetDealAutomationKey($input: SetDealAutomationKeyInput!) {
dealFlow {
setDealAutomationKey(input: $input) {
active
entityId
role
keyLabel
createdAt
}
}
}
mutation RevokeDealAutomationKey($input: RevokeDealAutomationKeyInput!) {
dealFlow {
revokeDealAutomationKey(input: $input) { # input: { dealId }
active
}
}
}
Auto-pay is the borrower's kill-switchable convenience; revoking the key stops the worker from firing their side, and they fall back to manual Repay.
The transferable note — selling the loan
When the "Sellable" checkbox is set, the loan-note class is deployed
transferable (transferable: true on deploy_loan_class). The
loan note is then a transferable creditor-position NFT — the
lender's right to receive repayments — which can be sold by
transferring the NFT.
The app's own loan flow repays the lender directly (the
service__pay_lender send targets the lender entity). The production
serviced model routes repayments into a servicing account and lets
the current note-holder collect under a send policy: selling the
loan = transferring the note, and on-chain ownerOf is read live on
every collect, so future collection re-routes to the buyer
automatically — no policy re-registration. The unattended driver for
that model is the loan-collect scheduler (see
yieldfabric-agents/src/jobs/loan_collect_scheduler.rs), which follows
the note's ownerOf. The server-side transferable_loan template
encodes this sellable-bearer-note shape.
Even in the app's direct-repay flow, marking the class transferable still lets the creditor position move; the difference is only who the per-period send targets — the original lender (direct) vs the live note-holder (serviced).
Worked example
A 100 000 AUD loan at 8% APR over 24 monthly periods. The level
payment is computed client-side for display (the platform's
amortizing_loan engine is authoritative at runtime):
principal = 100,000 AUD
rate = 8% / year → m = 0.08 / 12 ≈ 0.006667 per period
term = 24 periods
level payment P = principal · m / (1 − (1 + m)^(−24)) ≈ 4,522.73 AUD
Period Payment Interest Principal Balance
1 4,522.73 666.67 3,856.06 96,143.94
2 4,522.73 640.96 3,881.77 92,262.17
…
24 ~4,522.x ~29.9x ~4,492.x 0.00 (last clears the residue)
Total paid ≈ 24 × 4,522.73 ≈ 108,545.52 AUD
Total interest ≈ 8,545.52 AUD
Interest accrues on the outstanding balance, so it shrinks each
period while the principal portion grows; the final period clears any
rounding residue exactly. In the deal that 4,522.73 is
expected_payment_amount, and each period's service__pay_lender
sends the principal portion ($cashflow.principal_transfer, e.g.
3,856.06 in period 1) to the lender — the interest stays with the
lender as yield.
Common pitfalls
0x… address to counterpart / obligor0x… fails with "No entity found with name '0x…'".counterpartWalletId only when you have the wallet UUID and want to bind by it.counterpart_wallet_id on the mint nodeSome("") as a real (empty) wallet id and fails with "Wallet not found" instead of falling back to the entity. (This was a real bug that was fixed.)counterpart_wallet_id entirely on the mint node — pass only counterpart (the entity id).processDealPeriod, the deal silently goes Defaulted past its grace period.processDealPeriod manually per period.amortize / monthlyPayment).amortizing_loan engine does the authoritative split at runtime via evaluate_cashflow. Treat the UI numbers as a preview.ACTIVE covers both setup-running and servicing. Acting on the note before disbursement/accept completes fails.total_terms from deal_terms when you rely on the client projectiontotal_terms (the period count) if your client renders the projected cashflow; the engine itself seeds from the schedule count.DMS origination templates (alternative shapes)
The app-built deal above mints the note directly to the borrower and
disburses with an instant_send. The DMS also ships server-side
origination loan templates, which are a different artifact set:
loancashflow_ref is null (origination only).transferable_loansyndicated_debtcashflow_ref: "amortizing_loan" + deal_terms, the same periodic/amortizing engine as the app-built loan, at facility scale.Use the app-built periodic/amortizing loan when you want the simplest disburse-then-service shape; reach for the origination templates when you need a funded loan account, the swap-for-principal origination, or the serviced bearer-note model.
See also
- DMS (Deal Management System) — the deal lifecycle,
DealPlan, periodic phase,processDealPeriod, and the$step/$cashflow/$periodreference model this guide builds on. - Collateralisation — the repo / contingent-contract sibling: a one-repurchase locked-asset deal, where a loan is a periodic repayment stream.
- Building with YieldFabric — the wire-level "how do I call this" reference: auth, the federated gateway vs payments-direct, and the obligation recipes the loan setup DAG builds on.