Skip to content

Commit

Permalink
feature: on-chain loans (GalaChain#458)
Browse files Browse the repository at this point in the history
Adapted from the original September 06, 2022 commit message.

Implement the initial chaincode and data structures for loans/leasing.

Some details on requirements for leasing/loaning on chain were documented
in ancient times, internal link redacted.

At the time of this writing, (Sept 6th 2022) this document posited two types
of potential leasing, described as the "short term solution" (or
"centralized authority") and "long-term" (or "web3" solution).

The centralized authority solution essentially relies on a trusted authority
(such as a game server identity) to manage or facilitate the lending from
owner to borrower.

For some applications, prompting for and
receiving a signature from the user's private key may
presents unique UI challenges that might be difficult to solve in game or
in-store.

Thus the "centralized solution" is available for applications or
user collectives that prefer the approach of a trusted mediator/curator.

The web3 solution permits users to lend directly, peer-to-peer. No third
party required.

This merge introduces on-chain support for both models above in an abstract way.

Aspects of loans that make sense to provide directly in the SDK are included,
with the expectation that most applications will have their own additional chain calls
and handling for their specific use cases.

This technical designs introduces semantics borrowed from the fields of
archiving, collections curation, and cultural institutions management
(museums, libraries, archives, etc.).

For example, we use the term "Loan" in code rather than "Leasing", and
"registrar" rather than "broker".

If we believe that NFTs are digital cultural property similar
to artwork or artifacts, and we do,
then using terminology from the fields of art, manuscript, and antiquities
curation is more apt than that of the financial services industry.

"Registrars focus on... acquisitions, loans, exhibitions, deaccessions, storage,
"packing and shipping, security of objects in transit,
insurance policies, and risk management."

https://en.wikipedia.org/wiki/Registrar_(cultural_property)

Retrieved 2022-09-06

User stories written to shape this feature:

As an NFT owner, I want to loan out my item(s) and (potentially)
receive rewards while not in use, so that my cultural property
can be experienced more broadly while
being accessible and interoperable to a larger audience and user base.

As a NFT borrower, I want to access and use more valuable or powerful
items than I can afford, at lower cost within a specific time-boxed period,
so that I can enjoy a richer experience of cultural property than I
might otherwise be able.

As an NFT owner, I don't want the borrower to transfer my NFT
without my authorization, so that I maintain my ownership. In other
words, the NFT is locked to the loan and non-transferable for the duration.

Loans only applies to NFTs in this implementation, however this code is
expected to serve as a significant start for fungible token loans.
The "loan" semantic certainly can apply to fungible tokens as well.

There is a 1:1 relationship between a `LoanOffer` entry on-chain
and an NFT `TokenInstance`.

But `OfferLoan()` can take a partial key and create multiple `LoanOffer` entries,
similar to how partial key allowances work.

The token should be locked to the loan for the duration specified.

This implementation is intended to be generic and provide the basic
primitives for offering loans, accepting loan offers, locking tokens to a
loan, and preventing ownership transfer while the loan is in effect.

The implementation does not currently account for
application-specific aspects of loans, including whether
a loan requires a payment to enter into,
incurs rewards to the owner, etc. These details are left for
future enhancement or implementation by consuming clients/developers.

The provided types and methods could conceivably be used
alongside chaincode specific contracts or extended to support
further application or domain specific use cases.

The current data structures and chaincode methods can record
information to the ledger such as:

* this token was loaned by whom, to whom, from whom, etc.
* when loan was filled
* when loan ended
* the status of the loan completion (e.g. fulfilled, canceled)
* token locked / unlocked

Individual applications are expected to pick up further implementation
details in their respective channels.

This initial implementation is
considered experimental and is subject to change if early adopters
choose to utilize it and request or submit changes.
  • Loading branch information
sentientforest authored Dec 11, 2024
1 parent a27fdf9 commit 143da54
Show file tree
Hide file tree
Showing 15 changed files with 2,185 additions and 1 deletion.
335 changes: 335 additions & 0 deletions chain-api/src/types/Loan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
/*
* Copyright (c) Gala Games Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import BigNumber from "bignumber.js";
import { Exclude, Type } from "class-transformer";
import {
ArrayNotEmpty,
IsDefined,
IsInt,
IsNotEmpty,
IsOptional,
Min,
ValidateNested
} from "class-validator";
import { JSONSchema } from "class-validator-jsonschema";

import { ChainKey } from "../utils";
import {
BigNumberIsInteger,
BigNumberIsNotNegative,
BigNumberIsPositive,
BigNumberProperty,
IsUserAlias
} from "../validators";
import { ChainObject } from "./ChainObject";
import { TokenInstanceKey, TokenInstanceQuantity, TokenInstanceQueryKey } from "./TokenInstance";

/*
* Chain INDEX_KEY naming conventions for Loans:
* Prefix with "GCTL" for "Gala's Chain Token Loan".
* Followed by two characters determined by the following rules:
* a) If the Class name makes two or more words, the first leter of two of the words as makes sense.
* b) If the Class name is a single word, the first and last letters of the word.
* c) If there is a comflict with an existing INDEX_KEY, then whatever makes sense.
*/

/*
* Here, "Any" is set as the first value. First value = 0, which is a falsy value in TypeScript/JavaScript.
* When Querying by LoanStatus, this avoids the pitfalls of `if (dto.status) {}` failing for "Open".
* https://github.com/microsoft/TypeScript/issues/7473
* Retrieved 2022-09-07
* "I suggest that, as a general practice, enums should always be defined with the
* 0 value being equivalent to the "Zero Like", "Unspecified", or "Falsy" value for that enum type."
*/
export enum LoanStatus {
Any = 0,
Open = 1,
Contracted = 2,
Fulfilled = 3,
Cancelled = 4
}

export enum LoanClosedBy {
Unspecified = 0,
Owner = 1,
Registrar = 2
}

export class LoanOffer extends ChainObject {
@JSONSchema({ description: "TokenInstance collection. ChainKey property." })
@ChainKey({ position: 0 })
@IsNotEmpty()
public collection: string;

@JSONSchema({ description: "TokenInstance category. ChainKey property." })
@ChainKey({ position: 1 })
@IsNotEmpty()
public category: string;

@JSONSchema({ description: "TokenInstance type. ChainKey property." })
@ChainKey({ position: 2 })
@IsNotEmpty()
public type: string;

@JSONSchema({ description: "TokenInstance additionalKey. ChainKey property." })
@ChainKey({ position: 3 })
@IsDefined()
public additionalKey: string;

@JSONSchema({ description: "TokenInstance instance. ChainKey property." })
@ChainKey({ position: 4 })
@IsNotEmpty()
@BigNumberIsInteger()
@BigNumberIsNotNegative()
@BigNumberProperty()
public instance: BigNumber;

@JSONSchema({ description: "Chain identity of the NFT owner that offered the loan. ChainKey property." })
@ChainKey({ position: 5 })
@IsUserAlias()
public owner: string;

@JSONSchema({ description: "Timestamp tracking when the offer was made. ChainKey property." })
@ChainKey({ position: 6 })
@IsNotEmpty()
public created: number;

@JSONSchema({
description:
"ChainKey property. Numeric id to differentiate multiple " +
"LoanOffers written to chain, at the same time, for the same NFT, such as p2p loans offered " +
"to a small group of known users."
})
@ChainKey({ position: 7 })
public id: number;

@JSONSchema({
description: "Registrar chain identity. For p2p loans, equal to the " + "Loan.NULL_REGISTRAR_KEY."
})
@IsOptional()
@IsNotEmpty()
public registrar?: string;

@JSONSchema({ description: "Optional borrower identity, for loans offered to a specific group of users." })
@IsOptional()
@IsUserAlias()
public borrower?: string;

@JSONSchema({ description: "LoanStatus, e.g. Open, Contracted, Fulfilled, Cancelled." })
@IsNotEmpty()
public status: LoanStatus;

@JSONSchema({ description: "Optional reward property, available for use by consumer implementations." })
@ValidateNested({ each: true })
@Type(() => TokenInstanceQuantity)
@IsOptional()
@ArrayNotEmpty()
public reward?: Array<TokenInstanceQuantity>;

@JSONSchema({
description: "Number of times this offered can be accepted and fulfilled (non-concurrently)"
})
@BigNumberIsPositive()
@BigNumberIsInteger()
@BigNumberProperty()
public uses: BigNumber;

@JSONSchema({ description: "Number of uses spent." })
@BigNumberIsPositive()
@BigNumberIsInteger()
@BigNumberProperty()
public usesSpent: BigNumber;

@Min(0)
@IsInt()
public expires: number;

@Exclude()
public static INDEX_KEY = "GCTLOR";

@Exclude()
public tokenKey() {
const stringKey =
`${this.collection}|${this.category}|${this.type}|${this.additionalKey}|` +
`${this.instance.toFixed()}`;

return stringKey;
}

@Exclude()
public verifyTokenKey(key: TokenInstanceKey): boolean {
return (
this.collection === key.collection &&
this.category === key.category &&
this.type === key.type &&
this.additionalKey === key.additionalKey &&
this.instance.isEqualTo(key.instance)
);
}

@Exclude()
public Lender() {
const lender: Lender = new Lender();
lender.id = this.owner;
lender.status = LoanStatus.Open;
lender.offer = this.getCompositeKey();
lender.collection = this.collection;
lender.category = this.category;
lender.type = this.type;
lender.additionalKey = this.additionalKey;
lender.instance = this.instance;

return lender;
}
}

@JSONSchema({
description:
"Lender data written to chain. Lender.offer property can retrieve a " +
"specific LoanOffer. Useful within chaicnode for queries via " +
"full or partial key. For example, query by Lender's client identity to retrieve " +
"all offer IDs made by that Lender."
})
export class Lender extends ChainObject {
@JSONSchema({ description: "Client identity id for Lender that made the referenced LoanOffer." })
@ChainKey({ position: 0 })
@IsUserAlias()
id: string;

@JSONSchema({ description: "LoanStatus. ChainKey Property." })
@ChainKey({ position: 1 })
@IsNotEmpty()
status: LoanStatus;

@JSONSchema({ description: "LoanOffer chain key." })
@ChainKey({ position: 2 })
@IsNotEmpty()
offer: string;

@IsNotEmpty()
public collection: string;

@IsNotEmpty()
public category: string;

@IsNotEmpty()
public type: string;

@IsDefined()
public additionalKey: string;

@IsNotEmpty()
@BigNumberIsInteger()
@BigNumberIsNotNegative()
@BigNumberProperty()
public instance: BigNumber;

@Exclude()
public static INDEX_KEY = "GCTLLR";

@Exclude()
public matchesQuery(key: TokenInstanceQueryKey) {
const tokenKeys = key.publicKeyProperties();
const queryParams = key.toQueryParams();

for (let i = 0; i < queryParams.length; i++) {
if (this[tokenKeys[i]] !== queryParams[i]) {
return false;
}
}

return true;
}
}

export class LoanAgreement extends ChainObject {
@ChainKey({ position: 0 })
@IsUserAlias()
owner: string;

@ChainKey({ position: 1 })
@IsNotEmpty()
offer: string;

@ChainKey({ position: 2 })
loan: string;

@ChainKey({ position: 3 })
@IsUserAlias()
borrower: string;

@ChainKey({ position: 4 })
@IsNotEmpty()
created: number;

@Exclude()
public static INDEX_KEY = "GCTLLA";

@Exclude()
public static OBJECT_TYPE = "LoanAgreement";
}

export class Loan extends ChainObject {
@ChainKey({ position: 0 })
@IsNotEmpty()
public registrar: string;

@ChainKey({ position: 1 })
@IsNotEmpty()
public collection: string;

@ChainKey({ position: 2 })
@IsNotEmpty()
public category: string;

@ChainKey({ position: 3 })
@IsNotEmpty()
public type: string;

@ChainKey({ position: 4 })
@IsNotEmpty()
public additionalKey: string;

@ChainKey({ position: 5 })
@IsNotEmpty()
@BigNumberIsInteger()
@BigNumberIsNotNegative()
@BigNumberProperty()
public instance: BigNumber;

@ChainKey({ position: 6 })
@IsNotEmpty()
public start: number;

public end: number;

@IsUserAlias()
public owner: string;

@IsNotEmpty()
public borrower: string;

@IsNotEmpty()
public status: LoanStatus;

@IsNotEmpty()
public closedBy: LoanClosedBy;

@Exclude()
public static INDEX_KEY = "GCTLLN";

@Exclude()
public static NULL_REGISTRAR_KEY = "p2p";
}
Loading

0 comments on commit 143da54

Please sign in to comment.