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. contractId ≠ obligationResult ≠ on-chain id
Three identifiers live in the create response — they refer to different things:
contractIdCONTRACT-OBLIGATION-…). What acceptObligation keys off.obligationResultnull on create (the mint hasn't settled yet).obligationId (returned by accept)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).
initialPayments entry, due date in unlockSender / unlockReceiver.initialPayments array following the amortisation schedule.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
- Building with YieldFabric §Mint and accept an obligation — the same recipe with extra wire-level annotations.
- Deal Management System (DMS) — the deal lifecycle that drives obligation creation in production flows.
- Atomic Swaps — exchange obligations + payments atomically.
- Collateralisation — obligations as collateral with repurchase rights.
- Complete Workflows — end-to-end recipes that compose obligations, payments, and swaps.