feature:Enforce is_invoice_frozen inside invoice_nft.set_listed / set_funded themselves, not only in external callers - #533
Merged
OxDev-max merged 6 commits intoJul 26, 2026
Conversation
…_funded themselves, not only in external callers
…_funded themselves, not only in external callers
…_funded themselves, not only in external callers
…_funded themselves, not only in external callers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
closes #410
closes #409
closes #408
closes #407
Invoice NFT Contract
<<<<<<< HEAD
Overview
The
invoice_nftcontract is the source of truth for every invoice in the Kora protocol. It mints invoice NFTs, owns the invoice lifecycle state machine, and is the sole authority that may advance or block status transitions.Status State Machine
set_listedset_fundedset_repaidset_defaultedFreeze Mechanism
Design
Freeze enforcement is owned internally by
invoice_nft, not delegated to callers. Every status-mutating function (set_listed,set_funded,set_repaid) calls the privaterequire_not_frozenguard before executing. This provides defense-in-depth: no caller — current or future — can advance a frozen invoice's state by forgetting an external pre-check.This is intentional and important. Earlier designs relied on external callers (e.g.,
marketplace.fund_invoice) to callis_invoice_frozenthemselves before invoking invoice transitions. That approach is fragile: a single missed call site anywhere in the protocol silently defeats the freeze. The current design closes that class of bypass entirely.Admin Operations
freeze_invoiceunfreeze_invoiceis_invoice_frozentrueif the invoice is frozenError
A frozen invoice returns
KoraError::InvoiceFrozen (17)on any attempted transition.Use Cases
Storage
Freeze state is stored as a
persistentboolean underDataKey::FrozenInvoice(invoice_id). The key is removed (not set to false) on unfreeze to reclaim storage.Error Codes
InvoiceNotFoundInvoiceAlreadyExistsInvalidInvoiceStatusInvoiceExpiredInvalidAmountInvalidDueDateInvalidRiskScoreInvoiceFrozeninvoice_nftcontract is the canonical source of truth for all invoice state in the Kora Protocol. Each invoice is represented as an immutable NFT with a unique ID, capturing all financial and metadata details of the underlying invoice.Invoice NFT Data Model
Invoice Structure
Invoice Status Lifecycle
Key Invariants:
RepaidandDefaultedare terminal statesRisk Tiers
Risk tiers are derived from the risk score (0–100) assigned by verifiers:
Public API Surface
Initialization
Purpose: One-time initialization of the contract.
Parameters:
env— Soroban environmentadmin— Address to designate as the contract adminaccess_control— Address of the access control contract (for pause checks)Returns:
Ok(())on success, orKoraError::AlreadyInitializedif already initialized.Authorization: None required (one-time setup).
Storage Initialization:
Adminis setAccessControlcontract address is storedNextIdis initialized to 1InvoiceCountis initialized to 0Minting
Purpose: Create a new invoice NFT.
Parameters:
env— Soroban environmentsme— Address of the SME (seller/borrower)debtor_hash— SHA-256 hash of debtor PII (32 bytes, never plaintext)amount— Invoice amount in base units (e.g., cents for USDC)currency— Token symbol for the invoice (e.g., "USDC")due_date— Unix timestamp when payment is due (must be in the future)ipfs_cid— IPFS content hash for full invoice metadata (encrypted, access-controlled by SME)risk_score— Risk assessment score (0–100) from a verifierReturns: The newly allocated invoice ID, or an error.
Errors:
KoraError::ArithmeticOverflowif amount > i128::MAX / 2 or ID counter overflowsKoraError::ProtocolPausedif the protocol is pausedKoraError::InvalidInputif:amount <= 0due_date <= current_time(must be in the future)risk_score > 100debtor_hashis empty (0 bytes)ipfs_cidis emptyAuthorization: Requires
sme.require_auth().Security:
invoice_createdevent with ID, SME, and amountState Transitions
set_listed
Purpose: Transition invoice from
Created→Listed.Parameters:
env— Soroban environmentcaller— The caller's address (must be the marketplace contract)invoice_id— ID of the invoice to listReturns:
Ok(())on success, or an error.Errors:
KoraError::ProtocolPausedif the protocol is pausedKoraError::InvoiceNotFoundif invoice does not existKoraError::InvalidInvoiceStatusif invoice is not inCreatedstatusAuthorization: Requires
caller.require_auth()(implicitly requires the marketplace contract).Security: Only the marketplace contract (as verified at initialization) can list invoices.
set_funded
Purpose: Transition invoice from
Listed→Funded.Parameters:
env— Soroban environmentcaller— The caller's address (must be the financing pool contract)invoice_id— ID of the invoice to mark as fundedReturns:
Ok(())on success, or an error.Errors:
KoraError::ProtocolPausedif the protocol is pausedKoraError::InvoiceNotFoundif invoice does not existKoraError::InvalidInvoiceStatusif invoice is not inListedstatusAuthorization: Requires
caller.require_auth()(implicitly requires the financing pool contract).Side Effects: Records the
funded_attimestamp.set_repaid
Purpose: Transition invoice from
Funded→Repaid.Parameters:
env— Soroban environmentcaller— The caller's address (must be the financing pool contract)invoice_id— ID of the invoice to mark as repaidReturns:
Ok(())on success, or an error.Errors:
KoraError::InvoiceNotFoundif invoice does not existKoraError::InvalidInvoiceStatusif invoice is not inFundedstatusAuthorization: Requires
caller.require_auth()(implicitly requires the financing pool contract).Side Effects: Records the
repaid_attimestamp. Emitsinvoice_repaidevent.Note: This function does NOT check the pause flag — SMEs can always repay.
set_defaulted
Purpose: Transition invoice from
Funded→Defaulted(used after due date passes).Parameters:
env— Soroban environmentcaller— The caller's address (must be the admin)invoice_id— ID of the invoice to mark as defaultedReturns:
Ok(())on success, or an error.Errors:
KoraError::NotAdminif caller is not the adminKoraError::InvoiceNotFoundif invoice does not existKoraError::InvalidInvoiceStatusif invoice is not inFundedstatus or due date hasn't passedAuthorization: Requires
caller.require_auth()(implicitly requires the admin).Conditions:
due_dateSecurity: Admin-only to prevent accidental or malicious defaults.
Views
Purpose: Retrieve a full invoice by ID.
Returns: The complete
Invoicestruct, orKoraError::InvoiceNotFoundif not found.Security: No authorization check (public view).
Purpose: Get the next invoice ID that will be allocated.
Returns: The ID of the next invoice to be minted (starting at 1).
Security: No authorization check (public view).
Purpose: Get the total count of invoices minted.
Returns: The cumulative number of invoices created on this contract.
Security: No authorization check (public view).
Minting Rules
Who can mint? Any address can call
mint_invoice(), but must sign the transaction (viasme.require_auth())What are the constraints?
NFT Immutability
id,sme,debtor_hash,amount,currency,due_date,ipfs_cid,risk_score,risk_tier,created_atstatus(via state transitions)funded_at(set when transitioned toFunded)repaid_at(set when transitioned toRepaid)Transfer Rules
Invoice NFTs are not transferable in this version of the protocol. Each invoice is permanently associated with its SME creator. This simplification:
Future versions may allow transfers with strict controls (e.g., only to other SMEs in a whitelist, or only with admin approval).
Cross-Contract Call Paths
marketplace → invoice_nft
financing_pool → invoice_nft
admin → invoice_nft
Security Considerations
1. Debtor Privacy
debtor_hash) is stored as a privacy-preserving identifier2. Authorization
sme.require_auth())3. Immutability
4. Pause Enforcement
mint_invoice(),set_listed(), andset_funded()revert if protocol is pausedset_repaid()does NOT check pause flag — SMEs can always repayset_defaulted()does NOT check pause flag — defaults can be marked even if paused5. Arithmetic Safety
amount > i128::MAX / 2→ errorchecked_add()to detect overflowchecked_add()to detect overflow6. State Machine Enforced
Funded→Listed)Created→Funded)7. Re-entrancy
Known Limitations (v1)
Single Admin for Defaults
No Secondary Market
No Oracle
TTL Management
No Signature Delegation