From b8f2fbd99b85b8b8e0dfd01668917b0d9d58d428 Mon Sep 17 00:00:00 2001 From: Nicky Date: Tue, 18 Jun 2024 19:04:34 +0100 Subject: [PATCH 1/7] feat: send checks deploy script --- tilt/infra.Tiltfile | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tilt/infra.Tiltfile b/tilt/infra.Tiltfile index 8f50aaa77..c56587aa5 100644 --- a/tilt/infra.Tiltfile +++ b/tilt/infra.Tiltfile @@ -171,6 +171,19 @@ local_resource( serve_dir = _prj_root, ) +local_resource( + "anvil:anvil-add-send-check-fixtures", + "yarn contracts dev:anvil-add-send-check-fixtures", + labels=labels, + resource_deps = [ + "yarn:install", + "anvil:mainnet", + "anvil:base", + "contracts:build", + ], + trigger_mode = TRIGGER_MODE_MANUAL, +) + local_resource( "anvil:anvil-add-send-merkle-drop-fixtures", "yarn contracts dev:anvil-add-send-merkle-drop-fixtures", From b3b76cbd126e2db8bce02cf6226c77f2d5d1eff7 Mon Sep 17 00:00:00 2001 From: Nicky Date: Sat, 29 Jun 2024 14:10:18 +0100 Subject: [PATCH 2/7] feat: /send check creation MVP --- apps/next/pages/checks/create.tsx | 18 +++ .../checks/components/createSendCheck.tsx | 33 ++++++ .../checks/components/createSendCheckBtn.tsx | 27 +++++ packages/app/features/checks/types.ts | 70 ++++++++++++ .../app/features/checks/utils/checkUtils.ts | 33 ++++++ .../checks/utils/useCreateSendCheck.test.ts | 62 ++++++++++ .../checks/utils/useCreateSendCheck.ts | 85 ++++++++++++++ .../utils/useCreateSendCheckUserOp.test.ts | 107 +++++++++++++++++ .../checks/utils/useCreateSendCheckUserOp.ts | 108 ++++++++++++++++++ 9 files changed, 543 insertions(+) create mode 100644 apps/next/pages/checks/create.tsx create mode 100644 packages/app/features/checks/components/createSendCheck.tsx create mode 100644 packages/app/features/checks/components/createSendCheckBtn.tsx create mode 100644 packages/app/features/checks/types.ts create mode 100644 packages/app/features/checks/utils/checkUtils.ts create mode 100644 packages/app/features/checks/utils/useCreateSendCheck.test.ts create mode 100644 packages/app/features/checks/utils/useCreateSendCheck.ts create mode 100644 packages/app/features/checks/utils/useCreateSendCheckUserOp.test.ts create mode 100644 packages/app/features/checks/utils/useCreateSendCheckUserOp.ts diff --git a/apps/next/pages/checks/create.tsx b/apps/next/pages/checks/create.tsx new file mode 100644 index 000000000..bb59772f7 --- /dev/null +++ b/apps/next/pages/checks/create.tsx @@ -0,0 +1,18 @@ +import Head from 'next/head' +import { userProtectedGetSSP } from 'utils/userProtected' +import type { NextPageWithLayout } from '../_app' +import { CreateSendCheck } from 'app/features/checks/components/createSendCheck' + +export const CreateSendCheckPage: NextPageWithLayout = () => { + return ( + <> + + /send Checks + + + + ) +} + +export const getServerSideProps = userProtectedGetSSP() +export default CreateSendCheckPage diff --git a/packages/app/features/checks/components/createSendCheck.tsx b/packages/app/features/checks/components/createSendCheck.tsx new file mode 100644 index 000000000..671c27ab9 --- /dev/null +++ b/packages/app/features/checks/components/createSendCheck.tsx @@ -0,0 +1,33 @@ +import { CreateSendCheckBtn } from 'app/features/checks/components/createSendCheckBtn' +import type { CreateSendCheckBtnProps, EphemeralKeyPair } from 'app/features/checks/types' +import { useEffect, useState } from 'react' +import type { Hex } from 'viem' +import { generateCheckUrl } from 'app/features/checks/utils/checkUtils' + +export const CreateSendCheck = () => { + const [createCheckProps, setCreateCheckProps] = useState() + + useEffect(() => { + // set defaults for /send check creation + setCreateCheckProps({ + // TODO: pass dynamic args from parent + tokenAddress: '0x3f14920c99BEB920Afa163031c4e47a3e03B3e4A' as Hex, + amount: 1n, + onSuccess, + onError, + }) + }, []) + + const onSuccess = (senderAccountId: string, ephemeralKeypair: EphemeralKeyPair) => { + const checkUrl: string = generateCheckUrl(senderAccountId, ephemeralKeypair) + // TODO: show checkUrl to sender + console.log(checkUrl) + } + + const onError = (error: Error) => { + // TODO: handle error creating send check + throw error + } + + return createCheckProps && +} diff --git a/packages/app/features/checks/components/createSendCheckBtn.tsx b/packages/app/features/checks/components/createSendCheckBtn.tsx new file mode 100644 index 000000000..c78d4c1ed --- /dev/null +++ b/packages/app/features/checks/components/createSendCheckBtn.tsx @@ -0,0 +1,27 @@ +import { Button } from '@my/ui' +import type { CreateSendCheckBtnProps } from 'app/features/checks/types' +import { generateEphemeralKeypair } from 'app/features/checks/utils/checkUtils' +import { useCreateSendCheck } from 'app/features/checks/utils/useCreateSendCheck' + +export const CreateSendCheckBtn = (props: CreateSendCheckBtnProps) => { + const ephemeralKeypair = generateEphemeralKeypair() + + const createSendCheck = useCreateSendCheck({ + ephemeralKeypair, + ...props, + }) + + const onPress = async () => { + try { + const { senderAccountId, ephemeralKeypair, receipt } = await createSendCheck() + if (!receipt.success) { + props.onError(new Error('Error creating send check')) + } + props.onSuccess(senderAccountId, ephemeralKeypair) + } catch (e) { + props.onError(e) + } + } + + return +} diff --git a/packages/app/features/checks/types.ts b/packages/app/features/checks/types.ts new file mode 100644 index 000000000..c8e653449 --- /dev/null +++ b/packages/app/features/checks/types.ts @@ -0,0 +1,70 @@ +import type { GetUserOperationReceiptReturnType } from 'permissionless' +import type { Hex } from 'viem' + +/** + * Properties for the CreateSendCheck button component. + * + * @interface CreateSendCheckBtnProps + * @property {bigint} amount - The amount of the token to be sent. + * @property {Hex} tokenAddress - The address of the token. + * @property {(senderAccountId: string, ephemeralPrivkey: Hex) => void} onSuccess - Callback function to be called upon successful check creation and sending. Receives the sender's account ID and the ephemeral private key used in the operation. + * @property {(error: Error) => void} onError - Callback function to be called in case of an error during the check creation or sending process. + */ +export interface CreateSendCheckBtnProps { + amount: bigint + tokenAddress: Hex + onSuccess: (senderAccountId: string, ephemeralKeypair: EphemeralKeyPair) => void + onError: (error: Error) => void +} + +/** + * Defines the properties required to create and send a check. + * @interface CreateSendCheckProps + * @property {Hex} tokenAddress - The address of the token to be sent. + * @property {EphemeralKeyPair} ephemeralKeypair - The ephemeral key pair used for the transaction. + * @property {bigint} amount - The amount of the token to be sent. + */ +export interface CreateSendCheckProps { + tokenAddress: Hex + ephemeralKeypair: EphemeralKeyPair + amount: bigint +} + +/** + * Properties for creating and sending a check operation, extending the base properties required for check creation. + * + * @interface CreateSendCheckUserOpProps + * @extends CreateSendCheckProps + * @property {Hex} senderAddress - The address of the sender in hexadecimal format. + * @property {bigint} nonce - A unique nonce userOp nonce + */ +export interface CreateSendCheckUserOpProps extends CreateSendCheckProps { + senderAddress: Hex + nonce: bigint +} + +export type useCreateSendCheckReturnType = () => Promise + +/** + * Represents the return type of a function that creates and sends a check. + * @typedef {Object} CreateSendCheckReturnType + * @property {GetUserOperationReceiptReturnType} receipt - The receipt of the user operation. + * @property {string} senderAccountId - The account ID of the sender. + * @property {Hex} ephemeralPrivkey - The ephemeral private key used in the operation. + */ +export type CreateSendCheckReturnType = { + receipt: GetUserOperationReceiptReturnType + senderAccountId: string + ephemeralKeypair: EphemeralKeyPair +} + +/** + * Represents an ephemeral key pair sued for creating/claiming /send checks. + * @interface EphemeralKeyPair + * @property {`0x${string}`} ephemeralPrivkey - The private key of the ephemeral key pair, prefixed with `0x`. + * @property {`0x${string}`} ephemeralAddress - The address derived from the ephemeral key pair, prefixed with `0x`. + */ +export interface EphemeralKeyPair { + ephemeralPrivkey: `0x${string}` + ephemeralAddress: `0x${string}` +} diff --git a/packages/app/features/checks/utils/checkUtils.ts b/packages/app/features/checks/utils/checkUtils.ts new file mode 100644 index 000000000..19d482c32 --- /dev/null +++ b/packages/app/features/checks/utils/checkUtils.ts @@ -0,0 +1,33 @@ +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import type { EphemeralKeyPair } from 'app/features/checks/types' + +/** + * Generates a URL for claiming a check based on the sender's ID and an ephemeral key pair. + * The URL contains an encoded payload comprising the sender's ID, the ephemeral private key, + * and the ephemeral address, separated by colons and encoded for URL compatibility. + * + * @param {string} senderId - The sender's /send account id. + * @param {EphemeralKeyPair} ephemeralKeyPair - An object containing the ephemeral private key and address. + * @returns {string} The generated URL for claiming the check. + */ +export const generateCheckUrl = (senderId: string, ephemeralKeyPair: EphemeralKeyPair): string => { + const encodedPayload = encodeURIComponent( + `${senderId}:${ephemeralKeyPair.ephemeralPrivkey}:${ephemeralKeyPair.ephemeralAddress}` + ) + return `/check/claim/${encodedPayload}` +} + +/** + * Generates an ephemeral key pair for a /send check. + * This includes both an ephemeral private key and address. + * + * @returns {EphemeralKeyPair} The generated ephemeral key pair. + */ +export const generateEphemeralKeypair = (): EphemeralKeyPair => { + const ephemeralPrivkey = generatePrivateKey() + const ephemeralAddress = privateKeyToAccount(ephemeralPrivkey).address + return { + ephemeralPrivkey, + ephemeralAddress, + } +} diff --git a/packages/app/features/checks/utils/useCreateSendCheck.test.ts b/packages/app/features/checks/utils/useCreateSendCheck.test.ts new file mode 100644 index 000000000..1a2b08705 --- /dev/null +++ b/packages/app/features/checks/utils/useCreateSendCheck.test.ts @@ -0,0 +1,62 @@ +import { generateEphemeralKeypair } from 'app/features/checks/utils/checkUtils' +import { createSendCheck } from 'app/features/checks/utils/useCreateSendCheck' +import { getCreateSendCheckUserOp } from 'app/features/checks/utils/useCreateSendCheckUserOp' +import type { CreateSendCheckUserOpProps } from 'app/features/checks/types' +import type { UserOperation } from 'permissionless' +import * as mockMyWagmi from 'app/__mocks__/@my/wagmi' + +const userOpReceiptStub = { + receipt: { + transactionHash: '0x', + }, +} + +jest.mock('app/utils/useUserOpTransferMutation', () => ({ + __esModule: true, + ...jest.requireActual('app/utils/useUserOpTransferMutation'), + sendUserOpTransfer: jest.fn().mockReturnValue(userOpReceiptStub), +})) + +jest.mock('app/utils/userop.ts', () => ({ + signChallenge: jest.fn(), + signUserOp: jest.fn(), +})) + +jest.mock('@my/wagmi', () => ({ + __esModule: true, + ...jest.requireActual('@my/wagmi'), + ...mockMyWagmi, +})) + +beforeEach(() => { + window.location = { + ...window.location, + hostname: '127.0.0.1', + } +}) + +describe('/send check creation', () => { + let createSendCheckUserOp: UserOperation<'v0.7'> + + beforeEach(() => { + const props: CreateSendCheckUserOpProps = { + senderAddress: '0xb0b0000000000000000000000000000000000000', + nonce: 0n, + // /send token address + tokenAddress: '0x3f14920c99BEB920Afa163031c4e47a3e03B3e4A', + ephemeralKeypair: generateEphemeralKeypair(), + amount: 1n, + } + + createSendCheckUserOp = getCreateSendCheckUserOp(props) + }) + + it('can create /send check', async () => { + const receipt = await createSendCheck(createSendCheckUserOp) + expect(receipt).not.toBeNull() + }) + + it('throws if /send check userOp is not provided', async () => { + expect(createSendCheck()).rejects.toThrow() + }) +}) diff --git a/packages/app/features/checks/utils/useCreateSendCheck.ts b/packages/app/features/checks/utils/useCreateSendCheck.ts new file mode 100644 index 000000000..e59dc108e --- /dev/null +++ b/packages/app/features/checks/utils/useCreateSendCheck.ts @@ -0,0 +1,85 @@ +import { useCallback } from 'react' +import { useSendAccount } from 'app/utils/send-accounts' +import { useAccountNonce } from 'app/utils/userop' +import { useCreateSendCheckUserOp } from 'app/features/checks/utils/useCreateSendCheckUserOp' +import { sendUserOpTransfer } from 'app/utils/useUserOpTransferMutation' +import { + type CreateSendCheckProps, + CreateSendCheckReturnType, + type useCreateSendCheckReturnType, +} from 'app/features/checks/types' +import debug from 'debug' +import type { GetUserOperationReceiptReturnType, UserOperation } from 'permissionless' + +const logger = debug.log + +/** + * Hook returning a callback for creating and sending a check. This hook encapsulates the logic required to initiate the process of creating a check based on the specified properties, and then sending it to the intended recipient. + * @param {CreateSendCheckProps} props - The properties required for creating and sending a check, including the token address, ephemeral keypair, and the amount to be sent. + * @returns {CreateSendCheckReturnType} - An object containing the success of the /send checks creation userOp. See {@link CreateSendCheckReturnType} for more details. + */ +export const useCreateSendCheck = (props: CreateSendCheckProps): useCreateSendCheckReturnType => { + const { data: sendAccount, error: sendAccountError } = useSendAccount() + const { data: nonce, error: nonceError } = useAccountNonce({ sender: sendAccount?.address }) + + // get /send check creation user op + const createSendCheckUserOpQuery = useCreateSendCheckUserOp({ + senderAddress: sendAccount?.address, + nonce: nonce, + ...props, + }) + + return useCallback(async () => { + if (!sendAccount || sendAccountError) { + throw new Error( + `Unable to create /send check. Invalid /send account. Received: [${sendAccount}]. Error: [${sendAccountError}]` + ) + } + + if (nonce === undefined || nonceError) { + throw new Error( + `Unable to create /send check. Invalid nonce. Received: [${nonce}]. Error: [${nonceError}]` + ) + } + + const senderAccountId = sendAccount.id + + const receipt = await createSendCheck(createSendCheckUserOpQuery.data) + return { + receipt, + senderAccountId, + ephemeralKeypair: props.ephemeralKeypair, + } + }, [ + sendAccount, + sendAccountError, + nonce, + nonceError, + createSendCheckUserOpQuery.data, + props.ephemeralKeypair, + ]) +} + +/** + * Creates a /send check from a /send check userOp + * @param {UserOperation<'v0.7'>} createSendCheckUserOp - userOp for /send check creation + * @returns {Promise} - userOp receipt + */ +export const createSendCheck = async ( + createSendCheckUserOp?: UserOperation<'v0.7'> +): Promise => { + if (!createSendCheckUserOp) { + throw new Error( + `Unable to create /send check. /send check creation userOp required. Received: [${createSendCheckUserOp}]` + ) + } + + logger(`created /send check creation userOp: [${createSendCheckUserOp}]`) + + // send /send check creation user op + const receipt = await sendUserOpTransfer({ userOp: createSendCheckUserOp }) + + logger(`/send check creation userOp sent: [${receipt}]`) + logger(`/send check created: [${receipt.receipt.transactionHash}]`) + return receipt +} diff --git a/packages/app/features/checks/utils/useCreateSendCheckUserOp.test.ts b/packages/app/features/checks/utils/useCreateSendCheckUserOp.test.ts new file mode 100644 index 000000000..a58ad92b9 --- /dev/null +++ b/packages/app/features/checks/utils/useCreateSendCheckUserOp.test.ts @@ -0,0 +1,107 @@ +import { sendAccountAbi, sendCheckAddress, sendCheckAbi, tokenPaymasterAddress } from '@my/wagmi' +import { generateEphemeralKeypair } from 'app/features/checks/utils/checkUtils' +import type { CreateSendCheckUserOpProps } from 'app/features/checks/types' +import { getCreateSendCheckUserOp } from 'app/features/checks/utils/useCreateSendCheckUserOp' +import type { UserOperation } from 'permissionless' +import { type Hex, decodeFunctionData, erc20Abi, isAddress, maxUint256 } from 'viem' +import * as mockMyWagmi from 'app/__mocks__/@my/wagmi' + +jest.mock('@my/wagmi', () => ({ + __esModule: true, + ...jest.requireActual('@my/wagmi'), + ...mockMyWagmi, +})) + +describe('/send check userOps', () => { + const ephemeralKeypair = generateEphemeralKeypair() + let createSendCheckUserOpsProps: CreateSendCheckUserOpProps + let createSendCheckUserOp: UserOperation<'v0.7'> + + beforeEach(() => { + createSendCheckUserOpsProps = { + senderAddress: '0xb0b0000000000000000000000000000000000000', + // /send token address + tokenAddress: '0x3f14920c99BEB920Afa163031c4e47a3e03B3e4A', + ephemeralKeypair: ephemeralKeypair, + amount: 1n, + nonce: 0n, + } + + createSendCheckUserOp = getCreateSendCheckUserOp(createSendCheckUserOpsProps) + }) + + it('userOp properties are as expected', () => { + expect(createSendCheckUserOp.sender).toEqual(createSendCheckUserOpsProps.senderAddress) + expect(createSendCheckUserOp.nonce).toEqual(createSendCheckUserOpsProps.nonce) + expect(createSendCheckUserOp.paymaster).toEqual(tokenPaymasterAddress[845337]) + expect(createSendCheckUserOp.paymasterData).toEqual('0x') + expect(createSendCheckUserOp.paymasterAndData).toEqual('0x') + expect(createSendCheckUserOp.signature).toEqual('0x') + + // TODO: assert on gas limits + }) + + it('callData is as expected', () => { + const { functionName, args } = decodeFunctionData({ + abi: sendAccountAbi, + data: createSendCheckUserOp.callData, + }) + + expect(functionName).toEqual('executeBatch') + expect(args.length).toEqual(1) + + const batchTrns = args[0] + expect(batchTrns.length).toEqual(2) + + // first trn should be an approval trn + const approvalTrn = batchTrns[0] + const approvalTrnData = decodeFunctionData({ + abi: erc20Abi, + data: approvalTrn.data, + }) + expect(approvalTrn.dest).toEqual('0x3f14920c99BEB920Afa163031c4e47a3e03B3e4A') + expect(approvalTrn.value).toEqual(0n) + + // should approve send check contract to spend token + expect(approvalTrnData.functionName).toEqual('approve') + expect(approvalTrnData.args[0]).toEqual(sendCheckAddress[845337]) + expect(approvalTrnData.args[1]).toEqual(maxUint256) + + // second trn should be /create check trn + const createSendCheckTrn = batchTrns[1] + const createSendCheckTrnData = decodeFunctionData({ + abi: sendCheckAbi, + data: createSendCheckTrn.data, + }) + expect(createSendCheckTrn.dest).toEqual(sendCheckAddress[845337]) + expect(createSendCheckTrn.value).toEqual(0n) + + // should contain send check creation payload + expect(createSendCheckTrnData.functionName).toEqual('createCheck') + expect(createSendCheckTrnData.args.length).toEqual(3) + expect(createSendCheckTrnData.args[0]).toEqual(createSendCheckUserOpsProps.tokenAddress) + expect(createSendCheckTrnData.args[1]).toEqual( + createSendCheckUserOpsProps.ephemeralKeypair.ephemeralAddress + ) + expect(createSendCheckTrnData.args[2]).toEqual(createSendCheckUserOpsProps.amount) + }) + + it('invalid sender address', () => { + const invalidSenderAddress: Hex = '0x' + createSendCheckUserOpsProps.senderAddress = invalidSenderAddress + expect(isAddress(invalidSenderAddress)).toEqual(false) + expect(() => getCreateSendCheckUserOp(createSendCheckUserOpsProps)).toThrow( + 'Invalid send account address' + ) + }) + + it('invalid nonce', () => { + createSendCheckUserOpsProps.nonce = -1n + expect(() => getCreateSendCheckUserOp(createSendCheckUserOpsProps)).toThrow('Invalid nonce') + }) + + it('invalid amount', () => { + createSendCheckUserOpsProps.amount = -1n + expect(() => getCreateSendCheckUserOp(createSendCheckUserOpsProps)).toThrow() + }) +}) diff --git a/packages/app/features/checks/utils/useCreateSendCheckUserOp.ts b/packages/app/features/checks/utils/useCreateSendCheckUserOp.ts new file mode 100644 index 000000000..44069399a --- /dev/null +++ b/packages/app/features/checks/utils/useCreateSendCheckUserOp.ts @@ -0,0 +1,108 @@ +import { type UseQueryResult, useQuery } from '@tanstack/react-query' +import { type Hex, encodeFunctionData, erc20Abi, isAddress, maxUint256 } from 'viem' +import type { CreateSendCheckUserOpProps } from 'app/features/checks/types' +import { + baseMainnetClient, + sendAccountAbi, + sendCheckAbi, + sendCheckAddress, + tokenPaymasterAddress, +} from '@my/wagmi' +import type { UserOperation } from 'permissionless' +import { defaultUserOp } from 'app/utils/useUserOpTransferMutation' +import { assert } from '../../../utils/assert' + +const defaultCreateSendCheckUserOp: Pick< + UserOperation<'v0.7'>, + | 'callGasLimit' + | 'verificationGasLimit' + | 'preVerificationGas' + | 'maxFeePerGas' + | 'maxPriorityFeePerGas' + | 'paymasterVerificationGasLimit' + | 'paymasterPostOpGasLimit' +> = { + ...defaultUserOp, + callGasLimit: 1000000n, + verificationGasLimit: 5500000n, +} + +/** + * Tanstack query for /send check creation userOp + * @param {CreateSendCheckUserOpProps} props - properties for /send check creation + * @returns {UseQueryResult>} + */ +export const useCreateSendCheckUserOp = ( + props: CreateSendCheckUserOpProps +): UseQueryResult> => { + return useQuery({ + queryKey: ['createSendCheckUserOp'], + enabled: isEnabled(props), + queryFn: () => getCreateSendCheckUserOp(props), + }) +} + +/** + * Generate a /send check creation userOp + * @param {CreateSendCheckUserOpProps} props - properties for generating a create /send check userop + * @returns {UserOperation<'v0.7'>} - + */ +export const getCreateSendCheckUserOp = (props: CreateSendCheckUserOpProps) => { + assert(!!props.senderAddress && isAddress(props.senderAddress), 'Invalid send account address') + assert(typeof props.nonce === 'bigint' && props.nonce >= 0n, 'Invalid nonce') + + // generate calldata and userop + const callData = getCallData(props) + const paymaster = tokenPaymasterAddress[baseMainnetClient.chain.id] + const userOp: UserOperation<'v0.7'> = { + ...defaultCreateSendCheckUserOp, + callData, + sender: props.senderAddress, + nonce: props.nonce, + paymaster, + paymasterData: '0x', + paymasterAndData: '0x', + signature: '0x', + } + return userOp +} + +const getCallData = (props: CreateSendCheckUserOpProps): Hex => { + return encodeFunctionData({ + abi: sendAccountAbi, + functionName: 'executeBatch', + args: [ + [ + { + dest: props.tokenAddress, + value: 0n, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [sendCheckAddress[845337], maxUint256], + }), + }, + { + dest: sendCheckAddress[845337], + value: 0n, + data: encodeFunctionData({ + abi: sendCheckAbi, + functionName: 'createCheck', + args: [props.tokenAddress, props.ephemeralKeypair.ephemeralAddress, props.amount], + }), + }, + ], + ], + }) +} + +const isEnabled = (props: CreateSendCheckUserOpProps): boolean => { + return ( + !!props.tokenAddress && + !!props.ephemeralKeypair.ephemeralAddress && + !!props.ephemeralKeypair.ephemeralPrivkey && + props.amount !== undefined && + props.amount > 0n && + props.nonce !== undefined + ) +} From 95ea6deddc52da6277fcab244c2fb0c2c178fffc Mon Sep 17 00:00:00 2001 From: Nicky Date: Sun, 14 Jul 2024 20:28:17 +0100 Subject: [PATCH 3/7] feat: /send checks workflow MVP (create + claim) --- apps/next/pages/checks/claim.tsx | 40 ++++++ apps/next/pages/checks/claim/[payload].tsx | 38 ++++++ apps/next/pages/checks/create.tsx | 2 +- .../checks/components/claimSendCheck.tsx | 29 +++++ .../checks/components/createSendCheck.tsx | 17 ++- .../checks/components/createSendCheckBtn.tsx | 9 +- packages/app/features/checks/types.ts | 62 +++++++-- .../app/features/checks/utils/checkUtils.ts | 123 ++++++++++++++++-- .../utils/getClaimSendCheckUserOp.test.ts | 100 ++++++++++++++ .../checks/utils/getClaimSendCheckUserOp.ts | 87 +++++++++++++ ...st.ts => getCreateSendCheckUserOp.test.ts} | 42 +++--- ...kUserOp.ts => getCreateSendCheckUserOp.ts} | 69 +++------- .../checks/utils/useClaimSendCheck.test.ts | 61 +++++++++ .../checks/utils/useClaimSendCheck.ts | 74 +++++++++++ .../checks/utils/useCreateSendCheck.test.ts | 4 +- .../checks/utils/useCreateSendCheck.ts | 61 ++++----- tilt/infra.Tiltfile | 1 + 17 files changed, 683 insertions(+), 136 deletions(-) create mode 100644 apps/next/pages/checks/claim.tsx create mode 100644 apps/next/pages/checks/claim/[payload].tsx create mode 100644 packages/app/features/checks/components/claimSendCheck.tsx create mode 100644 packages/app/features/checks/utils/getClaimSendCheckUserOp.test.ts create mode 100644 packages/app/features/checks/utils/getClaimSendCheckUserOp.ts rename packages/app/features/checks/utils/{useCreateSendCheckUserOp.test.ts => getCreateSendCheckUserOp.test.ts} (74%) rename packages/app/features/checks/utils/{useCreateSendCheckUserOp.ts => getCreateSendCheckUserOp.ts} (53%) create mode 100644 packages/app/features/checks/utils/useClaimSendCheck.test.ts create mode 100644 packages/app/features/checks/utils/useClaimSendCheck.ts diff --git a/apps/next/pages/checks/claim.tsx b/apps/next/pages/checks/claim.tsx new file mode 100644 index 000000000..820c5f325 --- /dev/null +++ b/apps/next/pages/checks/claim.tsx @@ -0,0 +1,40 @@ +import type { ClaimSendCheckPayload } from 'app/features/checks/types' +import { ClaimSendCheck } from 'app/features/checks/components/claimSendCheck' +import { decodeClaimCheckUrl } from 'app/features/checks/utils/checkUtils' +import Head from 'next/head' +import type { GetUserOperationReceiptReturnType } from 'permissionless' +import { useEffect, useState } from 'react' + +export const ClaimSendCheckPage = (props: ClaimSendCheckPayload) => { + const [claimCheckPayload, setClaimCheckPayload] = useState() + + useEffect(() => { + const payload = window.location.hash.substring(1) + const claimCheckPayload: ClaimSendCheckPayload = decodeClaimCheckUrl(payload) + setClaimCheckPayload(claimCheckPayload) + }, []) + + const onSuccess = (receipt: GetUserOperationReceiptReturnType) => { + console.log(receipt) + } + + const onError = (e: Error) => { + // TODO: implement onError + console.log(e) + } + + return ( + <> + + Claim /send check + + {/* TODO: implement loading state */} + {/* TODO: implement error state */} + {claimCheckPayload && ( + + )} + + ) +} + +export default ClaimSendCheckPage diff --git a/apps/next/pages/checks/claim/[payload].tsx b/apps/next/pages/checks/claim/[payload].tsx new file mode 100644 index 000000000..2b7d3c54c --- /dev/null +++ b/apps/next/pages/checks/claim/[payload].tsx @@ -0,0 +1,38 @@ +import type { ClaimSendCheckPayload } from 'app/features/checks/types' +import { ClaimSendCheck } from 'app/features/checks/components/claimSendCheck' +import { decodeClaimCheckUrl } from 'app/features/checks/utils/checkUtils' +import type { GetServerSidePropsContext } from 'next' +import Head from 'next/head' +import type { GetUserOperationReceiptReturnType } from 'permissionless' + +export const ClaimSendCheckPage = (props: ClaimSendCheckPayload) => { + console.log('claimSendCheckPayload', props) + const onSuccess = (receipt: GetUserOperationReceiptReturnType) => { + console.log(receipt) + } + + const onError = (e: Error) => { + // TODO: implement onError + console.log(e) + } + + return ( + <> + + Claim /send check + + + + ) +} + +export const getServerSideProps = (context: GetServerSidePropsContext) => { + const payload = context.params?.payload + const claimSendCheckPayload: ClaimSendCheckPayload = decodeClaimCheckUrl(payload as string) + + return { + props: claimSendCheckPayload, + } +} + +export default ClaimSendCheckPage diff --git a/apps/next/pages/checks/create.tsx b/apps/next/pages/checks/create.tsx index bb59772f7..e174ad413 100644 --- a/apps/next/pages/checks/create.tsx +++ b/apps/next/pages/checks/create.tsx @@ -7,7 +7,7 @@ export const CreateSendCheckPage: NextPageWithLayout = () => { return ( <> - /send Checks + Send | Checks diff --git a/packages/app/features/checks/components/claimSendCheck.tsx b/packages/app/features/checks/components/claimSendCheck.tsx new file mode 100644 index 000000000..a7eebf734 --- /dev/null +++ b/packages/app/features/checks/components/claimSendCheck.tsx @@ -0,0 +1,29 @@ +import { Button } from '@my/ui' +import { useClaimSendCheck } from 'app/features/checks/utils/useClaimSendCheck' +import type { ClaimSendCheckPayload } from 'app/features/checks/types' +import type { GetUserOperationReceiptReturnType } from 'permissionless' + +interface Props { + payload: ClaimSendCheckPayload + onSuccess: (receipt: GetUserOperationReceiptReturnType) => void + onError: (error: Error) => void +} + +export const ClaimSendCheck = (props: Props) => { + const claimSendCheck = useClaimSendCheck(props.payload) + + const onPress = async () => { + try { + const receipt = await claimSendCheck() + if (!receipt.success) { + props.onError(new Error('Error claiming send check')) + return + } + props.onSuccess(receipt) + } catch (e) { + props.onError(e) + } + } + + return +} diff --git a/packages/app/features/checks/components/createSendCheck.tsx b/packages/app/features/checks/components/createSendCheck.tsx index 671c27ab9..2548c559d 100644 --- a/packages/app/features/checks/components/createSendCheck.tsx +++ b/packages/app/features/checks/components/createSendCheck.tsx @@ -2,7 +2,9 @@ import { CreateSendCheckBtn } from 'app/features/checks/components/createSendChe import type { CreateSendCheckBtnProps, EphemeralKeyPair } from 'app/features/checks/types' import { useEffect, useState } from 'react' import type { Hex } from 'viem' -import { generateCheckUrl } from 'app/features/checks/utils/checkUtils' +import { encodeClaimCheckUrl } from 'app/features/checks/utils/checkUtils' +import type { GetUserOperationReceiptReturnType } from 'permissionless' +import { baseMainnetClient, sendTokenAddress } from '@my/wagmi' export const CreateSendCheck = () => { const [createCheckProps, setCreateCheckProps] = useState() @@ -11,16 +13,19 @@ export const CreateSendCheck = () => { // set defaults for /send check creation setCreateCheckProps({ // TODO: pass dynamic args from parent - tokenAddress: '0x3f14920c99BEB920Afa163031c4e47a3e03B3e4A' as Hex, - amount: 1n, + tokenAddress: sendTokenAddress[baseMainnetClient.chain.id] as Hex, + amount: BigInt(100000), onSuccess, onError, }) }, []) - const onSuccess = (senderAccountId: string, ephemeralKeypair: EphemeralKeyPair) => { - const checkUrl: string = generateCheckUrl(senderAccountId, ephemeralKeypair) - // TODO: show checkUrl to sender + const onSuccess = ( + receipt: GetUserOperationReceiptReturnType, + senderAccountId: string, + ephemeralKeypair: EphemeralKeyPair + ) => { + const checkUrl: string = encodeClaimCheckUrl(senderAccountId, ephemeralKeypair) console.log(checkUrl) } diff --git a/packages/app/features/checks/components/createSendCheckBtn.tsx b/packages/app/features/checks/components/createSendCheckBtn.tsx index c78d4c1ed..f3ac14a87 100644 --- a/packages/app/features/checks/components/createSendCheckBtn.tsx +++ b/packages/app/features/checks/components/createSendCheckBtn.tsx @@ -13,15 +13,18 @@ export const CreateSendCheckBtn = (props: CreateSendCheckBtnProps) => { const onPress = async () => { try { - const { senderAccountId, ephemeralKeypair, receipt } = await createSendCheck() + const createSendCheckData = await createSendCheck() + const { senderAccountUuid, ephemeralKeypair, receipt } = createSendCheckData + if (!receipt.success) { props.onError(new Error('Error creating send check')) + return } - props.onSuccess(senderAccountId, ephemeralKeypair) + props.onSuccess(receipt, senderAccountUuid, ephemeralKeypair) } catch (e) { props.onError(e) } } - return + return } diff --git a/packages/app/features/checks/types.ts b/packages/app/features/checks/types.ts index c8e653449..b42c5a314 100644 --- a/packages/app/features/checks/types.ts +++ b/packages/app/features/checks/types.ts @@ -7,13 +7,17 @@ import type { Hex } from 'viem' * @interface CreateSendCheckBtnProps * @property {bigint} amount - The amount of the token to be sent. * @property {Hex} tokenAddress - The address of the token. - * @property {(senderAccountId: string, ephemeralPrivkey: Hex) => void} onSuccess - Callback function to be called upon successful check creation and sending. Receives the sender's account ID and the ephemeral private key used in the operation. + * @property {(receipt: GetUserOperationReceiptReturnType, senderAccountId: string, EphemeralKeyPair: Hex) => void} onSuccess - Callback function to be called upon successful check creation and sending. Receives the sender's account ID and the ephemeral private key used in the operation. * @property {(error: Error) => void} onError - Callback function to be called in case of an error during the check creation or sending process. */ export interface CreateSendCheckBtnProps { amount: bigint tokenAddress: Hex - onSuccess: (senderAccountId: string, ephemeralKeypair: EphemeralKeyPair) => void + onSuccess: ( + receipt: GetUserOperationReceiptReturnType, + senderAccountId: string, + ephemeralKeypair: EphemeralKeyPair + ) => void onError: (error: Error) => void } @@ -30,6 +34,36 @@ export interface CreateSendCheckProps { amount: bigint } +/** + * Payload for claiming a send check. + * + * See {@link generateCheckUrl} and {@link decodeClaimCheckUrl} for more information on how the payload is encoded for sharing. + * + * @interface ClaimSendCheckPayload + * @property {string} senderAccountId - The account ID of the sender. + * @property {EphemeralKeyPair} ephemeralKeypair - The ephemeral key pair associated with the transaction. + */ +export interface ClaimSendCheckPayload { + senderAccountUuid: string + ephemeralKeypair: EphemeralKeyPair +} + +/** + * Properties required for a claiming /send check userOp. + */ +export interface ClaimSendCheckProps extends ClaimSendCheckPayload { + signature: Hex +} + +/** + * Base properties required for a /send check userOp. + */ +interface SendCheckUserOp { + maxFeesPerGas: bigint + senderAddress: Hex + nonce: bigint +} + /** * Properties for creating and sending a check operation, extending the base properties required for check creation. * @@ -38,33 +72,37 @@ export interface CreateSendCheckProps { * @property {Hex} senderAddress - The address of the sender in hexadecimal format. * @property {bigint} nonce - A unique nonce userOp nonce */ -export interface CreateSendCheckUserOpProps extends CreateSendCheckProps { - senderAddress: Hex - nonce: bigint -} +export interface CreateSendCheckUserOpProps extends SendCheckUserOp, CreateSendCheckProps {} -export type useCreateSendCheckReturnType = () => Promise +export interface ClaimSendCheckUserOpProps extends SendCheckUserOp { + ephemeralKeypair: EphemeralKeyPair + signature: Hex +} /** * Represents the return type of a function that creates and sends a check. * @typedef {Object} CreateSendCheckReturnType * @property {GetUserOperationReceiptReturnType} receipt - The receipt of the user operation. - * @property {string} senderAccountId - The account ID of the sender. - * @property {Hex} ephemeralPrivkey - The ephemeral private key used in the operation. + * @property {string} senderAccountUuid - The account ID of the sender. + * @property {Hex} ephemeralKeypair - The ephemeral keypair used in the operation. */ export type CreateSendCheckReturnType = { receipt: GetUserOperationReceiptReturnType - senderAccountId: string + senderAccountUuid: string ephemeralKeypair: EphemeralKeyPair } +export type useCreateSendCheckReturnType = () => Promise + +export type useClaimSendCheckReturnType = () => Promise + /** * Represents an ephemeral key pair sued for creating/claiming /send checks. * @interface EphemeralKeyPair - * @property {`0x${string}`} ephemeralPrivkey - The private key of the ephemeral key pair, prefixed with `0x`. + * @property {`0x${string}`} ephemeralPrivateKey - The private key of the ephemeral key pair, prefixed with `0x`. * @property {`0x${string}`} ephemeralAddress - The address derived from the ephemeral key pair, prefixed with `0x`. */ export interface EphemeralKeyPair { - ephemeralPrivkey: `0x${string}` + ephemeralPrivateKey: `0x${string}` ephemeralAddress: `0x${string}` } diff --git a/packages/app/features/checks/utils/checkUtils.ts b/packages/app/features/checks/utils/checkUtils.ts index 19d482c32..43ff44a3d 100644 --- a/packages/app/features/checks/utils/checkUtils.ts +++ b/packages/app/features/checks/utils/checkUtils.ts @@ -1,20 +1,62 @@ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' -import type { EphemeralKeyPair } from 'app/features/checks/types' +import { assert } from 'app/utils/assert' +import type { EphemeralKeyPair, ClaimSendCheckPayload } from 'app/features/checks/types' +import { type Hex, isHex, keccak256, isAddress } from 'viem' +import type { UserOperation } from 'permissionless' +import { defaultUserOp } from 'app/utils/useUserOpTransferMutation' /** - * Generates a URL for claiming a check based on the sender's ID and an ephemeral key pair. - * The URL contains an encoded payload comprising the sender's ID, the ephemeral private key, - * and the ephemeral address, separated by colons and encoded for URL compatibility. + * Generates a URL for claiming a /send check. * + * The URL encodes information required for /send check retrieval. See {@see ClaimSendCheckPayload } for more information * @param {string} senderId - The sender's /send account id. * @param {EphemeralKeyPair} ephemeralKeyPair - An object containing the ephemeral private key and address. * @returns {string} The generated URL for claiming the check. */ -export const generateCheckUrl = (senderId: string, ephemeralKeyPair: EphemeralKeyPair): string => { +export const encodeClaimCheckUrl = ( + senderId: string, + ephemeralKeyPair: EphemeralKeyPair +): string => { const encodedPayload = encodeURIComponent( - `${senderId}:${ephemeralKeyPair.ephemeralPrivkey}:${ephemeralKeyPair.ephemeralAddress}` + `${senderId}:${ephemeralKeyPair.ephemeralPrivateKey}:${ephemeralKeyPair.ephemeralAddress}` ) - return `/check/claim/${encodedPayload}` + return `/checks/claim#${encodedPayload}` +} + +export const decodeClaimCheckUrl = (encodedPayload: string): ClaimSendCheckPayload => { + const decodedPayload = decodeURIComponent(encodedPayload) + const sendCheckPayload: ClaimSendCheckPayload = validateDecodedPayload(decodedPayload) + return sendCheckPayload +} + +/** + * Validates a /send check receive URL. + * @param {string} decodedPayload - a decoded claim /send check payload. { @see decodeClaimCheckUrl } + * @returns {ClaimSendCheckPayload} - decoded claim /send check payload + */ +const validateDecodedPayload = (decodedPayload: string): ClaimSendCheckPayload => { + const payloadParts = decodedPayload.split(':') + assert(payloadParts.length === 3, 'Invalid payload length') + + payloadParts.forEach((part, idx) => { + assert(!!part, 'Invalid payload part') + assert(part.length > 0, 'Invalid payload part') + + if (idx > 0) { + assert(isHex(part), `Ephemeral key is not a hex string. Received: ${part}`) + } + }) + + const senderAccountId = payloadParts[0] as string + const ephemeralKeypair = { + ephemeralPrivateKey: payloadParts[1] as Hex, + ephemeralAddress: payloadParts[2] as Hex, + } + + return { + senderAccountUuid: senderAccountId, + ephemeralKeypair, + } } /** @@ -24,10 +66,71 @@ export const generateCheckUrl = (senderId: string, ephemeralKeyPair: EphemeralKe * @returns {EphemeralKeyPair} The generated ephemeral key pair. */ export const generateEphemeralKeypair = (): EphemeralKeyPair => { - const ephemeralPrivkey = generatePrivateKey() - const ephemeralAddress = privateKeyToAccount(ephemeralPrivkey).address + const ephemeralPrivateKey = generatePrivateKey() + const ephemeralAddress = privateKeyToAccount(ephemeralPrivateKey).address return { - ephemeralPrivkey, + ephemeralPrivateKey, ephemeralAddress, } } + +/** + * Generates a claim /send check URL. + * @param {Hex} recipient - recipient address + * @param {Hex} ephemeralPrivateKey - ephemeral private key + * @returns {string} claim /send check URL. + */ +export const getCheckClaimSignature = async ( + recipient: Hex, + ephemeralPrivateKey: Hex +): Promise => { + const account = privateKeyToAccount(ephemeralPrivateKey) + const message = keccak256(recipient) + return await account.signMessage({ + message: { + raw: message, + }, + }) +} + +/** + * Helper function determining whether a claim /send check signature can be created. + * @param {Hex} ephemeralPrivateKey - ephemeral private key + * @param {Hex} recipient + * @returns {boolean} true if a claim /send check signature can be created, false otherwise. + */ +export const canCreateClaimCheckSignature = ( + ephemeralPrivateKey: Hex, + recipient?: Hex +): boolean => { + try { + assert(!!recipient && isAddress(recipient), `Invalid receiver. Received: [${recipient}]`) + assert( + !!ephemeralPrivateKey && isHex(ephemeralPrivateKey), + `Invalid ephemeralPrivateKey. Received: [${ephemeralPrivateKey}]` + ) + return true + } catch (e) { + return false + } +} + +/** + * Default /send check userOp. + * + * TODO: revisit gas limits + */ +export const defaultSendCheckUserOp: Pick< + UserOperation<'v0.7'>, + | 'callGasLimit' + | 'verificationGasLimit' + | 'preVerificationGas' + | 'maxFeePerGas' + | 'maxPriorityFeePerGas' + | 'paymasterVerificationGasLimit' + | 'paymasterPostOpGasLimit' +> = { + ...defaultUserOp, + callGasLimit: 1000000n, + verificationGasLimit: 5500000n, +} diff --git a/packages/app/features/checks/utils/getClaimSendCheckUserOp.test.ts b/packages/app/features/checks/utils/getClaimSendCheckUserOp.test.ts new file mode 100644 index 000000000..a52ec5837 --- /dev/null +++ b/packages/app/features/checks/utils/getClaimSendCheckUserOp.test.ts @@ -0,0 +1,100 @@ +import { + baseMainnetClient, + sendAccountAbi, + sendCheckAbi, + sendCheckAddress, + tokenPaymasterAddress, +} from '@my/wagmi' +import type { ClaimSendCheckUserOpProps } from 'app/features/checks/types' +import { generateEphemeralKeypair } from 'app/features/checks/utils/checkUtils' +import { getClaimSendCheckUserOp } from 'app/features/checks/utils/getClaimSendCheckUserOp' +import type { UserOperation } from 'permissionless' +import { decodeFunctionData } from 'viem' +import * as mockMyWagmi from 'app/__mocks__/@my/wagmi' + +jest.mock('@my/wagmi', () => ({ + __esModule: true, + ...jest.requireActual('@my/wagmi'), + ...mockMyWagmi, +})) + +describe('claim /send check userOp', () => { + let userOpProps: ClaimSendCheckUserOpProps + let userOp: UserOperation<'v0.7'> + + beforeEach(() => { + userOpProps = { + senderAddress: '0xb0b0000000000000000000000000000000000000', + nonce: 0n, + ephemeralKeypair: generateEphemeralKeypair(), + signature: '0x', + } + + userOp = getClaimSendCheckUserOp(userOpProps) + }) + + it('userOp properties are as expected', () => { + expect(userOp.sender).toEqual(userOpProps.senderAddress) + expect(userOp.nonce).toEqual(userOpProps.nonce) + expect(userOp.paymaster).toEqual(tokenPaymasterAddress[baseMainnetClient.chain.id]) + + // TODO: assert on paymaster sponsorship args once paymaster sponsorship is implemented + expect(userOp.paymasterData).toEqual('0x') + expect(userOp.signature).toEqual('0x') + }) + + it('callData is as expected', () => { + const { functionName, args } = decodeFunctionData({ + abi: sendAccountAbi, + data: userOp.callData, + }) + + expect(functionName).toEqual('executeBatch') + expect(args.length).toEqual(1) + + const batchTrns = args[0] + expect(batchTrns.length).toEqual(1) + + // claimCheck trn + const claimCheckTrn = batchTrns[0] + const claimCheckTrnData = decodeFunctionData({ + abi: sendCheckAbi, + data: claimCheckTrn.data, + }) + expect(claimCheckTrn.dest).toEqual(sendCheckAddress[baseMainnetClient.chain.id]) + expect(claimCheckTrn.value).toEqual(0n) + + // claimCheck trn data + expect(claimCheckTrnData.functionName).toEqual('claimCheck') + expect(claimCheckTrnData.args.length).toEqual(2) + expect(claimCheckTrnData.args[0]).toEqual(userOpProps.ephemeralKeypair.ephemeralAddress) + expect(claimCheckTrnData.args[1]).toEqual(userOpProps.signature) + }) + + describe('cannot create userOp with invalid properties', () => { + it('invalid senderAddress', () => { + userOpProps.senderAddress = undefined + expect(() => getClaimSendCheckUserOp(userOpProps)).toThrow('Invalid senderAddress') + }) + + it('invalid nonce', () => { + userOpProps.nonce = -1n + expect(() => getClaimSendCheckUserOp(userOpProps)).toThrow('Invalid nonce') + }) + + it('invalid signature', () => { + userOpProps.signature = undefined + expect(() => getClaimSendCheckUserOp(userOpProps)).toThrow('Invalid signature') + }) + + it('invalid ephemeralPrivateKey', () => { + userOpProps.ephemeralKeypair.ephemeralPrivateKey = undefined + expect(() => getClaimSendCheckUserOp(userOpProps)).toThrow('Invalid ephemeralPrivateKey') + }) + + it('invalid ephemeralAddress', () => { + userOpProps.ephemeralKeypair.ephemeralAddress = '0x' + expect(() => getClaimSendCheckUserOp(userOpProps)).toThrow('Invalid ephemeralAddress') + }) + }) +}) diff --git a/packages/app/features/checks/utils/getClaimSendCheckUserOp.ts b/packages/app/features/checks/utils/getClaimSendCheckUserOp.ts new file mode 100644 index 000000000..353765aee --- /dev/null +++ b/packages/app/features/checks/utils/getClaimSendCheckUserOp.ts @@ -0,0 +1,87 @@ +import { + baseMainnetClient, + sendAccountAbi, + sendCheckAbi, + sendCheckAddress, + tokenPaymasterAddress, +} from '@my/wagmi' +import type { ClaimSendCheckUserOpProps } from 'app/features/checks/types' +import { defaultSendCheckUserOp } from 'app/features/checks/utils/checkUtils' +import { assert } from 'app/utils/assert' +import type { UserOperation } from 'permissionless' +import { type Hex, encodeFunctionData, isAddress } from 'viem' + +export const getClaimSendCheckUserOp = ( + props: ClaimSendCheckUserOpProps +): UserOperation<'v0.7'> => { + validateClaimSendCheckUserOpProps(props) + + const callData = getCallData(props) + const paymaster = tokenPaymasterAddress[baseMainnetClient.chain.id] + + // TODO: add paymaster sponsorship + const paymasterData = getPaymasterData() + + const userOp: UserOperation<'v0.7'> = { + ...defaultSendCheckUserOp, + callData, + sender: props.senderAddress, + nonce: props.nonce, + paymaster, + paymasterData: '0x', + signature: '0x', + maxFeePerGas: props.maxFeesPerGas, + } + + return userOp +} + +const getPaymasterData = () => { + // TODO: implement +} + +const getCallData = (props: ClaimSendCheckUserOpProps): Hex => { + return encodeFunctionData({ + abi: sendAccountAbi, + functionName: 'executeBatch', + args: [ + [ + { + dest: sendCheckAddress[baseMainnetClient.chain.id], + value: 0n, + data: encodeFunctionData({ + abi: sendCheckAbi, + functionName: 'claimCheck', + args: [props.ephemeralKeypair.ephemeralAddress, props.signature], + }), + }, + ], + ], + }) +} + +const validateClaimSendCheckUserOpProps = (props: ClaimSendCheckUserOpProps) => { + assert( + !!sendCheckAddress[baseMainnetClient.chain.id] && + isAddress(sendCheckAddress[baseMainnetClient.chain.id]), + 'Invalid send check address' + ) + assert(!!props.maxFeesPerGas && typeof props.maxFeesPerGas === 'bigint', 'Invalid maxFeesPerGas') + assert(!!props.senderAddress && isAddress(props.senderAddress), 'Invalid senderAddress') + assert( + !!props.ephemeralKeypair.ephemeralAddress && isAddress(props.ephemeralKeypair.ephemeralAddress), + 'Invalid ephemeralAddress' + ) + assert(!!props.ephemeralKeypair.ephemeralPrivateKey, 'Invalid ephemeralPrivateKey') + assert(typeof props.nonce === 'bigint' && props.nonce >= 0n, 'Invalid nonce') + assert(!!props.signature, 'Invalid signature') +} + +export const canCreateClaimSendCheckUserOp = (props: ClaimSendCheckUserOpProps) => { + try { + validateClaimSendCheckUserOpProps(props) + return true + } catch (e) { + return false + } +} diff --git a/packages/app/features/checks/utils/useCreateSendCheckUserOp.test.ts b/packages/app/features/checks/utils/getCreateSendCheckUserOp.test.ts similarity index 74% rename from packages/app/features/checks/utils/useCreateSendCheckUserOp.test.ts rename to packages/app/features/checks/utils/getCreateSendCheckUserOp.test.ts index a58ad92b9..38f1bf76a 100644 --- a/packages/app/features/checks/utils/useCreateSendCheckUserOp.test.ts +++ b/packages/app/features/checks/utils/getCreateSendCheckUserOp.test.ts @@ -1,7 +1,14 @@ -import { sendAccountAbi, sendCheckAddress, sendCheckAbi, tokenPaymasterAddress } from '@my/wagmi' +import { + sendAccountAbi, + sendCheckAddress, + sendCheckAbi, + tokenPaymasterAddress, + sendTokenAddress, + baseMainnetClient, +} from '@my/wagmi' import { generateEphemeralKeypair } from 'app/features/checks/utils/checkUtils' import type { CreateSendCheckUserOpProps } from 'app/features/checks/types' -import { getCreateSendCheckUserOp } from 'app/features/checks/utils/useCreateSendCheckUserOp' +import { getCreateSendCheckUserOp } from 'app/features/checks/utils/getCreateSendCheckUserOp' import type { UserOperation } from 'permissionless' import { type Hex, decodeFunctionData, erc20Abi, isAddress, maxUint256 } from 'viem' import * as mockMyWagmi from 'app/__mocks__/@my/wagmi' @@ -12,12 +19,12 @@ jest.mock('@my/wagmi', () => ({ ...mockMyWagmi, })) -describe('/send check userOps', () => { +describe('create /send check userOp', () => { const ephemeralKeypair = generateEphemeralKeypair() let createSendCheckUserOpsProps: CreateSendCheckUserOpProps - let createSendCheckUserOp: UserOperation<'v0.7'> + let userOp: UserOperation<'v0.7'> - beforeEach(() => { + beforeEach(async () => { createSendCheckUserOpsProps = { senderAddress: '0xb0b0000000000000000000000000000000000000', // /send token address @@ -27,16 +34,16 @@ describe('/send check userOps', () => { nonce: 0n, } - createSendCheckUserOp = getCreateSendCheckUserOp(createSendCheckUserOpsProps) + userOp = getCreateSendCheckUserOp(createSendCheckUserOpsProps) }) it('userOp properties are as expected', () => { - expect(createSendCheckUserOp.sender).toEqual(createSendCheckUserOpsProps.senderAddress) - expect(createSendCheckUserOp.nonce).toEqual(createSendCheckUserOpsProps.nonce) - expect(createSendCheckUserOp.paymaster).toEqual(tokenPaymasterAddress[845337]) - expect(createSendCheckUserOp.paymasterData).toEqual('0x') - expect(createSendCheckUserOp.paymasterAndData).toEqual('0x') - expect(createSendCheckUserOp.signature).toEqual('0x') + expect(userOp.sender).toEqual(createSendCheckUserOpsProps.senderAddress) + expect(userOp.nonce).toEqual(createSendCheckUserOpsProps.nonce) + expect(userOp.paymaster).toEqual(tokenPaymasterAddress[845337]) + expect(userOp.paymasterData).toEqual('0x') + expect(userOp.paymasterAndData).toEqual('0x') + expect(userOp.signature).toEqual('0x') // TODO: assert on gas limits }) @@ -44,7 +51,7 @@ describe('/send check userOps', () => { it('callData is as expected', () => { const { functionName, args } = decodeFunctionData({ abi: sendAccountAbi, - data: createSendCheckUserOp.callData, + data: userOp.callData, }) expect(functionName).toEqual('executeBatch') @@ -59,12 +66,13 @@ describe('/send check userOps', () => { abi: erc20Abi, data: approvalTrn.data, }) - expect(approvalTrn.dest).toEqual('0x3f14920c99BEB920Afa163031c4e47a3e03B3e4A') + + expect(approvalTrn.dest).toEqual(sendTokenAddress[baseMainnetClient.chain.id]) expect(approvalTrn.value).toEqual(0n) // should approve send check contract to spend token expect(approvalTrnData.functionName).toEqual('approve') - expect(approvalTrnData.args[0]).toEqual(sendCheckAddress[845337]) + expect(approvalTrnData.args[0]).toEqual(sendCheckAddress[baseMainnetClient.chain.id]) expect(approvalTrnData.args[1]).toEqual(maxUint256) // second trn should be /create check trn @@ -73,7 +81,7 @@ describe('/send check userOps', () => { abi: sendCheckAbi, data: createSendCheckTrn.data, }) - expect(createSendCheckTrn.dest).toEqual(sendCheckAddress[845337]) + expect(createSendCheckTrn.dest).toEqual(sendCheckAddress[baseMainnetClient.chain.id]) expect(createSendCheckTrn.value).toEqual(0n) // should contain send check creation payload @@ -91,7 +99,7 @@ describe('/send check userOps', () => { createSendCheckUserOpsProps.senderAddress = invalidSenderAddress expect(isAddress(invalidSenderAddress)).toEqual(false) expect(() => getCreateSendCheckUserOp(createSendCheckUserOpsProps)).toThrow( - 'Invalid send account address' + 'Invalid sender address' ) }) diff --git a/packages/app/features/checks/utils/useCreateSendCheckUserOp.ts b/packages/app/features/checks/utils/getCreateSendCheckUserOp.ts similarity index 53% rename from packages/app/features/checks/utils/useCreateSendCheckUserOp.ts rename to packages/app/features/checks/utils/getCreateSendCheckUserOp.ts index 44069399a..75599d68e 100644 --- a/packages/app/features/checks/utils/useCreateSendCheckUserOp.ts +++ b/packages/app/features/checks/utils/getCreateSendCheckUserOp.ts @@ -1,4 +1,3 @@ -import { type UseQueryResult, useQuery } from '@tanstack/react-query' import { type Hex, encodeFunctionData, erc20Abi, isAddress, maxUint256 } from 'viem' import type { CreateSendCheckUserOpProps } from 'app/features/checks/types' import { @@ -9,61 +8,32 @@ import { tokenPaymasterAddress, } from '@my/wagmi' import type { UserOperation } from 'permissionless' -import { defaultUserOp } from 'app/utils/useUserOpTransferMutation' import { assert } from '../../../utils/assert' - -const defaultCreateSendCheckUserOp: Pick< - UserOperation<'v0.7'>, - | 'callGasLimit' - | 'verificationGasLimit' - | 'preVerificationGas' - | 'maxFeePerGas' - | 'maxPriorityFeePerGas' - | 'paymasterVerificationGasLimit' - | 'paymasterPostOpGasLimit' -> = { - ...defaultUserOp, - callGasLimit: 1000000n, - verificationGasLimit: 5500000n, -} - -/** - * Tanstack query for /send check creation userOp - * @param {CreateSendCheckUserOpProps} props - properties for /send check creation - * @returns {UseQueryResult>} - */ -export const useCreateSendCheckUserOp = ( - props: CreateSendCheckUserOpProps -): UseQueryResult> => { - return useQuery({ - queryKey: ['createSendCheckUserOp'], - enabled: isEnabled(props), - queryFn: () => getCreateSendCheckUserOp(props), - }) -} +import { defaultSendCheckUserOp } from 'app/features/checks/utils/checkUtils' /** * Generate a /send check creation userOp + * * @param {CreateSendCheckUserOpProps} props - properties for generating a create /send check userop - * @returns {UserOperation<'v0.7'>} - + * @returns {UserOperation<'v0.7'>} */ export const getCreateSendCheckUserOp = (props: CreateSendCheckUserOpProps) => { - assert(!!props.senderAddress && isAddress(props.senderAddress), 'Invalid send account address') - assert(typeof props.nonce === 'bigint' && props.nonce >= 0n, 'Invalid nonce') + validateCreateSendCheckUserOpProps(props) - // generate calldata and userop const callData = getCallData(props) const paymaster = tokenPaymasterAddress[baseMainnetClient.chain.id] + const userOp: UserOperation<'v0.7'> = { - ...defaultCreateSendCheckUserOp, + ...defaultSendCheckUserOp, callData, sender: props.senderAddress, nonce: props.nonce, paymaster, paymasterData: '0x', - paymasterAndData: '0x', signature: '0x', + maxFeePerGas: props.maxFeesPerGas, } + return userOp } @@ -79,11 +49,11 @@ const getCallData = (props: CreateSendCheckUserOpProps): Hex => { data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', - args: [sendCheckAddress[845337], maxUint256], + args: [sendCheckAddress[baseMainnetClient.chain.id], maxUint256], }), }, { - dest: sendCheckAddress[845337], + dest: sendCheckAddress[baseMainnetClient.chain.id], value: 0n, data: encodeFunctionData({ abi: sendCheckAbi, @@ -96,13 +66,16 @@ const getCallData = (props: CreateSendCheckUserOpProps): Hex => { }) } -const isEnabled = (props: CreateSendCheckUserOpProps): boolean => { - return ( - !!props.tokenAddress && - !!props.ephemeralKeypair.ephemeralAddress && - !!props.ephemeralKeypair.ephemeralPrivkey && - props.amount !== undefined && - props.amount > 0n && - props.nonce !== undefined +const validateCreateSendCheckUserOpProps = (props: CreateSendCheckUserOpProps) => { + assert(!!sendCheckAddress[baseMainnetClient.chain.id], 'Invalid send check address') + assert(!!props.maxFeesPerGas && typeof props.maxFeesPerGas === 'bigint', 'Invalid maxFeesPerGas') + assert(!!props.tokenAddress && isAddress(props.tokenAddress), 'Invalid token address') + assert( + !!props.ephemeralKeypair.ephemeralAddress && isAddress(props.ephemeralKeypair.ephemeralAddress), + 'Invalid ephemeral address' ) + assert(!!props.senderAddress && isAddress(props.senderAddress), 'Invalid sender address') + assert(!!props.ephemeralKeypair.ephemeralPrivateKey, 'Invalid ephemeral privkey') + assert(typeof props.amount === 'bigint' && props.amount > 0n, 'Invalid amount') + assert(typeof props.nonce === 'bigint' && props.nonce >= 0n, 'Invalid nonce') } diff --git a/packages/app/features/checks/utils/useClaimSendCheck.test.ts b/packages/app/features/checks/utils/useClaimSendCheck.test.ts new file mode 100644 index 000000000..f6e185a99 --- /dev/null +++ b/packages/app/features/checks/utils/useClaimSendCheck.test.ts @@ -0,0 +1,61 @@ +import { generateEphemeralKeypair } from 'app/features/checks/utils/checkUtils' +import { createSendCheck } from 'app/features/checks/utils/useCreateSendCheck' +import type { ClaimSendCheckUserOpProps } from 'app/features/checks/types' +import type { UserOperation } from 'permissionless' +import * as mockMyWagmi from 'app/__mocks__/@my/wagmi' +import { getClaimSendCheckUserOp } from 'app/features/checks/utils/getClaimSendCheckUserOp' +import { claimSendCheck } from 'app/features/checks/utils/useClaimSendCheck' + +const userOpReceiptStub = { + receipt: { + transactionHash: '0x', + }, +} + +jest.mock('app/utils/useUserOpTransferMutation', () => ({ + __esModule: true, + ...jest.requireActual('app/utils/useUserOpTransferMutation'), + sendUserOpTransfer: jest.fn().mockReturnValue(userOpReceiptStub), +})) + +jest.mock('app/utils/userop.ts', () => ({ + signChallenge: jest.fn(), + signUserOp: jest.fn(), +})) + +jest.mock('@my/wagmi', () => ({ + __esModule: true, + ...jest.requireActual('@my/wagmi'), + ...mockMyWagmi, +})) + +beforeEach(() => { + window.location = { + ...window.location, + hostname: '127.0.0.1', + } +}) + +describe('claim /send check', () => { + let userOp: UserOperation<'v0.7'> + + beforeEach(async () => { + const props: ClaimSendCheckUserOpProps = { + senderAddress: '0xb0b0000000000000000000000000000000000000', + nonce: 0n, + ephemeralKeypair: generateEphemeralKeypair(), + signature: '0x', + } + + userOp = getClaimSendCheckUserOp(props) + }) + + it('can create /send check', async () => { + const receipt = await claimSendCheck(userOp) + expect(receipt).not.toBeNull() + }) + + it('throws if /send check userOp is not provided', async () => { + expect(createSendCheck()).rejects.toThrow() + }) +}) diff --git a/packages/app/features/checks/utils/useClaimSendCheck.ts b/packages/app/features/checks/utils/useClaimSendCheck.ts new file mode 100644 index 000000000..1f09efee8 --- /dev/null +++ b/packages/app/features/checks/utils/useClaimSendCheck.ts @@ -0,0 +1,74 @@ +import { baseMainnetClient } from '@my/wagmi' +import type { ClaimSendCheckPayload, ClaimSendCheckUserOpProps } from 'app/features/checks/types' +import { + canCreateClaimCheckSignature, + getCheckClaimSignature, +} from 'app/features/checks/utils/checkUtils' +import { + canCreateClaimSendCheckUserOp, + getClaimSendCheckUserOp, +} from 'app/features/checks/utils/getClaimSendCheckUserOp' +import { useSendAccount } from 'app/utils/send-accounts' +import { sendUserOpTransfer } from 'app/utils/useUserOpTransferMutation' +import { useAccountNonce } from 'app/utils/userop' + +import debug from 'debug' +import type { GetUserOperationReceiptReturnType, UserOperation } from 'permissionless' +import { useEstimateFeesPerGas } from 'wagmi' + +const logger = debug.log + +export const useClaimSendCheck = ( + props: ClaimSendCheckPayload +): (() => Promise) => { + const { data: sendAccount } = useSendAccount() + const { data: nonce } = useAccountNonce({ sender: sendAccount?.address }) + const { data: feesPerGas } = useEstimateFeesPerGas({ + chainId: baseMainnetClient.chain.id, + }) + + return async () => { + const recipient = sendAccount?.address + + if (!canCreateClaimCheckSignature(props.ephemeralKeypair.ephemeralAddress, recipient)) { + throw new Error('Cannot claim /send check. Unabel to create signature.') + } + + const signature = await getCheckClaimSignature( + recipient as `0x${string}`, + props.ephemeralKeypair.ephemeralPrivateKey + ) + const claimSendCheckUserOpProps: ClaimSendCheckUserOpProps = { + senderAddress: sendAccount?.address as `0x${string}`, + nonce: nonce as bigint, + maxFeesPerGas: feesPerGas?.maxFeePerGas as bigint, + signature, + ...props, + } + + if (!canCreateClaimSendCheckUserOp(claimSendCheckUserOpProps)) { + throw new Error('Cannot claim /send check. Unable to create userOp.') + } + + const userOp = getClaimSendCheckUserOp(claimSendCheckUserOpProps as ClaimSendCheckUserOpProps) + const receipt = await claimSendCheck(userOp) + return receipt + } +} + +export const claimSendCheck = async ( + claimSendCheckUserOp: UserOperation<'v0.7'> +): Promise => { + if (!claimSendCheckUserOp) { + throw new Error( + `Unable to claim /send check. /send check claim userOp required. Received: [${claimSendCheckUserOp}]` + ) + } + + logger(`/send check claim userOp sent: [${claimSendCheckUserOp}]`) + const receipt = await sendUserOpTransfer({ userOp: claimSendCheckUserOp }) + + logger(`/send check claimed: [${receipt}]`) + logger(`/send check claim trn hash: [${receipt.receipt.transactionHash}]`) + return receipt +} diff --git a/packages/app/features/checks/utils/useCreateSendCheck.test.ts b/packages/app/features/checks/utils/useCreateSendCheck.test.ts index 1a2b08705..a5fd0d506 100644 --- a/packages/app/features/checks/utils/useCreateSendCheck.test.ts +++ b/packages/app/features/checks/utils/useCreateSendCheck.test.ts @@ -1,6 +1,6 @@ import { generateEphemeralKeypair } from 'app/features/checks/utils/checkUtils' import { createSendCheck } from 'app/features/checks/utils/useCreateSendCheck' -import { getCreateSendCheckUserOp } from 'app/features/checks/utils/useCreateSendCheckUserOp' +import { getCreateSendCheckUserOp } from 'app/features/checks/utils/getCreateSendCheckUserOp' import type { CreateSendCheckUserOpProps } from 'app/features/checks/types' import type { UserOperation } from 'permissionless' import * as mockMyWagmi from 'app/__mocks__/@my/wagmi' @@ -38,7 +38,7 @@ beforeEach(() => { describe('/send check creation', () => { let createSendCheckUserOp: UserOperation<'v0.7'> - beforeEach(() => { + beforeEach(async () => { const props: CreateSendCheckUserOpProps = { senderAddress: '0xb0b0000000000000000000000000000000000000', nonce: 0n, diff --git a/packages/app/features/checks/utils/useCreateSendCheck.ts b/packages/app/features/checks/utils/useCreateSendCheck.ts index e59dc108e..b64feba32 100644 --- a/packages/app/features/checks/utils/useCreateSendCheck.ts +++ b/packages/app/features/checks/utils/useCreateSendCheck.ts @@ -1,15 +1,17 @@ -import { useCallback } from 'react' import { useSendAccount } from 'app/utils/send-accounts' import { useAccountNonce } from 'app/utils/userop' -import { useCreateSendCheckUserOp } from 'app/features/checks/utils/useCreateSendCheckUserOp' import { sendUserOpTransfer } from 'app/utils/useUserOpTransferMutation' import { type CreateSendCheckProps, CreateSendCheckReturnType, type useCreateSendCheckReturnType, + type CreateSendCheckUserOpProps, } from 'app/features/checks/types' import debug from 'debug' import type { GetUserOperationReceiptReturnType, UserOperation } from 'permissionless' +import { getCreateSendCheckUserOp } from 'app/features/checks/utils/getCreateSendCheckUserOp' +import { useEstimateFeesPerGas } from 'wagmi' +import { baseMainnetClient } from '@my/wagmi' const logger = debug.log @@ -19,45 +21,31 @@ const logger = debug.log * @returns {CreateSendCheckReturnType} - An object containing the success of the /send checks creation userOp. See {@link CreateSendCheckReturnType} for more details. */ export const useCreateSendCheck = (props: CreateSendCheckProps): useCreateSendCheckReturnType => { - const { data: sendAccount, error: sendAccountError } = useSendAccount() - const { data: nonce, error: nonceError } = useAccountNonce({ sender: sendAccount?.address }) - - // get /send check creation user op - const createSendCheckUserOpQuery = useCreateSendCheckUserOp({ - senderAddress: sendAccount?.address, - nonce: nonce, - ...props, + const { data: sendAccount } = useSendAccount() + const { data: nonce } = useAccountNonce({ sender: sendAccount?.address }) + const { data: feesPerGas } = useEstimateFeesPerGas({ + chainId: baseMainnetClient.chain.id, }) - return useCallback(async () => { - if (!sendAccount || sendAccountError) { - throw new Error( - `Unable to create /send check. Invalid /send account. Received: [${sendAccount}]. Error: [${sendAccountError}]` - ) - } - - if (nonce === undefined || nonceError) { - throw new Error( - `Unable to create /send check. Invalid nonce. Received: [${nonce}]. Error: [${nonceError}]` - ) + // get /send check creation user op + return async () => { + const userOpProps: CreateSendCheckUserOpProps = { + senderAddress: sendAccount?.address as `0x${string}`, + nonce: nonce as bigint, + maxFeesPerGas: feesPerGas?.maxFeePerGas as bigint, + ...props, } - const senderAccountId = sendAccount.id + const userOp = getCreateSendCheckUserOp(userOpProps) + const receipt = await createSendCheck(userOp) + const senderAccountUuid = sendAccount?.user_id - const receipt = await createSendCheck(createSendCheckUserOpQuery.data) return { receipt, - senderAccountId, + senderAccountUuid, ephemeralKeypair: props.ephemeralKeypair, } - }, [ - sendAccount, - sendAccountError, - nonce, - nonceError, - createSendCheckUserOpQuery.data, - props.ephemeralKeypair, - ]) + } } /** @@ -66,7 +54,7 @@ export const useCreateSendCheck = (props: CreateSendCheckProps): useCreateSendCh * @returns {Promise} - userOp receipt */ export const createSendCheck = async ( - createSendCheckUserOp?: UserOperation<'v0.7'> + createSendCheckUserOp?: UserOperation<'v0.7'>[] ): Promise => { if (!createSendCheckUserOp) { throw new Error( @@ -74,12 +62,11 @@ export const createSendCheck = async ( ) } - logger(`created /send check creation userOp: [${createSendCheckUserOp}]`) - // send /send check creation user op + logger(`/send check creation userOp sent: [${createSendCheckUserOp}]`) const receipt = await sendUserOpTransfer({ userOp: createSendCheckUserOp }) - logger(`/send check creation userOp sent: [${receipt}]`) - logger(`/send check created: [${receipt.receipt.transactionHash}]`) + logger(`/send check created: [${receipt}]`) + logger(`/send check creation trn hash: [${receipt.receipt.transactionHash}]`) return receipt } diff --git a/tilt/infra.Tiltfile b/tilt/infra.Tiltfile index c56587aa5..74e9086ca 100644 --- a/tilt/infra.Tiltfile +++ b/tilt/infra.Tiltfile @@ -227,6 +227,7 @@ local_resource( "anvil:base", "anvil:anvil-token-paymaster-deposit", "anvil:anvil-deploy-fjord-send-verifier-fixtures", + "anvil:anvil-add-send-check-fixtures" ], ) From b0e0a742e5a5187d4ad744a8acd845a558a20b8d Mon Sep 17 00:00:00 2001 From: Nicky Date: Sun, 21 Jul 2024 12:36:29 +0100 Subject: [PATCH 4/7] feat: /send checks claim UI MVP --- apps/next/pages/checks/claim.tsx | 26 +++-- apps/next/pages/checks/claim/[payload].tsx | 38 ------- apps/next/pages/checks/create.tsx | 2 +- .../components/claim/ClaimSendCheck.tsx | 98 +++++++++++++++++++ .../components/claim/btn/ClaimButton.tsx | 28 ++++++ .../components/claim/btn/ClaimButtonGuest.tsx | 17 ++++ .../components/claim/btn/ClaimButtonUser.tsx | 35 +++++++ .../check/check-data/CheckTokenAmount.tsx | 34 +++++++ .../claim/check/check-data/CheckValue.tsx | 23 +++++ .../claim/check/check-data/ShowCheckData.tsx | 53 ++++++++++ .../claim/check/check-data/TokenIcon.tsx | 36 +++++++ .../claim/check/sender-data/SenderData.tsx | 23 +++++ .../check/sender-data/SenderProfileAvatar.tsx | 10 ++ .../check/sender-data/SenderProfileLink.tsx | 13 +++ .../check/sender-data/SenderProfileName.tsx | 13 +++ .../checks/components/claimSendCheck.tsx | 29 ------ .../CreateSendCheck.tsx} | 6 +- .../btn/CreateSendCheckBtn.tsx} | 4 +- packages/app/features/checks/types.ts | 26 +++-- .../app/features/checks/utils/checkUtils.ts | 11 ++- .../checks/utils/getCreateSendCheckUserOp.ts | 4 +- .../checks/utils/useCreateSendCheck.ts | 12 ++- .../features/checks/utils/useSendCheckData.ts | 24 +++++ packages/app/utils/coin-gecko/index.tsx | 26 ++++- packages/contracts/src/SendCheck.sol | 4 + 25 files changed, 496 insertions(+), 99 deletions(-) delete mode 100644 apps/next/pages/checks/claim/[payload].tsx create mode 100644 packages/app/features/checks/components/claim/ClaimSendCheck.tsx create mode 100644 packages/app/features/checks/components/claim/btn/ClaimButton.tsx create mode 100644 packages/app/features/checks/components/claim/btn/ClaimButtonGuest.tsx create mode 100644 packages/app/features/checks/components/claim/btn/ClaimButtonUser.tsx create mode 100644 packages/app/features/checks/components/claim/check/check-data/CheckTokenAmount.tsx create mode 100644 packages/app/features/checks/components/claim/check/check-data/CheckValue.tsx create mode 100644 packages/app/features/checks/components/claim/check/check-data/ShowCheckData.tsx create mode 100644 packages/app/features/checks/components/claim/check/check-data/TokenIcon.tsx create mode 100644 packages/app/features/checks/components/claim/check/sender-data/SenderData.tsx create mode 100644 packages/app/features/checks/components/claim/check/sender-data/SenderProfileAvatar.tsx create mode 100644 packages/app/features/checks/components/claim/check/sender-data/SenderProfileLink.tsx create mode 100644 packages/app/features/checks/components/claim/check/sender-data/SenderProfileName.tsx delete mode 100644 packages/app/features/checks/components/claimSendCheck.tsx rename packages/app/features/checks/components/{createSendCheck.tsx => create/CreateSendCheck.tsx} (89%) rename packages/app/features/checks/components/{createSendCheckBtn.tsx => create/btn/CreateSendCheckBtn.tsx} (84%) create mode 100644 packages/app/features/checks/utils/useSendCheckData.ts diff --git a/apps/next/pages/checks/claim.tsx b/apps/next/pages/checks/claim.tsx index 820c5f325..d566aa9e4 100644 --- a/apps/next/pages/checks/claim.tsx +++ b/apps/next/pages/checks/claim.tsx @@ -1,35 +1,45 @@ import type { ClaimSendCheckPayload } from 'app/features/checks/types' -import { ClaimSendCheck } from 'app/features/checks/components/claimSendCheck' +import { ClaimSendCheck } from 'app/features/checks/components/claim/ClaimSendCheck' import { decodeClaimCheckUrl } from 'app/features/checks/utils/checkUtils' import Head from 'next/head' -import type { GetUserOperationReceiptReturnType } from 'permissionless' import { useEffect, useState } from 'react' +import { Text, XStack, useToastController } from '@my/ui' +import { useRouter } from 'solito/router' export const ClaimSendCheckPage = (props: ClaimSendCheckPayload) => { const [claimCheckPayload, setClaimCheckPayload] = useState() + const toast = useToastController() + const router = useRouter() + useEffect(() => { const payload = window.location.hash.substring(1) const claimCheckPayload: ClaimSendCheckPayload = decodeClaimCheckUrl(payload) setClaimCheckPayload(claimCheckPayload) }, []) - const onSuccess = (receipt: GetUserOperationReceiptReturnType) => { - console.log(receipt) + const onSuccess = () => { + toast.show('Successfully claimed /send check', { type: 'success' }) + setTimeout(() => { + router.push('/') + }, 1000) } const onError = (e: Error) => { - // TODO: implement onError console.log(e) } return ( <> - Claim /send check + Send | Claim Send Check - {/* TODO: implement loading state */} - {/* TODO: implement error state */} + + + /send + + + {claimCheckPayload && ( )} diff --git a/apps/next/pages/checks/claim/[payload].tsx b/apps/next/pages/checks/claim/[payload].tsx deleted file mode 100644 index 2b7d3c54c..000000000 --- a/apps/next/pages/checks/claim/[payload].tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { ClaimSendCheckPayload } from 'app/features/checks/types' -import { ClaimSendCheck } from 'app/features/checks/components/claimSendCheck' -import { decodeClaimCheckUrl } from 'app/features/checks/utils/checkUtils' -import type { GetServerSidePropsContext } from 'next' -import Head from 'next/head' -import type { GetUserOperationReceiptReturnType } from 'permissionless' - -export const ClaimSendCheckPage = (props: ClaimSendCheckPayload) => { - console.log('claimSendCheckPayload', props) - const onSuccess = (receipt: GetUserOperationReceiptReturnType) => { - console.log(receipt) - } - - const onError = (e: Error) => { - // TODO: implement onError - console.log(e) - } - - return ( - <> - - Claim /send check - - - - ) -} - -export const getServerSideProps = (context: GetServerSidePropsContext) => { - const payload = context.params?.payload - const claimSendCheckPayload: ClaimSendCheckPayload = decodeClaimCheckUrl(payload as string) - - return { - props: claimSendCheckPayload, - } -} - -export default ClaimSendCheckPage diff --git a/apps/next/pages/checks/create.tsx b/apps/next/pages/checks/create.tsx index e174ad413..43e8054bb 100644 --- a/apps/next/pages/checks/create.tsx +++ b/apps/next/pages/checks/create.tsx @@ -1,7 +1,7 @@ import Head from 'next/head' import { userProtectedGetSSP } from 'utils/userProtected' import type { NextPageWithLayout } from '../_app' -import { CreateSendCheck } from 'app/features/checks/components/createSendCheck' +import { CreateSendCheck } from 'app/features/checks/components/create/CreateSendCheck' export const CreateSendCheckPage: NextPageWithLayout = () => { return ( diff --git a/packages/app/features/checks/components/claim/ClaimSendCheck.tsx b/packages/app/features/checks/components/claim/ClaimSendCheck.tsx new file mode 100644 index 000000000..e18030312 --- /dev/null +++ b/packages/app/features/checks/components/claim/ClaimSendCheck.tsx @@ -0,0 +1,98 @@ +import type { ClaimSendCheckPayload } from 'app/features/checks/types' +import { ClaimButtonGuest } from 'app/features/checks/components/claim/btn/ClaimButtonGuest' +import { ClaimButtonUser } from 'app/features/checks/components/claim/btn/ClaimButtonUser' +import { ShowCheckData } from 'app/features/checks/components/claim/check/check-data/ShowCheckData' +import { useProfileLookup } from 'app/utils/useProfileLookup' +import { Spinner, YStack, Text, Button, ButtonText, XStack } from '@my/ui' +import { useState } from 'react' +import { useSendCheckData } from 'app/features/checks/utils/useSendCheckData' +import { IconError } from 'app/components/icons' +import { useRouter } from 'next/navigation' +import { useTokenMetadata } from 'app/utils/coin-gecko' + +interface Props { + payload: ClaimSendCheckPayload + onSuccess: () => void + onError: (error: Error) => void +} + +export const ClaimSendCheck = (props: Props) => { + const [error, setError] = useState() + + const { data: sendCheckData, isLoading: sendCheckDataLoading } = useSendCheckData( + props.payload.ephemeralKeypair.ephemeralAddress + ) + const { data: tokenData } = useTokenMetadata(sendCheckData?.token as `0x${string}`) + const { data: profileData } = useProfileLookup('sendid', props.payload.senderSendId) + + const router = useRouter() + const isError = !!error + const signedIn = !!profileData + + const showSpinner = () => { + return ( + + ) + } + + const showError = (error: Error) => { + return ( + + + + + Unable to claim check + + + + {error.message} + + + + ) + } + + const showCheckData = () => { + return ( + <> + {sendCheckData && ( + + )} + {signedIn ? ( + + ) : ( + + )} + + ) + } + + const onError = (error: Error) => { + setError(error) + } + + return ( + + {isError ? showError(error) : sendCheckDataLoading ? showSpinner() : showCheckData()} + + ) +} diff --git a/packages/app/features/checks/components/claim/btn/ClaimButton.tsx b/packages/app/features/checks/components/claim/btn/ClaimButton.tsx new file mode 100644 index 000000000..87a87927b --- /dev/null +++ b/packages/app/features/checks/components/claim/btn/ClaimButton.tsx @@ -0,0 +1,28 @@ +import { Button, ButtonText, XStack } from '@my/ui' +import { CheckValue } from 'app/features/checks/components/claim/check/check-data/CheckValue' + +export interface ClaimSendCheckBtnProps { + tokenId?: number + tokenAmount?: bigint +} + +interface Props extends ClaimSendCheckBtnProps { + onPress: () => void +} + +export const ClaimButton = (props: Props) => { + const showCheckValue = () => { + return ( + props.tokenAmount && + props.tokenId && + ) + } + return ( + + ) +} diff --git a/packages/app/features/checks/components/claim/btn/ClaimButtonGuest.tsx b/packages/app/features/checks/components/claim/btn/ClaimButtonGuest.tsx new file mode 100644 index 000000000..66e3e8c34 --- /dev/null +++ b/packages/app/features/checks/components/claim/btn/ClaimButtonGuest.tsx @@ -0,0 +1,17 @@ +import { + ClaimButton, + type ClaimSendCheckBtnProps, +} from 'app/features/checks/components/claim/btn/ClaimButton' +import { useRouter } from 'solito/router' + +interface Props extends ClaimSendCheckBtnProps {} + +export const ClaimButtonGuest = (props: Props) => { + const router = useRouter() + + const onPress = () => { + router.push('/auth/onboarding') + } + + return +} diff --git a/packages/app/features/checks/components/claim/btn/ClaimButtonUser.tsx b/packages/app/features/checks/components/claim/btn/ClaimButtonUser.tsx new file mode 100644 index 000000000..c46b564e4 --- /dev/null +++ b/packages/app/features/checks/components/claim/btn/ClaimButtonUser.tsx @@ -0,0 +1,35 @@ +import { + ClaimButton, + type ClaimSendCheckBtnProps, +} from 'app/features/checks/components/claim/btn/ClaimButton' +import type { ClaimSendCheckPayload } from 'app/features/checks/types' +import { useClaimSendCheck } from 'app/features/checks/utils/useClaimSendCheck' + +interface Props extends ClaimSendCheckBtnProps { + payload: ClaimSendCheckPayload + onSuccess: () => void + onError: (error: Error) => void +} + +export const ClaimButtonUser = (props: Props) => { + const claimSendCheck = useClaimSendCheck(props.payload) + const claimCheck = () => { + claimSendCheck() + .then((receipt) => { + if (!receipt.success) { + props.onError(new Error('Error claiming send check')) + return + } + props.onSuccess() + }) + .catch((error) => { + props.onError(error) + }) + } + + const onPress = () => { + claimCheck() + } + + return +} diff --git a/packages/app/features/checks/components/claim/check/check-data/CheckTokenAmount.tsx b/packages/app/features/checks/components/claim/check/check-data/CheckTokenAmount.tsx new file mode 100644 index 000000000..7dabe3a4c --- /dev/null +++ b/packages/app/features/checks/components/claim/check/check-data/CheckTokenAmount.tsx @@ -0,0 +1,34 @@ +import { XStack, Text } from '@my/ui' +import { TokenIcon } from 'app/features/checks/components/claim/check/check-data/TokenIcon' + +interface Props { + tokenAmount: bigint + tokenName: string + tokenIconSize: number + + tokenImageUrl?: string + tokenInfoUrl?: string +} + +export const CheckTokenAmount = (props: Props) => { + const showTokenIcon = () => { + if (props.tokenImageUrl) { + return ( + + ) + } + } + return ( + + {showTokenIcon()} + + {props.tokenAmount.toLocaleString()} {props.tokenName} + + + ) +} diff --git a/packages/app/features/checks/components/claim/check/check-data/CheckValue.tsx b/packages/app/features/checks/components/claim/check/check-data/CheckValue.tsx new file mode 100644 index 000000000..9896c17cd --- /dev/null +++ b/packages/app/features/checks/components/claim/check/check-data/CheckValue.tsx @@ -0,0 +1,23 @@ +import { Text } from '@my/ui' +import { useTokenPrice } from 'app/utils/coin-gecko' +import { useMemo } from 'react' + +interface Props { + tokenId: string + tokenAmount: bigint +} + +export const CheckValue = (props: Props) => { + const { data } = useTokenPrice(props.tokenId) + + const checkValue: number | undefined = useMemo(() => { + if (data?.[props.tokenId]?.usd) { + const tokenPrice = data?.[props.tokenId]?.usd + return Number(props.tokenAmount) * (tokenPrice as number) + } + }, [data, props.tokenAmount, props.tokenId]) + + if (checkValue) { + return ${checkValue.toFixed(2).toLocaleString()} + } +} diff --git a/packages/app/features/checks/components/claim/check/check-data/ShowCheckData.tsx b/packages/app/features/checks/components/claim/check/check-data/ShowCheckData.tsx new file mode 100644 index 000000000..d39eeafe0 --- /dev/null +++ b/packages/app/features/checks/components/claim/check/check-data/ShowCheckData.tsx @@ -0,0 +1,53 @@ +import { Text, XStack, YStack } from '@my/ui' +import type { SendCheckData } from 'app/features/checks/types' +import { SenderData } from 'app/features/checks/components/claim/check/sender-data/SenderData' +import { CheckTokenAmount } from 'app/features/checks/components/claim/check/check-data/CheckTokenAmount' +import { useProfileLookup } from 'app/utils/useProfileLookup' + +interface Props { + sendCheckData: SendCheckData + tokenData?: object + senderSendId: string + onError: (error: Error) => void +} + +export const ShowCheckData = (props: Props) => { + const { data: profileData } = useProfileLookup('sendid', props.senderSendId) + + const showNote = () => { + const hasNote = !!props.sendCheckData?.note + if (hasNote) { + return {props.sendCheckData.note} + } + } + + console.log(props.tokenData) + + return ( + + + + {profileData?.name ? ' has sent you' : 'You have received'} + + + + + {showNote()} + + ) +} diff --git a/packages/app/features/checks/components/claim/check/check-data/TokenIcon.tsx b/packages/app/features/checks/components/claim/check/check-data/TokenIcon.tsx new file mode 100644 index 000000000..0c16f7024 --- /dev/null +++ b/packages/app/features/checks/components/claim/check/check-data/TokenIcon.tsx @@ -0,0 +1,36 @@ +import { Circle, Image, Link } from '@my/ui' + +interface Props { + tokenImageUrl: string + tokenName?: string + tokenInfoUrl?: string + tokenIconSize?: number +} + +export const TokenIcon = (props: Props) => { + const defaultTokenIconSize: number = 50 + + const getIconAlt = () => { + return props.tokenName ? `${props.tokenName} token icon` : 'token icon' + } + + const showTokenIcon = () => { + return ( + + {getIconAlt()} + + ) + } + return props.tokenInfoUrl ? ( + {showTokenIcon()} + ) : ( + showTokenIcon() + ) +} diff --git a/packages/app/features/checks/components/claim/check/sender-data/SenderData.tsx b/packages/app/features/checks/components/claim/check/sender-data/SenderData.tsx new file mode 100644 index 000000000..782ab14d1 --- /dev/null +++ b/packages/app/features/checks/components/claim/check/sender-data/SenderData.tsx @@ -0,0 +1,23 @@ +import type { Functions } from '@my/supabase/database.types' +import { XStack } from '@my/ui' +import { SenderProfileAvatar } from 'app/features/checks/components/claim/check/sender-data/SenderProfileAvatar' +import { SenderProfileLink } from 'app/features/checks/components/claim/check/sender-data/SenderProfileLink' +import { SenderProfileName } from 'app/features/checks/components/claim/check/sender-data/SenderProfileName' + +interface Props { + profileData: Functions<'profile_lookup'>[number] + senderSendId: string +} + +export const SenderData = (props: Props) => { + return ( + props.profileData?.name && ( + + + + {props.profileData?.name && } + + + ) + ) +} diff --git a/packages/app/features/checks/components/claim/check/sender-data/SenderProfileAvatar.tsx b/packages/app/features/checks/components/claim/check/sender-data/SenderProfileAvatar.tsx new file mode 100644 index 000000000..4de3f3039 --- /dev/null +++ b/packages/app/features/checks/components/claim/check/sender-data/SenderProfileAvatar.tsx @@ -0,0 +1,10 @@ +import type { Functions } from '@my/supabase/database.types' +import { AvatarProfile } from 'app/features/profile/AvatarProfile' + +interface Props { + profileData: Functions<'profile_lookup'>[number] +} + +export const SenderProfileAvatar = (props: Props) => { + return props.profileData && +} diff --git a/packages/app/features/checks/components/claim/check/sender-data/SenderProfileLink.tsx b/packages/app/features/checks/components/claim/check/sender-data/SenderProfileLink.tsx new file mode 100644 index 000000000..f29d04b3d --- /dev/null +++ b/packages/app/features/checks/components/claim/check/sender-data/SenderProfileLink.tsx @@ -0,0 +1,13 @@ +import { Link } from '@my/ui' +import { useProfileHref } from 'app/utils/useProfileHref' +import type { PropsWithChildren } from 'react' + +interface Props extends PropsWithChildren { + senderSendId: string +} + +export const SenderProfileLink = (props: Props) => { + const profileHref: string = useProfileHref('sendid', props.senderSendId) + + return {props.children} +} diff --git a/packages/app/features/checks/components/claim/check/sender-data/SenderProfileName.tsx b/packages/app/features/checks/components/claim/check/sender-data/SenderProfileName.tsx new file mode 100644 index 000000000..c38f97164 --- /dev/null +++ b/packages/app/features/checks/components/claim/check/sender-data/SenderProfileName.tsx @@ -0,0 +1,13 @@ +import { Text } from '@my/ui' + +interface Props { + profileName: string +} + +export const SenderProfileName = (props: Props) => { + return ( + + {props.profileName} + + ) +} diff --git a/packages/app/features/checks/components/claimSendCheck.tsx b/packages/app/features/checks/components/claimSendCheck.tsx deleted file mode 100644 index a7eebf734..000000000 --- a/packages/app/features/checks/components/claimSendCheck.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Button } from '@my/ui' -import { useClaimSendCheck } from 'app/features/checks/utils/useClaimSendCheck' -import type { ClaimSendCheckPayload } from 'app/features/checks/types' -import type { GetUserOperationReceiptReturnType } from 'permissionless' - -interface Props { - payload: ClaimSendCheckPayload - onSuccess: (receipt: GetUserOperationReceiptReturnType) => void - onError: (error: Error) => void -} - -export const ClaimSendCheck = (props: Props) => { - const claimSendCheck = useClaimSendCheck(props.payload) - - const onPress = async () => { - try { - const receipt = await claimSendCheck() - if (!receipt.success) { - props.onError(new Error('Error claiming send check')) - return - } - props.onSuccess(receipt) - } catch (e) { - props.onError(e) - } - } - - return -} diff --git a/packages/app/features/checks/components/createSendCheck.tsx b/packages/app/features/checks/components/create/CreateSendCheck.tsx similarity index 89% rename from packages/app/features/checks/components/createSendCheck.tsx rename to packages/app/features/checks/components/create/CreateSendCheck.tsx index 2548c559d..23a40a609 100644 --- a/packages/app/features/checks/components/createSendCheck.tsx +++ b/packages/app/features/checks/components/create/CreateSendCheck.tsx @@ -1,4 +1,4 @@ -import { CreateSendCheckBtn } from 'app/features/checks/components/createSendCheckBtn' +import { CreateSendCheckBtn } from 'app/features/checks/components/create/btn/CreateSendCheckBtn' import type { CreateSendCheckBtnProps, EphemeralKeyPair } from 'app/features/checks/types' import { useEffect, useState } from 'react' import type { Hex } from 'viem' @@ -22,10 +22,10 @@ export const CreateSendCheck = () => { const onSuccess = ( receipt: GetUserOperationReceiptReturnType, - senderAccountId: string, + senderSendId: string, ephemeralKeypair: EphemeralKeyPair ) => { - const checkUrl: string = encodeClaimCheckUrl(senderAccountId, ephemeralKeypair) + const checkUrl: string = encodeClaimCheckUrl(senderSendId, ephemeralKeypair) console.log(checkUrl) } diff --git a/packages/app/features/checks/components/createSendCheckBtn.tsx b/packages/app/features/checks/components/create/btn/CreateSendCheckBtn.tsx similarity index 84% rename from packages/app/features/checks/components/createSendCheckBtn.tsx rename to packages/app/features/checks/components/create/btn/CreateSendCheckBtn.tsx index f3ac14a87..eb7b1e1f2 100644 --- a/packages/app/features/checks/components/createSendCheckBtn.tsx +++ b/packages/app/features/checks/components/create/btn/CreateSendCheckBtn.tsx @@ -14,13 +14,13 @@ export const CreateSendCheckBtn = (props: CreateSendCheckBtnProps) => { const onPress = async () => { try { const createSendCheckData = await createSendCheck() - const { senderAccountUuid, ephemeralKeypair, receipt } = createSendCheckData + const { senderSendId, ephemeralKeypair, receipt } = createSendCheckData if (!receipt.success) { props.onError(new Error('Error creating send check')) return } - props.onSuccess(receipt, senderAccountUuid, ephemeralKeypair) + props.onSuccess(receipt, senderSendId, ephemeralKeypair) } catch (e) { props.onError(e) } diff --git a/packages/app/features/checks/types.ts b/packages/app/features/checks/types.ts index b42c5a314..aa39a6313 100644 --- a/packages/app/features/checks/types.ts +++ b/packages/app/features/checks/types.ts @@ -1,13 +1,25 @@ import type { GetUserOperationReceiptReturnType } from 'permissionless' import type { Hex } from 'viem' +/** + * Represents the data that a /send check holds. + */ +export interface SendCheckData { + ephemeralAddress: Hex + amount: bigint + token: Hex + + // Optional, off-chain data below + note?: string +} + /** * Properties for the CreateSendCheck button component. * * @interface CreateSendCheckBtnProps * @property {bigint} amount - The amount of the token to be sent. * @property {Hex} tokenAddress - The address of the token. - * @property {(receipt: GetUserOperationReceiptReturnType, senderAccountId: string, EphemeralKeyPair: Hex) => void} onSuccess - Callback function to be called upon successful check creation and sending. Receives the sender's account ID and the ephemeral private key used in the operation. + * @property {(receipt: GetUserOperationReceiptReturnType, senderSendId: string, EphemeralKeyPair: Hex) => void} onSuccess - Callback function to be called upon successful check creation and sending. Receives the sender's account ID and the ephemeral private key used in the operation. * @property {(error: Error) => void} onError - Callback function to be called in case of an error during the check creation or sending process. */ export interface CreateSendCheckBtnProps { @@ -15,7 +27,7 @@ export interface CreateSendCheckBtnProps { tokenAddress: Hex onSuccess: ( receipt: GetUserOperationReceiptReturnType, - senderAccountId: string, + senderSendId: string, ephemeralKeypair: EphemeralKeyPair ) => void onError: (error: Error) => void @@ -40,11 +52,11 @@ export interface CreateSendCheckProps { * See {@link generateCheckUrl} and {@link decodeClaimCheckUrl} for more information on how the payload is encoded for sharing. * * @interface ClaimSendCheckPayload - * @property {string} senderAccountId - The account ID of the sender. + * @property {string} senderSendId - The send ID of the sender's profile {@see profiles table`}. * @property {EphemeralKeyPair} ephemeralKeypair - The ephemeral key pair associated with the transaction. */ export interface ClaimSendCheckPayload { - senderAccountUuid: string + senderSendId: string ephemeralKeypair: EphemeralKeyPair } @@ -83,16 +95,16 @@ export interface ClaimSendCheckUserOpProps extends SendCheckUserOp { * Represents the return type of a function that creates and sends a check. * @typedef {Object} CreateSendCheckReturnType * @property {GetUserOperationReceiptReturnType} receipt - The receipt of the user operation. - * @property {string} senderAccountUuid - The account ID of the sender. + * @property {string} senderSendId - The send ID of the sender's profile {@see profiles table}. * @property {Hex} ephemeralKeypair - The ephemeral keypair used in the operation. */ export type CreateSendCheckReturnType = { receipt: GetUserOperationReceiptReturnType - senderAccountUuid: string + senderSendId: number ephemeralKeypair: EphemeralKeyPair } -export type useCreateSendCheckReturnType = () => Promise +export type useCreateSendCheckReturnType = () => Promise export type useClaimSendCheckReturnType = () => Promise diff --git a/packages/app/features/checks/utils/checkUtils.ts b/packages/app/features/checks/utils/checkUtils.ts index 43ff44a3d..4317a4cab 100644 --- a/packages/app/features/checks/utils/checkUtils.ts +++ b/packages/app/features/checks/utils/checkUtils.ts @@ -9,16 +9,17 @@ import { defaultUserOp } from 'app/utils/useUserOpTransferMutation' * Generates a URL for claiming a /send check. * * The URL encodes information required for /send check retrieval. See {@see ClaimSendCheckPayload } for more information - * @param {string} senderId - The sender's /send account id. + * @param {string} senderSendId - The sender's send ID {@see profiles table}. * @param {EphemeralKeyPair} ephemeralKeyPair - An object containing the ephemeral private key and address. * @returns {string} The generated URL for claiming the check. */ export const encodeClaimCheckUrl = ( - senderId: string, + senderSendId: string, ephemeralKeyPair: EphemeralKeyPair ): string => { + // TODO: /send check notes: add note field in encoded payload const encodedPayload = encodeURIComponent( - `${senderId}:${ephemeralKeyPair.ephemeralPrivateKey}:${ephemeralKeyPair.ephemeralAddress}` + `${senderSendId}:${ephemeralKeyPair.ephemeralPrivateKey}:${ephemeralKeyPair.ephemeralAddress}` ) return `/checks/claim#${encodedPayload}` } @@ -47,14 +48,14 @@ const validateDecodedPayload = (decodedPayload: string): ClaimSendCheckPayload = } }) - const senderAccountId = payloadParts[0] as string + const senderSendId = payloadParts[0] as string const ephemeralKeypair = { ephemeralPrivateKey: payloadParts[1] as Hex, ephemeralAddress: payloadParts[2] as Hex, } return { - senderAccountUuid: senderAccountId, + senderSendId, ephemeralKeypair, } } diff --git a/packages/app/features/checks/utils/getCreateSendCheckUserOp.ts b/packages/app/features/checks/utils/getCreateSendCheckUserOp.ts index 75599d68e..3fbc8f5fb 100644 --- a/packages/app/features/checks/utils/getCreateSendCheckUserOp.ts +++ b/packages/app/features/checks/utils/getCreateSendCheckUserOp.ts @@ -17,7 +17,9 @@ import { defaultSendCheckUserOp } from 'app/features/checks/utils/checkUtils' * @param {CreateSendCheckUserOpProps} props - properties for generating a create /send check userop * @returns {UserOperation<'v0.7'>} */ -export const getCreateSendCheckUserOp = (props: CreateSendCheckUserOpProps) => { +export const getCreateSendCheckUserOp = ( + props: CreateSendCheckUserOpProps +): UserOperation<'v0.7'> => { validateCreateSendCheckUserOpProps(props) const callData = getCallData(props) diff --git a/packages/app/features/checks/utils/useCreateSendCheck.ts b/packages/app/features/checks/utils/useCreateSendCheck.ts index b64feba32..1e8c0cadc 100644 --- a/packages/app/features/checks/utils/useCreateSendCheck.ts +++ b/packages/app/features/checks/utils/useCreateSendCheck.ts @@ -12,6 +12,7 @@ import type { GetUserOperationReceiptReturnType, UserOperation } from 'permissio import { getCreateSendCheckUserOp } from 'app/features/checks/utils/getCreateSendCheckUserOp' import { useEstimateFeesPerGas } from 'wagmi' import { baseMainnetClient } from '@my/wagmi' +import { useUser } from 'app/utils/useUser' const logger = debug.log @@ -22,6 +23,7 @@ const logger = debug.log */ export const useCreateSendCheck = (props: CreateSendCheckProps): useCreateSendCheckReturnType => { const { data: sendAccount } = useSendAccount() + const { profile } = useUser() const { data: nonce } = useAccountNonce({ sender: sendAccount?.address }) const { data: feesPerGas } = useEstimateFeesPerGas({ chainId: baseMainnetClient.chain.id, @@ -29,6 +31,9 @@ export const useCreateSendCheck = (props: CreateSendCheckProps): useCreateSendCh // get /send check creation user op return async () => { + if (!profile?.send_id || !nonce || !sendAccount?.address || !feesPerGas) { + return + } const userOpProps: CreateSendCheckUserOpProps = { senderAddress: sendAccount?.address as `0x${string}`, nonce: nonce as bigint, @@ -38,11 +43,10 @@ export const useCreateSendCheck = (props: CreateSendCheckProps): useCreateSendCh const userOp = getCreateSendCheckUserOp(userOpProps) const receipt = await createSendCheck(userOp) - const senderAccountUuid = sendAccount?.user_id - + const senderSendId = profile.send_id return { receipt, - senderAccountUuid, + senderSendId, ephemeralKeypair: props.ephemeralKeypair, } } @@ -54,7 +58,7 @@ export const useCreateSendCheck = (props: CreateSendCheckProps): useCreateSendCh * @returns {Promise} - userOp receipt */ export const createSendCheck = async ( - createSendCheckUserOp?: UserOperation<'v0.7'>[] + createSendCheckUserOp: UserOperation<'v0.7'> ): Promise => { if (!createSendCheckUserOp) { throw new Error( diff --git a/packages/app/features/checks/utils/useSendCheckData.ts b/packages/app/features/checks/utils/useSendCheckData.ts new file mode 100644 index 000000000..cbc605493 --- /dev/null +++ b/packages/app/features/checks/utils/useSendCheckData.ts @@ -0,0 +1,24 @@ +import { type UseQueryResult, useQuery } from '@tanstack/react-query' +import { baseMainnetClient, sendCheckAbi, sendCheckAddress } from '@my/wagmi' +import { type Hex, getContract } from 'viem' +import type { SendCheckData } from 'app/features/checks/types' + +export const useSendCheckData = ( + ephemeralAddress: Hex, + queryProps? +): UseQueryResult => { + return useQuery({ + queryKey: ['sendCheckData'], + queryFn: () => getSendCheckData(ephemeralAddress), + ...queryProps, + }) +} + +const getSendCheckData = async (ephemeralAddress: Hex): Promise => { + const sendChecks = getContract({ + address: sendCheckAddress[baseMainnetClient.chain.id] as Hex, + abi: sendCheckAbi, + client: baseMainnetClient, + }) + return await sendChecks.read.getCheck([ephemeralAddress]) +} diff --git a/packages/app/utils/coin-gecko/index.tsx b/packages/app/utils/coin-gecko/index.tsx index bb534c53a..0b252973e 100644 --- a/packages/app/utils/coin-gecko/index.tsx +++ b/packages/app/utils/coin-gecko/index.tsx @@ -1,5 +1,6 @@ -import { useQuery } from '@tanstack/react-query' +import { type UseQueryResult, useQuery } from '@tanstack/react-query' import type { coins } from 'app/data/coins' +import type { Hex } from 'viem' import { z } from 'zod' export const MarketDataSchema = z @@ -81,3 +82,26 @@ export const useTokenMarketData = ( staleTime: 1000 * 60 * 5, // 5 minutes }) } + +/** + * Fetch token metadata (from contract address) + * @param {Hex} contractAddress - token contract address + */ +export const useTokenMetadata = (contractAddress: Hex, queryParams?): UseQueryResult => { + return useQuery({ + queryKey: ['token-thumbnail', contractAddress], + queryFn: async (): Promise => { + const response = await fetch( + `https://api.coingecko.com/api/v3/coins/ethereum?contract_address=${contractAddress}&vs_currency=usd` + ) + + if (!response.ok) + throw new Error(`Failed to fetch token metadata ${contractAddress} ${response.status}`) + const data = await response.json() + + return data + }, + enabled: !!contractAddress, + ...queryParams, + }) +} diff --git a/packages/contracts/src/SendCheck.sol b/packages/contracts/src/SendCheck.sol index 724ac48f9..e0510bf6e 100644 --- a/packages/contracts/src/SendCheck.sol +++ b/packages/contracts/src/SendCheck.sol @@ -56,4 +56,8 @@ contract SendCheck { delete checks[ephemeralAddress]; check.token.safeTransfer(msg.sender, check.amount); } + + function getCheck(address ephemeralAddress) external view returns (Check memory) { + return checks[ephemeralAddress]; + } } From 7339b6a20563721f5e1ce2d44a2f70e7da648946 Mon Sep 17 00:00:00 2001 From: Nicky Date: Sun, 21 Jul 2024 13:33:47 +0100 Subject: [PATCH 5/7] feat: /send checks post-claim UI --- .../components/claim/ClaimSendCheck.tsx | 27 ++++++++++++++----- .../components/claim/btn/ClaimButton.tsx | 20 +++++++++++--- .../app/features/checks/utils/checkUtils.ts | 21 +++++++++++++-- 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/packages/app/features/checks/components/claim/ClaimSendCheck.tsx b/packages/app/features/checks/components/claim/ClaimSendCheck.tsx index e18030312..0edf82644 100644 --- a/packages/app/features/checks/components/claim/ClaimSendCheck.tsx +++ b/packages/app/features/checks/components/claim/ClaimSendCheck.tsx @@ -4,11 +4,12 @@ import { ClaimButtonUser } from 'app/features/checks/components/claim/btn/ClaimB import { ShowCheckData } from 'app/features/checks/components/claim/check/check-data/ShowCheckData' import { useProfileLookup } from 'app/utils/useProfileLookup' import { Spinner, YStack, Text, Button, ButtonText, XStack } from '@my/ui' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useSendCheckData } from 'app/features/checks/utils/useSendCheckData' import { IconError } from 'app/components/icons' import { useRouter } from 'next/navigation' import { useTokenMetadata } from 'app/utils/coin-gecko' +import { checkExists } from 'app/features/checks/utils/checkUtils' interface Props { payload: ClaimSendCheckPayload @@ -19,16 +20,28 @@ interface Props { export const ClaimSendCheck = (props: Props) => { const [error, setError] = useState() - const { data: sendCheckData, isLoading: sendCheckDataLoading } = useSendCheckData( - props.payload.ephemeralKeypair.ephemeralAddress - ) - const { data: tokenData } = useTokenMetadata(sendCheckData?.token as `0x${string}`) + const { + data: sendCheckData, + isLoading: sendCheckDataLoading, + isSuccess, + } = useSendCheckData(props.payload.ephemeralKeypair.ephemeralAddress, { + retry: 3, + }) + const { data: tokenData } = useTokenMetadata(sendCheckData?.token as `0x${string}`, { + retry: false, + }) const { data: profileData } = useProfileLookup('sendid', props.payload.senderSendId) const router = useRouter() const isError = !!error const signedIn = !!profileData + useMemo(() => { + if (isSuccess && !checkExists(sendCheckData)) { + setError(new Error("Check doesn't exist or has been claimed already.")) + } + }, [isSuccess, sendCheckData]) + const showSpinner = () => { return ( { Unable to claim check - - {error.message} + + {error.message ?? 'Please try again'} ) diff --git a/packages/app/features/checks/utils/checkUtils.ts b/packages/app/features/checks/utils/checkUtils.ts index 4317a4cab..dc8a56c03 100644 --- a/packages/app/features/checks/utils/checkUtils.ts +++ b/packages/app/features/checks/utils/checkUtils.ts @@ -1,7 +1,11 @@ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { assert } from 'app/utils/assert' -import type { EphemeralKeyPair, ClaimSendCheckPayload } from 'app/features/checks/types' -import { type Hex, isHex, keccak256, isAddress } from 'viem' +import type { + EphemeralKeyPair, + ClaimSendCheckPayload, + SendCheckData, +} from 'app/features/checks/types' +import { type Hex, isHex, keccak256, isAddress, zeroAddress } from 'viem' import type { UserOperation } from 'permissionless' import { defaultUserOp } from 'app/utils/useUserOpTransferMutation' @@ -116,6 +120,19 @@ export const canCreateClaimCheckSignature = ( } } +/** + * Helper function determining whether a /send check exists given a /send check claim payload. + * @param {SendCheckData} checkData + * @returns {boolean} + */ +export const checkExists = (checkData: SendCheckData): boolean => { + return ( + checkData.amount !== 0n && + checkData.ephemeralAddress !== zeroAddress && + checkData.token !== zeroAddress + ) +} + /** * Default /send check userOp. * From 63458d8f4991b04371fa694d9535012a0d708087 Mon Sep 17 00:00:00 2001 From: Nicky Date: Tue, 30 Jul 2024 19:21:38 +0100 Subject: [PATCH 6/7] feat: /send checks create UI --- apps/next/pages/checks/create.tsx | 2 + packages/app/components/SearchBar.tsx | 8 +- .../checks/components/ManageChecksBtn.tsx | 16 ++ .../components/claim/ClaimSendCheck.tsx | 76 +++++++--- .../components/claim/btn/ClaimButton.tsx | 4 +- .../check/check-data/CheckTokenAmount.tsx | 3 - .../claim/check/check-data/ShowCheckData.tsx | 24 +-- .../claim/check/check-data/TokenIcon.tsx | 32 ++-- .../components/create/CreateSendCheck.tsx | 124 ++++++++++++---- .../components/create/CreateSendCheckBtn.tsx | 27 ++++ .../components/create/ShareSendCheckURL.tsx | 138 ++++++++++++++++++ .../create/btn/CreateSendCheckBtn.tsx | 30 ---- packages/app/features/checks/types.ts | 29 ++-- .../app/features/checks/utils/checkUtils.ts | 2 +- .../checks/utils/getCreateSendCheckUserOp.ts | 6 +- .../checks/utils/useCreateSendCheck.ts | 24 ++- .../app/features/home/TokenBalanceCard.tsx | 2 +- packages/app/features/send/SendAmountForm.tsx | 10 +- .../features/send/components/SendTopNav.tsx | 2 +- packages/app/features/send/screen.tsx | 46 +++--- packages/app/routers/params.tsx | 13 ++ packages/app/utils/coin-gecko/index.tsx | 21 ++- packages/wagmi/src/generated.ts | 38 +++++ 23 files changed, 489 insertions(+), 188 deletions(-) create mode 100644 packages/app/features/checks/components/ManageChecksBtn.tsx create mode 100644 packages/app/features/checks/components/create/CreateSendCheckBtn.tsx create mode 100644 packages/app/features/checks/components/create/ShareSendCheckURL.tsx delete mode 100644 packages/app/features/checks/components/create/btn/CreateSendCheckBtn.tsx diff --git a/apps/next/pages/checks/create.tsx b/apps/next/pages/checks/create.tsx index 43e8054bb..e17d89ebb 100644 --- a/apps/next/pages/checks/create.tsx +++ b/apps/next/pages/checks/create.tsx @@ -2,6 +2,7 @@ import Head from 'next/head' import { userProtectedGetSSP } from 'utils/userProtected' import type { NextPageWithLayout } from '../_app' import { CreateSendCheck } from 'app/features/checks/components/create/CreateSendCheck' +import { CoinField } from 'app/components/FormFields' export const CreateSendCheckPage: NextPageWithLayout = () => { return ( @@ -10,6 +11,7 @@ export const CreateSendCheckPage: NextPageWithLayout = () => { Send | Checks + ) } diff --git a/packages/app/components/SearchBar.tsx b/packages/app/components/SearchBar.tsx index 5d03c8360..ee90ca3ef 100644 --- a/packages/app/components/SearchBar.tsx +++ b/packages/app/components/SearchBar.tsx @@ -23,7 +23,7 @@ import { SchemaForm } from 'app/utils/SchemaForm' import { shorten } from 'app/utils/strings' import { useSearchResultHref } from 'app/utils/useSearchResultHref' import * as Linking from 'expo-linking' -import { useEffect, useState } from 'react' +import { type PropsWithChildren, useEffect, useState } from 'react' import { FormProvider } from 'react-hook-form' import { Link } from 'solito/link' import { useRouter } from 'solito/router' @@ -45,7 +45,7 @@ const formatResultsKey = (str: string): string => { return str.replace(/_matches/g, '').replace(/_/g, ' ') } -function SearchResults() { +function SearchResults(props: PropsWithChildren) { const { results, isLoading, error } = useTagSearch() const [queryParams] = useRootScreenParams() const { search: query } = queryParams @@ -58,8 +58,10 @@ function SearchResults() { ) } + + // if there are no results or an error, show the children if (!results || error) { - return null + return props.children } if (isAddress(query ?? '')) { diff --git a/packages/app/features/checks/components/ManageChecksBtn.tsx b/packages/app/features/checks/components/ManageChecksBtn.tsx new file mode 100644 index 000000000..2110f81d9 --- /dev/null +++ b/packages/app/features/checks/components/ManageChecksBtn.tsx @@ -0,0 +1,16 @@ +import { Button, ButtonText } from '@my/ui' +import { useRouter } from 'solito/router' + +export const ManageChecksBtn = () => { + const router = useRouter() + + const onPress = () => { + router.push('/checks') + } + + return ( + + ) +} diff --git a/packages/app/features/checks/components/claim/ClaimSendCheck.tsx b/packages/app/features/checks/components/claim/ClaimSendCheck.tsx index 0edf82644..d51cdee1e 100644 --- a/packages/app/features/checks/components/claim/ClaimSendCheck.tsx +++ b/packages/app/features/checks/components/claim/ClaimSendCheck.tsx @@ -3,13 +3,14 @@ import { ClaimButtonGuest } from 'app/features/checks/components/claim/btn/Claim import { ClaimButtonUser } from 'app/features/checks/components/claim/btn/ClaimButtonUser' import { ShowCheckData } from 'app/features/checks/components/claim/check/check-data/ShowCheckData' import { useProfileLookup } from 'app/utils/useProfileLookup' -import { Spinner, YStack, Text, Button, ButtonText, XStack } from '@my/ui' +import { Spinner, YStack, Text, Button, ButtonText, XStack, Card, Label } from '@my/ui' import { useMemo, useState } from 'react' import { useSendCheckData } from 'app/features/checks/utils/useSendCheckData' import { IconError } from 'app/components/icons' import { useRouter } from 'next/navigation' -import { useTokenMetadata } from 'app/utils/coin-gecko' +import { useCoinGeckoTokenId, useTokenMarketData } from 'app/utils/coin-gecko' import { checkExists } from 'app/features/checks/utils/checkUtils' +import { GreenSquare } from 'app/features/home/TokenBalanceCard' interface Props { payload: ClaimSendCheckPayload @@ -27,9 +28,11 @@ export const ClaimSendCheck = (props: Props) => { } = useSendCheckData(props.payload.ephemeralKeypair.ephemeralAddress, { retry: 3, }) - const { data: tokenData } = useTokenMetadata(sendCheckData?.token as `0x${string}`, { - retry: false, + const { data: tokenId } = useCoinGeckoTokenId(sendCheckData?.token as `0x${string}`, { + retry: 3, }) + const { data: tokenData } = useTokenMarketData(tokenId) + const { data: profileData } = useProfileLookup('sendid', props.payload.senderSendId) const router = useRouter() @@ -73,29 +76,64 @@ export const ClaimSendCheck = (props: Props) => { ) } + const showHeader = () => { + return ( + + + + + ) + } + const showCheckData = () => { return ( - <> - {sendCheckData && ( - - )} + + + {showHeader()} + {sendCheckData && ( + + )} + {signedIn ? ( ) : ( - + )} - + ) } @@ -103,9 +141,5 @@ export const ClaimSendCheck = (props: Props) => { setError(error) } - return ( - - {isError ? showError(error) : sendCheckDataLoading ? showSpinner() : showCheckData()} - - ) + return isError ? showError(error) : sendCheckDataLoading ? showSpinner() : showCheckData() } diff --git a/packages/app/features/checks/components/claim/btn/ClaimButton.tsx b/packages/app/features/checks/components/claim/btn/ClaimButton.tsx index ba0b0f389..7c15d797b 100644 --- a/packages/app/features/checks/components/claim/btn/ClaimButton.tsx +++ b/packages/app/features/checks/components/claim/btn/ClaimButton.tsx @@ -3,7 +3,7 @@ import { CheckValue } from 'app/features/checks/components/claim/check/check-dat import { useState } from 'react' export interface ClaimSendCheckBtnProps { - tokenId?: number + tokenId?: string tokenAmount?: bigint } @@ -33,7 +33,7 @@ export const ClaimButton = (props: Props) => { {showCheckValue()} )} - {loading && } + {loading && } ) diff --git a/packages/app/features/checks/components/claim/check/check-data/CheckTokenAmount.tsx b/packages/app/features/checks/components/claim/check/check-data/CheckTokenAmount.tsx index 7dabe3a4c..272923da2 100644 --- a/packages/app/features/checks/components/claim/check/check-data/CheckTokenAmount.tsx +++ b/packages/app/features/checks/components/claim/check/check-data/CheckTokenAmount.tsx @@ -5,9 +5,7 @@ interface Props { tokenAmount: bigint tokenName: string tokenIconSize: number - tokenImageUrl?: string - tokenInfoUrl?: string } export const CheckTokenAmount = (props: Props) => { @@ -17,7 +15,6 @@ export const CheckTokenAmount = (props: Props) => { ) diff --git a/packages/app/features/checks/components/claim/check/check-data/ShowCheckData.tsx b/packages/app/features/checks/components/claim/check/check-data/ShowCheckData.tsx index d39eeafe0..3863fbfc6 100644 --- a/packages/app/features/checks/components/claim/check/check-data/ShowCheckData.tsx +++ b/packages/app/features/checks/components/claim/check/check-data/ShowCheckData.tsx @@ -1,12 +1,12 @@ import { Text, XStack, YStack } from '@my/ui' -import type { SendCheckData } from 'app/features/checks/types' +import type { SendCheckData, TokenMetadata } from 'app/features/checks/types' import { SenderData } from 'app/features/checks/components/claim/check/sender-data/SenderData' import { CheckTokenAmount } from 'app/features/checks/components/claim/check/check-data/CheckTokenAmount' import { useProfileLookup } from 'app/utils/useProfileLookup' interface Props { sendCheckData: SendCheckData - tokenData?: object + tokenMetadata?: TokenMetadata senderSendId: string onError: (error: Error) => void } @@ -21,29 +21,17 @@ export const ShowCheckData = (props: Props) => { } } - console.log(props.tokenData) - return ( - + {profileData?.name ? ' has sent you' : 'You have received'} diff --git a/packages/app/features/checks/components/claim/check/check-data/TokenIcon.tsx b/packages/app/features/checks/components/claim/check/check-data/TokenIcon.tsx index 0c16f7024..8062bf4c6 100644 --- a/packages/app/features/checks/components/claim/check/check-data/TokenIcon.tsx +++ b/packages/app/features/checks/components/claim/check/check-data/TokenIcon.tsx @@ -1,9 +1,8 @@ -import { Circle, Image, Link } from '@my/ui' +import { Circle, Image } from '@my/ui' interface Props { tokenImageUrl: string tokenName?: string - tokenInfoUrl?: string tokenIconSize?: number } @@ -14,23 +13,16 @@ export const TokenIcon = (props: Props) => { return props.tokenName ? `${props.tokenName} token icon` : 'token icon' } - const showTokenIcon = () => { - return ( - - {getIconAlt()} - - ) - } - return props.tokenInfoUrl ? ( - {showTokenIcon()} - ) : ( - showTokenIcon() + return ( + + {getIconAlt()} + ) } diff --git a/packages/app/features/checks/components/create/CreateSendCheck.tsx b/packages/app/features/checks/components/create/CreateSendCheck.tsx index 23a40a609..807e8247e 100644 --- a/packages/app/features/checks/components/create/CreateSendCheck.tsx +++ b/packages/app/features/checks/components/create/CreateSendCheck.tsx @@ -1,38 +1,104 @@ -import { CreateSendCheckBtn } from 'app/features/checks/components/create/btn/CreateSendCheckBtn' -import type { CreateSendCheckBtnProps, EphemeralKeyPair } from 'app/features/checks/types' -import { useEffect, useState } from 'react' +import { useMemo, useState } from 'react' +import { SendAmountForm } from 'app/features/send/SendAmountForm' +import { encodeClaimCheckUrl, generateEphemeralKeypair } from 'app/features/checks/utils/checkUtils' +import { useCreateSendCheck } from 'app/features/checks/utils/useCreateSendCheck' import type { Hex } from 'viem' -import { encodeClaimCheckUrl } from 'app/features/checks/utils/checkUtils' -import type { GetUserOperationReceiptReturnType } from 'permissionless' -import { baseMainnetClient, sendTokenAddress } from '@my/wagmi' +import { Label, Text, useToastController, XStack, YStack } from '@my/ui' +import { useSendScreenParams } from 'app/routers/params' +import { GreenSquare } from 'app/features/home/TokenBalanceCard' +import { ShareSendCheckURL } from 'app/features/checks/components/create/ShareSendCheckURL' +import { assert } from 'app/utils/assert' export const CreateSendCheck = () => { - const [createCheckProps, setCreateCheckProps] = useState() - - useEffect(() => { - // set defaults for /send check creation - setCreateCheckProps({ - // TODO: pass dynamic args from parent - tokenAddress: sendTokenAddress[baseMainnetClient.chain.id] as Hex, - amount: BigInt(100000), - onSuccess, - onError, + const [claimCheckUrl, setClaimCheckUrl] = useState() + const [{ sendToken, amount }] = useSendScreenParams() + + const ephemeralKeypair = useMemo(() => generateEphemeralKeypair(), []) + + const parsedToken: Hex = useMemo(() => { + return sendToken as Hex + }, [sendToken]) + + const parsedAmount: bigint = useMemo(() => { + return BigInt(amount ?? 0) + }, [amount]) + + const createSendCheck = useCreateSendCheck({ + ephemeralKeypair, + tokenAddress: sendToken as Hex, + amount: BigInt(amount ?? 0), + }) + + const toast = useToastController() + + const onError = (error?: Error | string) => { + if (error) { + toast.show(`Error creating send check. ${error}`, { + type: 'error', + duration: 5000, + }) + return + } + toast.show('Error creating send check', { + type: 'error', + duration: 5000, }) - }, []) - - const onSuccess = ( - receipt: GetUserOperationReceiptReturnType, - senderSendId: string, - ephemeralKeypair: EphemeralKeyPair - ) => { - const checkUrl: string = encodeClaimCheckUrl(senderSendId, ephemeralKeypair) - console.log(checkUrl) } - const onError = (error: Error) => { - // TODO: handle error creating send check - throw error + const onSubmit = async () => { + try { + validateSubmission(parsedToken, parsedAmount) + + const createSendCheckData = await createSendCheck() + if (!createSendCheckData) { + onError() + return + } + + const { receipt, senderSendId, ephemeralKeypair } = createSendCheckData + if (!receipt.success) { + onError('User operation failed.') + return + } + + // generate claim check url + const claimCheckUrl = + window.location.origin + encodeClaimCheckUrl(senderSendId, ephemeralKeypair) + setClaimCheckUrl(claimCheckUrl) + } catch (e) { + onError(e) + } + } + + if (claimCheckUrl) { + return } - return createCheckProps && + return ( + + + + + + + + + + Create a Check + + + Send money to anyone via a URL. + + + + + + ) +} + +const validateSubmission = (selectedCoin?: Hex, parsedAmount?: bigint) => { + assert(!!selectedCoin, 'Invalid coin') + assert(typeof parsedAmount === 'bigint' && parsedAmount > 0n, 'Invalid amount') } diff --git a/packages/app/features/checks/components/create/CreateSendCheckBtn.tsx b/packages/app/features/checks/components/create/CreateSendCheckBtn.tsx new file mode 100644 index 000000000..11feff4c3 --- /dev/null +++ b/packages/app/features/checks/components/create/CreateSendCheckBtn.tsx @@ -0,0 +1,27 @@ +import { Button, Text, YStack } from '@my/ui' +import { Link } from '@tamagui/lucide-icons' +import { ScreenParams, useSendScreenParams } from 'app/routers/params' + +export const CreateSendCheckBtn = () => { + const [sendParams, setSendParams] = useSendScreenParams() + + const onPress = () => { + setSendParams( + { + ...sendParams, + screen: ScreenParams.SEND_CHECKS, + }, + { webBehavior: 'replace' } + ) + } + + return ( + + ) +} diff --git a/packages/app/features/checks/components/create/ShareSendCheckURL.tsx b/packages/app/features/checks/components/create/ShareSendCheckURL.tsx new file mode 100644 index 000000000..65275d89e --- /dev/null +++ b/packages/app/features/checks/components/create/ShareSendCheckURL.tsx @@ -0,0 +1,138 @@ +import { + XStack, + YStack, + Text, + Label, + useToastController, + styled, + ScrollView, + Card, + Tooltip, +} from '@my/ui' +import { ManageChecksBtn } from 'app/features/checks/components/ManageChecksBtn' +import { GreenSquare } from 'app/features/home/TokenBalanceCard' +import { Clipboard as IconClipboard } from '@tamagui/lucide-icons' +import * as Clipboard from 'expo-clipboard' + +interface Props { + url: string +} + +const SelectableText = styled(Text, { + variants: { + selectable: { + true: { + userSelect: 'text', + cursor: 'text', + selectable: true, + }, + false: { + userSelect: 'none', + cursor: 'default', + selectable: true, + }, + }, + }, +}) + +export const ShareSendCheckURL = (props: Props) => { + const toast = useToastController() + + const onPress = async () => { + await Clipboard.setStringAsync(props.url) + toast.show('Copied to clipboard', { + type: 'success', + duration: 5000, + }) + } + + const showCopyLink = () => { + return ( + + + + + + {props.url} + + + + + ) + } + + return ( + + + + + + + + + Check created + + + Sharing the URL below will allow recipients to claim your check: + + + + {showCopyLink()} + + + Click to copy to clipboard + + + + + + + ) +} diff --git a/packages/app/features/checks/components/create/btn/CreateSendCheckBtn.tsx b/packages/app/features/checks/components/create/btn/CreateSendCheckBtn.tsx deleted file mode 100644 index eb7b1e1f2..000000000 --- a/packages/app/features/checks/components/create/btn/CreateSendCheckBtn.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Button } from '@my/ui' -import type { CreateSendCheckBtnProps } from 'app/features/checks/types' -import { generateEphemeralKeypair } from 'app/features/checks/utils/checkUtils' -import { useCreateSendCheck } from 'app/features/checks/utils/useCreateSendCheck' - -export const CreateSendCheckBtn = (props: CreateSendCheckBtnProps) => { - const ephemeralKeypair = generateEphemeralKeypair() - - const createSendCheck = useCreateSendCheck({ - ephemeralKeypair, - ...props, - }) - - const onPress = async () => { - try { - const createSendCheckData = await createSendCheck() - const { senderSendId, ephemeralKeypair, receipt } = createSendCheckData - - if (!receipt.success) { - props.onError(new Error('Error creating send check')) - return - } - props.onSuccess(receipt, senderSendId, ephemeralKeypair) - } catch (e) { - props.onError(e) - } - } - - return -} diff --git a/packages/app/features/checks/types.ts b/packages/app/features/checks/types.ts index aa39a6313..3d69f9693 100644 --- a/packages/app/features/checks/types.ts +++ b/packages/app/features/checks/types.ts @@ -13,26 +13,6 @@ export interface SendCheckData { note?: string } -/** - * Properties for the CreateSendCheck button component. - * - * @interface CreateSendCheckBtnProps - * @property {bigint} amount - The amount of the token to be sent. - * @property {Hex} tokenAddress - The address of the token. - * @property {(receipt: GetUserOperationReceiptReturnType, senderSendId: string, EphemeralKeyPair: Hex) => void} onSuccess - Callback function to be called upon successful check creation and sending. Receives the sender's account ID and the ephemeral private key used in the operation. - * @property {(error: Error) => void} onError - Callback function to be called in case of an error during the check creation or sending process. - */ -export interface CreateSendCheckBtnProps { - amount: bigint - tokenAddress: Hex - onSuccess: ( - receipt: GetUserOperationReceiptReturnType, - senderSendId: string, - ephemeralKeypair: EphemeralKeyPair - ) => void - onError: (error: Error) => void -} - /** * Defines the properties required to create and send a check. * @interface CreateSendCheckProps @@ -118,3 +98,12 @@ export interface EphemeralKeyPair { ephemeralPrivateKey: `0x${string}` ephemeralAddress: `0x${string}` } + +/** + * Represents the token metadata shown on the claim /send check page. + */ +export interface TokenMetadata { + name: string + imageUrl: string + coinGeckoTokenId: string +} diff --git a/packages/app/features/checks/utils/checkUtils.ts b/packages/app/features/checks/utils/checkUtils.ts index dc8a56c03..7d1cb3402 100644 --- a/packages/app/features/checks/utils/checkUtils.ts +++ b/packages/app/features/checks/utils/checkUtils.ts @@ -18,7 +18,7 @@ import { defaultUserOp } from 'app/utils/useUserOpTransferMutation' * @returns {string} The generated URL for claiming the check. */ export const encodeClaimCheckUrl = ( - senderSendId: string, + senderSendId: number, ephemeralKeyPair: EphemeralKeyPair ): string => { // TODO: /send check notes: add note field in encoded payload diff --git a/packages/app/features/checks/utils/getCreateSendCheckUserOp.ts b/packages/app/features/checks/utils/getCreateSendCheckUserOp.ts index 3fbc8f5fb..101dfa9fd 100644 --- a/packages/app/features/checks/utils/getCreateSendCheckUserOp.ts +++ b/packages/app/features/checks/utils/getCreateSendCheckUserOp.ts @@ -69,7 +69,11 @@ const getCallData = (props: CreateSendCheckUserOpProps): Hex => { } const validateCreateSendCheckUserOpProps = (props: CreateSendCheckUserOpProps) => { - assert(!!sendCheckAddress[baseMainnetClient.chain.id], 'Invalid send check address') + assert(!!sendCheckAddress, 'Undefined send checks address') + assert( + !!sendCheckAddress[baseMainnetClient.chain.id], + `Undefined send checks address for chain ${baseMainnetClient.chain.id}` + ) assert(!!props.maxFeesPerGas && typeof props.maxFeesPerGas === 'bigint', 'Invalid maxFeesPerGas') assert(!!props.tokenAddress && isAddress(props.tokenAddress), 'Invalid token address') assert( diff --git a/packages/app/features/checks/utils/useCreateSendCheck.ts b/packages/app/features/checks/utils/useCreateSendCheck.ts index 1e8c0cadc..bbfd3b4e9 100644 --- a/packages/app/features/checks/utils/useCreateSendCheck.ts +++ b/packages/app/features/checks/utils/useCreateSendCheck.ts @@ -13,6 +13,8 @@ import { getCreateSendCheckUserOp } from 'app/features/checks/utils/getCreateSen import { useEstimateFeesPerGas } from 'wagmi' import { baseMainnetClient } from '@my/wagmi' import { useUser } from 'app/utils/useUser' +import { type Hex, isAddress } from 'viem' +import { assert } from 'app/utils/assert' const logger = debug.log @@ -31,9 +33,13 @@ export const useCreateSendCheck = (props: CreateSendCheckProps): useCreateSendCh // get /send check creation user op return async () => { - if (!profile?.send_id || !nonce || !sendAccount?.address || !feesPerGas) { - return - } + validateCreateSendCheckArgs( + profile?.send_id, + nonce, + sendAccount?.address, + feesPerGas?.maxFeePerGas + ) + const userOpProps: CreateSendCheckUserOpProps = { senderAddress: sendAccount?.address as `0x${string}`, nonce: nonce as bigint, @@ -74,3 +80,15 @@ export const createSendCheck = async ( logger(`/send check creation trn hash: [${receipt.receipt.transactionHash}]`) return receipt } + +const validateCreateSendCheckArgs = ( + sendId?: number, + nonce?: bigint, + senderAddress?: Hex, + feesPerGas?: bigint +) => { + assert(!!sendId, 'Invalid send ID.') + assert(typeof nonce === 'bigint' && nonce >= 0n, `Invalid nonce. Received: ${nonce}`) + assert(!!senderAddress && isAddress(senderAddress), 'Invalid sender address.') + assert(!!feesPerGas && feesPerGas > 0n, `Invalid fees per gas. Received: ${feesPerGas}`) +} diff --git a/packages/app/features/home/TokenBalanceCard.tsx b/packages/app/features/home/TokenBalanceCard.tsx index 38fd1a59b..d0dfb7b84 100644 --- a/packages/app/features/home/TokenBalanceCard.tsx +++ b/packages/app/features/home/TokenBalanceCard.tsx @@ -18,7 +18,7 @@ const USDollar = new Intl.NumberFormat('en-US', { currency: 'USD', }) -const GreenSquare = styled(Stack, { +export const GreenSquare = styled(Stack, { name: 'Surface', w: 11, h: 11, diff --git a/packages/app/features/send/SendAmountForm.tsx b/packages/app/features/send/SendAmountForm.tsx index 8896fc3fe..2564f0723 100644 --- a/packages/app/features/send/SendAmountForm.tsx +++ b/packages/app/features/send/SendAmountForm.tsx @@ -13,6 +13,10 @@ import { useBalance } from 'wagmi' import { z } from 'zod' import { SendRecipient } from './confirm/screen' +interface Props { + onSubmit?: () => Promise +} + const removeDuplicateInString = (text: string, substring: string) => { const [first, ...after] = text.split(substring) return first + (after.length ? `${substring}${after.join('')}` : '') @@ -23,7 +27,7 @@ const SendAmountSchema = z.object({ token: formFields.coin, }) -export function SendAmountForm() { +export function SendAmountForm(props: Props) { const form = useForm>() const { data: sendAccount } = useSendAccount() const router = useRouter() @@ -89,7 +93,7 @@ export function SendAmountForm() { {({ amount, token }) => ( - + {sendParams.recipient && } {amount} - {sendParams.recipient ? 'Enter Amount' : 'Select Recipient'} + {sendParams.recipient ? 'Enter Amount' : sendParams.screen || 'Select Recipient'} diff --git a/packages/app/features/send/screen.tsx b/packages/app/features/send/screen.tsx index c80498fd5..429195ece 100644 --- a/packages/app/features/send/screen.tsx +++ b/packages/app/features/send/screen.tsx @@ -13,15 +13,17 @@ import { } from '@my/ui' import Search from 'app/components/SearchBar' import { TagSearchProvider, useTagSearch } from 'app/provider/tag-search' -import { useSendScreenParams } from 'app/routers/params' +import { ScreenParams, useSendScreenParams } from 'app/routers/params' import { useProfileLookup } from 'app/utils/useProfileLookup' import { useState } from 'react' import { SendAmountForm } from './SendAmountForm' import { SendRecipient } from './confirm/screen' import { type Address, isAddress } from 'viem' +import { CreateSendCheckBtn } from 'app/features/checks/components/create/CreateSendCheckBtn' +import { CreateSendCheck } from 'app/features/checks/components/create/CreateSendCheck' export const SendScreen = () => { - const [{ recipient, idType }] = useSendScreenParams() + const [{ recipient, idType, screen }] = useSendScreenParams() const { data: profile, isLoading, error } = useProfileLookup(idType ?? 'tag', recipient ?? '') if (isLoading) return if (error) throw new Error(error.message) @@ -29,6 +31,11 @@ export const SendScreen = () => { if (idType === 'address' && isAddress(recipient as Address)) { return } + + if (!recipient && screen === ScreenParams.SEND_CHECKS) { + return + } + if (!profile) return ( @@ -56,6 +63,14 @@ export const SendScreen = () => { function SendSearchBody() { const { isLoading, error } = useTagSearch() + const noSearchResultsPlaceholder = () => { + return ( + + + + ) + } + return ( {isLoading && ( @@ -69,7 +84,10 @@ function SendSearchBody() { {error.message} )} - + + {/** Render a placeholder if there are no search results */} + {noSearchResultsPlaceholder()} + ) } @@ -77,6 +95,7 @@ function SendSearchBody() { function NoSendAccount({ profile }: { profile: Functions<'profile_lookup'>[number] }) { const toast = useToastController() const [clicked, setClicked] = useState(false) + return ( @@ -101,27 +120,6 @@ function NoSendAccount({ profile }: { profile: Functions<'profile_lookup'>[numbe has no send account! Ask them to create one or write a /send Check. - - - {clicked && ( - - - /send Checks Coming Soon - - - )} ) } diff --git a/packages/app/routers/params.tsx b/packages/app/routers/params.tsx index b90ab850b..4f975e6a5 100644 --- a/packages/app/routers/params.tsx +++ b/packages/app/routers/params.tsx @@ -5,6 +5,10 @@ import type { Address } from 'viem' export type RootParams = { nav?: 'home' | 'settings'; token?: string; search?: string } +export enum ScreenParams { + SEND_CHECKS = 'Send Checks', +} + const { useParam: useRootParam, useParams: useRootParams } = createParam() const useNav = () => { @@ -72,6 +76,7 @@ export type SendScreenParams = { amount?: string sendToken?: `0x${string}` | 'eth' note?: string + screen?: ScreenParams } const { useParam: useSendParam, useParams: useSendParams } = createParam() @@ -97,6 +102,12 @@ const useAmount = () => { return [amount, setAmountParam] as const } +const useScreen = () => { + const [screen, useScreen] = useSendParam('screen') + + return [screen, useScreen] as const +} + export const useSendToken = () => { const [sendToken, setSendTokenParam] = useSendParam('sendToken', { initial: usdcAddress[baseMainnet.id], @@ -118,6 +129,7 @@ export const useSendScreenParams = () => { const [amount] = useAmount() const [sendToken] = useSendToken() const [note] = useNote() + const [screen] = useScreen() return [ { @@ -126,6 +138,7 @@ export const useSendScreenParams = () => { amount, sendToken, note, + screen, }, setParams, ] as const diff --git a/packages/app/utils/coin-gecko/index.tsx b/packages/app/utils/coin-gecko/index.tsx index 0b252973e..c0e4250f1 100644 --- a/packages/app/utils/coin-gecko/index.tsx +++ b/packages/app/utils/coin-gecko/index.tsx @@ -1,5 +1,5 @@ import { type UseQueryResult, useQuery } from '@tanstack/react-query' -import type { coins } from 'app/data/coins' +import { coins } from 'app/data/coins' import type { Hex } from 'viem' import { z } from 'zod' @@ -66,7 +66,7 @@ export const useSendPrice = () => useTokenPrice('send-token' as const) /** * Fetch coin market data */ -export const useTokenMarketData = (tokenId: T) => { +export const useTokenMarketData = (tokenId?: T) => { return useQuery({ queryKey: ['coin-market-data', tokenId], queryFn: async () => { @@ -80,17 +80,23 @@ export const useTokenMarketData = ( return MarketDataSchema.parse(data) }, staleTime: 1000 * 60 * 5, // 5 minutes + enabled: !!tokenId, }) } /** - * Fetch token metadata (from contract address) + * Get a token's goin gecko id from its contract address * @param {Hex} contractAddress - token contract address */ -export const useTokenMetadata = (contractAddress: Hex, queryParams?): UseQueryResult => { +export const useCoinGeckoTokenId = (contractAddress: Hex, queryParams?): UseQueryResult => { return useQuery({ - queryKey: ['token-thumbnail', contractAddress], - queryFn: async (): Promise => { + queryKey: ['get-coin-gecko-token-id', contractAddress], + queryFn: async (): Promise => { + const coin = coins.find((coin) => coin.token === contractAddress) + if (coin) { + return coin.coingeckoTokenId + } + const response = await fetch( `https://api.coingecko.com/api/v3/coins/ethereum?contract_address=${contractAddress}&vs_currency=usd` ) @@ -98,8 +104,7 @@ export const useTokenMetadata = (contractAddress: Hex, queryParams?): UseQueryRe if (!response.ok) throw new Error(`Failed to fetch token metadata ${contractAddress} ${response.status}`) const data = await response.json() - - return data + return data.id }, enabled: !!contractAddress, ...queryParams, diff --git a/packages/wagmi/src/generated.ts b/packages/wagmi/src/generated.ts index 06839708a..ddae8eeff 100644 --- a/packages/wagmi/src/generated.ts +++ b/packages/wagmi/src/generated.ts @@ -2347,6 +2347,9 @@ export const sendAirdropsSafeConfig = { ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // SendCheck ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +export const sendCheckAddress = { + 845337: '0x6f6f570f45833e249e27022648a26f4076f48f78' +} export const sendCheckAbi = [ { @@ -2389,6 +2392,25 @@ export const sendCheckAbi = [ outputs: [], stateMutability: 'nonpayable', }, + { + type: 'function', + inputs: [{ name: 'ephemeralAddress', internalType: 'address', type: 'address' }], + name: 'getCheck', + outputs: [ + { + name: '', + internalType: 'struct Check', + type: 'tuple', + components: [ + { name: 'ephemeralAddress', internalType: 'address', type: 'address' }, + { name: 'from', internalType: 'address', type: 'address' }, + { name: 'amount', internalType: 'uint256', type: 'uint256' }, + { name: 'token', internalType: 'contract IERC20', type: 'address' }, + ], + }, + ], + stateMutability: 'view', + }, { type: 'event', anonymous: false, @@ -6275,6 +6297,14 @@ export const readSendCheckChecks = /*#__PURE__*/ createReadContract({ functionName: 'checks', }) +/** + * Wraps __{@link readContract}__ with `abi` set to __{@link sendCheckAbi}__ and `functionName` set to `"getCheck"` + */ +export const readSendCheckGetCheck = /*#__PURE__*/ createReadContract({ + abi: sendCheckAbi, + functionName: 'getCheck', +}) + /** * Wraps __{@link writeContract}__ with `abi` set to __{@link sendCheckAbi}__ */ @@ -10967,6 +10997,14 @@ export const useReadSendCheckChecks = /*#__PURE__*/ createUseReadContract({ functionName: 'checks', }) +/** + * Wraps __{@link useReadContract}__ with `abi` set to __{@link sendCheckAbi}__ and `functionName` set to `"getCheck"` + */ +export const useReadSendCheckGetCheck = /*#__PURE__*/ createUseReadContract({ + abi: sendCheckAbi, + functionName: 'getCheck', +}) + /** * Wraps __{@link useWriteContract}__ with `abi` set to __{@link sendCheckAbi}__ */ From 75be2e2f5d9d88a0836a4b6d11f150bc02089f59 Mon Sep 17 00:00:00 2001 From: Nicky Date: Wed, 7 Aug 2024 22:37:20 +0100 Subject: [PATCH 7/7] CREATE2 for DeploySendCheck --- packages/contracts/script/DeploySendCheck.s.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts/script/DeploySendCheck.s.sol b/packages/contracts/script/DeploySendCheck.s.sol index c316686f5..6bd3f2da7 100644 --- a/packages/contracts/script/DeploySendCheck.s.sol +++ b/packages/contracts/script/DeploySendCheck.s.sol @@ -13,7 +13,7 @@ contract DeploySendCheckScript is Script, Helper { function run() external returns (SendCheck) { vm.startBroadcast(); - SendCheck sendCheck = new SendCheck(); + SendCheck sendCheck = new SendCheck{salt: keccak256("SendCheck")}(); vm.stopBroadcast(); return sendCheck;