Skip to main content
Every lending account is one token in the position-NFT collection. The account_id is that token’s token_id, and whoever holds the token owns the account. The controller stores no owner address. It calls owner_of(account_id) on this contract for every authority check, so the NFT is the single source of truth for who may borrow, withdraw, or run a strategy on an account.
Transferring this token transfers the whole position — collateral and debt together, in one step. There is no protocol-side handover, no acceptance step, and no way to move the collateral without the debt. Treat the token as the position itself, not as a receipt for it.

Where it sits

contracts/position-nft implements OpenZeppelin’s NonFungibleToken with the Enumerable contract type, plus the NonFungibleEnumerable extension. Holders therefore get the standard non-fungible surface, and the protocol adds five entry points of its own. The controller owns it. Governance deploys it through AdminOperation::DeployPositionNft and upgrades it through UpgradePositionNft(hash). Skip the deploy and every account-touching call fails with #53 PositionNftNotSet.

Protocol entry points

mint, burn, and renew all call controller.require_auth(). You cannot mint yourself an account or burn someone else’s — those happen only as a side effect of supply with account_id = 0, and of the account being emptied.Account deletion always routes through the controller’s remove_account_and_burn_nft, so a deleted account can never leave a live token behind.

Standard non-fungible surface

These come from the OpenZeppelin base and behave as the standard specifies. Ownership and transfer Approvals Enumeration Metadata name(), symbol(), and token_uri(token_id). The URI is {base_uri}{token_id}?isStatic=true&chain=STELLAR, and it panics with the OpenZeppelin NonExistentToken error for a burned or never-minted id.
NFT approvals are not controller delegates. Approving an address here lets it transfer the token. It does not let it borrow or withdraw on the account — that needs a controller delegate grant via add_delegate, and the delegate must already be a governance-approved position manager.The two systems are independent. Read Accounts and risk before assuming one implies the other.

Two details that surprise people

token_id is a u32, but account_id is a u64. The controller’s API takes and returns u64 account ids; the NFT stores them as u32. The live id space is therefore bounded by u32, not u64. Token id 0 is consumed at construction. That is what makes account_id = 0 safe to use as the “create a new account” sentinel across supply, multiply, flash_position, migrate_from_blend, and SeizeMode::Credit(0) — no real account can ever have id 0.

What a transfer means for the new holder

The new holder inherits the account exactly as it stood:
  • the same collateral and the same debt;
  • the same spoke binding, which never changes;
  • the same position mode, which never changes;
  • the same stamped risk parameters on each supply position.
They do not inherit the previous owner’s delegates. A DelegateGrant records granted_by, so the moment the token moves, the old owner’s grants read as empty for everyone else. The stale entry is purged on the new owner’s next delegate write. No cleanup call exists or is needed.
If the token returns to the granting address before that write, the original grant list re-arms. That is an intended property of the lazy-revoke design, not a bug — but it is worth knowing if you move a position and move it back.

Next

Accounts and risk

Account storage, delegates, spoke binding, and the risk gates.

Controller ABI

Every entry point that resolves ownership through this contract.

Security model

Where position ownership sits in the trust boundary.

Addresses

Deployed position-NFT addresses per network.