Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: buildPayTransaction utilities in preparation for Pay button #1177

Merged
merged 8 commits into from
Aug 31, 2024
131 changes: 131 additions & 0 deletions src/api/buildPayTransaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { base, mainnet } from 'viem/chains';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';
import { PAY_HYDRATE_CHARGE } from '../network/definitions/pay';
import { sendRequest } from '../network/request';
import {
PAY_INVALID_CHARGE_ERROR_MESSAGE,
PAY_UNSUPPORTED_CHAIN_ERROR_MESSAGE,
UNCAUGHT_PAY_ERROR_MESSAGE,
} from '../pay/constants';
/**
* @vitest-environment node
*/
import { buildPayTransaction } from './buildPayTransaction';
import {
MOCK_HYDRATE_CHARGE_INVALID_CHARGE_ERROR_RESPONSE,
MOCK_HYDRATE_CHARGE_SUCCESS_RESPONSE,
MOCK_INVALID_CHARGE_ID,
MOCK_VALID_CHARGE_ID,
MOCK_VALID_PAYER_ADDRESS,
} from './mocks';
import type {
BuildPayTransactionParams,
HydrateChargeAPIParams,
} from './types';

vi.mock('../network/request');

describe('buildPayTransaction', () => {
afterEach(() => {
vi.clearAllMocks();
});

it('should return a Pay Transaction', async () => {
const mockParams: BuildPayTransactionParams = {
address: MOCK_VALID_PAYER_ADDRESS,
chainId: base.id,
chargeId: MOCK_VALID_CHARGE_ID,
};
const mockAPIParams: HydrateChargeAPIParams = {
sender: MOCK_VALID_PAYER_ADDRESS,
chargeId: MOCK_VALID_CHARGE_ID,
chainId: base.id,
};
(sendRequest as Mock).mockResolvedValue(
MOCK_HYDRATE_CHARGE_SUCCESS_RESPONSE,
);
const payTransaction = await buildPayTransaction(mockParams);
expect(payTransaction).toEqual(MOCK_HYDRATE_CHARGE_SUCCESS_RESPONSE.result);
expect(sendRequest).toHaveBeenCalledTimes(1);
expect(sendRequest).toHaveBeenCalledWith(PAY_HYDRATE_CHARGE, [
mockAPIParams,
]);
});

it('should return an error for chains other than Base', async () => {
const mockParams: BuildPayTransactionParams = {
address: MOCK_VALID_PAYER_ADDRESS,
chainId: mainnet.id,
chargeId: MOCK_VALID_CHARGE_ID,
};
(sendRequest as Mock).mockResolvedValue(
MOCK_HYDRATE_CHARGE_SUCCESS_RESPONSE,
);
const error = await buildPayTransaction(mockParams);
expect(error).toEqual({
code: 'AmBPTa01',
error: 'Pay Transactions must be on Base',
message: PAY_UNSUPPORTED_CHAIN_ERROR_MESSAGE,
});
expect(sendRequest).not.toHaveBeenCalled();
});

it('should return an error if sendRequest fails', async () => {
const mockParams: BuildPayTransactionParams = {
address: MOCK_VALID_PAYER_ADDRESS,
chainId: base.id,
chargeId: MOCK_VALID_CHARGE_ID,
};
const mockAPIParams: HydrateChargeAPIParams = {
sender: MOCK_VALID_PAYER_ADDRESS,
chargeId: MOCK_VALID_CHARGE_ID,
chainId: base.id,
};
(sendRequest as Mock).mockResolvedValue(
MOCK_HYDRATE_CHARGE_SUCCESS_RESPONSE,
);
const hydratedCharge = await buildPayTransaction(mockParams);
expect(hydratedCharge).toEqual(MOCK_HYDRATE_CHARGE_SUCCESS_RESPONSE.result);
expect(sendRequest).toHaveBeenCalledTimes(1);
expect(sendRequest).toHaveBeenCalledWith(PAY_HYDRATE_CHARGE, [
mockAPIParams,
]);
const mockError = new Error(
'buildPayTransaction: Error: Failed to send request',
);
(sendRequest as Mock).mockRejectedValue(mockError);
const error = await buildPayTransaction(mockParams);
expect(error).toEqual({
code: 'AmBPTa03',
error: 'Something went wrong',
message: UNCAUGHT_PAY_ERROR_MESSAGE,
});
});

it('should return an error object from buildPayTransaction', async () => {
const mockParams: BuildPayTransactionParams = {
address: MOCK_VALID_PAYER_ADDRESS,
chainId: base.id,
chargeId: MOCK_INVALID_CHARGE_ID,
};
const mockAPIParams: HydrateChargeAPIParams = {
sender: MOCK_VALID_PAYER_ADDRESS,
chargeId: MOCK_INVALID_CHARGE_ID,
chainId: base.id,
};
(sendRequest as Mock).mockResolvedValue(
MOCK_HYDRATE_CHARGE_INVALID_CHARGE_ERROR_RESPONSE,
);
const error = await buildPayTransaction(mockParams);
expect(error).toEqual({
code: 'AmBPTa02',
error: 'method not found - Not found',
message: PAY_INVALID_CHARGE_ERROR_MESSAGE,
});
expect(sendRequest).toHaveBeenCalledTimes(1);
expect(sendRequest).toHaveBeenCalledWith(PAY_HYDRATE_CHARGE, [
mockAPIParams,
]);
});
});
50 changes: 50 additions & 0 deletions src/api/buildPayTransaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { base } from 'viem/chains';
import { PAY_HYDRATE_CHARGE } from '../network/definitions/pay';
import { sendRequest } from '../network/request';
import { PAY_UNSUPPORTED_CHAIN_ERROR_MESSAGE } from '../pay/constants';
import type {
BuildPayTransactionParams,
BuildPayTransactionResponse,
HydrateChargeAPIParams,
} from './types';
import { getPayErrorMessage } from './utils/getPayErrorMessage';

export async function buildPayTransaction({
address,
chainId,
chargeId,
}: BuildPayTransactionParams): Promise<BuildPayTransactionResponse> {
if (chainId !== base.id) {
return {
code: 'AmBPTa01', // Api Module Build Pay Transaction Error 01
error: 'Pay Transactions must be on Base',
message: PAY_UNSUPPORTED_CHAIN_ERROR_MESSAGE,
};
}
try {
const res = await sendRequest<
HydrateChargeAPIParams,
BuildPayTransactionResponse
>(PAY_HYDRATE_CHARGE, [
{
sender: address,
chainId: chainId,
chargeId,
},
]);
if (res.error) {
return {
code: 'AmBPTa02', // Api Module Build Pay Transaction Error 02
error: res.error.message,
message: getPayErrorMessage(res.error?.code),
};
}
return res.result;
} catch (_error) {
return {
code: 'AmBPTa03', // Api Module Build Pay Transaction Error 03
error: 'Something went wrong',
message: getPayErrorMessage(),
};
}
}
3 changes: 3 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// 🌲☀️🌲
export { buildSwapTransaction } from './buildSwapTransaction';
export { buildPayTransaction } from './buildPayTransaction';
export { getSwapQuote } from './getSwapQuote';
export { getTokens } from './getTokens';
export type {
APIError,
BuildPayTransactionParams,
BuildPayTransactionResponse,
BuildSwapTransactionParams,
GetSwapQuoteParams,
GetSwapQuoteResponse,
Expand Down
38 changes: 38 additions & 0 deletions src/api/mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export const MOCK_VALID_CHARGE_ID = '1b03e80d-4e87-46fd-9772-422a1b693fb7';
export const MOCK_INVALID_CHARGE_ID = '00000000-0000-0000-0000-000000000000';
export const MOCK_VALID_PAYER_ADDRESS =
'0x98fAbEA34A3A377916EBF7793f37E11EE98D29Eb';
export const MOCK_HYDRATE_CHARGE_SUCCESS_RESPONSE = {
id: 1,
jsonrpc: '2.0',
result: {
id: MOCK_VALID_CHARGE_ID,
callData: {
deadline: '2024-08-29T23:00:38Z',
feeAmount: '10000',
id: '0xd2e57fb373f246768a193cadd4a5ce1e',
operator: '0xd1db362f9d23a029834375afa2b37d91d2e67a95',
prefix: '0x4b3220496e666f726d6174696f6e616c204d6573736167653a20333220',
recipient: '0xb724dcF5f1156dd8E2AB217921b5Bd46a9e5cAa5',
recipientAmount: '990000',
recipientCurrency: '0xF175520C52418dfE19C8098071a252da48Cd1C19',
refundDestination: MOCK_VALID_PAYER_ADDRESS,
signature:
'0xb49a08026bdfdc55e3b1b797a9481fbdb7a9246c73f5f77fece76d5f24e979561f2168862aed0dd72980a4f9930cf23836084f3326b98b5546b280c4f0d57aae1b',
},
metaData: {
chainId: 8453,
contractAddress: '0x131642c019AF815Ae5F9926272A70C84AE5C37ab',
sender: MOCK_VALID_PAYER_ADDRESS,
settlementCurrencyAddress: '0xF175520C52418dfE19C8098071a252da48Cd1C19',
},
},
};
export const MOCK_HYDRATE_CHARGE_INVALID_CHARGE_ERROR_RESPONSE = {
id: 1,
jsonrpc: '2.0',
error: {
code: -32601,
message: 'method not found - Not found',
},
};
44 changes: 44 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,47 @@ export type RawTransactionData = {
};

export type SwapAPIParams = GetQuoteAPIParams | GetSwapAPIParams;

/**
* Note: exported as public Type
*/
export type BuildPayTransactionParams = {
address: Address; // The address of the wallet paying
chainId: number; // The Chain ID of the payment Network (only Base is supported)
chargeId: string; // The ID of the Commerce Charge to be paid
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where can I read more about what's a Commerce Charge? Do we have live docs anywhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably the page I would point to: https://docs.cdp.coinbase.com/commerce-onchain/docs/crypto-payments for an overview of what a charge is (though not great info for this context, we'll have to update it).

Where do you think we should add this link in the code?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok let's come back on this one, mostly i am questioning the word charge.

};

export type HydrateChargeAPIParams = {
sender: Address; // The address of the wallet paying
chainId: number; // The Chain ID of the payment Network (only Base is supported)
chargeId: string; // The ID of the Commerce Charge to be paid
};

export type PayTransaction = {
id: string; // The id of the Commerce Charge to be paid
callData: {
// Collection of fields used to make the contract call to the Payment contract
deadline: string; // Timestamp of when the payment will expire and be unpayable
feeAmount: string; // The amount of the processing fee in the recipient currency
id: string; // The id of the prepared transaction
operator: Address; // The address of the operator of the Payment contract
prefix: Address; // The prefix of the signature generated by Commerce
recipient: Address; // The address funds will settle in
recipientAmount: string; // The amount the recipient will get in the recipient currency
recipientCurrency: Address; // The address of the currency being paid (always USDC)
refundDestination: Address; // The wallet address of the payer
signature: Address; // The signature generated by the Payment contract operator, encoding the payment details
};
metaData: {
// Collection of metadata needed to make the contract call to the Payment Contract
chainId: number; // The chain this prepared transaction can be paid on
contractAddress: Address; // The address of the Payment contract
sender: Address; // The wallet address of the payer
settlementCurrencyAddress: Address; // The address of the currency being paid (always USDC)
};
};

/**
* Note: exported as public Type
*/
export type BuildPayTransactionResponse = PayTransaction | APIError;
36 changes: 36 additions & 0 deletions src/api/utils/getPayErrorMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import {
GENERAL_PAY_ERROR_MESSAGE,
PAY_INVALID_CHARGE_ERROR_MESSAGE,
PAY_INVALID_PARAMETER_ERROR_MESSAGE,
PAY_TOO_MANY_REQUESTS_ERROR_MESSAGE,
UNCAUGHT_PAY_ERROR_MESSAGE,
} from '../../pay/constants';
import { getPayErrorMessage } from './getPayErrorMessage';

describe('getPayErrorMessage', () => {
it('should return TOO_MANY_REQUESTS_ERROR_MESSAGE for errorCode -32001', () => {
const result = getPayErrorMessage(-32001);
expect(result).toBe(PAY_TOO_MANY_REQUESTS_ERROR_MESSAGE);
});

it('should return PAY_INVALID_CHARGE_ERROR_MESSAGE for errorCode -32601', () => {
const result = getPayErrorMessage(-32601);
expect(result).toBe(PAY_INVALID_CHARGE_ERROR_MESSAGE);
});

it('should return PAY_INVALID_PARAMETER_ERROR_MESSAGE for errorCode -32602', () => {
const result = getPayErrorMessage(-32602);
expect(result).toBe(PAY_INVALID_PARAMETER_ERROR_MESSAGE);
});

it('should return GENERAL_PAY_ERROR_MESSAGE for misc errorCode', () => {
const result = getPayErrorMessage(-32603);
expect(result).toBe(GENERAL_PAY_ERROR_MESSAGE);
});

it('should return UNCAUGHT_PAY_ERROR_MESSAGE for no errorCode', () => {
const result = getPayErrorMessage();
expect(result).toBe(UNCAUGHT_PAY_ERROR_MESSAGE);
});
});
27 changes: 27 additions & 0 deletions src/api/utils/getPayErrorMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
GENERAL_PAY_ERROR_MESSAGE,
PAY_INVALID_CHARGE_ERROR_MESSAGE,
PAY_INVALID_PARAMETER_ERROR_MESSAGE,
PAY_TOO_MANY_REQUESTS_ERROR_MESSAGE,
UNCAUGHT_PAY_ERROR_MESSAGE,
} from '../../pay/constants';

export function getPayErrorMessage(errorCode?: number) {
if (!errorCode) {
return UNCAUGHT_PAY_ERROR_MESSAGE;
}

if (errorCode === -32001) {
return PAY_TOO_MANY_REQUESTS_ERROR_MESSAGE;
}

if (errorCode === -32601) {
return PAY_INVALID_CHARGE_ERROR_MESSAGE;
}

if (errorCode === -32602) {
return PAY_INVALID_PARAMETER_ERROR_MESSAGE;
}

return GENERAL_PAY_ERROR_MESSAGE;
}
1 change: 1 addition & 0 deletions src/network/definitions/pay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const PAY_HYDRATE_CHARGE = 'pay_hydrateCharge';
8 changes: 8 additions & 0 deletions src/pay/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const GENERAL_PAY_ERROR_MESSAGE = 'PAY_ERROR';
avidreder marked this conversation as resolved.
Show resolved Hide resolved
export const PAY_UNSUPPORTED_CHAIN_ERROR_MESSAGE = 'UNSUPPORTED_CHAIN';
export const PAY_TOO_MANY_REQUESTS_ERROR_MESSAGE =
'PAY_TOO_MANY_REQUESTS_ERROR';
export const PAY_INVALID_CHARGE_ERROR_MESSAGE = 'PAY_INVALID_CHARGE_ERROR';
export const PAY_INVALID_PARAMETER_ERROR_MESSAGE =
'PAY_INVALID_PARAMETER_ERROR';
export const UNCAUGHT_PAY_ERROR_MESSAGE = 'UNCAUGHT_PAY_ERROR';
Loading