@renft/sdk
Version:
**ReNFT** is a multi-chain highly gas-optimised NFT rental protocol and platform that can be whitelabel integrated into any project to enable collateral-free in-house renting, lending, and reward share (scholarship automation).
269 lines (231 loc) • 8.71 kB
text/typescript
import {
BigNumber,
BigNumberish,
formatFixed,
parseFixed,
} from '@ethersproject/bignumber';
import {
MAX_DECIMAL_LENGTH,
MAX_PRICE,
NETWORK_RESOLVERS,
NUM_BITS_IN_BYTE,
} from './consts';
import { EVMNetworkType, NFTStandard, PaymentToken } from './types';
// consts that predominantly pertain to this file
const BITSIZE_MAX_VALUE = 32;
const HALF_BITSIZE = 16;
const PRICE_BITSIZE = 32;
/**
* hexchar is 0 to 15 which is 2 ** 4 - 1.
* This means that hexchar (aka nibble) is half a byte,
* since byte is 8 bits. This function converts number
* of bytes to number of nibbles.
*
* e.g. 2 bytes is 4 nibbles
*
* @param byteCount
* @returns number of nibbles that represent the byteCount bytes
*/
export const bytesToNibbles = (byteCount: number) => {
if (typeof byteCount != 'number') throw new Error('only numbers supported');
if (byteCount < 1) throw new Error('invalid byteCount');
return byteCount * 2;
};
/**
* (21.42, 32) -> 0x0015002A
*
* (1.2, 32) -> 0x00010002
*
* Notice how the whole decimal part is reprsented by the first 4 nibbles,
* whereas the decimal part is represented by the second part, i.e. the
* last 4 nibbles
*
* @param number
* @param bitsize
* @returns number's padded (of bitsize total length) hex format
*/
export const toPaddedHex = (number: number, bitsize: number) => {
// in node.js this function fails for bitsize above 32 bits
if (bitsize > BITSIZE_MAX_VALUE)
throw new Error(
`bitsize ${bitsize} above maximum value ${BITSIZE_MAX_VALUE}`
);
// conversion to unsigned form based on
if (number < 0) throw new Error('unsigned number not supported');
// 8 bits = 1 byteCount; 16 bits = 2 byteCount, ...
const byteCount = Math.ceil(bitsize / NUM_BITS_IN_BYTE);
// shifting 0 bits removes decimals
// toString(16) converts into hex
// .padStart(byteCount * 2, "0") adds byte
return (
'0x' +
(number >>> 0)
.toString(16)
.toUpperCase()
// 1 nibble = 4 bits. 1 byte = 2 nibbles
.padStart(bytesToNibbles(byteCount), '0')
);
};
const scaleDecimal = (num: string) => {
const numLen = num.length;
for (let i = 0; i < MAX_DECIMAL_LENGTH - numLen; i++) {
num = num + '0';
}
return Number(num);
};
/**
* Converts a number into the format that is acceptable by the ReNFT contract.
* TLDR; to fit a single storage slot in the ReNFT contract, we split the whole
* and decimal parts of a number to only have maximum 4 digits. That means, the
* maximum price is 9999.9999. If more decimals are supplied, they are truncated.
* If price exceeds the maximum whole part, this throws.
* @param price value to pack
* @returns price format that is acceptable by ReNFT contract
*/
export const packPrice = (price: string | number) => {
if (price > MAX_PRICE) throw new Error(`supplied price exceeds ${MAX_PRICE}`);
const parts = price.toString().split('.');
const whole = Number(parts[0]);
if (whole < 0) throw new Error("can't pack negative price");
const wholeHex = toPaddedHex(Number(whole), HALF_BITSIZE);
if (parts.length === 1) return wholeHex.concat('0000');
if (parts.length !== 2) throw new Error('price packing issue');
if (parts[1].length > MAX_DECIMAL_LENGTH)
throw new Error(
`supplied price exceeds decimal length of ${MAX_DECIMAL_LENGTH}`
);
const decimal = scaleDecimal(parts[1].slice(0, MAX_DECIMAL_LENGTH));
return wholeHex.concat(toPaddedHex(Number(decimal), HALF_BITSIZE).slice(2));
};
const sameLength = <T>(a: T[], b: T[]) => a.length === b.length;
const validateSameLength = (...args: any[]) => {
let prev: any = args[0];
for (const curr of args) {
if (!curr) continue;
if (!sameLength(prev, curr)) throw new Error('args length variable');
prev = curr;
}
return true;
};
const decimalToPaddedHexString = (number: number, bitsize: number): string => {
const byteCount = Math.ceil(bitsize / 8);
const maxBinValue = Math.pow(2, bitsize) - 1;
if (bitsize > 32) throw new Error('number above maximum value');
if (number < 0) number = maxBinValue + number + 1;
return (
'0x' +
(number >>> 0)
.toString(16)
.toUpperCase()
.padStart(byteCount * 2, '0')
);
};
/**
* To save as much gas as possible, we have decided to pack the rental
* price tightly in the Lending struct in our contract. For this purpose,
* we have decided to use 4 bytes to express the price. Leading two bytes
* are used to signify the whole part of the price and the last two bytes
* are used to signify the decimal part of the price. This function deals
* with converting the packed price back to the human readable price.
* @param price packed price to convert to human readable price
*/
export const unpackPrice = (price: BigNumberish) => {
// price is from 1 to 4294967295. i.e. from 0x00000001 to 0xffffffff
const numHex = decimalToPaddedHexString(Number(price), PRICE_BITSIZE).slice(
2
);
let whole = parseInt(numHex.slice(0, 4), 16);
let decimal = parseInt(numHex.slice(4), 16);
if (whole > 9999) whole = 9999;
if (decimal > 9999) decimal = 9999;
let decimalStr = decimal.toString();
const decimalLen = decimalStr.length;
const maxLen = 4;
for (let i = 0; i < maxLen - decimalLen; i++) {
decimalStr = '0' + decimalStr;
}
return parseFloat(`${whole}.${decimalStr}`);
};
type IObjectKeysValues = string[] | boolean[] | number[] | PaymentToken[];
interface IObjectKeys {
[key: string]: IObjectKeysValues | undefined;
}
interface PrepareBatch extends IObjectKeys {
nftStandard?: NFTStandard[];
nftAddress: string[];
tokenID: string[];
amount?: number[];
maxRentDuration?: number[];
dailyRentPrice?: string[];
nftPrice?: string[];
paymentToken?: PaymentToken[];
rentDuration?: number[];
lendingID?: string[];
rentingID?: string[];
rentAmount?: string[];
}
/**
* Our contracts take arrays of NFT addresses, their token ids, and other
* relevant informatino for lending / renting. Contract assumes a specific
* ordering for these. That is how we achieve minimal gas usage. This function
* facilitates that ordering. In a nutshell, it puts all the ERC721s together,
* followed by ERC1155s, which also sit next to each other in the sorted array.
* This helps our contracts with calling the ERC1155's bundle transfer, and
* that is yet another gas saving trick.
*
* To spend as little gas as possible, arguments must follow a particular format
* when passed to the contract. This function prepares whatever inputs you want
* to send, and returns the inputs in an optimal format.
*
* This algorithm's time complexity is pretty awful. But, it will never run on
* large arrays, so it doesn't really matter.
* @param args arguments that the client is intending to call the contracts
* with.
*/
export const prepareBatch = (args: PrepareBatch) => {
if (args.nftAddress.length <= 1) return args;
validateSameLength(args);
const preparedBatch: PrepareBatch = { nftAddress: [], tokenID: [] };
// input: ['a', 'b', 'a', 'c']
// output: [0, 2, 1, 3]
const sortIndices = (nft: string[]): number[] => {
const comp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
const indices = new Array(nft.length).fill(0).map((_, i) => i);
return indices.sort((a, b) => comp(nft[a], nft[b]));
};
const sortWithIndices = (items: any[], indices: number[]) => {
return indices.map(i => items[i]);
};
const indices = sortIndices(args.nftAddress);
Object.keys(args).forEach(key => {
//@ts-ignore
preparedBatch[key] = sortWithIndices(args[key], indices);
});
return preparedBatch;
};
// TODO: deprecate the usage of these in front & api. People should use
// parseFixed directly.
// TODO: haven't tested the Bytes conversion here. Do **NOT** use with Bytes
export const toWhoopiScaledAmount = (
v: BigNumberish,
c: EVMNetworkType,
t: PaymentToken
): BigNumber => {
if (t === PaymentToken.SENTINEL)
throw new TypeError('Invalid payment token. Non-sentinels supported only.');
const { [c]: resolver } = NETWORK_RESOLVERS;
return parseFixed(String(v), resolver[t].scale);
};
// TODO: deprecate the usage of these in front & api. People should use
// formatFixed directly.
// TODO: haven't tested the Bytes conversion here. Do **NOT** use with Bytes
export const fromWhoopiScaledAmount = (
v: BigNumberish,
c: EVMNetworkType.AVALANCHE_MAINNET | EVMNetworkType.AVALANCHE_FUJI_TESTNET,
t: PaymentToken
): string => {
if (t === PaymentToken.SENTINEL)
throw new TypeError('Invalid payment token. Non-sentinels supported only.');
const { [c]: resolver } = NETWORK_RESOLVERS;
return formatFixed(v, resolver[t].scale);
};