Skip to content

Partial repayment & withdrawal [DEMO] - #161

Merged
lilbonekit merged 7 commits into
devfrom
feature/partial-repayment
Sep 3, 2026
Merged

lilbonekit merged 7 commits into
devfrom
feature/partial-repayment

Conversation

@lilbonekit

@lilbonekit lilbonekit commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Partial repayment test matrix

principalAmount = 10000, principalInterestRate = 1000 (10%) → totalFee = 1000, totalProtocolFee = 100, totalAmountToRepay = 11000. collateralAmount = 5000.

Case Phase before Repayment amountToRepay Result Offer Status
1 NoRepayments partial 300 Lender vault created active (270), protocol-fee vault created active (30) A, B
2 NoRepayments partial 1000 Lender vault created active (900), protocol-fee vault created finalized in the same tx (100) C
3 NoRepayments final 11000 Single-shot full repay from scratch (RepayOfferDemo), both vaults created and finalized directly E
4 RepayingOfferFee partial 300 Both vaults Supply (active → active) A
5 RepayingOfferFee partial 400 Lender vault Supply (active → active, 900), protocol-fee vault Supply → FinalSupply (60 → 100, finalized) A
6 RepayingOfferFee final 10700 Both vaults Supply → FinalSupply in the same tx (lender 10900, protocol-fee 100) B
7 RepayingPrincipal partial 2000 Only lender vault Supply (900 → 2900); protocol-fee vault untouched (already finalized) A
8 RepayingPrincipal final 8000 / 10000 Lender vault Supply → FinalSupply (→ 10900); protocol-fee vault untouched A, C

Offers

  • Offer A (indexer id 1): full 1 → 4 → 5 → 7 → 8 chain, five repayments (300, 300, 400, 2000, 8000).
  • Offer B (indexer id 2): 1 → 6 chain, two repayments (300, then 10700 final — closes fee and principal in the same tx).
  • Offer C (indexer id 3): single repayment (1000) that exactly closes the fee on the first try, case 2. Then finished off with a final 10000 repayment, reconfirming case 8.
  • Offer D: skipped. Created to test case 3 (single full repay from scratch via RepayOfferDemo), but a partial repayment (1000) was run on it first by mistake, so it ended up duplicating offer C's case 2/8 path instead of testing anything new. Left as-is, not used for the matrix.
  • Offer E (fresh offer): case 3 — single RepayOfferDemo call for the full 11000 from a brand-new, untouched offer.

Vault withdraw test matrix

Offer 6: principalAmount = 10000, principalInterestRate = 1000 (10%) → totalFee = 1000, totalProtocolFee = 100, totalAmountToRepay = 11000. collateralAmount = 5000.

Case Vault state before Action Amount Result Status
1 First partial repay (5500 / 11000) 5500 Lender vault created active (5400); protocol-fee vault created finalized in the same tx (100)
2 Lender vault active (5400) WithdrawPart 5399 Vault continues active, reduced to 1
3 Lender vault active (5400) WithdrawPart (negative test) 5400 (= full balance) Rejected client-side before touching the wallet
4 Lender vault active (1) Partial repay (Supply, active → active) 2500 Vault balance 1 → 2501
5 Lender vault active (2501) WithdrawPart (chained, second) 1500 Vault continues active, reduced to 1001
6 Lender vault active (1001) Final repay (Supply → FinalSupply) 3000 (remaining debt) Vault balance 1001 → 4001, vault finalized
7 Lender vault finalized (4001) WithdrawAll (Claim) full 4001 Lender NFT burned, 4001 released

@lilbonekit
lilbonekit changed the base branch from main to dev August 27, 2026 15:05
@lilbonekit
lilbonekit marked this pull request as draft August 27, 2026 15:05
@lilbonekit lilbonekit changed the title Partial repayment [DEMO] Partial repayment & withdrawal [DEMO] Aug 27, 2026
@lilbonekit
lilbonekit marked this pull request as ready for review August 28, 2026 10:30
Comment thread web/src/simplicity/asset-auth-vault/program.ts Outdated
Comment thread web/src/simplicity/lending/program.ts
Comment thread web/src/simplicity/lending/utils.ts
Comment thread web/src/hooks/usePartialRepayOffer.ts Outdated
Comment thread web/src/hooks/useLenderVaultWithdrawPart.ts
Comment thread web/src/hooks/usePartialRepayOffer.ts Outdated
@nikita1tup

Copy link
Copy Markdown
Collaborator

# Indexer / Backend Setup

@Hrom131 could you move the Simplicity artifact generation into the Docker build?

The flow in web/docs/HOW_TO_SETUP_INDEXER.md is host-side: rustup, clone smplx, cargo install --path crates/cli, then simplex install && simplex build. Since src/artifacts/simf/ and deps/ are gitignored, a fresh checkout fails with include_simf! "File not found" until those run in the right order — I hit this today, and the doc was missing the simplex install step too.

Would a builder stage in crates/indexer/Dockerfile that installs the pinned CLI and runs simplex install && simplex build before cargo build work? Then docker compose up -d --build is the only step, and the doc drops to that plus cp .env.example .env. Let me know what you think and whether you'd have time for it.

Comment thread web/src/hooks/useLenderVaultClaim.ts
Comment thread web/src/hooks/usePartialRepayOffer.ts Outdated
const alreadyRepaidProtocolFee = getProtocolFee(alreadyRepaidFee)
const feeLeft = toUint64(totalFee - alreadyRepaidFee, 'feeLeft')
const feeRepaidNow = toUint64(minUint64(feeLeft, amountToRepay), 'feeRepaidNow')
const protocolFeeRepaidNow = getProtocolFee(feeRepaidNow)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This per-installment rounding disagrees with the covenant, which computes cumulative protocol fee after the repayment minus cumulative protocol fee before it. For example, fee portions of 15 then 5 should advance the protocol vault from 1 to 2, but this adds 0 on the second transaction and covenant finalization fails. Please calculate getProtocolFee(alreadyRepaidFee + feeRepaidNow) - alreadyRepaidProtocolFee.

Comment thread web/src/hooks/useRepayOffer.ts Outdated
)
// create-offer tx vout 2 = Borrower NFT (asset id needed for program reconstruction) —
// fetched once above via createOfferTx, reused here instead of a second round trip.
const borrowerNftTxOut = requireTxOut(createOfferTx, 2, 'Borrower NFT reference')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reusing the creation transaction fixes the borrower NFT lookup, but the reconstruction below still uses the active output's reduced collateral as compile-time collateralAmount and defaults state to the original total debt. Therefore an expired offer cannot be liquidated after a partial repayment. Please recover original collateral from the creation output and pass the indexer's current debt to both the spend info and liquidation witness.

Comment thread web/src/hooks/useProtocolFeeVaultClaim.ts Outdated
Comment thread web/src/hooks/useLenderVaultWithdrawPart.ts Outdated
Comment thread web/src/hooks/useProtocolFeeVaultWithdrawPart.ts Outdated
@lilbonekit

Copy link
Copy Markdown
Collaborator Author

Review fix verification

create-offer txid c884f548bc295ea9e8ae3992c3f8414d569a9883c136237acae1943645a3027a: principalAmount = 10000, principalInterestRate = 1000 (10%) → totalFee = 1000, totalProtocolFee = 100, totalAmountToRepay = 11000.

Cumulative protocol fee rounding (usePartialRepayOffer.ts)

Case Phase amountToRepay Result Tx Status
1 NoRepayments → RepayingOfferFee 15 Protocol-fee vault created active (alreadySupplied=1), lender vault active (alreadySupplied=14) 0a8f3d4556...
2 RepayingOfferFee 5 Protocol-fee vault 1 → 2 (floor(20*0.1) - floor(15*0.1) = 1, cumulative), lender vault 14 → 18 ab156dcd01...

Before the fix, case 2 computed protocolFeeRepaidNow = getProtocolFee(5) = 0 (old per-installment formula), producing a vault output the covenant rejects.

Second consecutive WithdrawPart, no repay/supply in between (useLenderVaultWithdrawPart.ts)

Case Vault before Vault producer amountToWithdraw Result Tx Status
1 Active, balance 18, alreadySupplied=18 repay tx (ab156dcd...) 8 Balance 18 → 10, alreadySupplied unchanged (18) 4c438569e9...
2 Active, balance 10, alreadySupplied=18 another WithdrawPart (4c438569...) 3 Balance 10 → 7, alreadySupplied unchanged (18) 1ea8f33636...

Case 2 is the regression check: the vault being spent was produced by a prior WithdrawPart, not a repay/supply tx. Before the fix, the borrower-NFT asset id was read from inputs[0] of the vault's producing tx — correct only when that producer is a repay/supply tx (borrower NFT always at input 0 there). For a producer that's itself a WithdrawPart, inputs[0] is the previous vault, not the borrower NFT, so the reconstructed program — and the on-chain script hash check — would have failed. Fixed by reading the borrower NFT asset from createOfferTx vout 2 instead, which is producer-independent.

@ardier16

ardier16 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

@lilbonekit

Blocking

1. Liquidation after partial repayment can permanently lock the lender vault balance

LiquidateOfferModal.tsx:74 now passes the reduced debt, allowing a partially repaid offer to be liquidated. However, useLiquidateOffer.ts:189 burns the lender NFT without consuming or finalizing the active lender vault.

After a partial repayment, this vault contains repaid principal. WithdrawPart requires the lender NFT and cannot empty the vault completely, while WithdrawAll only works for a finalized vault. Once liquidation burns the lender NFT, the remaining lender-vault balance is no longer recoverable.

Please prevent liquidation while an active lender vault exists, or update the protocol so liquidation atomically finalizes or claims that vault.

Important

2. Full repayment after partial repayment underestimates the required fee

RepayOfferModal.tsx:34-35 budgets only the Lending covenant and borrower-NFT signature. When repaying after an earlier partial repayment, usePartialRepayOffer also consumes one or two AssetAuthVault inputs using the FinalSupply branch, each currently assigned 30,000 weight units.

As a result, fee selection at RepayOfferModal.tsx:84-90 can select insufficient L-BTC even when the wallet has enough available funds.

Please add the FinalSupply weight for each active vault to the fee budget and use the same calculation for the balance check at line 126.

3. Repayment confirmation displays the original amounts

RepayOfferModal.tsx:131-140 shows the original principal, interest, total repayment, and collateral. After a partial repayment, the transaction actually repays offer.current_debt and returns offer.collateral_remaining.

The confirmation should reflect the amounts the transaction will move.

4. Liquidation confirmation displays the original collateral

LiquidateOfferModal.tsx:95 uses offer.collateral_amount, while the liquidation transaction transfers only the remaining collateral from the active offer output. This should use offer.collateral_remaining.

@lilbonekit

lilbonekit commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

@lilbonekit

Blocking

1. Liquidation after partial repayment can permanently lock the lender vault balance

LiquidateOfferModal.tsx:74 now passes the reduced debt, allowing a partially repaid offer to be liquidated. However, useLiquidateOffer.ts:189 burns the lender NFT without consuming or finalizing the active lender vault.

After a partial repayment, this vault contains repaid principal. WithdrawPart requires the lender NFT and cannot empty the vault completely, while WithdrawAll only works for a finalized vault. Once liquidation burns the lender NFT, the remaining lender-vault balance is no longer recoverable.

Please prevent liquidation while an active lender vault exists, or update the protocol so liquidation atomically finalizes or claims that vault.

Important

2. Full repayment after partial repayment underestimates the required fee

RepayOfferModal.tsx:34-35 budgets only the Lending covenant and borrower-NFT signature. When repaying after an earlier partial repayment, usePartialRepayOffer also consumes one or two AssetAuthVault inputs using the FinalSupply branch, each currently assigned 30,000 weight units.

As a result, fee selection at RepayOfferModal.tsx:84-90 can select insufficient L-BTC even when the wallet has enough available funds.

Please add the FinalSupply weight for each active vault to the fee budget and use the same calculation for the balance check at line 126.

3. Repayment confirmation displays the original amounts

RepayOfferModal.tsx:131-140 shows the original principal, interest, total repayment, and collateral. After a partial repayment, the transaction actually repays offer.current_debt and returns offer.collateral_remaining.

The confirmation should reflect the amounts the transaction will move.

4. Liquidation confirmation displays the original collateral

LiquidateOfferModal.tsx:95 uses offer.collateral_amount, while the liquidation transaction transfers only the remaining collateral from the active offer output. This should use offer.collateral_remaining.

1 — confirmed, real fund-loss gap. Root cause is the covenant: WithdrawPart can never empty the vault (amount_to_withdraw < vault_amount), WithdrawAll needs finalized (only via full repay) — mutually exclusive with liquidation. No app-level fix. Discussed with @Hrom131 , proper fix lands in V2 (atomic claim on liquidation). Short-term: warn lender to claim vault first, no hard block.

2 — confirmed bug, separate task (dynamic fee weight instead of static constant).

3/4 — won't-fix, these modals are getting replaced next design pass.

@lilbonekit
lilbonekit merged commit 66ddf17 into dev Sep 3, 2026
3 checks passed
@lilbonekit
lilbonekit deleted the feature/partial-repayment branch September 3, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants