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.

Guides/Structure

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_loan cashflow 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 buildLoanDraft in 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:

  1. A cashflow engine. The plan carries cashflow_ref: "amortizing_loan" plus a deal_terms block. Each repayment is split into interest (accrued on the outstanding balance) and principal (which pays the balance down). The amortizing_loan engine does this split authoritatively at runtime.
  2. 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:

Role
lender
Who
the proposer
What they do
Authors the deal, disburses the principal at activation, receives the principal portion of each repayment.
Role
borrower
Who
the counterparty / obligor
What they do
Signs the deal, accepts the loan obligation, repays each period.

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

Step
deploy_loan_class
Task
deploy_contract
What it does
Deploys the loan-note obligation class. symbol: "LOAN", open_minting: false, and transferable set from the "Sellable" checkbox.
Step
mint_loan
Task
create_composed_contract
What it does
Mints the loan note + repayment schedule. counterpart 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.
Step
disburse
Task
instant_send
What it does
The lender (proposer) sends the principal up front to the borrower.
Step
accept_loan
Task
accept_obligation
What it does
The borrower accepts the loan obligation, locking in the repayment schedule. Links contract_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:

Field
loan_balance
Meaning
The principal (starting outstanding balance).
Field
interest_pct
Meaning
Annual interest rate, percent.
Field
expected_payment_amount
Meaning
The level per-period repayment.
Field
arrears
Meaning
Starting arrears (0 at origination).

The 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:

Step
service__collect
Task
wait_for_payment_completion
What it does
Waits for the period's repayment to settle. Reads payment_id from $period.payment_id.
Step
service__evaluate
Task
evaluate_cashflow
What it does
Runs the amortizing_loan engine for the period — splits the repayment into interest + principal and emits $cashflow.* outputs. Reads period_index from $period.index.
Step
service__pay_lender
Task
instant_send
What it does
Sends the principal portion ($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.Y reads an earlier step's output, $cashflow.X reads a field the cashflow engine emitted, and $period.X reads 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 processDealPeriod for 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

Pitfall
Passing a 0x… address to counterpart / obligor
Why it bites
These are entity ids, not wallet addresses. The resolver looks the entity up by name; a 0x… fails with "No entity found with name '0x…'".
Avoid
Always pass the borrower's entity id. Use counterpartWalletId only when you have the wallet UUID and want to bind by it.
Pitfall
Setting an empty counterpart_wallet_id on the mint node
Why it bites
The composed-contract resolver treats Some("") 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.)
Avoid
Omit counterpart_wallet_id entirely on the mint node — pass only counterpart (the entity id).
Pitfall
Forgetting periodic processing
Why it bites
A loan has a periodic phase; if nothing calls processDealPeriod, the deal silently goes Defaulted past its grace period.
Avoid
Run a scheduler (or arm the borrower's auto-pay), or call processDealPeriod manually per period.
Pitfall
Treating the UI amortization as authoritative
Why it bites
The interest/principal split shown in the UI is computed client-side for display (amortize / monthlyPayment).
Avoid
The platform's amortizing_loan engine does the authoritative split at runtime via evaluate_cashflow. Treat the UI numbers as a preview.
Pitfall
Expecting the periodic phase to run before the setup phase finishes
Why it bites
ACTIVE covers both setup-running and servicing. Acting on the note before disbursement/accept completes fails.
Avoid
Check the deal's workflow status; servicing only begins after the setup DAG completes.
Pitfall
Omitting total_terms from deal_terms when you rely on the client projection
Why it bites
The first-party app's cashflow projection returns nothing without it.
Avoid
Include total_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:

Template
loan
Shape
The lender creates a funded loan account (a group account), the borrower mints a bare loan-agreement obligation and swaps it for the principal disbursed from that account, then repays into the account each period. cashflow_ref is null (origination only).
Template
transferable_loan
Shape
A loan as a transferable bearer note: repayments go into a servicing account and the current note-holder collects under a send policy — selling the loan re-routes collection. Drives the loan-collect scheduler.
Template
syndicated_debt
Shape
Uses cashflow_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 / $period reference 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.