diff --git a/packages/sdk/src/TokenboundClient.ts b/packages/sdk/src/TokenboundClient.ts index 11f3d9e..abf4c64 100644 --- a/packages/sdk/src/TokenboundClient.ts +++ b/packages/sdk/src/TokenboundClient.ts @@ -7,9 +7,13 @@ import { hexToNumber, getAddress, encodeFunctionData, + // Abi, + parseUnits, + BaseError, + ContractFunctionRevertedError, Abi, } from "viem" -import { erc6551AccountAbi, erc6551RegistryAbi, erc1155Abi, erc721Abi } from '../abis' +import { erc6551AccountAbi, erc6551RegistryAbi, erc1155Abi, erc721Abi, erc20Abi } from '../abis' import { getAccount, computeAccount, @@ -22,9 +26,10 @@ import { import { AbstractEthersSigner, AbstractEthersTransactionResponse, - SegmentedERC1155Bytecode + SegmentedERC6551Bytecode } from "./types" import { chainIdToChain, segmentBytecode } from "./utils" +import { normalize } from "viem/ens" export const NFTTokenType = { ERC721: "ERC721", @@ -314,9 +319,9 @@ class TokenboundClient { /** * Deconstructs the bytecode of a tokenbound account into its constituent parts. * @param {`0x${string}`} params.accountAddress The address of the tokenbound account. - * @returns a Promise that resolves to a SegmentedERC1155Bytecode object, or null if the account is not deployed + * @returns a Promise that resolves to a SegmentedERC6551Bytecode object, or null if the account is not deployed */ - public async deconstructBytecode({accountAddress}: BytecodeParams): Promise { + public async deconstructBytecode({accountAddress}: BytecodeParams): Promise { try { const rawBytecode = await this.publicClient.getBytecode({address: accountAddress}) @@ -434,6 +439,13 @@ class TokenboundClient { try { + // return await this.executeCall({ + // account: tbAccountAddress, + // to: tokenContract, + // value: BigInt(0), + // data: transferCallData + // }) + if(this.signer) { // Ethers const preparedNFTTransfer = { @@ -466,6 +478,127 @@ class TokenboundClient { } + /** + * Executes an ETH transfer call on a tokenbound account + * @param {string} params.account The tokenbound account address + * @param {number} params.amount The amount of ETH to transfer, in decimal format (eg. 0.1 ETH = 0.1) + * @param {string} params.recipientAddress The address to which the ETH should be transferred + * @returns a Promise that resolves to the transaction hash of the executed call + */ + public async transferETH(params: ETHTransferParams): Promise<`0x${string}`> { + const { + account: tbAccountAddress, + amount, + recipientAddress + } = params + + const weiValue = parseUnits(`${amount}`, 18) // convert ETH to wei + let recipient = getAddress(recipientAddress) + + // @BJ todo: debug + // const isENS = recipientAddress.endsWith(".eth") + // if (isENS) { + // recipient = await this.publicClient.getEnsResolver({name: normalize(recipientAddress)}) + // if (!recipient) { + // throw new Error('Failed to resolve ENS address'); + // } + // } + // console.log('RECIPIENT_ADDRESS', recipient) + + try { + return await this.executeCall({ + account: tbAccountAddress, + to: recipient, + value: weiValue, + data: '0x' + }) + + } catch(err) { + console.log(err) + if (err instanceof BaseError) { + const revertError = err.walk(err => err instanceof ContractFunctionRevertedError) + if (revertError instanceof ContractFunctionRevertedError) { + const errorName = revertError.data?.errorName ?? '' + console.log('ERROR NAME', errorName) + console.log('REVERT ERROR DATA', revertError) + // do something with `errorName` + } + } + throw err + } + + } + + /** + * Executes an ERC-20 transfer call on a tokenbound account + * @param {string} params.account The tokenbound account address + * @param {number} params.amount The amount of ERC-20 to transfer, in decimal format (eg. 0.1 USDC = 0.1) + * @param {string} params.recipientAddress The address to which the ETH should be transferred + * @param {string} params.erc20tokenAddress The address of the ERC-20 token contract + * @param {string} params.erc20tokenDecimals The decimal specification of the ERC-20 token + * @returns a Promise that resolves to the transaction hash of the executed call + */ + public async transferERC20(params: ERC20TransferParams): Promise<`0x${string}`> { + const { + account: tbAccountAddress, + amount, + recipientAddress, + erc20tokenAddress, + erc20tokenDecimals, + } = params + + if(erc20tokenDecimals < 0 || erc20tokenDecimals > 18) throw new Error("Decimal value out of range. Should be between 0 and 18.") + + const amountBaseUnit = parseUnits(`${amount}`, erc20tokenDecimals) + + const recipient = recipientAddress.endsWith(".eth") + ? await this.publicClient.getEnsResolver({name: normalize(recipientAddress)}) + : recipientAddress + + const callData = encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [recipient, amountBaseUnit], + }) + + const unencodedTransferERC20ExecuteCall = { + abi: erc6551AccountAbi, + functionName: 'executeCall', + args: [erc20tokenAddress, 0, callData], + } + + try { + + if(this.signer) { // Ethers + + const preparedERC20Transfer = { + to: tbAccountAddress, + value: BigInt(0), + data: encodeFunctionData(unencodedTransferERC20ExecuteCall), + } + + return await this.signer.sendTransaction(preparedERC20Transfer).then((tx:AbstractEthersTransactionResponse) => tx.hash) as `0x${string}` + + } + else if(this.walletClient) { + const { request } = await this.publicClient.simulateContract({ + address: getAddress(tbAccountAddress), + account: this.walletClient.account, + ...unencodedTransferERC20ExecuteCall + }) + + return await this.walletClient.writeContract(request) + } + else { + throw new Error("No wallet client or signer available.") + } + + } catch (error) { + console.log(error) + throw error + } + } + } export { diff --git a/packages/sdk/src/test/TestAll.test.ts b/packages/sdk/src/test/TestAll.test.ts index 6ae8bb1..230823b 100644 --- a/packages/sdk/src/test/TestAll.test.ts +++ b/packages/sdk/src/test/TestAll.test.ts @@ -13,6 +13,9 @@ import { encodeFunctionData, Log, parseUnits, + formatEther, + encodeAbiParameters, + parseAbiParameters, } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { @@ -28,10 +31,23 @@ const TIMEOUT = 60000 // default 10000 const ANVIL_CONFIG: CreateAnvilOptions = { forkChainId: ACTIVE_CHAIN.id, + // gasLimit: 1000000000000, + // disableBlockGasLimit: true, + // blockBaseFeePerGas: 300000000, forkUrl: import.meta.env.VITE_ANVIL_MAINNET_FORK_ENDPOINT, forkBlockNumber: import.meta.env.VITE_ANVIL_MAINNET_FORK_BLOCK_NUMBER? parseInt(import.meta.env.VITE_ANVIL_MAINNET_FORK_BLOCK_NUMBER): undefined, } +const ANVIL_USER_0 = getAddress(ANVIL_ACCOUNTS[0].address) +const ANVIL_USER_1 = getAddress(ANVIL_ACCOUNTS[1].address) + +// const ANVIL_COMMAND = { +// // SET_ADDRESS_BYTECODE: 'cast rpc anvil_setCode 0x4e59b44847b379578588920ca78fbf26c0b4956c 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3', +// SET_ADDRESS_BYTECODE: `cast rpc anvil_setCode ${ANVIL_ACCOUNTS[0].address} 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3`, +// DEPLOY_REGISTRY: 'forge script --fork-url http://127.0.0.1:8545 6551contracts/script/DeployRegistry.s.sol --broadcast', +// DEPLOY_ACCOUNT_IMPLEMENTATION: `forge create 6551contracts/src/examples/simple/SimpleERC6551Account.sol:SimpleERC6551Account --rpc-url=$RPC_URL --private-key=$PRIVATE_KEY` +// } + // Zora Webb's First Deep Field: https://zora.co/collect/eth:0x28ee638f2fcb66b4106acab7efd225aeb2bd7e8d const ZORA_WEBB_TOKEN_PROXY_ADDRESS = getAddress('0x28ee638f2fcb66b4106acab7efd225aeb2bd7e8d') @@ -40,6 +56,14 @@ const ZORA_WEBB_TOKEN_TBA: `0x${string}` = getAddress('0xc33f0A7FcD69Ba00b4e9804 const TOKENID_IN_EOA: string = '10010' const TOKENID_IN_TBA: string = '10011' +async function getZora721Balance({publicClient, walletAddress}:{publicClient: PublicClient, walletAddress: `0x${string}`}) { + return await publicClient.readContract({ + address: ZORA_WEBB_TOKEN_PROXY_ADDRESS, + abi: zora721DropABI, + functionName: 'balanceOf', + args: [walletAddress] + }) +} describe('ComboTester', () => { @@ -99,7 +123,7 @@ function runTxTests({ }) await anvil.start() - console.log(`\x1b[94m ${testName}-----> anvil.start() \x1b[0m`); + console.log(`\x1b[94m ${testName}-----> anvil.start() \x1b[0m`) } catch (err) { console.error('Error during setup:', err) @@ -122,7 +146,7 @@ function runTxTests({ abi: zora721DropABI, eventName: 'Transfer', args: { - to: ANVIL_ACCOUNTS[0].address + to: ANVIL_USER_0 }, onLogs: (logs) => { mintLogs = logs @@ -157,7 +181,7 @@ function runTxTests({ if (walletClient) { mintTxHash = await walletClient.sendTransaction({ chain: ACTIVE_CHAIN, - account: getAddress(ANVIL_ACCOUNTS[0].address), + account: ANVIL_USER_0, ...prepared721Mint }) } @@ -168,10 +192,13 @@ function runTxTests({ }).then((tx: providers.TransactionResponse) => tx.hash) } + const zoraBalanceInAnvilWallet = await getZora721Balance({publicClient, walletAddress: ANVIL_USER_0}) + await waitFor(() => { expect(mintLogs.length).toBe(mintQuantity) expect(mintTxHash).toMatch(ADDRESS_REGEX) expect(ZORA_WEBB_TOKEN.tokenId).toBe(TOKENID_IN_EOA) + expect(zoraBalanceInAnvilWallet).toBe(2n) unwatch() }) @@ -179,8 +206,6 @@ function runTxTests({ it('can transfer one of the minted NFTs to the TBA', async () => { - const ANVIL_USER_0 = getAddress(ANVIL_ACCOUNTS[0].address) - const transferCallData = encodeFunctionData({ abi: zora721DropABI, functionName: 'safeTransferFrom', @@ -192,12 +217,12 @@ function runTxTests({ }) const preparedNFTTransfer = { - to: ZORA_WEBB_TOKEN_TBA, + to: ZORA_WEBB_TOKEN_PROXY_ADDRESS, value: 0n, data: transferCallData, } - let transferHash: string + let transferHash: `0x${string}` if (walletClient) { transferHash = await walletClient.sendTransaction({ @@ -206,15 +231,24 @@ function runTxTests({ ...preparedNFTTransfer }) } - else if (signer) { + else { transferHash = await signer.sendTransaction({ chainId: ACTIVE_CHAIN.id, ...preparedNFTTransfer }).then((tx: providers.TransactionResponse) => tx.hash) } + const transactionReceipt = await publicClient.getTransactionReceipt({ + hash: transferHash + }) + + const tbaNFTBalance = await getZora721Balance({publicClient, walletAddress: ZORA_WEBB_TOKEN_TBA}) + console.log('# of NFTs in TBA: ', tbaNFTBalance) + await waitFor(() => { expect(transferHash).toMatch(ADDRESS_REGEX) + expect(transactionReceipt.status).toBe('success') + expect(tbaNFTBalance).toBe(1n) }) }, TIMEOUT) @@ -239,52 +273,144 @@ function runTxTests({ const ethAmount = 1 const ethAmountWei = parseUnits(`${ethAmount}`, 18) - const ANVIL_USER_0 = getAddress(ANVIL_ACCOUNTS[0].address) const preparedETHTransfer = { to: ZORA_WEBB_TOKEN_TBA, value: ethAmountWei, - data: '0x', + // data is optional if nil } let transferHash: `0x${string}` if (walletClient) { transferHash = await walletClient.sendTransaction({ chain: ACTIVE_CHAIN, - account: ANVIL_USER_0, - ...{preparedETHTransfer} + account: walletClient.account!.address, + ...preparedETHTransfer }) - } - else if (signer) { + } else { transferHash = await signer.sendTransaction({ chainId: ACTIVE_CHAIN.id, ...preparedETHTransfer }).then((tx: providers.TransactionResponse) => tx.hash) } + const balanceAfter = await publicClient.getBalance({ + address: ZORA_WEBB_TOKEN_TBA, + }) + await waitFor(() => { expect(transferHash).toMatch(ADDRESS_REGEX) + expect(balanceAfter).toBe(ethAmountWei) }) - }) + }, TIMEOUT) it('can executeCall with the TBA', async () => { const executedCallTxHash = await tokenboundClient.executeCall({ - account: ZORA_WEBB_TOKEN_TBA, // In viem, we get 'No Signer available' + account: ZORA_WEBB_TOKEN_TBA, to: ZORA_WEBB_TOKEN_PROXY_ADDRESS, value: 0n, data: '', }) + const transactionReceipt = await publicClient.getTransactionReceipt({ + hash: executedCallTxHash + }) + await waitFor(() => { expect(executedCallTxHash).toMatch(ADDRESS_REGEX) + expect(transactionReceipt.status).toMatch('success') }) }, TIMEOUT) - test.todo('can transferETH with the TBA', async () => {}) - test.todo('can transferNFT with the TBA', async () => {}) - test.todo('can mint an 1155', async () => {}) + it('can transferETH with the TBA', async () => { + + const EXPECTED_BALANCE_BEFORE = parseUnits('1', 18) + const EXPECTED_BALANCE_AFTER = parseUnits('0.5', 18) + + const balanceBefore = await publicClient.getBalance({ + address: ZORA_WEBB_TOKEN_TBA, + }) + const ethTransferHash = await tokenboundClient.transferETH({ + account: ZORA_WEBB_TOKEN_TBA, + amount: 0.5, + recipientAddress: ANVIL_USER_1 + }) + const balanceAfter = await publicClient.getBalance({ + address: ZORA_WEBB_TOKEN_TBA, + }) + + console.log('BEFORE: ', formatEther(balanceBefore), 'AFTER: ', formatEther(balanceAfter)) + + await waitFor(() => { + expect(ethTransferHash).toMatch(ADDRESS_REGEX) + expect(balanceBefore).toBe(EXPECTED_BALANCE_BEFORE) + expect(balanceAfter).toBe(EXPECTED_BALANCE_AFTER) + }) + }) + + it('can transferNFT with the TBA', async () => { + const transferNFTHash = await tokenboundClient.transferNFT({ + account: ZORA_WEBB_TOKEN_TBA, + tokenType: 'ERC721', + tokenContract: ZORA_WEBB_TOKEN_PROXY_ADDRESS, + tokenId: TOKENID_IN_TBA, + recipientAddress: ANVIL_USER_1, + }) + + const anvilAccount1NFTBalance = await getZora721Balance({publicClient, walletAddress: ANVIL_USER_1}) + + await waitFor(() => { + expect(transferNFTHash).toMatch(ADDRESS_REGEX) + expect(anvilAccount1NFTBalance).toBe(1n) + }) + }) + + // it('can mint an 1155', async () => { + + // const ethToWei = function (eth: number) { + // return parseUnits(eth.toString(), 18) + // } + + // // Stapleverse 'Pidge in Hand' drop: https://zora.co/collect/eth:0xafd7b9edc5827f7e39dcd425d8de8d4e1cb292c1/3 + // const zora1155MinterAddress = getAddress('0x8A1DBE9b1CeB1d17f92Bebf10216FCFAb5C3fbA7') // IMinter1155 minter contract is FIXED_PRICE_SALE_STRATEGY from https://github.com/ourzora/zora-1155-contracts/blob/main/addresses/1.json + // // proxyContractAddress: `0x${string}` = '0xafd7b9edc5827f7e39dcd425d8de8d4e1cb292c1' + // const stapleversePidgeInHandDrop = { + // proxyContractAddress: getAddress('0xafd7b9edc5827f7e39dcd425d8de8d4e1cb292c1'), // proxy address + // tokenId: BigInt(3), + // mintFee: ethToWei(0.025), // 0.025 ETH + // quantity: BigInt(1), + // } + + // const minterArguments: `0x${string}` = encodeAbiParameters( + // parseAbiParameters('address'), + // [address] + // ) + + // // const { + // // config, + // // // error + // // } = usePrepareContractWrite({ + // // chainId: 1, + // // account: address, + // // abi: zora1155ABI, + // // address: stapleversePidgeInHandDrop.proxyContractAddress, + // // functionName: 'mint', + // // walletClient, + // // value: stapleversePidgeInHandDrop.mintFee, + // // args: [ + // // zora1155MinterAddress, + // // stapleversePidgeInHandDrop.tokenId, + // // stapleversePidgeInHandDrop.quantity, + // // minterArguments, + // // ], + // // }) + + + // }) + + test.todo('can transferNFT with an 1155', async () => {}) test.todo('can transferERC20', async () => {}) diff --git a/packages/sdk/src/test/utils/debug.ts b/packages/sdk/src/test/utils/debug.ts new file mode 100644 index 0000000..cf81e55 --- /dev/null +++ b/packages/sdk/src/test/utils/debug.ts @@ -0,0 +1,18 @@ +import { PublicClient } from 'viem' + +/** + * Emits console output related to the given transaction hash for debugging purposes. + * + * @param publicClient - The viem public client instance. + * @param hash - The transaction hash to debug. + * @returns void + */ +export async function debugTransaction({publicClient, hash}:{publicClient: PublicClient, hash: `0x${string}`}) { + + console.log('DEBUGGING TRANSACTION: ', hash) + const transactionReceipt = await publicClient.getTransactionReceipt({ + hash + }) + + console.log('transactionReceipt', transactionReceipt) + } \ No newline at end of file diff --git a/packages/sdk/src/types/erc1155Bytecode.ts b/packages/sdk/src/types/erc1155Bytecode.ts index bd04b93..9bf8555 100644 --- a/packages/sdk/src/types/erc1155Bytecode.ts +++ b/packages/sdk/src/types/erc1155Bytecode.ts @@ -1,6 +1,6 @@ -// Segmented ERC1155 bytecode +// Segmented ERC6551 bytecode -export type SegmentedERC1155Bytecode = { +export type SegmentedERC6551Bytecode = { erc1167Header: string, // 10 bytes implementationAddress: `0x${string}`, // 20 bytes erc1167Footer: string, // 15 bytes