diff --git a/package.json b/package.json index 9136039..00cf9a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oreid-js", - "version": "4.7.1", + "version": "4.8.0", "description": "Add authentication and signing to any blockchain app", "author": "AIKON", "license": "MIT", diff --git a/src/api/endpoints/index.ts b/src/api/endpoints/index.ts index f34113b..293c796 100644 --- a/src/api/endpoints/index.ts +++ b/src/api/endpoints/index.ts @@ -13,3 +13,4 @@ export * from './newUserWithToken' export * from './passwordLessSendCode' export * from './passwordLessVerifyCode' export * from './signTransaction' +export * from './validateTransaction' diff --git a/src/api/endpoints/validateTransaction.ts b/src/api/endpoints/validateTransaction.ts new file mode 100644 index 0000000..9cf1694 --- /dev/null +++ b/src/api/endpoints/validateTransaction.ts @@ -0,0 +1,95 @@ +import OreIdContext from '../../core/IOreidContext' +import { ApiEndpoint, ChainNetwork, RequestType, ValidateTransactionFees, ValidateTransactionResources} from '../../models' +import { assertHasApiKeyOrAccessToken, assertParamsHaveRequiredValues } from '../helpers' +import { ApiResultWithErrorCode } from '../models' + +export type ValidateTransactionParams = { + chainNetwork: ChainNetwork + encodedTransaction?: string + transactionChainAccount?: string + transactionOptionsStringified?: string + transactionRecordId?: string +} + +export type ApiValidateTransactionBodyParams = { + chain_network: ChainNetwork + encoded_transaction?: string + transaction_chain_account?: string + transaction_options_stringified?: string + transaction_record_id?: string +} + +export type ValidateTransactionPayerParams = { + chainNetwork: ChainNetwork + encodedTransaction?: string + payerChainAccount: string + transactionChainAccount?: string + transactionOptionsStringified?: string + transactionRecordId?: string +} + +export type ApiValidateTransactionPayerBodyParams = { + chain_network: ChainNetwork + encoded_transaction?: string + payer_chain_account: string + transaction_chain_account?: string + transaction_options_stringified?: string + transaction_record_id?: string +} + +export type ValidateTransactionResult = { + isValid: boolean + canChange: boolean + validFrom: string + validTo: string + errorMessage: string + fees: ValidateTransactionFees + resources: ValidateTransactionResources + actions: string[] +} & ApiResultWithErrorCode + +export async function callApiValidateTransaction( + oreIdContext: OreIdContext, + params: ValidateTransactionParams, +): Promise { + const apiName = ApiEndpoint.ValidateTransaction + + const { chainNetwork, encodedTransaction, transactionChainAccount, transactionOptionsStringified, transactionRecordId } = params + const body: ApiValidateTransactionBodyParams = { + chain_network: chainNetwork, + encoded_transaction: encodedTransaction, + transaction_chain_account: transactionChainAccount, + transaction_options_stringified: transactionOptionsStringified, + transaction_record_id: transactionRecordId + } + + assertHasApiKeyOrAccessToken(oreIdContext, apiName) + assertParamsHaveRequiredValues(params, ['chainNetwork'], apiName) + + const results = await oreIdContext.callOreIdApi(RequestType.Post, ApiEndpoint.ValidateTransaction, body, null) + return results +} + +export async function callApiValidatePayerTransaction( + oreIdContext: OreIdContext, + params: ValidateTransactionPayerParams, +): Promise { + const apiName = ApiEndpoint.ValidatePayerTransaction + + const { chainNetwork, encodedTransaction, payerChainAccount, transactionChainAccount, transactionOptionsStringified, transactionRecordId } = params + + const body: ApiValidateTransactionPayerBodyParams = { + chain_network: chainNetwork, + encoded_transaction: encodedTransaction, + payer_chain_account: payerChainAccount, + transaction_chain_account: transactionChainAccount, + transaction_options_stringified: transactionOptionsStringified, + transaction_record_id: transactionRecordId + } + + assertHasApiKeyOrAccessToken(oreIdContext, apiName) + assertParamsHaveRequiredValues(params, ['chainNetwork', 'payerChainAccount'], apiName) + + const results = await oreIdContext.callOreIdApi(RequestType.Post, ApiEndpoint.ValidatePayerTransaction, body, null) + return results +} diff --git a/src/api/models.ts b/src/api/models.ts index d727cd8..9fc02d3 100644 --- a/src/api/models.ts +++ b/src/api/models.ts @@ -22,6 +22,8 @@ export enum ApiEndpoint { PasswordLessSendCode = 'account/login-passwordless-send-code', PasswordLessVerifyCode = 'account/login-passwordless-verify-code', TransactionSign = 'transaction/sign', + ValidatePayerTransaction = 'transaction/validate-payer', + ValidateTransaction = 'transaction/validate', UpdateDelayWalletSetup = 'account/update-delay-wallet-setup', } diff --git a/src/core/oreId.spec.ts b/src/core/oreId.spec.ts index 9edb827..eb015c6 100644 --- a/src/core/oreId.spec.ts +++ b/src/core/oreId.spec.ts @@ -1,14 +1,50 @@ import { OreIdOptions } from '../core/IOreIdOptions' import OreId from './oreId' -import Transaction from '../transaction/transaction' -import { callApiCustodialNewAccount, callApiCustodialMigrateAccount } from '../api' +import { callApiCustodialNewAccount, callApiCustodialMigrateAccount, callApiValidateTransaction } from '../api' +import { ChainNetwork, TransactionData, UserChainAccount, UserData } from '../models' + +const payerErrorMessage = 'a low resource error message' +const validationErrorMessage = 'a error message' + jest.mock('../api', () => ({ ...jest.requireActual('../api'), callApiCustodialMigrateAccount: jest.fn(), callApiCustodialNewAccount: jest.fn(), + callApiValidateTransaction: jest.fn().mockImplementation(() => Promise.resolve( + { + isValid: false, errorMessage: validationErrorMessage, + fees: { + chainSupportsFees: true, + feesByPriority: [ + { + priority: "low", + fee: "0", + } + ] + } + }, + )), + callApiValidatePayerTransaction: jest.fn().mockImplementation(() => Promise.resolve( + { + resources: { + chainSupportsResources: true, + resourcesRequired: true, + resourceEstimationType: "exact", + lowResourceErrorMessages: [payerErrorMessage], + }, + fees: { + chainSupportsFees: true, + feesByPriority: [ + { + priority: "low", + fee: "0", + lowFeeErrorMessage: "balance available is 0" + } + ], + }} + )), })) -jest.mock('../transaction/transaction') // use factories as this is good to ensure that the values are these, and that the tests do not change the values const getOptions = (): OreIdOptions => ({ @@ -94,18 +130,69 @@ describe('custodial Custodial Account', () => { }) }) -describe('Transaction', () => { - test('createTransaction', async () => { - //@ts-ignore - jest.spyOn(oreId._auth.user, 'hasData', 'get').mockReturnValue(true) - const transactionReturn = { param: 'return' } - ;(Transaction as jest.Mock).mockReturnValue(transactionReturn) +describe('Create new Transaction with createTransaction', () => { + const userChainAccount: UserChainAccount = { + chainNetwork: ChainNetwork.AlgoBeta, + chainAccount: 'chainAccount', + defaultPermission: { name: 'active' }, + permissions: [{ name: 'active' }], + } + + const userData: UserData = { + chainAccounts: [userChainAccount], + email: 'email', + name: 'name', + username: 'username', + picture: 'picture', + accountName: 'accountName', + } + + const transactionData : TransactionData = { + account: 'accountName', + chainAccount: 'chainAccount', + chainNetwork: ChainNetwork.AlgoBeta, + transaction: {"actions":[{"from":"HRFT6WNEDH5LAN4JTUQIVYFHPZB7JUMPHGIYZZZOMOKBHHUV4HGEFF3JFA","to":"TM4HSPWPRUHEHBVVAYGX3YQTQG5KSEZ4OMAN6NPGELNPYOB7SYEA2PODTQ","amount":1000000,"note":"Hello World"}]} + } + + beforeEach(() => { + jest.spyOn(oreId.auth.user, 'getData').mockResolvedValue(userData) + jest.spyOn(oreId.auth.user, 'hasData', 'get').mockReturnValue(true) + jest.spyOn(oreId.auth.user, 'accountName', 'get').mockReturnValue('accountName') + }) + + test('createTransaction should not throw but fill error fields on validation failure when doesNotThrow is true', async () => { + const result = await oreId.createTransaction(transactionData, true) + + expect(result.validationData).toBeDefined(); + expect(result.validationData.isValid).toBeFalsy(); + expect(result.validationError).toBe('a error message'); + expect(result.payerErrors).toContain('balance available is 0'); + expect(result.payerErrors).toContain('a low resource error message'); + }) + + test('createTransaction should throw an error on data validation failure when doesNotThrow is set to false', async () => { + try{ + await oreId.createTransaction(transactionData, false) + } + catch(error){ + // doesNotThrow is false by default + expect(error.message).toBe(`Validation error: ${validationErrorMessage}`); + } + }) - expect(Transaction).not.toBeCalled() - const transactionData = { param: 'my-params' } - const result = await oreId.createTransaction(transactionData as any) + test('createTransaction should throw an error on a payer validation failure when doesNotThrow is set to false', async () => { + (callApiValidateTransaction as jest.Mock).mockImplementationOnce(() => Promise.resolve( + { + isValid: true + } + )); - expect(result).toEqual(transactionReturn) - expect(Transaction).toBeCalledWith({ oreIdContext: oreId, user: expect.any(Object), data: transactionData }) + try{ + // doesNotThrow is false by default + await oreId.createTransaction(transactionData) + } + catch(error){ + expect(error.message).toBe(`Fee or Resource error: ${payerErrorMessage}`); + } }) }) diff --git a/src/core/oreId.ts b/src/core/oreId.ts index a5fa672..f3615dc 100644 --- a/src/core/oreId.ts +++ b/src/core/oreId.ts @@ -225,11 +225,20 @@ export default class OreId implements IOreidContext { } /** Create a new Transaction object - used for composing and signing transactions */ - async createTransaction(data: TransactionData) { + async createTransaction(data: TransactionData, dontThrowOnErrors: boolean = false) { if (!this._auth.user.hasData) { await this._auth.user.getData() } - return new Transaction({ oreIdContext: this, user: this.auth.user, data }) + const transaction = new Transaction({ oreIdContext: this, user: this.auth.user, data }) + await transaction.validate(); + + if (!dontThrowOnErrors && transaction.hasErrors) { + if (!transaction.validationData.isValid) { + throw new Error(`Validation error: ${transaction.validationData.errorMessage}`) + } + throw new Error(`Fee or Resource error: ${transaction.payerErrors[0]}`) + } + return transaction } /** Call the setBusyCallback() callback provided in optiont diff --git a/src/transaction/models.ts b/src/transaction/models.ts index d2c999f..394ccd3 100644 --- a/src/transaction/models.ts +++ b/src/transaction/models.ts @@ -45,5 +45,24 @@ export type TransactionSignOptions = { state?: string } +export type FeesByPriority = { + priority: any + fee: string + lowFeeErrorMessage: string +} + +export type ValidateTransactionFees = { + chainSupportsFees: boolean + feesByPriority: FeesByPriority[] + resourceEstimationType: string +} + +export type ValidateTransactionResources = { + chainSupportsResources: boolean + resourcesRequired: string + resourceEstimationType: string + lowResourceErrorMessages: string[] +} + export interface CreateTransactionData extends Omit {} diff --git a/src/transaction/transaction.ts b/src/transaction/transaction.ts index f8698c7..e0e71d4 100644 --- a/src/transaction/transaction.ts +++ b/src/transaction/transaction.ts @@ -6,13 +6,18 @@ import { SignatureProviderSignResult, SignWithOreIdResult, TransactionData, + ValidateTransactionFees, + ValidateTransactionResources, } from '../models' import TransitHelper from '../transit/TransitHelper' import { assertHasApiKey, callApiCanAutosignTransaction, callApiCustodialSignTransaction, + callApiValidateTransaction, + callApiValidatePayerTransaction, callApiSignTransaction, + ValidateTransactionResult, } from '../api' import { getOreIdSignUrl } from '../core/urlGenerators' import Helpers from '../utils/helpers' @@ -20,7 +25,7 @@ import UalHelper from '../ual/UalHelper' import { User } from '../user/user' export default class Transaction { - constructor(args: { oreIdContext: OreIdContext; user: User; data: TransactionData }) { + constructor(args: { oreIdContext: OreIdContext; user: User; data: TransactionData }) { this._oreIdContext = args.oreIdContext this._user = args.user this.assertValidTransactionAndSetData(args.data) @@ -38,12 +43,48 @@ export default class Transaction { private _user: User + private _validationData: ValidateTransactionResult; + + private _hasErrors: boolean; + + private _payerFees: ValidateTransactionFees; + + private _payerResources: ValidateTransactionResources; + + private _payerErrors: string[]; + + private _validationError: string; + get data() { return this._data } + get validationData() { + return this._validationData + } + + get hasErrors() { + return this._hasErrors + } + + get payerFees() { + return this._payerFees + } + + get payerResources() { + return this._payerResources + } + + get payerErrors() { + return this._payerErrors + } + + get validationError() { + return this._validationError + } + /** ensure all required parameters are provided */ - assertValidTransactionAndSetData(createTransactionData: CreateTransactionData) { + async assertValidTransactionAndSetData(createTransactionData: CreateTransactionData) { const { chainNetwork, transaction, signedTransaction } = createTransactionData || {} const missingFields: string[] = [] const validationIssues: string[] = [] @@ -60,8 +101,6 @@ export default class Transaction { validationIssues.push('Transaction Data error - Expecting a user.accountName - is the user logged-in in?') if (transaction && signedTransaction) validationIssues.push('Only provide one: transaction OR signedTransaction') - // TODO: call this.validate() - // transaction OR signedTransaction - check for valid JSON object if (!Helpers.isNullOrEmpty(missingFields)) { @@ -124,11 +163,18 @@ export default class Transaction { // TODO: check user.chainAccounts that /** validates that transaction is well-formed for the blockcahin - * Returns array of errors + * Sets validation properties on the transaction object if data or payer validation fails */ - async validate(): Promise { - // TODO: call API validateTransaction on OREID Service - transaction/validate api endpoint - throw new Error('Not Implemented') + async validate() { + const payerChainAccount = this._data.chainAccount; + const validationResult = await callApiValidateTransaction(this._oreIdContext, this._data) + const {fees, resources} = await callApiValidatePayerTransaction(this._oreIdContext, {payerChainAccount, ...this._data}) + this._validationData = validationResult + this._validationError = validationResult.errorMessage + this._payerFees = fees + this._payerResources = resources + this._payerErrors = [...resources.lowResourceErrorMessages, ...fees.feesByPriority.map(f => f.lowFeeErrorMessage)] + this._hasErrors = !validationResult.isValid || this.payerErrors.length > 0 } // TODO: add depricated