Skip to content

Commit

Permalink
chain release2.9 compatibility Grace Period and overdue (#3387)
Browse files Browse the repository at this point in the history
* Refactor: add yarn to script to avoid errors

* Feat: add some methods and interfaces
- a method to get NU billing information
- a method to get contract payment state
- a method to get used resources by the node contract
- some related interfaces

* Feat: calculate unique name prcice

* chore: inhance docstirng

* Feat: support contract payment state calculations

* Refactor: unlock contracts to use new overdue calculations

* Refactor:
enhance code readablity.
reduce number of requests by passing the contract info to all chiled functions

* chore: inhance imports

* Refactor: unlock contracts
- use proxy url in unlockMycontracts
- refactor unlockContracts to accept array of contracts
- add function to unlock contracts by their ids

* add overdue details interfaces

* chore: contract overdue
- add function that takes only contract to unlock it
- add calculate overdue functions

* chore: expose get over due amount by the contract info only

* fix: remove duplicated call

* refactor:
mUSD converter will return the price in mTFT
fix unique name price calc
fix imports

* WIP: support overdue changes on contracts list in dashboard

* refactor: fix unique name contract

* wip fix calculations

* feat: support Payment state
- add contracts on rented node to the rent node overdue

* chore: clean up

* refactor: enhance contract lock state
it is storing the rent contract on a map by the node id, and ignore the unwanted calculations

* refactor: enhance contract lock
unlock dialog now will check if the node contract on rented node or not; if so will retrive the rent contract cost and store the and will avoid duplication

* refactor: fix is on rented node flag

* refactor: call the associated rent contract on unlock rent contracts

* feat: show the rent contract that will be unlocked as it is associated with selected contracts

* Style: fix loading spinner in lock dialogs

* fix: build

* fix: passing contracts to client

* docs: add deprecated annotation

* return the contract cost whatever if it is on a rented node or not

* docs: WIP adding docstrings

* WIP: support unlock node contract if the associated rent contract is in created state

* docs: WIP adding docstrings

* cleanup: remove unused module

* fix: build
remove unused import

* Chore: support unlock node contract if its rent contract is in created state

* Chore: avoid bill same contract multiple times

* Chore: use currency module to convert usd to tft

* refactor:
use the calculator module,
remove the added pricing related interfaces and functions

add helper method to convert from bytes to gb

* chore: list all contracts
to avoid default page size 50, add a function to work around that.

* fix: build
return if the contract list is empty

* Chore: apply comments
- fix typos
- fix calculation in ipv4 cost
- fix nu cost calaculations

* Chore: support multiple ipv4 per contract

* Chore: enahnce code readability
separate node contracts on rented node cost calculations to multiple functions
create constants with numbers repeatedly  used in the calculations
enhance docstring

* Chore:
fix typo
fix build
fix name convention

* Fix: avoid having deleted rent contracts while listing rent contracts

* Fix: pass CRU as number without conversion

* Chore: add node extra fees to rent contract cost

* refactor: include premuim price for certified nodes in the unbuiled nu

* chore: set decimals to 7 in convert tft price

* refactor: convert the monthly cost to avoid missing decimals on converting cost per second

* fix: get total overdue
skip the node contracts on rented nodes as it already calculated in rent contract

* refactor: calculate node contract on rented node
we were ignoring the elapsed seconds for node contracts and we assume that it is the same as rent contract

* refactor
reorder the calculation logic to be easier in debugging

* refactor:
pass the contracts by id as the contracts do not have the public_ips_number field

* fix: reset total overdue on refresh

* reset rentcontract on reset table

* Fix: add premium price for ip price on rent node

* Chore: remove unused functions

* Chore: set to fixed point 15 and fix the error msg

* Chore: include node contract on rented node in the getTotalOverDue function this will be needed for UI

* Style: rephrase unlock dialog msg
  • Loading branch information
0oM4R authored Oct 31, 2024
1 parent 4d8bbd4 commit f9a5b06
Show file tree
Hide file tree
Showing 13 changed files with 733 additions and 118 deletions.
2 changes: 1 addition & 1 deletion packages/grid_client/scripts/compare_locked_balance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ async function getUsersWithContracts(grid: GridClient) {

return users;
}

/** @deprecated */
async function getContractsLockedAmount(grid: GridClient, contracts: Contract[]) {
const contractLockDetails = await Promise.all(
contracts.map(contract => grid.contracts.contractLock({ id: +contract.contractID })),
Expand Down
338 changes: 321 additions & 17 deletions packages/grid_client/src/clients/tf-grid/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import {
import GridProxyClient, {
CertificationType,
Contract,
ContractsQuery,
ContractState,
ContractType,
} from "@threefold/gridproxy_client";
import {
BillingInformation,
ContractLock,
ContractLockOptions,
ContractPaymentState,
Contracts,
ExtrinsicResult,
GetDedicatedNodePriceOptions,
NodeContractUsedResources,
SetDedicatedNodeExtraFeesOptions,
} from "@threefold/tfchain_client";
import { GridClientError } from "@threefold/types";
import { Decimal } from "decimal.js";

import { formatErrorMessage } from "../../helpers";
import { ContractStates } from "../../modules";
import { bytesToGB, formatErrorMessage } from "../../helpers";
import { calculator, ContractStates, currency } from "../../modules";
import { Graphql } from "../graphql/client";

export type DiscountLevel = "None" | "Default" | "Bronze" | "Silver" | "Gold";
Expand Down Expand Up @@ -109,6 +119,40 @@ export interface LockContracts {
totalAmountLocked: number;
}

export type OverdueDetails = { [key: number]: number };

export interface ContractsOverdue {
nameContracts: OverdueDetails;
nodeContracts: OverdueDetails;
rentContracts: OverdueDetails;
totalOverdueAmount: number;
}
export interface CalculateOverdueOptions {
contractInfo: Contract;
gridProxyClient: GridProxyClient;
}

/**
* Represents the total cost associated with the provided contracts.
*
* @interface TotalContractsCost
*
* @property {number} ipsCost - Total cost for the provided amount for ips per mount in USD.
* @property {Decimal} nuCost - The total unbilled amount of network usage (NU), represented as a Decimal Unit USD.
* @property {Decimal} overdraft - The overdraft amount, the sum of `additionalOverdraft` and `standardOverdraft` represented as a Decimal as Unit TFT.
*/
interface TotalContractsCost {
ipsCost: number;
nuCost: number;
overdraft: Decimal;
}

const SECONDS_ONE_HOUR = 60 * 60;

const HOURS_ONE_MONTH = 24 * 30;

const TFT_CONVERSION_FACTOR = 10 ** 7;

class TFContracts extends Contracts {
async listContractsByTwinId(options: ListContractByTwinIdOptions): Promise<GqlContracts> {
options.stateList = options.stateList || [ContractStates.Created, ContractStates.GracePeriod];
Expand Down Expand Up @@ -297,13 +341,241 @@ class TFContracts extends Contracts {
});
}

/** @deprecated */
async contractLock(options: ContractLockOptions) {
const res = await super.contractLock(options);
const amountLocked = new Decimal(res.amountLocked);
res.amountLocked = amountLocked.div(10 ** 7).toNumber();
return res;
}

/**
* Function to convert USD to TFT
* @param {Decimal} USD the amount in USD.
* @returns {Decimal} The amount in TFT.
*/
private async convertToTFT(USD: Decimal) {
try {
const tftPrice = (await this.client.tftPrice.get()) ?? 0;
const tft = new currency(tftPrice, 15).convertUSDtoTFT({ amount: USD.toNumber() });
return new Decimal(tft);
} catch (error) {
throw new GridClientError(`Failed to convert to TFT due: ${error}`);
}
}
/**
* list all contracts, this is not restricted with the items counts
* this basically check if the count is larger than the page size, it make another request with the item count as the size pram.
* @param {GridProxyClient} proxy will be used to list the contracts
* @param {Partial<ContractsQuery>} queries
* @returns
*/
async listAllContracts(proxy: GridProxyClient, queries: Partial<ContractsQuery>) {
const contracts = await proxy.contracts.list({ ...queries, retCount: true });
if (contracts.data.length < contracts.count!) {
return (
await proxy.contracts.list({
...queries,
size: contracts.count!,
})
).data;
} else return contracts.data;
}

/**
* List all the grace period contracts on a node
* @param {number} nodeId rented node to list its contracts
* @param {GridProxyClient} proxy
* @returns {Contract[]}
*/
private async getNodeContractsOnRentedNode(nodeId: number, proxy: GridProxyClient): Promise<Contract[]> {
return await this.listAllContracts(proxy, {
nodeId,
state: [ContractState.GracePeriod],
numberOfPublicIps: 1,
});
}

/**
* Get the contract billing info, and add the additional price markup if the node is certified
* @param {Contract} contract contract to get its billing info
* @returns {Decimal}
*/
private async getUnbilledNu(contract_id: number, node_id: number) {
const billingInfo = await this.client.contracts.getContractBillingInformationByID(contract_id);
const unbilledNU = billingInfo.amountUnbilled;
if (unbilledNU > 0) {
const nodeInfo = await this.client.nodes.get({ id: node_id });
const isCertified = nodeInfo.certification === CertificationType.Certified;
if (isCertified) {
/** premium pricing is 25% on certified nodes */
const premiumUnbilledNU = unbilledNU * (125 / 100);
return premiumUnbilledNU;
}
}
return unbilledNU;
}

/**
* Calculate contract cost for all node contracts on rented node.
* @description will list all node contracts with public ip and calculate all contracts overdue .
* please note that the unbilled NU amount is added to the total overdraft exactly as the in the other contract types.
* the IPV4 cost to add to the estimated cost of the rent contract.
*/
private async getContractsCostOnRentedNode(nodeId: number, proxy: GridProxyClient): Promise<Decimal> {
const contracts = await this.getNodeContractsOnRentedNode(nodeId, proxy);

if (contracts.length == 0) return new Decimal(0);
const costPromises = contracts.reduce((acc: Promise<Decimal>[], contract) => {
acc.push(this.calculateContractOverDue({ contractInfo: contract, gridProxyClient: proxy }));
return acc;
}, []);
const costResult = await Promise.all(costPromises);

const totalContractsCost = costResult.reduce((acc: Decimal, contractCost) => acc.add(contractCost), new Decimal(0));
return totalContractsCost;
}

/**
* This function is for calculating the estimated cost of the contract per month.
* @description
* Name contract cost is fixed price for the unique name,
* Rent contract cost is the cost of the total node resources,
* Node contract have two cases:
* 1- on rented contract, this case the cost will be only for the ipv4.
* 2- on shared node, this will be the shared price of the used resources
* @param {Contract} contract
* @param {GridProxyClient} proxy
* @returns the cost of the contract per month in USD
*/
async getContractCost(contract: Contract, proxy: GridProxyClient) {
const calc = new calculator(this.client);

if (contract.type == ContractType.Name) return await calc.namePricing();

//TODO allow ipv4 to be number

// Other contract types need the node information
const nodeDetails = await proxy.nodes.byId(contract.details.nodeId);

const isCertified = nodeDetails.certificationType === CertificationType.Certified;
if (contract.type == ContractType.Rent) {
const { cru, sru, mru, hru } = nodeDetails.total_resources;
/**node extra fees in mille USD per month */
const extraFeesInMilliUsd = await this.client.contracts.getDedicatedNodeExtraFee({ nodeId: nodeDetails.nodeId });
const extraFeeUSD = extraFeesInMilliUsd / 1000;
const USDCost = (
await calc.calculateWithMyBalance({
ipv4u: false,
certified: isCertified,
cru: cru,
mru: bytesToGB(mru),
hru: bytesToGB(hru),
sru: bytesToGB(sru),
})
).dedicatedPrice;

return USDCost + extraFeeUSD;
}

/** Node Contract */

/** Node contract on rented node
* If the node contract has IPV4 will return the price of the ipv4 per month
* If not there is no cost, will return zero
*/

if (nodeDetails.rented) {
if (!contract.details.number_of_public_ips) return 0;
/** ip price in USD per hour */
const ipPrice = (await this.client.pricingPolicies.get({ id: 1 })).ipu.value / TFT_CONVERSION_FACTOR;
const pricePerMonth = ipPrice * HOURS_ONE_MONTH;
if (isCertified) return pricePerMonth * (125 / 100);
return pricePerMonth;
}

const usedREsources: NodeContractUsedResources = await this.client.contracts.getNodeContractResources({
id: contract.contract_id,
});
const { cru, sru, mru, hru } = usedREsources.used;
const USDCost = (
await calc.calculateWithMyBalance({
ipv4u: !!contract.details.number_of_public_ips,
certified: isCertified,
cru: cru,
mru: bytesToGB(mru),
hru: bytesToGB(hru),
sru: bytesToGB(sru),
})
).sharedPrice;

return USDCost;
}

/**
* Calculates the overdue amount for a contract.
*
* @description This method calculates the overdue amount, the overdue amount basically is the sum of three parts:
* 1- Total over draft: is the sum of additional overdraft and standard overdraft.
* 2- Unbilled NU: is the unbilled amount of network usage.
* 3- The estimated cost of the contract for the total period: this part is dependant on the contract type and if the contract is on rented node or not.
* If the contract is rent contract, will add both of ipv4 cost and the total overdue of all associated contracts.
* The total period is the time since the last billing added to Allowance period.
* The resulting overdue amount represents the amount that needs to be addressed.
*
* @param {CalculateOverdueOptions} options - The options containing the contract and gridProxyClient.
* @returns {Promise<number>} - The calculated overdue amount in TFT.
*/
async calculateContractOverDue(options: CalculateOverdueOptions) {
const contractInfo = options.contractInfo;

const { standardOverdraft, additionalOverdraft, lastUpdatedSeconds } =
await this.client.contracts.getContractPaymentState(contractInfo.contract_id);

/**Calculate the elapsed seconds since last billing*/
const elapsedSeconds = Math.ceil(Date.now() / 1000 - lastUpdatedSeconds);

// time since the last billing with allowance time of **one hour**
const totalPeriodTime = elapsedSeconds + SECONDS_ONE_HOUR;

/** Cost in USD */
const contractMonthlyCost = new Decimal(await this.getContractCost(contractInfo, options.gridProxyClient));

const contractMonthlyCostTFT = await this.convertToTFT(contractMonthlyCost);
/** contract cost per second in TFT */
const contractCostPerSecond = contractMonthlyCostTFT.div(HOURS_ONE_MONTH * SECONDS_ONE_HOUR);

/** cost of the current billing period and the mentioned allowance time in TFT*/
const totalPeriodCost = contractCostPerSecond.times(totalPeriodTime);

/**Calculate total overDraft in Unit TFT*/
const totalOverDraft = new Decimal(standardOverdraft).add(additionalOverdraft);

/** Un-billed amount in unit USD for the network usage, including the premium price for the certified node */
const unbilledNU = await this.getUnbilledNu(contractInfo.contract_id, contractInfo.details.nodeId);

const unbilledNuTFTUnit = await this.convertToTFT(new Decimal(unbilledNU));

const overdue = totalOverDraft.add(unbilledNuTFTUnit);

/** TFT */
const overdueTFT = overdue.div(TFT_CONVERSION_FACTOR);
const contractOverdue = overdueTFT.add(totalPeriodCost);

/** list all node contracts on the rented node and add their values */
if (contractInfo.type == ContractType.Rent) {
/** The contracts on the rented node, this includes total overdraft, total ips count, and total unbuilled amount*/
const totalContractsCost = await this.getContractsCostOnRentedNode(
contractInfo.details.nodeId,
options.gridProxyClient,
);
const totalContractsOverDue = contractOverdue.add(totalContractsCost);
return totalContractsOverDue;
}
return contractOverdue;
}

/**
* WARNING: Please be careful when executing this method, it will delete all your contracts.
* @param {CancelMyContractOptions} options
Expand Down Expand Up @@ -331,28 +603,60 @@ class TFContracts extends Contracts {
await this.client.applyAllExtrinsics(extrinsics);
return ids;
}

async batchUnlockContracts(ids: number[]) {
const billableContractsIDs: number[] = [];
for (const id of ids) {
if ((await this.contractLock({ id })).amountLocked > 0) billableContractsIDs.push(id);
/**
* Async function that request to resume the passed contracts.
*
* @description
* This function create array of `ExtrinsicResult<number>` to use in `applyAllExtrinsics`.
* It's not guaranteed that the contracts will be resumed; It just trigger billing request; if it pass the contract will be resumed.
* the function will ignore all contracts that do not have overdue, also if there is sum rent contracts, its associated node contracts that have ipv4 will be added.
* @param {Contract[]} contracts contracts to be
* @param {GridProxyClient} proxy
* @returns {number[]} contract ids that have been requested to resume
*/
async batchUnlockContracts(contracts: Contract[], proxy: GridProxyClient) {
const billableContractsIDs: Set<number> = new Set();
for (const contract of contracts) {
const contractOverdue = (
await this.calculateContractOverDue({ contractInfo: contract, gridProxyClient: proxy })
).toNumber();
if (contractOverdue > 0) {
billableContractsIDs.add(contract.contract_id);

if (contract.type == ContractType.Rent) {
/** add associated node contracts on the rented node `with public ip` to the contracts to bill */
const nodeContracts = await this.listAllContracts(proxy, {
numberOfPublicIps: 1,
state: [ContractState.GracePeriod],
nodeId: contract.details.nodeId,
});
nodeContracts.forEach(contract => billableContractsIDs.add(contract.contract_id));
}
}
}
const extrinsics: ExtrinsicResult<number>[] = [];
for (const id of billableContractsIDs) {
for (const id of Array.from(billableContractsIDs)) {
extrinsics.push(await this.unlock(id));
}
return this.client.applyAllExtrinsics(extrinsics);
}

async unlockMyContracts(graphqlURL: string) {
const contracts = await this.listMyContracts({
stateList: [ContractStates.GracePeriod],
graphqlURL,
/**
* Request to resume all grace period contracts associated with the current twinId
* @description
* This function lists all grace period contracts, then call {@link batchUnlockContracts}.
* @param {String} gridProxyUrl
* @returns contract ids that have been requested to resume.
*/
async unlockMyContracts(gridProxyUrl: string) {
const proxy = new GridProxyClient(gridProxyUrl);
const contracts = await this.listAllContracts(proxy, {
state: [ContractState.GracePeriod],
twinId: await this.client.twins.getMyTwinId(),
});
const ids: number[] = [...contracts.nameContracts, ...contracts.nodeContracts, ...contracts.rentContracts].map(
contract => parseInt(contract.contractID),
);
return await this.batchUnlockContracts(ids);

if (contracts.length == 0) return [];
return await this.batchUnlockContracts(contracts as Contract[], proxy);
}

async getDedicatedNodeExtraFee(options: GetDedicatedNodePriceOptions): Promise<number> {
Expand Down
Loading

0 comments on commit f9a5b06

Please sign in to comment.