We are thrilled to introduce the implementation of loan smart contracts, that allow lending and borrowing directly on chain. Loan contracts are essential financial tools that allow individuals or entities to borrow money with agreed-upon terms and conditions. Leveraging the strengths of smart contract technology has the potential to revolutionize how loans are managed, enhancing security, efficiency, and transparency. Our implementation focuses on collateralized loans with a deadline.
How Collateralized Loans with a Deadline Work
In our example, a borrower borrows tokens from a lender using bitcoin as collaterals.
- Collateral: a borrower provides an asset (in our case bitcoin) as collateral. This is locked in the smart contract for the duration of the loan. The value of the collateral typically exceeds the loan amount (overcollateralized), providing security for the lender.
- Loan Disbursement: once the collateral is locked, the loan amount in BSV-20 tokens is disbursed to the borrower. The contract specifies the terms, including the loan amount, interest rate, and repayment deadline.
- Repayment Deadline: the borrower is required to repay the loan along with any agreed-upon interest by a specific deadline. This includes both the principal and interest amount.
- Default and Liquidation: if the borrower fails to repay by the deadline, the smart contract allows the lender to withdraw the collateral that was provided by the borrower.
- Repayment and Collateral Release: if the borrower repays the loan in full by the deadline, the smart contract releases the collateral back to the borrower.
Single Borrower
The following loan smart contract examples lifecycle consisted of the following steps:
- Initialization: the contract is initialized with the loan terms (amount, interest rate, collateral, deadline) and the identities of the lender and borrower.
- Borrowing: the borrower triggers the
borrow
method, receiving the loan amount upon successfully locking in the collateral. - Repayment or Foreclosure:
- If the borrower repays the loan on time, the
repay
method is triggered, returning the collateral and paying the lender. - If the borrower fails to repay, the lender can invoke
foreclose
to claim the collateral.
class Bsv20Loan extends BSV20V2 {
@prop()
lender: PubKey
@prop()
borrower: PubKey
// Lent BSV-20 token amount.
@prop()
tokenAmt: bigint
// Fixed interest rate of the loan.
// 1 = 1%
@prop()
interestRate: bigint
// Collateral satoshis.
@prop()
collateral: bigint
// Deadline of the loan.
@prop()
deadline: bigint
// Flag that indicates wether the
// loan was already taken.
@prop(true)
taken: boolean
@prop()
oraclePubKey: RabinPubKey
...
@method()
public borrow() {
// Check loan isn't taken yet.
assert(this.taken == false, 'loan already taken')
this.taken = true
// Pay borrower the principal, i.e. token amount locked in the contract.
let outputs = BSV20V2.buildTransferOutput(
pubKey2Addr(this.borrower),
this.id,
this.tokenAmt
)
// Make sure borrower deposited collateral and propagate contract.
outputs += this.buildStateOutput(this.collateral)
outputs += this.buildChangeOutput()
assert(hash256(outputs) == this.ctx.hashOutputs, 'hashOutputs mismatch')
}
@method()
public repay(oracleMsg: ByteString, oracleSig: RabinSig) {
// Check loan is already taken.
assert(this.taken == true, 'loan not taken yet')
// Check oracle signature.
assert(
RabinVerifier.verifySig(oracleMsg, oracleSig, this.oraclePubKey),
'oracle sig verify failed'
)
// Check that we're unlocking the UTXO specified in the oracles message.
assert(
slice(this.prevouts, 0n, 36n) == slice(oracleMsg, 0n, 36n),
'first input is not spending specified ordinal UTXO'
)
// Get token amount held by the UTXO from oracle message.
const utxoTokenAmt = byteString2Int(slice(oracleMsg, 36n, 44n))
// Check token amount is correct.
const interest = (this.tokenAmt * this.interestRate) / 100n
assert(utxoTokenAmt == this.tokenAmt + interest, 'invalid token amount')
// Pay lender back the principal token amount plus interest.
let outputs = BSV20V2.buildTransferOutput(
pubKey2Addr(this.lender),
this.id,
this.tokenAmt + interest
)
// Pay back borrowers collateral.
outputs += Utils.buildAddressOutput(
pubKey2Addr(this.borrower),
this.collateral
)
outputs += this.buildChangeOutput()
// Enforce outputs.
assert(hash256(outputs) == this.ctx.hashOutputs, 'hashOutputs mismatch')
}
@method()
public foreclose(sigLender: Sig) {
// Check lender sig.
assert(this.checkSig(sigLender, this.lender), 'invalid sig lender')
// Check if deadline reached.
assert(this.timeLock(this.deadline), 'deadline not yet reached')
}
}
As before, an oracle is queried to validate authenticity of tokens.
Multiple Borrowers
The loan contract above only allows a single borrower. We can extend it to support multiple borrowers. This smart contract is able to manage several loans simultaneously.
In the upgraded contract, we've refined our approach to accommodate multiple borrowers within a single smart contract. The contract now utilizes a Borrower
data structure, where each borrower's details such as public key, loan amount, and deadline are stored.
The process of loan management has been streamlined to support dynamic borrower interactions. Borrowers can request loans, and these requests are recorded in individual slots within the contract, along with the necessary collateral calculated based on the loan amount. Lenders have the ability to review and approve these requests, setting the loan terms and initiating the transfer of funds.
Furthermore, the contract facilitates the handling of repayments and the option for lenders to foreclose on loans in case of defaults.
Let’s define the technical workflow of this updated smart contract:
Requesting a Loan
A borrower requests a loan, providing their public key and the desired loan amount. The contract calculates the necessary collateral and updates the borrower’s slot.
@method()
public requestLoan(
slotIdx: bigint,
amt: bigint,
borrowerPubKey: PubKey,
borrowerSig: Sig
) {
// Check slot index is empty.
const borrower = this.borrowers[Number(slotIdx)]
assert(borrower.emptySlot == true, 'slot is not empty')
// Check borrower sig.
assert(
this.checkSig(borrowerSig, borrowerPubKey),
'invalid sig borrower'
)
// Add to borrowers array.
this.borrowers[Number(slotIdx)] = {
emptySlot: false,
approved: false,
pubKey: borrowerPubKey,
amt: amt,
deadline: 0n,
}
// Ensure that borrower deposited collateral
// and propagate contract.
const collateral = this.collateralPerToken * amt
let outputs = this.buildStateOutput(this.ctx.utxo.value + collateral)
outputs += this.buildChangeOutput()
assert(hash256(outputs) == this.ctx.hashOutputs, 'hashOutputs mismatch')
}
Approving the Loan
The lender reviews the loan request and, if acceptable, approves it. The loan amount is transferred to the borrower, and the repayment deadline is set. To facilitate the enforcement of a relative time-lock, we extract the block height from the block header of the latest contract instance.
@method()
public approveLoan(
slotIdx: bigint,
oracleMsg: ByteString,
oracleSig: RabinSig,
merkleProof: MerkleProof,
blockHeader: BlockHeader,
lenderSig: Sig
) {
// Check slot index is not empty and not yet approved.
const borrower = this.borrowers[Number(slotIdx)]
assert(borrower.emptySlot == false, 'slot is empty')
assert(borrower.approved == false, 'request was already approved')
// Check lender sig.
assert(this.checkSig(lenderSig, this.lender), 'invalid sig lender')
// Check merkle proof.
const prevTxid = Sha256(this.ctx.utxo.outpoint.txid)
assert(
Blockchain.isValidBlockHeader(blockHeader, this.minBHTarget),
'BH does not meet min target'
)
assert(
Blockchain.txInBlock(prevTxid, blockHeader, merkleProof),
'invalid Merkle proof'
)
// Mark slot approved and set deadline.
// Get block-height via block header.
this.borrowers[Number(slotIdx)] = {
emptySlot: false,
approved: true,
pubKey: borrower.pubKey,
amt: borrower.amt,
deadline: blockHeader.time + 52560n, // ~ 1 year
}
// Check oracle signature.
assert(
RabinVerifier.verifySig(oracleMsg, oracleSig, this.oraclePubKey),
'oracle sig verify failed'
)
// Check that we're unlocking the UTXO specified in the oracles message.
assert(
slice(this.prevouts, 0n, 36n) == slice(oracleMsg, 0n, 36n),
'first input is not spending specified ordinal UTXO'
)
// Get token amount held by the UTXO from oracle message.
const utxoTokenAmt = byteString2Int(slice(oracleMsg, 36n, 44n))
// Check token amount is correct.
assert(utxoTokenAmt == borrower.amt, 'invalid token amount')
// Construct next instance of contract.
let outputs = this.buildStateOutput(this.ctx.utxo.value)
// Pay borrower the token amount.
outputs += BSV20V2.buildTransferOutput(
pubKey2Addr(this.lender),
this.id,
borrower.amt
)
outputs += this.buildChangeOutput()
// Enforce outputs.
assert(hash256(outputs) == this.ctx.hashOutputs, 'hashOutputs mismatch')
}
Repaying or Foreclosing:
The borrower either repays the loan by the deadline, or the lender initiates foreclosure to claim the collateral in case of default.
@method()
public repay(
slotIdx: bigint,
oracleMsg: ByteString,
oracleSig: RabinSig,
borrowerSig: Sig
) {
// Check slot index is not empty and approved.
const borrower = this.borrowers[Number(slotIdx)]
assert(borrower.emptySlot == false, 'slot is empty')
assert(borrower.approved, 'borrow request not approved')
// Check borrower sig.
assert(
this.checkSig(borrowerSig, borrower.pubKey),
'invalid sig for borrower'
)
// Check oracle signature.
assert(
RabinVerifier.verifySig(oracleMsg, oracleSig, this.oraclePubKey),
'oracle sig verify failed'
)
// Check that we're unlocking the UTXO specified in the oracles message.
assert(
slice(this.prevouts, 0n, 36n) == slice(oracleMsg, 0n, 36n),
'first input is not spending specified ordinal UTXO'
)
// Get token amount held by the UTXO from oracle message.
const utxoTokenAmt = byteString2Int(slice(oracleMsg, 36n, 44n))
// Check token amount is correct.
assert(utxoTokenAmt == borrower.amt, 'invalid token amount')
// Construct next instance of contract.
const collateral = this.collateralPerToken * utxoTokenAmt
let outputs = this.buildStateOutput(this.ctx.utxo.value - collateral)
// Pay lender back the owed amount.
const interest = (borrower.amt * this.interestRate) / 100n
outputs += BSV20V2.buildTransferOutput(
pubKey2Addr(this.lender),
this.id,
borrower.amt + interest
)
// Pay back borrowers collateral.
outputs += Utils.buildAddressOutput(
pubKey2Addr(borrower.pubKey),
collateral
)
outputs += this.buildChangeOutput()
// Enforce outputs.
assert(hash256(outputs) == this.ctx.hashOutputs, 'hashOutputs mismatch')
}
The contract also implements methods to cancel request and to deny approvals.
The full code for both the single-borrower and multiple-borrower contracts can be explored further on GitHub.