Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

fix(unlock-app): checkout frame errors #14912

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions unlock-app/app/frames/checkout/approve/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Abi, encodeFunctionData, erc20Abi } from 'viem'
import { frames } from '../frames'
import { transaction } from 'frames.js/core'

export const POST = frames(async (ctx) => {
if (!ctx?.message) {
throw new Error('Invalid frame message')
}

const lock = ctx.state.lock!
const { address: lockAddress, network, priceForUser } = lock

const calldata = encodeFunctionData({
Copy link
Member

Choose a reason for hiding this comment

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

You don't need to use that! unlock-js has the logic you need to use.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I didn’t catch that, the encodeFunctionData part or the entire erc20 approve 🤔

Copy link
Member

Choose a reason for hiding this comment

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

So I think you should first check if the user has already approved enough (and you can compute the amount that needs to be approved based on the ehckout config (especially if it supports renewals)

Sorry my initial comment was unclear. Check unlock-js where there is already logic around how to compute the amount to be approved.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

seen the logic in unlock-js/src/PublicLock/v10/getPurchaseKeysArguments to account for renewals and done it slightly differently.
my approach now checks if a lock is renewable to decide whether to render the approve button, so you can always approve a different amount before the purchase to replace any previous approval

abi: erc20Abi,
functionName: 'approve',
args: [lockAddress as `0x${string}`, BigInt(priceForUser!)],
})

return transaction({
chainId: `eip155:${network}`,
method: 'eth_sendTransaction',
params: {
abi: erc20Abi as Abi,
to: ctx.state.lock?.tokenAddress as `0x${string}`,
data: calldata,
value: '0x0',
},
})
})
5 changes: 4 additions & 1 deletion unlock-app/app/frames/checkout/components/DefaultImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ interface DefaultImageProps {
}

export function DefaultImage({ lock, rightSideContent }: DefaultImageProps) {
const { image, defaultImage, price, name, description } = lock
const { image, defaultImage, price, name, description, isSoldOut, isMember } =
lock

const defaultRightSideContent = (
<div tw="flex flex-col">
Expand All @@ -22,6 +23,8 @@ export function DefaultImage({ lock, rightSideContent }: DefaultImageProps) {
</p>
</div>
<p tw="m-0 p-0 text-3xl">🏷️ {price}</p>
{isSoldOut ? <p tw="text-red-500">SOLD OUT !</p> : null}
{isMember ? <p>ALREADY A MEMBER !</p> : null}
</div>
)
const leftImage = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { DefaultImage } from './DefaultImage'
export function TransactionSuccess({ lock }: any) {
const rightSideContent = (
<div tw="flex flex-col">
<p tw="text-6xl">Success! Purchased {lock.name} membership!</p>
<p>Success !</p>
<p tw="text-3xl">Purchased {lock.name} membership !</p>
</div>
)

Expand Down
72 changes: 63 additions & 9 deletions unlock-app/app/frames/checkout/components/defaultFrame.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,69 @@
/* eslint-disable react/jsx-key */
import React from 'react'
import { Button } from 'frames.js/next'
import { State } from '../frames'
import { getAddressForFid } from 'frames.js'
import { DefaultImage } from './DefaultImage'
import { isMember as checkIsMember, getKeyPrice, checkAllowance } from './utils'

export function getDefaultFrame(state: State) {
const lock = state.lock!
export async function getDefaultFrame(ctx: any) {
const lock = ctx.state.lock!
const isErc20 = !!ctx.state.lock?.tokenAddress
const approved = ctx.searchParams.approved || lock.erc20Approved || !isErc20
let userAddress: string
const buttonText = approved
? 'Mint'
: `Approve ${lock.currencySymbol} spending`

const fid = ctx.message?.requesterFid
if (fid && !lock.priceForUser) {
userAddress = await getAddressForFid({
fid,
options: { fallbackToCustodyAddress: true },
})

const { address: lockAddress, network, tokenAddress } = lock

const isMember = await checkIsMember(lockAddress, network, userAddress)
lock.isMember = isMember

const keyPrice = await getKeyPrice({
lockAddress,
network,
userAddress,
})
lock.priceForUser = keyPrice

if (tokenAddress) {
const allowance = await checkAllowance(
lockAddress,
Number(network),
userAddress as string,
tokenAddress!
)
if (Number(allowance) >= Number(keyPrice) && !lock.isRenewable) {
lock.erc20Approved = true
}
}
}

const buttons =
!lock.isSoldOut && !lock.isMember
? [
fid ? (
<Button
action="tx"
target={approved ? '/txdata' : '/approve'}
post_url={approved ? '?success=true' : '?approved=true'}
>
{buttonText}
</Button>
) : (
<Button action="post" target="/">
Continue
</Button>
),
]
: []

return {
image: <DefaultImage lock={lock} />,
Expand All @@ -17,11 +75,7 @@ export function getDefaultFrame(state: State) {
'Cache-Control': 'max-age=1',
},
},
buttons: [
<Button action="tx" target="/txdata" post_url="?success=true">
Purchase a key
</Button>,
],
state,
buttons,
state: ctx.state,
}
}
70 changes: 70 additions & 0 deletions unlock-app/app/frames/checkout/components/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import { Web3Service } from '@unlock-protocol/unlock-js'
import { locksmith } from '../../../../src/config/locksmith'
import networks from '@unlock-protocol/networks'
import { config as appConfig } from '~/config/app'
import { erc20Abi } from 'viem'
import { ethers } from 'ethers'
import { MAX_UINT } from '~/constants'

interface GetKeyPriceParams {
lockAddress: string
network: number
userAddress: string
}

export async function getConfig(id: string) {
const { config } = await fetch(
Expand Down Expand Up @@ -39,8 +48,17 @@ export async function getLockDataFromCheckout(id: string) {
})()

const web3Service = new Web3Service(networks)

const res = await web3Service.getLock(lockAddress, network)
const price = `${res.keyPrice} ${res.currencySymbol}`
const tokenAddress = res.currencyContractAddress
const isRenewable =
Number(res.publicLockVersion) >= 11 &&
res.expirationDuration !== MAX_UINT &&
!!tokenAddress

const keysAvailable = await web3Service.keysAvailable(lockAddress, network)
const isSoldOut = Number(keysAvailable) < 1

const redirectUri = config.redirectUri
const redirectText = config.endingCallToAction
Expand All @@ -53,9 +71,61 @@ export async function getLockDataFromCheckout(id: string) {
defaultImage,
description,
price,
currencySymbol: res.currencySymbol,
tokenAddress,
redirectUri,
redirectText,
isSoldOut,
isRenewable,
}

return lock
}

export async function getKeyPrice({
lockAddress,
network,
userAddress,
}: GetKeyPriceParams) {
const web3Service = new Web3Service(networks)
const mydata = '0x'
let price = await web3Service.purchasePriceFor({
lockAddress,
userAddress: userAddress,
network,
data: mydata,
referrer: userAddress,
})
price = price.toString()
return price
}

export async function checkAllowance(
lockAddress: string,
network: number,
userAddress: string,
tokenAddress: string
) {
const web3Service = new Web3Service(networks)
const contract = new ethers.Contract(
tokenAddress,
erc20Abi,
web3Service.providerForNetwork(network)
)
const allowance = await contract.allowance(userAddress, lockAddress)
return allowance
}

export async function isMember(
lockAddress: string,
network: number,
userAddress: string
) {
const web3Service = new Web3Service(networks)
const isMember = await web3Service.getHasValidKey(
lockAddress,
userAddress,
network
)
return isMember
}
9 changes: 8 additions & 1 deletion unlock-app/app/frames/checkout/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@ import { createFrames } from 'frames.js/next'
export type Lock = {
name: string
address: string
network: string
network: number
image: string
defaultImage?: string
description: string
price: string
priceForUser?: string
currencySymbol: string
tokenAddress?: string
erc20Approved?: boolean
redirectUri?: string
redirectText?: string
isSoldOut: boolean
isMember?: boolean
isRenewable: boolean
}

export type State = {
Expand Down
4 changes: 2 additions & 2 deletions unlock-app/app/frames/checkout/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const getInitialFrame = frames(async (ctx) => {
const lock = await getLockDataFromCheckout(id)
state.lock = lock

return getDefaultFrame(state)
return await getDefaultFrame(ctx)
})

const getOtherFrames = frames(async (ctx) => {
Expand Down Expand Up @@ -46,7 +46,7 @@ const getOtherFrames = frames(async (ctx) => {
}
}

return getDefaultFrame(state)
return await getDefaultFrame(ctx)
})

export const GET = getInitialFrame
Expand Down
31 changes: 6 additions & 25 deletions unlock-app/app/frames/checkout/txdata/route.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,22 @@
import { Abi, encodeFunctionData } from 'viem'
import { frames } from '../frames'
import { transaction } from 'frames.js/core'
import { PublicLockV14 } from '@unlock-protocol/contracts'
import { Web3Service } from '@unlock-protocol/unlock-js'
import networks from '@unlock-protocol/networks'

const abi = PublicLockV14.abi
import { PublicLock } from '@unlock-protocol/contracts'

export const POST = frames(async (ctx) => {
if (!ctx?.message) {
throw new Error('Invalid frame message')
}

const userAddress = ctx.message.connectedAddress!
const userAddress = ctx.message.address!
const { address: lockAddress, priceForUser } = ctx.state.lock!
const network = Number(ctx.state.lock!.network)
const lockAddress = ctx.state.lock!.address

async function getKeyPrice() {
const web3Service = new Web3Service(networks)
const mydata = '0x'
let price = await web3Service.purchasePriceFor({
lockAddress,
userAddress: userAddress,
network,
data: mydata,
referrer: userAddress,
})
price = price.toString()
return price
}

const keyPrice = await getKeyPrice()
const abi = PublicLock.abi

const calldata = encodeFunctionData({
Copy link
Member

Choose a reason for hiding this comment

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

Please use unlock'js walletService here as well so this supports any version of the lock contract!

Copy link
Contributor Author

@iMac7 iMac7 Nov 15, 2024

Choose a reason for hiding this comment

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

I ran into errors getting the abi with walletService.lockContractAbiVersion() which seems the closest method in functionality imo.
Would it work for all versions just importing the generic PublicLock without a version, or i'll need to dig into walletservice a bit more 🤔

abi,
functionName: 'purchase',
args: [[keyPrice], [userAddress], [userAddress], [userAddress], ['0x']],
args: [[priceForUser], [userAddress], [userAddress], [userAddress], ['0x']],
})

return transaction({
Expand All @@ -45,7 +26,7 @@ export const POST = frames(async (ctx) => {
abi: abi as Abi,
to: lockAddress as `0x${string}`,
data: calldata,
value: keyPrice.toString(),
value: priceForUser,
},
})
})
Loading