Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions crates/contracts/src/programs/lending/scanners.rs
Original file line number Diff line number Diff line change
Expand Up @@ -641,9 +641,14 @@ fn repayment_vault_deltas(
debt_before: u64,
amount_to_repay: u64,
) -> Option<(u64, u64)> {
let protocol_fee_repaid = params
let debt_after = debt_before.checked_sub(amount_to_repay)?;
let protocol_fee_before = params
.offer_parameters
.get_repaid_protocol_fee(debt_before, amount_to_repay);
.get_already_repaid_protocol_fee(debt_before);
let protocol_fee_after = params
.offer_parameters
.get_already_repaid_protocol_fee(debt_after);
let protocol_fee_repaid = protocol_fee_after.checked_sub(protocol_fee_before)?;
let lender_delta = amount_to_repay.checked_sub(protocol_fee_repaid)?;

Some((lender_delta, protocol_fee_repaid))
Expand Down Expand Up @@ -1028,6 +1033,60 @@ mod tests {
assert_eq!(scan.debt_after, debt_after);
}

// Protocol fee must accrue cumulatively (floor(20*0.1) - floor(15*0.1) = 1), not per
// installment (floor(5*0.1) = 0), or a second fee-phase repayment goes unrecognized.
#[test]
fn discover_partial_repayment_second_fee_installment() {
let params = test_params();
let total = params.offer_parameters.get_total_amount_to_repay();
// First repayment of 15 already applied: fee repaid so far = 15, protocol fee = 1.
let debt_before = total - 15;
let active = LendingOffer::new_active(params, debt_before);
let amount_to_repay = 5_u64;
let debt_after = debt_before - amount_to_repay;
let continuing = LendingOffer::new_active(params, debt_after);

let lender_before = 14_u64;
let protocol_before = 1_u64;
// Correct, cumulative expectation: protocol fee goes 1 -> 2, lender absorbs the rest.
let protocol_after_supplied = 2_u64;
let lender_after_supplied = lender_before + (amount_to_repay - 1);

let lender_after = AssetAuthVault::new_active(
params.get_lender_vault_parameters(),
lender_after_supplied,
);
let protocol_after = AssetAuthVault::new_active(
params.get_protocol_fee_vault_parameters(),
protocol_after_supplied,
);

let tx = tx_with_outputs(vec![
explicit_output(params.borrower_nft_asset_id, 1, script(&[0x51])),
explicit_output(
params.collateral_asset_id,
3_000 - params.offer_parameters.get_collateral_for_principal(15 + 5),
continuing.get_script_pubkey(),
),
explicit_output(
params.principal_asset_id,
lender_after_supplied,
lender_after.get_script_pubkey(),
),
explicit_output(
params.principal_asset_id,
protocol_after_supplied,
protocol_after.get_script_pubkey(),
),
]);

let scan = active
.discover_partial_repayment(&tx, 0, 0, Some((lender_before, Some(protocol_before))))
.expect("second fee-phase repayment");
assert_eq!(scan.amount_to_repay, amount_to_repay);
assert_eq!(scan.debt_after, debt_after);
}

#[test]
fn scan_full_repayment_no_repayments_phase() {
let params = test_params();
Expand Down
2 changes: 1 addition & 1 deletion crates/indexer/configuration/base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ esplora:
indexer:
protocol_fee_keeper_asset_id: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5'
interval: 10000
last_indexed_height: 2549435
last_indexed_height: 2601330
asset_registry:
registry_url: 'https://assets-testnet.blockstream.info'
24 changes: 19 additions & 5 deletions web/plugins/simplicitySourcesPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,40 @@ const SIMPLICITY_SOURCES_VIRTUAL = '\0virtual:simplicity-sources'
const SIMPLICITY_SOURCES_ID = 'virtual:simplicity-sources'

interface SimplicitySourcesPluginOptions {
// Path to the `contract_sources.json` map generated by
// `cargo run -p lending-contracts --example export_contract_sources`.
// Path to the `metadata.json` artifact generated by `simplex build` in crates/contracts
// (committed under crates/contracts/src/artifacts/metadata.json).
sourcesPath: string
}

interface SimplicityMetadata {
sources: Record<string, { cmr: string; content: string }>
}

function toShortName(fileName: string): string {
return fileName.replace(/\.simf$/, '')
}

export function simplicitySourcesPlugin(options: SimplicitySourcesPluginOptions): Plugin {
let viteConfig: ResolvedConfig
let resolvedSourcesPath: string

function readSources(): Record<string, string> {
if (!fs.existsSync(resolvedSourcesPath)) {
throw new Error(
`Simplicity contract sources not found: ${resolvedSourcesPath}\n` +
'Generate it with `cargo run -p lending-contracts --example export_contract_sources` in crates/contracts.',
`Simplicity contract metadata not found: ${resolvedSourcesPath}\n` +
'Generate it with `simplex build` in crates/contracts.',
)
}

const raw = fs.readFileSync(resolvedSourcesPath, 'utf-8')
const metadata = JSON.parse(raw) as SimplicityMetadata

return JSON.parse(raw) as Record<string, string>
return Object.fromEntries(
Object.entries(metadata.sources).map(([fileName, { content }]) => [
toShortName(fileName),
content,
]),
)
}

return {
Expand Down
21 changes: 20 additions & 1 deletion web/src/api/indexer/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function resolvePendingOutpoint(offer: OfferDetails): string | null {
}

export function resolveActiveOutpoint(offer: OfferDetails): string | null {
const utxo = offer.utxos.find(u => u.utxo_type === 'active_offer')
const utxo = offer.utxos.find(u => u.utxo_type === 'active_offer' && u.spent_txid === null)
return utxo ? toOutpoint(utxo) : null
}

Expand All @@ -30,6 +30,16 @@ export function resolveLenderVaultOutpoint(offer: OfferDetails): string | null {
return vault ? toOutpoint(vault) : null
}

export function resolveActiveLenderVaultOutpoint(offer: OfferDetails): string | null {
const vault = offer.vaults.find(v => v.vault_type === 'lender' && !v.is_finalized)
return vault ? toOutpoint(vault) : null
}

export function resolveActiveProtocolFeeVaultOutpoint(offer: OfferDetails): string | null {
const vault = offer.vaults.find(v => v.vault_type === 'protocol_fee' && !v.is_finalized)
return vault ? toOutpoint(vault) : null
}

export function resolveLenderNftOutpoint(offer: OfferDetails): string | null {
const lender = offer.participants.find(p => p.participant_type === 'lender')
return lender ? toOutpoint(lender) : null
Expand All @@ -39,3 +49,12 @@ export function resolveBorrowerNftOutpoint(offer: OfferDetails): string | null {
const borrower = offer.participants.find(p => p.participant_type === 'borrower')
return borrower ? toOutpoint(borrower) : null
}

export function resolveBorrowerPrincipalOutpoint(offer: OfferDetails): string | null {
return offer.borrower_principal_utxo ? toOutpoint(offer.borrower_principal_utxo) : null
}

export function resolveProtocolFeeVaultOutpoint(offer: OfferDetails): string | null {
const vault = offer.vaults.find(v => v.vault_type === 'protocol_fee' && v.is_finalized)
return vault ? toOutpoint(vault) : null
}
1 change: 1 addition & 0 deletions web/src/components/modals/ClaimModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export default function ClaimModal({ isOpen, offer, onClose, onSuccess }: ClaimM
return claimLenderVault({
lenderVaultOutpoint: vaultOutpoint,
lenderNftOutpoint,
createOfferTxid: offer.created_at_txid,
feeOutpoints: feeUtxos.map(utxoToOutpointString),
})
})
Expand Down
1 change: 1 addition & 0 deletions web/src/components/modals/LiquidateOfferModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export default function LiquidateOfferModal({
activeOfferOutpoint,
createOfferTxid: offer.created_at_txid,
lenderNftOutpoint,
currentDebt: fullOffer.current_debt.toString(),
feeOutpoints: feeUtxos.map(utxoToOutpointString),
})
})
Expand Down
26 changes: 18 additions & 8 deletions web/src/components/modals/RepayOfferModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ import { FALLBACK_FEE_RATE_SAT_PER_KVB, fetchFeeRateSatPerKvb } from '@/api/espl
import { esploraQueryKeys } from '@/api/esplora/queryKeys'
import { fetchOffer } from '@/api/indexer/methods'
import type { OfferShort } from '@/api/indexer/schemas'
import { resolveActiveOutpoint, resolveBorrowerNftOutpoint } from '@/api/indexer/utils'
import {
resolveActiveLenderVaultOutpoint,
resolveActiveOutpoint,
resolveActiveProtocolFeeVaultOutpoint,
resolveBorrowerNftOutpoint,
} from '@/api/indexer/utils'
import OfferActionShell from '@/components/modals/OfferActionShell'
import OfferDetailsBody from '@/components/modals/OfferDetailsBody'
import { NETWORK_CONFIG } from '@/constants/network-config'
import { useFormatAmount } from '@/hooks/useFormatAmount'
import { useRepayOffer } from '@/hooks/useRepayOffer'
import { usePartialRepayOffer } from '@/hooks/usePartialRepayOffer'
import { useStandardTransactionFlow } from '@/hooks/useStandardTransactionFlow'
import {
estimateFeeBudgetSats,
Expand Down Expand Up @@ -45,7 +50,7 @@ export default function RepayOfferModal({
const { principalAsset } = NETWORK_CONFIG
const { syncWallet, getBlindedWalletUtxos, scriptPubkey, confirmedBalances } = useWallet()
const { lwkNetwork } = useLwk()
const { repayOffer } = useRepayOffer()
const { partialRepayOffer } = usePartialRepayOffer()
const runStandardTransactionFlow = useStandardTransactionFlow()
const { addPendingTx } = usePendingTransactions()
const { formatCollateralDisplay, formatPrincipalAmount } = useFormatAmount()
Expand All @@ -59,8 +64,9 @@ export default function RepayOfferModal({
const borrowerNftOutpoint = resolveBorrowerNftOutpoint(fullOffer)
if (!borrowerNftOutpoint) throw new Error('Borrower NFT UTXO not found')

const totalToRepay =
offer.principal_amount + calcInterest(offer.principal_amount, offer.interest_rate)
// current_debt already accounts for any prior partial repayment — repaying it in full here
// closes the loan the same way a single-shot repay on a fresh offer would.
const totalToRepay = fullOffer.current_debt

await syncWallet()
const [blindedWalletUtxos, feeRate] = await Promise.all([
Expand All @@ -83,9 +89,14 @@ export default function RepayOfferModal({
feeRate,
)

return repayOffer({
return partialRepayOffer({
activeOfferOutpoint,
createOfferTxid: fullOffer.created_at_txid,
borrowerNftOutpoint,
amountToRepay: totalToRepay.toString(),
currentDebt: totalToRepay.toString(),
lenderVaultOutpoint: resolveActiveLenderVaultOutpoint(fullOffer) ?? undefined,
protocolFeeVaultOutpoint: resolveActiveProtocolFeeVaultOutpoint(fullOffer) ?? undefined,
principalOutpoints: principalUtxos.map(utxoToOutpointString),
feeOutpoints: feeUtxos.map(utxoToOutpointString),
})
Expand All @@ -105,8 +116,7 @@ export default function RepayOfferModal({
},
})

const totalToRepay =
offer.principal_amount + calcInterest(offer.principal_amount, offer.interest_rate)
const totalToRepay = offer.current_debt
const { data: feeRate = FALLBACK_FEE_RATE_SAT_PER_KVB } = useQuery({
queryKey: esploraQueryKeys.feeRate,
queryFn: () => fetchFeeRateSatPerKvb(),
Expand Down
10 changes: 3 additions & 7 deletions web/src/hooks/useCreateOffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,13 +345,9 @@ export function useCreateOffer() {
},
scripts: {
lendingScriptHash: bytesToHex(lendingScriptHash),
lenderVaultCovHash: bytesToHex(derivedLendingParams.lenderVaultCovHash),
finalizedLenderVaultCovHash: bytesToHex(
derivedLendingParams.finalizedLenderVaultCovHash,
),
protocolFeeVaultCovHash: bytesToHex(derivedLendingParams.protocolFeeVaultCovHash),
finalizedProtocolFeeVaultCovHash: bytesToHex(
derivedLendingParams.finalizedProtocolFeeVaultCovHash,
lenderVaultTapleafHash: bytesToHex(derivedLendingParams.lenderVaultTapleafHash),
protocolFeeVaultTapleafHash: bytesToHex(
derivedLendingParams.protocolFeeVaultTapleafHash,
),
principalOutputScriptHash: bytesToHex(derivedLendingParams.principalOutputScriptHash),
},
Expand Down
27 changes: 21 additions & 6 deletions web/src/hooks/useLenderVaultClaim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ import { usePendingTransactions } from '@/providers/pendingTransactions/usePendi
import { useWallet } from '@/providers/wallet/useWallet'
import {
ASSET_AUTH_VAULT_MAX_WEIGHT_TO_SATISFY,
buildAssetAuthVaultSpendInfo,
buildAssetAuthVaultWitness,
loadAssetAuthVaultProgram,
} from '@/simplicity/asset-auth-vault/program'
import { buildCovenantSpendInfo } from '@/simplicity/taproot'
import { findPendingOfferMetadata } from '@/simplicity/lending/metadata'
import { getProtocolFee, getTotalAmountToRepay, getTotalFee } from '@/simplicity/lending/utils'
import { bytesToHex } from '@/utils/hex'
import { getProcessingTxids } from '@/utils/pendingTransactions'
import { toBytes32, toUint32, toUint64 } from '@/utils/uint'
Expand All @@ -48,6 +50,7 @@ const LENDER_NFT_BURN_OUTPUT_INDEX = 0
export interface LenderVaultClaimParams {
lenderVaultOutpoint: string
lenderNftOutpoint: string
createOfferTxid: string
Comment thread
ardier16 marked this conversation as resolved.
feeOutpoints: string[]
principalRecipientAddress?: string
}
Expand Down Expand Up @@ -89,9 +92,10 @@ export function useLenderVaultClaim() {
if (feeUtxos.some(utxo => !isPolicyAssetUtxo(utxo, lwkNetwork.policyAsset()))) {
throw new Error('Fee outpoints must be wallet L-BTC UTXOs')
}
const [lenderVaultTx, lenderNftTx, feeTxs, feeRate] = await Promise.all([
const [lenderVaultTx, lenderNftTx, createOfferTx, feeTxs, feeRate] = await Promise.all([
fetchTransaction(lenderVaultOutpoint),
fetchTransaction(lenderNftOutpoint),
fetchTransaction(new OutPoint(`${params.createOfferTxid}:0`)),
Promise.all(feeOutpoints.map(o => fetchTransaction(o))),
fetchFeeRateSatPerKvbAbovePending(getProcessingTxids(pendingTxs)),
])
Expand All @@ -117,17 +121,28 @@ export function useLenderVaultClaim() {
const lenderNftAsset = requireExplicitAsset(lenderNftTxOut, 'Lender NFT')
const borrowerNftAsset = requireExplicitAsset(borrowerNftPreRepayTxOut, 'Borrower NFT')
assertExplicitAmount(lenderNftTxOut, NFT_AMOUNT, 'Lender NFT')

const metadata = await findPendingOfferMetadata(createOfferTx)
const offerParameters = {
principalAmount: metadata.principalAmount,
principalInterestRate: metadata.principalInterestRate,
}
const lenderVaultSupplyGoal = toUint64(
getTotalAmountToRepay(offerParameters) - getProtocolFee(getTotalFee(offerParameters)),
'lenderVaultSupplyGoal',
)
const lenderVaultProgram = loadAssetAuthVaultProgram({
vaultAssetId: toBytes32(principalAsset.toBytes(), 'principalAssetId'),
keeperAuthAssetId: toBytes32(lenderNftAsset.toBytes(), 'lenderNftAssetId'),
keeperAuthAssetAmount: toUint64(NFT_AMOUNT, 'lenderNftAmount'),
withKeeperAssetBurn: true,
supplierAuthAssetId: toBytes32(borrowerNftAsset.toBytes(), 'borrowerNftAssetId'),
supplyGoal: lenderVaultSupplyGoal,
withKeeperAssetBurn: true,
withSupplierAssetBurn: true,
finalizedVaultCovHash: toBytes32(new Uint8Array(32)),
})
const lenderVaultSpendInfo = buildAssetAuthVaultSpendInfo(lenderVaultProgram, {
isActive: false,
alreadySupplied: lenderVaultSupplyGoal,
})
const lenderVaultSpendInfo = buildCovenantSpendInfo(lenderVaultProgram)

assertScriptMatches(
lenderVaultTxOut.scriptPubkey(),
Expand Down
Loading
Loading