Simple Loan
1. Summary
PWNSimpleLoan.sol contract manages the simple loan type in the PWN protocol. This contract also acts as a Vault for all assets used in simple loans.
2. Important links
3. Contract details
- PWNSimpleLoan.sol is written in Solidity version 0.8.16
Features
- Manages simple loan flow
- Creation
- Repayment
- Claim
- Implements an option for the lender to extend the expiration (maturity) date of a loan
- Acts as a vault for all assets used in simple loans
The expiration of a loan can be extended by a maximum of 90 days into the future. This is a measure to protect lenders from accidentally extending a loan maturity date too far. Lenders can extend a loan expiration date an unlimited amount of times meaning a loan expiration date can be extended indefinitely.
Minumum duration of a simple loan is 10 minutes.
Inherited contracts, implemented Interfaces and ERCs
Functions
createLOAN
Overview
Users use this function to start a simple loan in the PWN protocol.
The function assumes a prior token approval to a contract address or signed permits.
This function takes four arguments supplied by the caller:
ProposalSpec calldata
proposalSpec
- Proposal specification structLenderSpec calldata
lenderSpec
- Lender specification structCallerSpec calldata
callerSpec
- Caller specification structbytes calldata
extra
- Auxiliary data that are emitted in theLOANCreated
event. They are not used in the contract logic.
Implementation
function createLOAN(
ProposalSpec calldata proposalSpec,
LenderSpec calldata lenderSpec,
CallerSpec calldata callerSpec,
bytes calldata extra
) external returns (uint256 loanId) {
// Check provided proposal contract
if (!hub.hasTag(proposalSpec.proposalContract, PWNHubTags.LOAN_PROPOSAL)) {
revert AddressMissingHubTag({ addr: proposalSpec.proposalContract, tag: PWNHubTags.LOAN_PROPOSAL });
}
// Revoke nonce if needed
if (callerSpec.revokeNonce) {
revokedNonce.revokeNonce(msg.sender, callerSpec.nonce);
}
// If refinancing a loan, check that the loan can be repaid
if (callerSpec.refinancingLoanId != 0) {
LOAN storage loan = LOANs[callerSpec.refinancingLoanId];
_checkLoanCanBeRepaid(loan.status, loan.defaultTimestamp);
}
// Accept proposal and get loan terms
(bytes32 proposalHash, Terms memory loanTerms) = PWNSimpleLoanProposal(proposalSpec.proposalContract)
.acceptProposal({
acceptor: msg.sender,
refinancingLoanId: callerSpec.refinancingLoanId,
proposalData: proposalSpec.proposalData,
proposalInclusionProof: proposalSpec.proposalInclusionProof,
signature: proposalSpec.signature
});
// Check that provided lender spec is correct
if (msg.sender != loanTerms.lender && loanTerms.lenderSpecHash != getLenderSpecHash(lenderSpec)) {
revert InvalidLenderSpecHash({ current: loanTerms.lenderSpecHash, expected: getLenderSpecHash(lenderSpec) });
}
// Check minimum loan duration
if (loanTerms.duration < MIN_LOAN_DURATION) {
revert InvalidDuration({ current: loanTerms.duration, limit: MIN_LOAN_DURATION });
}
// Check maximum accruing interest APR
if (loanTerms.accruingInterestAPR > MAX_ACCRUING_INTEREST_APR) {
revert InterestAPROutOfBounds({ current: loanTerms.accruingInterestAPR, limit: MAX_ACCRUING_INTEREST_APR });
}
if (callerSpec.refinancingLoanId == 0) {
// Check loan credit and collateral validity
_checkValidAsset(loanTerms.credit);
_checkValidAsset(loanTerms.collateral);
} else {
// Check refinance loan terms
_checkRefinanceLoanTerms(callerSpec.refinancingLoanId, loanTerms);
}
// Create a new loan
loanId = _createLoan({
loanTerms: loanTerms,
lenderSpec: lenderSpec
});
emit LOANCreated({
loanId: loanId,
proposalHash: proposalHash,
proposalContract: proposalSpec.proposalContract,
refinancingLoanId: callerSpec.refinancingLoanId,
terms: loanTerms,
lenderSpec: lenderSpec,
extra: extra
});
// Execute permit for the caller
if (callerSpec.permitData.length > 0) {
Permit memory permit = abi.decode(callerSpec.permitData, (Permit));
_checkPermit(msg.sender, loanTerms.credit.assetAddress, permit);
_tryPermit(permit);
}
// Settle the loan
if (callerSpec.refinancingLoanId == 0) {
// Transfer collateral to Vault and credit to borrower
_settleNewLoan(loanTerms, lenderSpec);
} else {
// Update loan to repaid state
_updateRepaidLoan(callerSpec.refinancingLoanId);
// Repay the original loan and transfer the surplus to the borrower if any
_settleLoanRefinance({
refinancingLoanId: callerSpec.refinancingLoanId,
loanTerms: loanTerms,
lenderSpec: lenderSpec
});
}
}
LOANCreated
LOANCreated is emitted when a new simple loan is created.
This event has two parameters:
uint256 indexed
loanId
- ID of the LOAN token that is associated with the created loanbytes32 indexed
proposalHash
- Hash of the proposal structaddress indexed
proposalContract
- Address of the proposal contractuint256
refinancingLoanId
- ID of a loan to be refinanced. Zero if creating a new loan.Terms
terms
- Terms struct defining simple loan parametersLenderSpec
lenderSpec
- LenderSpec structbytes
extra
- Auxiliary data provided by the caller to thecreateLOAN
function
repayLOAN
Overview
Borrowers use this function to repay simple loans in the PWN Protocol.
This function takes two arguments supplied by the caller:
uint256
loanId
- ID of the loan that is being repaidbytes calldata
permitData
- Permit data for a loan asset signed by borrower
Implementation
function repayLOAN(
uint256 loanId,
bytes calldata permitData
) external {
LOAN storage loan = LOANs[loanId];
_checkLoanCanBeRepaid(loan.status, loan.defaultTimestamp);
// Update loan to repaid state
_updateRepaidLoan(loanId);
// Execute permit for the caller
if (permitData.length > 0) {
Permit memory permit = abi.decode(permitData, (Permit));
_checkPermit(msg.sender, loan.creditAddress, permit);
_tryPermit(permit);
}
// Transfer the repaid credit to the Vault
uint256 repaymentAmount = loanRepaymentAmount(loanId);
_pull(loan.creditAddress.ERC20(repaymentAmount), msg.sender);
// Transfer collateral back to borrower
_push(loan.collateral, loan.borrower);
// Try to repay directly
try this.tryClaimRepaidLOAN(loanId, repaymentAmount, loanToken.ownerOf(loanId)) {} catch {
// Note: Safe transfer or supply to a pool can fail. In that case leave the LOAN token in repaid state and
// wait for the LOAN token owner to claim the repaid credit. Otherwise lender would be able to prevent
// borrower from repaying the loan.
}
}
claimLOAN
Overview
Holders of LOAN tokens (lenders) use this function to claim a repaid loan or defaulted collateral. The claimed asset is transferred to the LOAN token holder and the LOAN token is burned.
This function takes one argument supplied by the caller:
uint256
loanId
- ID of the loan that is being claimed
Implementation
function claimLOAN(uint256 loanId) external {
LOAN storage loan = LOANs[loanId];
// Check that caller is LOAN token holder
if (loanToken.ownerOf(loanId) != msg.sender)
revert CallerNotLOANTokenHolder();
if (loan.status == 0)
// Loan is not existing or from a different loan contract
revert NonExistingLoan();
else if (loan.status == 3)
// Loan has been paid back
_settleLoanClaim({ loanId: loanId, loanOwner: msg.sender, defaulted: false });
else if (loan.status == 2 && loan.defaultTimestamp <= block.timestamp)
// Loan is running but expired
_settleLoanClaim({ loanId: loanId, loanOwner: msg.sender, defaulted: true });
else
// Loan is in wrong state
revert LoanRunning();
}
extendLOAN
Overview
This function extends loan default date with signed extension proposal signed by borrower or the LOAN token owner (usually the lender).
This function takes three arguments supplied by the caller:
ExtensionProposal calldata
extension
- Loan extension proposal structbytes calldata
signature
- Signature of the extension proposalbytes calldata
permitData
- Callers credit permit data
Implementation
function extendLOAN(
ExtensionProposal calldata extension,
bytes calldata signature,
bytes calldata permitData
) external {
LOAN storage loan = LOANs[extension.loanId];
// Check that loan is in the right state
if (loan.status == 0)
revert NonExistingLoan();
if (loan.status == 3) // cannot extend repaid loan
revert LoanRepaid();
// Check extension validity
bytes32 extensionHash = getExtensionHash(extension);
if (!extensionProposalsMade[extensionHash])
if (!PWNSignatureChecker.isValidSignatureNow(extension.proposer, extensionHash, signature))
revert PWNSignatureChecker.InvalidSignature({ signer: extension.proposer, digest: extensionHash });
// Check extension expiration
if (block.timestamp >= extension.expiration)
revert Expired({ current: block.timestamp, expiration: extension.expiration });
// Check extension nonce
if (!revokedNonce.isNonceUsable(extension.proposer, extension.nonceSpace, extension.nonce))
revert PWNRevokedNonce.NonceNotUsable({
addr: extension.proposer,
nonceSpace: extension.nonceSpace,
nonce: extension.nonce
});
// Check caller and signer
address loanOwner = loanToken.ownerOf(extension.loanId);
if (msg.sender == loanOwner) {
if (extension.proposer != loan.borrower) {
// If caller is loan owner, proposer must be borrower
revert InvalidExtensionSigner({
allowed: loan.borrower,
current: extension.proposer
});
}
} else if (msg.sender == loan.borrower) {
if (extension.proposer != loanOwner) {
// If caller is borrower, proposer must be loan owner
revert InvalidExtensionSigner({
allowed: loanOwner,
current: extension.proposer
});
}
} else {
// Caller must be loan owner or borrower
revert InvalidExtensionCaller();
}
// Check duration range
if (extension.duration < MIN_EXTENSION_DURATION)
revert InvalidExtensionDuration({
duration: extension.duration,
limit: MIN_EXTENSION_DURATION
});
if (extension.duration > MAX_EXTENSION_DURATION)
revert InvalidExtensionDuration({
duration: extension.duration,
limit: MAX_EXTENSION_DURATION
});
// Revoke extension proposal nonce
revokedNonce.revokeNonce(extension.proposer, extension.nonceSpace, extension.nonce);
// Update loan
uint40 originalDefaultTimestamp = loan.defaultTimestamp;
loan.defaultTimestamp = originalDefaultTimestamp + extension.duration;
// Emit event
emit LOANExtended({
loanId: extension.loanId,
originalDefaultTimestamp: originalDefaultTimestamp,
extendedDefaultTimestamp: loan.defaultTimestamp
});
// Skip compensation transfer if it's not set
if (extension.compensationAddress != address(0) && extension.compensationAmount > 0) {
MultiToken.Asset memory compensation = extension.compensationAddress.ERC20(extension.compensationAmount);
// Check compensation asset validity
_checkValidAsset(compensation);
// Transfer compensation to the loan owner
if (permitData.length > 0) {
Permit memory permit = abi.decode(permitData, (Permit));
_checkPermit(msg.sender, extension.compensationAddress, permit);
_tryPermit(permit);
}
_pushFrom(compensation, loan.borrower, loanOwner);
}
}
makeExtensionProposal
Overview
This function an on-chain extension proposal.
This function takes one argument supplied by the caller:
ExtensionProposal calldata
extension
- Loan extension proposal struct
Implementation
function makeExtensionProposal(ExtensionProposal calldata extension) external {
// Check that caller is a proposer
if (msg.sender != extension.proposer)
revert InvalidExtensionSigner({ allowed: extension.proposer, current: msg.sender });
// Mark extension proposal as made
bytes32 extensionHash = getExtensionHash(extension);
extensionProposalsMade[extensionHash] = true;
emit ExtensionProposalMade(extensionHash, extension.proposer, extension);
}
View Functions
getLOAN
Overview
Returns a tuple with information about a supplied loan ID.
This function takes one argument supplied by the caller:
uint256
loanId
- ID of the loan to get parameters for
Implementation
function getLOAN(uint256 loanId) external view returns (
uint8 status,
uint40 startTimestamp,
uint40 defaultTimestamp,
address borrower,
address originalLender,
address loanOwner,
uint24 accruingInterestAPR,
uint256 fixedInterestAmount,
MultiToken.Asset memory credit,
MultiToken.Asset memory collateral,
address originalSourceOfFunds,
uint256 repaymentAmount
) {
LOAN storage loan = LOANs[loanId];
status = _getLOANStatus(loanId);
startTimestamp = loan.startTimestamp;
defaultTimestamp = loan.defaultTimestamp;
borrower = loan.borrower;
originalLender = loan.originalLender;
loanOwner = loan.status != 0 ? loanToken.ownerOf(loanId) : address(0);
accruingInterestAPR = loan.accruingInterestAPR;
fixedInterestAmount = loan.fixedInterestAmount;
credit = loan.creditAddress.ERC20(loan.principalAmount);
collateral = loan.collateral;
originalSourceOfFunds = loan.originalSourceOfFunds;
repaymentAmount = loanRepaymentAmount(loanId);
}
loanRepaymentAmount
Overview
Calculates the loan repayment amount for supplied loan ID.
This function takes one argument supplied by the caller:
uint256
loanId
- ID of the loan to get loan repayment amount for
Implementation
function loanRepaymentAmount(uint256 loanId) public view returns (uint256) {
LOAN storage loan = LOANs[loanId];
// Check non-existent loan
if (loan.status == 0) return 0;
// Return loan principal with accrued interest
return loan.principalAmount + _loanAccruedInterest(loan);
}
getExtensionHash
Overview
Returns hash of the supplied loan extension proposal struct.
This function takes one argument supplied by the caller:
ExtensionProposal calldata
extension
- Loan extension proposal struct
Implementation
function getExtensionHash(ExtensionProposal calldata extension) public view returns (bytes32) {
return keccak256(abi.encodePacked(
hex"1901",
DOMAIN_SEPARATOR,
keccak256(abi.encodePacked(
EXTENSION_PROPOSAL_TYPEHASH,
abi.encode(extension)
))
));
}
isValidAsset
Overview
Checks if the supplied asset is valid with the MultiToken dependency lib and the category registry.
This function takes one argument supplied by the caller:
MultiToken.Asset
asset
- The asset to check (see MultiToken)
Implementation
function isValidAsset(MultiToken.Asset memory asset) public view returns (bool) {
return MultiToken.isValid(asset, categoryRegistry);
}
loanMetadataUri
Overview
Returns a metadata URI for LOAN tokens. This URI is defined in PWN Config.
This function doesn't take any arguments.
Implementation
function loanMetadataUri() override external view returns (string memory) {
return config.loanMetadataUri(address(this));
}
getStateFingerprint
Overview
This function returns the current token state fingerprint for a supplied token ID. See ERC-5646 standard specification for more detailed information.
This function takes one argument supplied by the caller:
uint256
tokenId
- ID of the LOAN token to get a fingerprint for
Implementation
function getStateFingerprint(uint256 tokenId) external view virtual override returns (bytes32) {
LOAN storage loan = LOANs[tokenId];
if (loan.status == 0)
return bytes32(0);
// The only mutable state properties are:
// - status: updated for expired loans based on block.timestamp
// - defaultTimestamp: updated when the loan is extended
// - fixedInterestAmount: updated when the loan is repaid and waiting to be claimed
// - accruingInterestAPR: updated when the loan is repaid and waiting to be claimed
// Others don't have to be part of the state fingerprint as it does not act as a token identification.
return keccak256(abi.encode(
_getLOANStatus(tokenId),
loan.defaultTimestamp,
loan.fixedInterestAmount,
loan.accruingInterestAPR
));
}
Events
The PWN Simple Loan contract defines one event and no custom errors.
event LOANCreated(uint256 indexed loanId, bytes32 indexed proposalHash, address indexed proposalContract, uint256 refinancingLoanId, Terms terms, LenderSpec lenderSpec, bytes extra);
event LOANPaidBack(uint256 indexed loanId);
event LOANClaimed(uint256 indexed loanId, bool indexed defaulted);
event LOANExtended(uint256 indexed loanId, uint40 originalDefaultTimestamp, uint40 extendedDefaultTimestamp);
event ExtensionProposalMade(bytes32 indexed extensionHash, address indexed proposer, ExtensionProposal proposal);
LOANCreated
LOANCreated is emitted when a new simple loan is created.
This event has two parameters:
uint256 indexed
loanId
- ID of the LOAN token that is associated with the created loanbytes32 indexed
proposalHash
- Hash of the proposal structaddress indexed
proposalContract
- Address of the proposal contractuint256
refinancingLoanId
- ID of a loan to be refinanced. Zero if creating a new loan.Terms
terms
- Terms struct defining simple loan parametersLenderSpec
lenderSpec
- LenderSpec structbytes
extra
- Auxiliary data provided by the caller to thecreateLOAN
function
LOANPaidBack
LOANPaidBack event is emitted when a borrower repays a simple loan.
This event has one parameter:
uint256 indexed
loanId
- ID of the LOAN token that is associated with the repaid loan
LOANClaimed
LOANClaimed event is emitted when a lender claims repaid asset or defaulted collateral.
This event has two parameters:
uint256 indexed
loanId
- ID of the LOAN token that is associated with the claimed loanbool indexed
defaulted
- Boolean determining if the claimed loan was defaulted or properly repaid
LOANExtended
LOANExtended event is emitted when a LOAN token holder extends a loan.
This event has three parameters:
uint256 indexed
loanId
- ID of the LOAN token that is associated with the loan being extendeduint40
originalDefaultTimestamp
- Original timestampuint40
extendedDefaultTimestamp
- New timestamp
ExtensionProposalMade
ExtensionProposalMade event is emitted when an on-chain loan extension proposal is made.
This event has two parameters:
bytes32 indexed
extensionHash
- Hash of the created extension proposaladdress indexed
proposer
- Address of the account that created the extension proposalExtensionProposal
proposal
- Extension proposal struct
Errors
error LoanNotRunning();
error LoanRunning();
error LoanRepaid();
error LoanDefaulted(uint40);
error NonExistingLoan();
error CallerNotLOANTokenHolder();
error RefinanceBorrowerMismatch(address currentBorrower, address newBorrower);
error RefinanceCreditMismatch();
error RefinanceCollateralMismatch();
error InvalidLenderSpecHash(bytes32 current, bytes32 expected);
error InvalidDuration(uint256 current, uint256 limit);
error InterestAPROutOfBounds(uint256 current, uint256 limit);
error CallerNotVault();
error InvalidSourceOfFunds(address sourceOfFunds);
error InvalidExtensionCaller();
error InvalidExtensionSigner(address allowed, address current);
error InvalidExtensionDuration(uint256 duration, uint256 limit);
error InvalidMultiTokenAsset(uint8 category, address addr, uint256 id, uint256 amount);
LoanNotRunning
A LoanNotRunning error is thrown when managed loan is not running.
This error has doesn't define any parameters.
LoanRunning
A LoanRunning error is thrown when managed loan is running.
This error has doesn't define any parameters.
LoanRepaid
A LoanRepaid error is thrown when managed loan is repaid.
This error has doesn't define any parameters.
LoanDefaulted
A NonExistingLoan error is thrown when loan doesn't exist.
This error has doesn't define any parameters.
CallerNotLOANTokenHolder
A CallerNotLOANTokenHolder error is thrown when caller is not a LOAN token holder.
This error has doesn't define any parameters.
RefinanceBorrowerMismatch
A RefinanceBorrowerMismatch error is thrown when refinancing loan terms have different borrower than the original loan.
This error has two parameters:
address
currentBorrower
address
newBorrower
RefinanceCreditMismatch
A RefinanceCreditMismatch error is thrown when refinancing loan terms have different credit asset than the original loan.
This error has doesn't define any parameters.
RefinanceCollateralMismatch
A RefinanceCollateralMismatch error is thrown when refinancing loan terms have different collateral asset than the original loan.
This error has doesn't define any parameters.
InvalidLenderSpecHash
A InvalidLenderSpecHash error is thrown when hash of provided lender spec doesn't match the one in loan terms.
This error has two parameters:
bytes32
current
- Provided lender spec hashbytes32
expected
- Expected lender spec hash
InvalidDuration
A InvalidDuration error is thrown when loan duration is below the minimum (10 minutes).
This error has two parameters:
uint256
current
- Provided loan durationuint256
limit
- Provided loan duration
InterestAPROutOfBounds
A InterestAPROutOfBounds error is thrown when accruing interest APR is above the maximum.
This error has two parameters:
uint256
current
- Current accrued interestuint256
limit
- Maximum accrued interest
CallerNotVault
A CallerNotVault error is thrown when caller is not a vault.
This error has doesn't define any parameters.
InvalidSourceOfFunds
A InvalidSourceOfFunds error is thrown when pool based source of funds doesn't have a registered adapter.
This error has one parameter:
address
sourceOfFunds
InvalidExtensionCaller
A InvalidExtensionCaller error is thrown when caller is not a loan borrower or lender.
This error has doesn't define any parameters.
InvalidExtensionSigner
A InvalidExtensionSigner error is thrown when signer is not a loan extension proposer.
This error has two parameters:
address
allowed
address
current
InvalidExtensionDuration
A InvalidExtensionDuration error is thrown when loan extension duration is out of bounds.
This error has two parameters:
uint256
duration
uint256
limit
InvalidMultiTokenAsset
A InvalidMultiTokenAsset error is thrown when MultiToken Asset struct is invalid which can happen because of invalid category, address, id or amount.
See MultiToken for more information about the Asset struct.
This error has four parameters:
uint8
category
address
addr
uint256
id
uint256
amount
Terms
Struct
Type | Name | Comment |
---|---|---|
address | lender | Address of a lender |
address | borrower | Address of a borrower |
uint32 | duration | Loan duration in seconds |
MultiToken.Asset (see Asset struct) | collateral | Asset used as a loan collateral |
MultiToken.Asset (see Asset struct) | credit | Asset used as a loan credit |
uint256 | fixedInterestAmount | Fixed interest amount in credit asset tokens. It is the minimum amount of interest which has to be paid by a borrower |
uint24 | accruingInterestAPR | Accruing interest APR with 2 decimals |
bytes32 | lenderSpecHash | Hash of a lender specification struct |
bytes32 | borrowerSpecHash | Hash of a borrower specification struct |
ProposalSpec
Struct
Type | Name | Comment |
---|---|---|
address | proposalContract | Address of a loan proposal contract |
bytes | proposalData | Encoded proposal data that is passed to the loan proposal contract |
bytes32[] | proposalInclusionProof | Inclusion proof of the proposal in the proposal contract |
bytes | signature | Signature of the proposal |
LenderSpec
Struct
Type | Name | Comment |
---|---|---|
address | sourceOfFunds | Address of a source of funds. This can be the lenders address, if the loan is funded directly, or a pool address from which the funds are withdrawn on the lenders behalf |
CallerSpec
Struct
Type | Name | Comment |
---|---|---|
uint256 | refinancingLoanId | ID of a loan to be refinanced. Zero if creating a new loan |
bool | revokeNonce | Flag if the callers nonce should be revoked |
uint256 | nonce | Callers nonce to be revoked. Nonce is revoked from the current nonce space |
bytes | permitData | Callers permit data for a loans credit asset |
LOAN
Struct
Type | Name | Comment |
---|---|---|
uint8 | status | 0 -> None/Dead 2 -> Running 3 -> Repaid 4 -> Expired |
address | creditAddress | Address of an asset used as a loan credit |
address | originalSourceOfFunds | Address of a source of funds that was used to fund the loan |
uint40 | startTimestamp | Unix timestamp (in seconds) of a start date |
uint40 | defaultTimestamp | Unix timestamp (in seconds) of a default date |
address | borrower | Address of a borrower |
address | originalLender | Address of a lender that funded the loan |
uint24 | accruingInterestAPR | Accruing interest APR with 2 decimals |
uint256 | fixedInterestAmount | Fixed interest amount in credit asset tokens. It is the minimum amount of interest which has to be paid by a borrower. This property is reused to store the final interest amount if the loan is repaid and waiting to be claimed. |
uint256 | principalAmount | Principal amount in credit asset tokens |
MultiToken.Asset (see Asset struct) | collateral | Asset used as a loan collateral |
ExtensionProposal
Struct
Type | Name | Comment |
---|---|---|
uint256 | loanId | ID of a loan to be extended |
address | compensationAddress | Address of a compensation asset |
uint256 | compensationAmount | Amount of a compensation asset that a borrower has to pay to a lender |
uint40 | duration | Duration of the extension in seconds |
uint40 | expiration | Unix timestamp (in seconds) of an expiration date |
address | proposer | Address of a proposer that signed the extension proposal |
uint256 | nonceSpace | Nonce space of the extension proposal nonce |
uint256 | nonce | Nonce of the extension proposal |