Contracts & Obligations

Counterparty agreements that can carry payments or be pure attestations. The seven gotchas everyone hits the first time, with the canonical mint→poll→accept recipe.

The atomic building blocks a signed deal compiles down to — counterparty agreements that can carry payments or be a pure attestation.

An obligation is a counterparty agreement. Most often it represents "X owes Y a payment of Z on date D, conditional on E" — invoice-shaped. But it's also the right primitive for pure agreements: T&C acceptance, role attestation, on-boarding consent — anything where two parties record that they agreed to a body of text. A signed DealPlan compiles down to one or more obligations; the tncshell reference impl wires the pure-agreement flow end-to-end.

One GraphQL gateway, one JWT. Post obligation mutations and joined reads to $YF_GATEWAY/graphql (api.yieldfabric.com/graphql). Status polling for async on-chain work uses $YF_PAYMENTS/api/users/.../messages/... over REST — that one's not GraphQL.

Mint and accept — the canonical pattern

Both halves are GraphQL mutations through the gateway; in between, you poll the REST message-status endpoint on payments until the on-chain mint settles. This keeps the async settlement boundary explicit.

# 1. Mint — admin / issuer's bearer.
#    The resolver derives the minter's account from the JWT, picks the
#    obligation contract address from the chain registry, generates the
#    on-chain token id, AND auto-generates a fresh idempotency_key.
#    You only supply the counterpart and the off-chain `data` blob.
CREATE_RESP=$(curl -s -X POST $YF_GATEWAY/graphql \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation($input: CreateObligationInput!) { createObligation(input: $input) { success contractId obligationResult messageId message } }",
    "variables": { "input": {
      "counterpartWalletId": "<UUID from /protected/jwt default_wallet_id>",
      "data": { "kind": "tnc", "version": "v1.0", "title": "...", "body_sha256": "..." }
    }}
  }')

CONTRACT_ID=$(echo "$CREATE_RESP" | jq -r .data.createObligation.contractId)
MSG_ID=$(echo "$CREATE_RESP" | jq -r .data.createObligation.messageId)

# 2. Wait for the on-chain mint to settle.
#    The mutation returns as soon as the message is enqueued; the contract
#    isn't readable by acceptObligation until the executor finishes.
ADMIN_USER_ID=$(curl -s $YF_AUTH/protected/jwt -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r .user_id)
until curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
  "$YF_PAYMENTS/api/users/$ADMIN_USER_ID/messages/$MSG_ID" \
  | jq -e '.executed' > /dev/null; do sleep 2; done

# 3. Accept — counterparty's bearer (the user).
ACCEPT_RESP=$(curl -s -X POST $YF_GATEWAY/graphql \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"query\": \"mutation(\$input: AcceptObligationInput!) { acceptObligation(input: \$input) { success message obligationId messageId } }\",
    \"variables\": { \"input\": { \"contractId\": \"$CONTRACT_ID\" } }
  }")

The seven things that trip people up

These are every gotcha we hit while wiring tncshell end-to-end.

1. counterpart is an entity name, not an address

The counterpart / obligor / destinationId String fields are entity-name lookups (think email or display name). Passing a raw 0x… address fails with "No entity found with name '0x…'". Use the matching *WalletId field whenever you have the wallet UUID — every authenticated caller does, via /protected/jwt's default_wallet_id.

# ✓
counterpartWalletId: "550e8400-..."

# ✓
counterpart: "alice@example.com"

# ✗ — "No entity found with name '0x1234...'"
counterpart: "0x1234..."

2. Don't pass a deterministic idempotencyKey from app code

The resolver dedupes on idempotencyKey. If you compute it from something stable (sha256(user_id || action)), every retry after a stuck message will return the stuck messageId again without re-enqueueing — the executor never gets a fresh message to run. Two safe shapes:

  • Omit it. The resolver auto-generates a per-call key from timestamp + user.
  • Generate a UUIDv4 per submission. Stable for replay protection on the immediate retry window, but never identical across logical submissions.

3. contractIdobligationResult ≠ on-chain id

Three identifiers live in the create response — they refer to different things:

Field
contractId
What it is
The YF-side handle (CONTRACT-OBLIGATION-…). What acceptObligation keys off.
Field
obligationResult
What it is
The on-chain BigUint id. Often null on create (the mint hasn't settled yet).
Field
obligationId (returned by accept)
What it is
The on-chain id once settled. Your durable receipt.

Save both. The contractId is your YF handle for follow-up calls; the on-chain id is what you point at in compliance / dispute / external-audit contexts.

4. Always wait for create to settle before submitting accept

The create mutation returns as soon as the message is enqueued; the on-chain mint runs async. If you fire acceptObligation before the create's message reaches terminal state, you get "Contract not found: CONTRACT-OBLIGATION-...". Poll GET /api/users/{user_id}/messages/{message_id} every 2s until executed is non-null first.

Exception — self-counterpart auto-accept (next item).

5. Self-counterpart + no payments = auto-accept fast path

When the caller mints an obligation to themselves AND supplies no initialPayments, the on-chain safeMint inlines the acceptance: ObligationCreated and ObligationAccepted fire in the same receipt; the YF row lands at COMPLETED in one message.

This is the right shape for T&C / attestation flows — the user is both obligor and counterparty, the obligation has no monetary side, so one click + one settle == done.

A follow-up acceptObligation is now an idempotent no-op success — the resolver detects status == "COMPLETED" and synthesises a success response without enqueueing a duplicate message. Existing two-call patterns keep working; new code can drop the second call.

6. Zero-payment obligations omit the financial fields

A pure agreement (T&C, attestation, role acceptance) carries no denomination, no notional, no obligor / obligorWalletId, no initialPayments. The resolver treats this shape as a pure agreement.

mutation {
  createObligation(input: {
    counterpartWalletId: "$WALLET_ID",       # = the caller, for auto-accept
    data: {
      kind: "tnc",
      version: "v1.0",
      title: "Terms of service",
      body_sha256: "<hex>"
    }
  }) { contractId messageId }
}

7. Minter needs CryptoOperations permission

The executor's vault retrieves the minter's default key pair from /keys/users/{id}/default-key, which gates on CryptoOperations. SuperAdmin / Admin / Manager / Operator users get this from the role-default fallback (auto-applied when user_permissions is empty); narrower roles need it granted explicitly via POST /auth/users/{id}/permissions. See Authentication §Permissions: the auto-fallback gotcha.

Obligation shapes

The same createObligation mutation handles every shape. What changes is the initialPayments payload (if any).

Shape
Pure agreement
Use it for
T&C, attestation, role accept
What changes
No payments, no denomination, no obligor. Self-counterpart for auto-accept.
Shape
Invoice
Use it for
Single-payment commitment
What changes
One initialPayments entry, due date in unlockSender / unlockReceiver.
Shape
Loan
Use it for
Principal + amortising repayment
What changes
initialPayments array following the amortisation schedule.
Shape
Annuity
Use it for
Recurring payment stream
What changes
initialPayments array, one entry per period.

Annuity example

An obligation with five daily scheduled payments. Each carries its own unlockSender / unlockReceiver timestamps gating when the payment can be released.

curl -X POST $YF_GATEWAY/graphql \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation($input: CreateObligationInput!) { createObligation(input: $input) { success contractId messageId } }",
    "variables": { "input": {
      "counterpartWalletId": "$WALLET_ID",
      "denomination": "aud-token-asset",
      "obligorWalletId": "$WALLET_ID",
      "notional": "5",
      "expiry": "2026-11-01",
      "data": { "name": "Annuity Stream", "description": "5-day annuity" },
      "initialPayments": {
        "amount": "5",
        "payments": [
          { "unlockSender": "2026-11-01T00:00:00+00:00", "unlockReceiver": "2026-11-01T00:00:00+00:00" },
          { "unlockSender": "2026-11-02T00:00:00+00:00", "unlockReceiver": "2026-11-02T00:00:00+00:00" },
          { "unlockSender": "2026-11-03T00:00:00+00:00", "unlockReceiver": "2026-11-03T00:00:00+00:00" },
          { "unlockSender": "2026-11-04T00:00:00+00:00", "unlockReceiver": "2026-11-04T00:00:00+00:00" },
          { "unlockSender": "2026-11-05T00:00:00+00:00", "unlockReceiver": "2026-11-05T00:00:00+00:00" }
        ]
      }
    }}
  }'

Self-referential obligations (construction primitive)

A pattern where the obligor and counterparty are the same party — used as a construction primitive for structured deals.

Why self-referential?

  • Build complex structures (tranches, waterfalls) without counterparty risk during construction — you're the only party involved.
  • Lock the structure by accepting your own obligation (or rely on the auto-accept fast path if there are no initial payments).
  • Atomically transfer to the real counterparty via a swap once the structure is complete.
  • Guarantees the counterparty receives the fully-formed instrument or nothing — never a half-built one.

See Complete Workflows §Annuity securitisation for the end-to-end recipe.

Query contracts

# Cross-subgraph read — goes via the gateway.
curl -X POST $YF_GATEWAY/graphql \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query GetEntityContracts($entityId: ID!) { contractFlow { coreContracts { byEntityId(entityId: $entityId) { id name status contractType payments { id amount status } } } } }",
    "variables": { "entityId": "your-entity-id" }
  }'

See also

YieldFabric docs(317)