UNPKG

@test-org122/hypernet-core

Version:

Hypernet Core. Represents the SDK for running the Hypernet Protocol.

570 lines (502 loc) 21.7 kB
import { ResultUtils } from "@test-org122/utils"; import { IPaymentService } from "@interfaces/business"; import { IAccountsRepository, ILinkRepository, IMerchantConnectorRepository } from "@interfaces/data"; import { IPaymentRepository } from "@interfaces/data/IPaymentRepository"; import { BigNumber, EthereumAddress, Payment, PublicIdentifier, PublicKey, PullPayment, PushPayment, ResultAsync, Result, HypernetConfig, HypernetContext, InitializedHypernetContext, HexString, } from "@interfaces/objects"; import { AcceptPaymentError, CoreUninitializedError, InsufficientBalanceError, InvalidParametersError, InvalidPaymentError, LogicalError, MerchantConnectorError, MerchantValidationError, OfferMismatchError, RouterChannelUnknownError, VectorError, } from "@interfaces/objects/errors"; import { EPaymentState } from "@interfaces/types"; import { IConfigProvider, IContextProvider, ILogUtils } from "@interfaces/utilities"; import { err, errAsync, ok, okAsync } from "neverthrow"; /** * PaymentService uses Vector internally to send payments on the requested channel. * The order of operations for sending funds is as follows: * sendFunds() is called by sender, which creates a Message transfer with Vector, which triggers * offerReceived() on the recipient side, which tosses an event up to the user, who then calls * acceptOffers() to accept the sender's funds, which creates an Insurance transfer with Vector, which triggers * stakePosted() on the sender's side, which finally creates the Parameterized transfer with Vector, which triggers * paymentPosted() on the recipient's side, which finalizes/resolves the vector parameterized transfer. * * Note that the general expected order of operations is mirrored by the ordering of functions within this class. * * @todo we should also finalize the insurance transfer, and maybe finalize the offer transfer */ export class PaymentService implements IPaymentService { /** * Creates an instanceo of the paymentService. */ constructor( protected linkRepository: ILinkRepository, protected accountRepository: IAccountsRepository, protected contextProvider: IContextProvider, protected configProvider: IConfigProvider, protected paymentRepository: IPaymentRepository, protected merchantConnectorRepository: IMerchantConnectorRepository, protected logUtils: ILogUtils, ) {} /** * Authorizes funds to a specified counterparty, with an amount, rate, & expiration date. * @param counterPartyAccount the public identifier of the counterparty to authorize funds to * @param totalAuthorized the total amount the counterparty is allowed to "pull" * @param expirationDate the latest time in which the counterparty can pull funds. This must be after the full maturation date of totalAuthorized, as calculated via deltaAmount and deltaTime. * @param deltaAmount The amount per deltaTime to authorize * @param deltaTime the number of seconds after which deltaAmount will be authorized, up to the limit of totalAuthorized. * @param requiredStake the amount of stake the counterparyt must put up as insurance * @param paymentToken the (Ethereum) address of the payment token * @param merchantUrl the registered URL for the merchant that will resolve any disputes. */ public authorizeFunds( counterPartyAccount: PublicIdentifier, totalAuthorized: BigNumber, expirationDate: number, deltaAmount: string, deltaTime: number, requiredStake: BigNumber, paymentToken: EthereumAddress, merchantUrl: string, ): ResultAsync<Payment, RouterChannelUnknownError | CoreUninitializedError | VectorError | Error> { // @TODO Check deltaAmount, deltaTime, totalAuthorized, and expiration date // totalAuthorized / (deltaAmount/deltaTime) > ((expiration date - now) + someMinimumNumDays) return this.paymentRepository.createPullPayment( counterPartyAccount, totalAuthorized.toString(), deltaTime, deltaAmount, expirationDate, requiredStake.toString(), paymentToken, merchantUrl, ); } public pullFunds( paymentId: string, amount: BigNumber, ): ResultAsync<Payment, RouterChannelUnknownError | CoreUninitializedError | VectorError | Error> { // Pull the up the payment return this.paymentRepository.getPaymentsByIds([paymentId]).andThen((payments) => { const payment = payments.get(paymentId); // Verify that it is indeed a pull payment if (payment instanceof PullPayment) { // Verify that we're not pulling too quickly (greater than the average rate) if (payment.amountTransferred.add(amount).gt(payment.vestedAmount)) { return errAsync( new InvalidParametersError( `Amount of ${amount} exceeds the vested payment amount of ${payment.vestedAmount}`, ), ); } // Verify that the amount we're trying to pull does not exceed the total authorized amount if (payment.amountTransferred.add(amount).gt(payment.authorizedAmount)) { return errAsync( new InvalidParametersError( `Amount of ${amount} exceeds the total authorized amount of ${payment.authorizedAmount}`, ), ); } // Create the PullRecord return this.paymentRepository.createPullRecord(paymentId, amount.toString()); } else { return errAsync(new InvalidParametersError("Can not pull funds from a non pull payment")); } }); } /** * Sends a payment to the specified recipient. * Internally, creates a null/message/offer transfer to communicate * with the counterparty and signal a request for a stake. * @param counterPartyAccount the intended recipient * @param amount the amount of payment to send * @param expirationDate the expiration date at which point this payment will revert * @param requiredStake the amount of insurance the counterparty should put up * @param paymentToken the (Ethereum) address of the payment token * @param merchantUrl the registered URL for the merchant that will resolve any disputes. */ public sendFunds( counterPartyAccount: PublicIdentifier, amount: string, expirationDate: number, requiredStake: string, paymentToken: EthereumAddress, merchantUrl: string, ): ResultAsync<Payment, Error> { // TODO: Sanity checking on the values return this.paymentRepository.createPushPayment( counterPartyAccount, amount, expirationDate, requiredStake, paymentToken, merchantUrl, ); } /** * Called when someone has sent us a payment offer. * Lookup the transfer, and convert it to a payment. * Then, publish an RXJS event to the user. * @param paymentId the paymentId for the offer */ public offerReceived( paymentId: string, ): ResultAsync<void, LogicalError | RouterChannelUnknownError | CoreUninitializedError | VectorError | Error> { const prerequisites = ResultUtils.combine([ this.paymentRepository.getPaymentsByIds([paymentId]), this.contextProvider.getInitializedContext(), ]); return prerequisites.andThen((vals) => { const [payments, context] = vals; const payment = payments.get(paymentId); if (payment == null) { return errAsync(new LogicalError(`PaymentService:offerReceived():Could not get payment!`)); } if (payment.state !== EPaymentState.Proposed) { // The payment has already moved forward, somehow. // We don't need to do anything, we probably got called // by another instance of the core. return okAsync(undefined); } // Payment state is 'Proposed', continue to handle if (payment instanceof PushPayment) { // Someone wants to send us a pushPayment, emit up to the api context.onPushPaymentProposed.next(payment); } else if (payment instanceof PullPayment) { // Someone wants to send us a pullPayment, emit up to the api context.onPullPaymentProposed.next(payment); } else { throw new Error("Unknown payment type!"); } return okAsync(undefined); }); } /** * For each paymentID provided, attempts to accept funds (ie: provide a stake) for that payment. * @param paymentIds a list of paymentIds for which to accept funds for */ public acceptOffers( paymentIds: string[], ): ResultAsync<Result<Payment, AcceptPaymentError>[], InsufficientBalanceError | AcceptPaymentError> { let config: HypernetConfig; let payments: Map<string, Payment>; const merchantUrls = new Set<string>(); return ResultUtils.combine([this.configProvider.getConfig(), this.paymentRepository.getPaymentsByIds(paymentIds)]) .andThen((vals) => { [config, payments] = vals; // Iterate over the payments, and find all the merchant URLs. for (const keyval of payments) { merchantUrls.add(keyval[1].merchantUrl); } return ResultUtils.combine([ this.accountRepository.getBalanceByAsset(config.hypertokenAddress), this.merchantConnectorRepository.getMerchantPublicKeys(Array.from(merchantUrls)), ]); }) .andThen((vals) => { const [hypertokenBalance, publicKeys] = vals; // If we don't have a public key for each merchant, then we should not proceed. if (merchantUrls.size != publicKeys.size) { return errAsync(new MerchantValidationError("Not all merchants are authorized!")); } // For each payment ID, call the singular version of acceptOffers // Wrap each one as a Result object, and return an array of Results let totalStakeRequired = BigNumber.from(0); // First, verify to make sure that we have enough hypertoken to cover the insurance collectively for (const [key, payment] of payments) { if (payment.state !== EPaymentState.Proposed) { return errAsync( new AcceptPaymentError(`Cannot accept payment ${payment.id}, it is not in the Proposed state`), ); } totalStakeRequired = totalStakeRequired.add(BigNumber.from(payment.requiredStake)); } // Check the balance and make sure you have enough HyperToken to cover it if (hypertokenBalance.freeAmount.lt(totalStakeRequired)) { return errAsync(new InsufficientBalanceError("Not enough Hypertoken to cover provided payments.")); } // Now that we know we can (probably) make the payments, let's try const stakeAttempts = new Array<Promise<Result<Payment, AcceptPaymentError>>>(); for (const keyval of payments) { const [paymentId, payment] = keyval; this.logUtils.log(`PaymentService:acceptOffers: attempting to provide stake for payment ${paymentId}`); // We need to get the public key of the merchant for the payment const merchantPublicKey = publicKeys.get(payment.merchantUrl); if (merchantPublicKey != null) { const stakeAttempt = this.paymentRepository.provideStake(paymentId, merchantPublicKey).match( (payment) => { return ok(payment) as Result<Payment, AcceptPaymentError>; }, (e) => { return err( new AcceptPaymentError(`Payment ${paymentId} could not be staked! Source exception: ${e}`), ) as Result<Payment, AcceptPaymentError>; }, ); stakeAttempts.push(stakeAttempt); } else { throw new Error("Merchant does not have a public key; are they "); } } return ResultAsync.fromPromise(Promise.all(stakeAttempts), (e) => e as AcceptPaymentError); }); } /** * Notifies the service that a stake has been posted; if verified, * then provides assets to the counterparty (ie a parameterizedPayment) * @param paymentId the paymentId for the stake */ public stakePosted( paymentId: string, ): ResultAsync<void, CoreUninitializedError | OfferMismatchError | InvalidParametersError> { const prerequisites = ResultUtils.combine([ this.paymentRepository.getPaymentsByIds([paymentId]), this.contextProvider.getInitializedContext(), ]); let payments: Map<string, Payment>; let context: InitializedHypernetContext; return prerequisites.andThen((vals) => { [payments, context] = vals; const payment = payments.get(paymentId); this.logUtils.log(`${paymentId}: ${JSON.stringify(payment)}`); if (payment == null) { this.logUtils.error(`Invalid payment ID: ${paymentId}`); return errAsync(new InvalidParametersError("Invalid payment ID!")); } // Let the UI know we got an insurance transfer if (payment instanceof PushPayment) { context.onPushPaymentUpdated.next(payment); } if (payment instanceof PullPayment) { context.onPullPaymentUpdated.next(payment); } // Notified the UI, move on to advancing the state of the payment. // Payment state must be in "staked" in order to progress if (payment.state !== EPaymentState.Staked) { this.logUtils.error(`Invalid payment ${paymentId}, it must be in the staked status. Cannot provide payment!`); return errAsync( new InvalidParametersError( `Invalid payment ${paymentId}, it must be in the staked status. Cannot provide payment!`, ), ); } // If we created the stake, we can ignore this if (payment.from !== context.publicIdentifier) { this.logUtils.log("Not providing asset since payment is not from us!"); return okAsync(undefined); } // If the payment state is staked, we know that the proper // insurance has been posted. this.logUtils.log(`Providing asset for paymentId ${paymentId}`); return this.paymentRepository.provideAsset(paymentId).andThen((updatedPayment) => { if (updatedPayment instanceof PushPayment) { this.logUtils.log("Providing asset for pushpayment."); context.onPushPaymentUpdated.next(updatedPayment); } if (updatedPayment instanceof PullPayment) { this.logUtils.log("Providing asset for pullpayment."); context.onPullPaymentUpdated.next(updatedPayment); } return okAsync(undefined); }); }); } /** * Notifies the service that the parameterized payment has been created. * Called by the reciever of a parameterized transfer, AFTER they have put up stake, * and after the sender has created the Parameterized transfer * @param paymentId the payment ID to accept/resolve */ public paymentPosted(paymentId: string): ResultAsync<void, InvalidParametersError> { const prerequisites = ResultUtils.combine([ this.paymentRepository.getPaymentsByIds([paymentId]), this.contextProvider.getInitializedContext(), ]); let payments: Map<string, Payment>; let context: InitializedHypernetContext; return prerequisites.andThen((vals) => { [payments, context] = vals; const payment = payments.get(paymentId); if (payment == null) { return errAsync(new InvalidParametersError("Invalid payment ID!")); } // Payment state must be in "approved" to finalize if (payment.state !== EPaymentState.Approved) { return errAsync( new InvalidParametersError( `Invalid payment ${paymentId}, it must be in the approved status. Cannot provide payment!`, ), ); } // Make sure the stake is legit // if (!payment.requiredStake.eq(payment.amountStaked)) { // // TODO: We should be doing more here. The whole payment should be aborted. // return errAsync(new OfferMismatchError(`Invalid stake provided for payment ${paymentId}`)); // } // If we're the ones that *sent* the payment, we can ignore this if (payment.from === context.publicIdentifier) { this.logUtils.log("Doing nothing in paymentPosted because we are the ones that posted the payment!"); return okAsync(undefined); } // If the payment state is approved, we know that it matches our insurance payment if (payment instanceof PushPayment) { // Resolve the parameterized payment immediately for the full balnce return this.paymentRepository .finalizePayment(paymentId, payment.paymentAmount.toString()) .map((finalizedPayment) => { context.onPushPaymentUpdated.next(finalizedPayment as PushPayment); }); } else if (payment instanceof PullPayment) { // Notify the user that the funds have been approved. context.onPullPaymentApproved.next(payment); } return okAsync(undefined); }); } /** * Notifies the service that the parameterized payment has been resolved. * @param paymentId the payment id that has been resolved. */ public paymentCompleted( paymentId: HexString, ): ResultAsync<void, InvalidParametersError | RouterChannelUnknownError | CoreUninitializedError | VectorError> { let payments: Map<string, Payment>; let context: HypernetContext; return ResultUtils.combine([ this.paymentRepository.getPaymentsByIds([paymentId]), this.contextProvider.getContext(), ]).andThen((vals) => { [payments, context] = vals; const payment = payments.get(paymentId); if (payment == null) { return errAsync(new InvalidParametersError("Invalid payment ID!")); } // @todo add some additional checking here if (payment instanceof PushPayment) { context.onPushPaymentUpdated.next(payment); } if (payment instanceof PullPayment) { context.onPullPaymentUpdated.next(payment); } return okAsync(undefined); }); } /** * Right now, if the insurance is resolved, all we need to do is generate an update event. * * @param paymentId */ public insuranceResolved(paymentId: HexString): ResultAsync<void, InvalidParametersError> { let payments: Map<string, Payment>; let context: HypernetContext; return ResultUtils.combine([ this.paymentRepository.getPaymentsByIds([paymentId]), this.contextProvider.getContext(), ]).andThen((vals) => { [payments, context] = vals; const payment = payments.get(paymentId); if (payment == null) { return errAsync(new InvalidParametersError("Invalid payment ID!")); } // @todo add some additional checking here if (payment instanceof PushPayment) { context.onPushPaymentUpdated.next(payment); } if (payment instanceof PullPayment) { context.onPullPaymentUpdated.next(payment); } return okAsync(undefined); }); } /** * Notifies the service that a pull-payment has been recorded. * @param paymentId the paymentId for the pull-payment */ public pullRecorded(paymentId: string): ResultAsync<void, InvalidParametersError> { const prerequisites = ResultUtils.combine([ this.paymentRepository.getPaymentsByIds([paymentId]), this.contextProvider.getContext(), ]); let payments: Map<string, Payment>; let context: HypernetContext; return prerequisites.andThen((vals) => { [payments, context] = vals; const payment = payments.get(paymentId); if (payment == null) { return errAsync(new InvalidParametersError("Invalid payment ID!")); } // Notify the world that this pull payment was updated if (payment instanceof PullPayment) { context.onPullPaymentUpdated.next(payment); } return okAsync(undefined); }); } public initiateDispute( paymentId: string, ): ResultAsync< Payment, | InvalidParametersError | CoreUninitializedError | MerchantConnectorError | RouterChannelUnknownError | CoreUninitializedError | VectorError | Error > { // Get the payment return this.paymentRepository .getPaymentsByIds([paymentId]) .andThen((payments) => { const payment = payments.get(paymentId); if (payment == null) { return errAsync<void, InvalidParametersError>(new InvalidParametersError("Invalid payment ID")); } // You can only dispute payments that are in the accepted state- the reciever has taken their money. // The second condition can't happen if it's in Accepted unless something is very, very badly wrong, // but it keeps typescript happy if (payment.state != EPaymentState.Accepted || payment.details.insuranceTransferId == null) { return errAsync<void, InvalidParametersError>( new InvalidParametersError("Can not dispute a payment that is not in the Accepted state"), ); } // Resolve the dispute return this.merchantConnectorRepository.resolveChallenge( payment.merchantUrl, paymentId, payment.details.insuranceTransferId, ); }) .andThen(() => { return this.paymentRepository.getPaymentsByIds([paymentId]); }) .andThen((payments) => { const payment = payments.get(paymentId); if (payment == null) { return errAsync(new InvalidParametersError("Invalid payment ID")); } return okAsync(payment); }); } }