diff --git a/src/tempo/Decorator.ts b/src/tempo/Decorator.ts index 3dc202e482..2696f3158a 100644 --- a/src/tempo/Decorator.ts +++ b/src/tempo/Decorator.ts @@ -2308,6 +2308,76 @@ export type Decorator< approveSync: ( parameters: tokenActions.approveSync.Parameters, ) => Promise + /** + * Sets allowance via EIP-2612 permit signature. + * + * @example + * ```ts + * import { createClient, http } from 'viem' + * import { privateKeyToAccount } from 'viem/accounts' + * import { tempo } from 'viem/chains' + * import { tempoActions } from 'viem/tempo' + * + * const client = createClient({ + * account: privateKeyToAccount('0x...'), + * chain: tempo + * transport: http(), + * }).extend(tempoActions()) + * + * const hash = await client.token.permit({ + * token: '0x...', + * owner: '0x...', + * spender: '0x...', + * value: 100n, + * deadline: 1710000000n, + * v: 27, + * r: '0x...', + * s: '0x...', + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The transaction hash. + */ + permit: ( + parameters: tokenActions.permit.Parameters, + ) => Promise + /** + * Sets allowance via EIP-2612 permit signature. + * + * @example + * ```ts + * import { createClient, http } from 'viem' + * import { privateKeyToAccount } from 'viem/accounts' + * import { tempo } from 'viem/chains' + * import { tempoActions } from 'viem/tempo' + * + * const client = createClient({ + * account: privateKeyToAccount('0x...'), + * chain: tempo + * transport: http(), + * }).extend(tempoActions()) + * + * const result = await client.token.permitSync({ + * token: '0x...', + * owner: '0x...', + * spender: '0x...', + * value: 100n, + * deadline: 1710000000n, + * v: 27, + * r: '0x...', + * s: '0x...', + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The transaction receipt and event data. + */ + permitSync: ( + parameters: tokenActions.permitSync.Parameters, + ) => Promise /** * Burns TIP20 tokens from a blocked address. * @@ -2601,6 +2671,86 @@ export type Decorator< getBalance: ( parameters: tokenActions.getBalance.Parameters, ) => Promise + /** + * Gets the EIP-712 domain separator for a TIP20 token. + * + * @example + * ```ts + * import { createClient, http } from 'viem' + * import { tempo } from 'viem/chains' + * import { tempoActions } from 'viem/tempo' + * + * const client = createClient({ + * chain: tempo + * transport: http(), + * }).extend(tempoActions()) + * + * const domainSeparator = await client.token.getDomainSeparator({ + * token: '0x...', + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The EIP-712 domain separator. + */ + getDomainSeparator: ( + parameters: tokenActions.getDomainSeparator.Parameters, + ) => Promise + /** + * Gets the EIP-2612 nonce for an account on a TIP20 token. + * + * @example + * ```ts + * import { createClient, http } from 'viem' + * import { privateKeyToAccount } from 'viem/accounts' + * import { tempo } from 'viem/chains' + * import { tempoActions } from 'viem/tempo' + * + * const client = createClient({ + * account: privateKeyToAccount('0x...'), + * chain: tempo + * transport: http(), + * }).extend(tempoActions()) + * + * const nonce = await client.token.getNonce({ + * token: '0x...', + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The nonce. + */ + getNonce: ( + parameters: tokenActions.getNonce.Parameters, + ) => Promise + /** + * Gets the opted-in supply for rewards on a TIP20 token. + * + * @example + * ```ts + * import { createClient, http } from 'viem' + * import { tempo } from 'viem/chains' + * import { tempoActions } from 'viem/tempo' + * + * const client = createClient({ + * chain: tempo + * transport: http(), + * }).extend(tempoActions()) + * + * const optedInSupply = await client.token.getOptedInSupply({ + * token: '0x...', + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The opted-in supply. + */ + getOptedInSupply: ( + parameters: tokenActions.getOptedInSupply.Parameters, + ) => Promise /** * Gets TIP20 token metadata including name, symbol, currency, decimals, and total supply. * @@ -4414,6 +4564,8 @@ export function decorator() { approve: (parameters) => tokenActions.approve(client, parameters), approveSync: (parameters) => tokenActions.approveSync(client, parameters), + permit: (parameters) => tokenActions.permit(client, parameters), + permitSync: (parameters) => tokenActions.permitSync(client, parameters), burnBlocked: (parameters) => tokenActions.burnBlocked(client, parameters), burnBlockedSync: (parameters) => @@ -4429,8 +4581,13 @@ export function decorator() { getAllowance: (parameters) => tokenActions.getAllowance(client, parameters), getBalance: (parameters) => tokenActions.getBalance(client, parameters), + getDomainSeparator: (parameters) => + tokenActions.getDomainSeparator(client, parameters), getMetadata: (parameters) => tokenActions.getMetadata(client, parameters), + getNonce: (parameters) => tokenActions.getNonce(client, parameters), + getOptedInSupply: (parameters) => + tokenActions.getOptedInSupply(client, parameters), getRoleAdmin: (parameters) => tokenActions.getRoleAdmin(client, parameters), hasRole: (parameters) => tokenActions.hasRole(client, parameters), diff --git a/src/tempo/actions/token.test.ts b/src/tempo/actions/token.test.ts index 22eb676d9c..3a519f3c07 100644 --- a/src/tempo/actions/token.test.ts +++ b/src/tempo/actions/token.test.ts @@ -1,7 +1,7 @@ import { setTimeout } from 'node:timers/promises' import { Hex } from 'ox' import { TokenId, TokenRole } from 'ox/tempo' -import { parseUnits } from 'viem' +import { parseSignature, parseUnits } from 'viem' import { getCode, writeContractSync } from 'viem/actions' import { Abis, Addresses, TokenIds } from 'viem/tempo' import { describe, expect, test } from 'vitest' @@ -218,6 +218,84 @@ describe('approve', () => { }) }) +describe('permit', () => { + test('default', async () => { + const { token } = await actions.token.createSync(client, { + currency: 'USD', + name: 'Permit Test Token', + symbol: 'PERMIT', + }) + + const { name } = await actions.token.getMetadata(client, { + token, + }) + const nonceBefore = await actions.token.getNonce(client, { + token, + }) + const value = parseUnits('125', 6) + const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 60) + + if (!client.chain) throw new Error('chain is required.') + + const signature = await account.signTypedData({ + domain: { + chainId: client.chain.id, + name, + verifyingContract: token, + version: '1', + }, + message: { + deadline, + nonce: nonceBefore, + owner: account.address, + spender: account2.address, + value, + }, + primaryType: 'Permit', + types: { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + }) + const { r, s, v, yParity } = parseSignature(signature) + + const { receipt, ...result } = await actions.token.permitSync(client, { + deadline, + owner: account.address, + r, + s, + spender: account2.address, + token, + v: Number(v ?? BigInt(27 + yParity)), + value, + }) + expect(receipt).toBeDefined() + expect(result).toMatchInlineSnapshot(` + { + "amount": 125000000n, + "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "spender": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", + } + `) + + const allowance = await actions.token.getAllowance(client, { + spender: account2.address, + token, + }) + expect(allowance).toBe(value) + + const nonceAfter = await actions.token.getNonce(client, { + token, + }) + expect(nonceAfter).toBe(nonceBefore + 1n) + }) +}) + describe('create', () => { test('default', async () => { const { receipt, salt, token, tokenId, ...result } = @@ -312,6 +390,45 @@ describe('getBalance', () => { }) }) +describe('getDomainSeparator', () => { + test('default', async () => { + const domainSeparator = await actions.token.getDomainSeparator(client, { + token: addresses.alphaUsd, + }) + + expect(domainSeparator).toMatch(/^0x[0-9a-fA-F]{64}$/) + }) +}) + +describe('getNonce', () => { + test('default', async () => { + const nonce = await actions.token.getNonce(client, { + token: addresses.alphaUsd, + }) + + expect(nonce).toBeGreaterThanOrEqual(0n) + }) + + test('behavior: explicit account', async () => { + const nonce = await actions.token.getNonce(client, { + account: account2.address, + token: addresses.alphaUsd, + }) + + expect(nonce).toBeGreaterThanOrEqual(0n) + }) +}) + +describe('getOptedInSupply', () => { + test('default', async () => { + const optedInSupply = await actions.token.getOptedInSupply(client, { + token: addresses.alphaUsd, + }) + + expect(optedInSupply).toBeGreaterThanOrEqual(0n) + }) +}) + describe('getMetadata', () => { test('default', async () => { const metadata = await actions.token.getMetadata(client, { diff --git a/src/tempo/actions/token.ts b/src/tempo/actions/token.ts index e4b2e326cc..298c9c1edd 100644 --- a/src/tempo/actions/token.ts +++ b/src/tempo/actions/token.ts @@ -240,6 +240,249 @@ export namespace approveSync { export type ErrorType = BaseErrorType } +/** + * Sets allowance via EIP-2612 permit signature. + * + * @example + * ```ts + * import { createClient, http } from 'viem' + * import { tempo } from 'viem/chains' + * import { Actions } from 'viem/tempo' + * import { privateKeyToAccount } from 'viem/accounts' + * + * const client = createClient({ + * account: privateKeyToAccount('0x...'), + * chain: tempo.extend({ feeToken: '0x20c0000000000000000000000000000000000001' }) + * transport: http(), + * }) + * + * const result = await Actions.token.permit(client, { + * token: '0x...', + * owner: '0x...', + * spender: '0x...', + * value: 100n, + * deadline: 1710000000n, + * v: 27, + * r: '0x...', + * s: '0x...', + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The transaction hash. + */ +export async function permit< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: permit.Parameters, +): Promise { + return permit.inner(writeContract, client, parameters) +} + +export namespace permit { + export type Parameters< + chain extends Chain | undefined = Chain | undefined, + account extends Account | undefined = Account | undefined, + > = WriteParameters & Args + + export type Args = { + /** Permit deadline (unix timestamp, seconds). */ + deadline: bigint + /** Permit owner. */ + owner: Address + /** Signature `r`. */ + r: Hex.Hex + /** Signature `s`. */ + s: Hex.Hex + /** Permit spender. */ + spender: Address + /** Address or ID of the TIP20 token. */ + token: TokenId.TokenIdOrAddress + /** Signature `v`. */ + v: number + /** Allowance value. */ + value: bigint + } + + export type ReturnValue = WriteContractReturnType + + // TODO: exhaustive error type + export type ErrorType = BaseErrorType + + /** @internal */ + export async function inner< + action extends typeof writeContract | typeof writeContractSync, + chain extends Chain | undefined, + account extends Account | undefined, + >( + action: action, + client: Client, + parameters: permit.Parameters, + ): Promise> { + const { deadline, owner, r, s, spender, token, v, value, ...rest } = + parameters + const call = permit.call({ + deadline, + owner, + r, + s, + spender, + token, + v, + value, + }) + return (await action(client, { + ...rest, + ...call, + } as never)) as never + } + + /** + * Defines a call to the `permit` function. + * + * Can be passed as a parameter to: + * - [`estimateContractGas`](https://viem.sh/docs/contract/estimateContractGas): estimate the gas cost of the call + * - [`simulateContract`](https://viem.sh/docs/contract/simulateContract): simulate the call + * - [`sendCalls`](https://viem.sh/docs/actions/wallet/sendCalls): send multiple calls + * + * @example + * ```ts + * import { createClient, http, walletActions } from 'viem' + * import { tempo } from 'viem/chains' + * import { Actions } from 'viem/tempo' + * + * const client = createClient({ + * chain: tempo.extend({ feeToken: '0x20c0000000000000000000000000000000000001' }) + * transport: http(), + * }).extend(walletActions) + * + * const { result } = await client.sendCalls({ + * calls: [ + * actions.token.permit.call({ + * token: '0x20c0...babe', + * owner: '0x20c0...dead', + * spender: '0x20c0...beef', + * value: 100n, + * deadline: 1710000000n, + * v: 27, + * r: '0x...', + * s: '0x...', + * }), + * ] + * }) + * ``` + * + * @param args - Arguments. + * @returns The call. + */ + export function call(args: Args) { + const { deadline, owner, r, s, spender, token, v, value } = args + return defineCall({ + address: TokenId.toAddress(token), + abi: Abis.tip20, + functionName: 'permit', + args: [owner, spender, value, deadline, v, r, s], + }) + } + + /** + * Extracts the `Approval` event from logs. + * + * @param logs - The logs. + * @returns The `Approval` event. + */ + export function extractEvent(logs: Log[]) { + const [log] = parseEventLogs({ + abi: Abis.tip20, + logs, + eventName: 'Approval', + strict: true, + }) + if (!log) throw new Error('`Approval` event not found.') + return log + } +} + +/** + * Sets allowance via EIP-2612 permit signature. + * + * @example + * ```ts + * import { createClient, http } from 'viem' + * import { tempo } from 'viem/chains' + * import { Actions } from 'viem/tempo' + * import { privateKeyToAccount } from 'viem/accounts' + * + * const client = createClient({ + * account: privateKeyToAccount('0x...'), + * chain: tempo.extend({ feeToken: '0x20c0000000000000000000000000000000000001' }) + * transport: http(), + * }) + * + * const result = await Actions.token.permitSync(client, { + * token: '0x...', + * owner: '0x...', + * spender: '0x...', + * value: 100n, + * deadline: 1710000000n, + * v: 27, + * r: '0x...', + * s: '0x...', + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The transaction receipt and event data. + */ +export async function permitSync< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: permitSync.Parameters, +): Promise { + const { throwOnReceiptRevert = true, ...rest } = parameters + const receipt = await permit.inner(writeContractSync, client, { + ...rest, + throwOnReceiptRevert, + } as never) + const { args } = permit.extractEvent(receipt.logs) + return { + ...args, + receipt, + } as never +} + +export namespace permitSync { + export type Parameters< + chain extends Chain | undefined = Chain | undefined, + account extends Account | undefined = Account | undefined, + > = permit.Parameters + + export type Args = permit.Args + + export type ReturnValue = Compute< + GetEventArgs< + typeof Abis.tip20, + 'Approval', + { + IndexedOnly: false + Required: true + } + > & { + /** Transaction receipt. */ + receipt: TransactionReceipt + } + > + + // TODO: exhaustive error type + export type ErrorType = BaseErrorType +} + /** * Burns TIP20 tokens from a blocked address. * @@ -1263,6 +1506,209 @@ export namespace getBalance { } } +/** + * Gets the EIP-712 domain separator for a TIP20 token. + * + * @example + * ```ts + * import { createClient, http } from 'viem' + * import { tempo } from 'viem/chains' + * import { Actions } from 'viem/tempo' + * + * const client = createClient({ + * chain: tempo.extend({ feeToken: '0x20c0000000000000000000000000000000000001' }) + * transport: http(), + * }) + * + * const domainSeparator = await Actions.token.getDomainSeparator(client, { + * token: '0x...', + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The EIP-712 domain separator. + */ +export async function getDomainSeparator( + client: Client, + parameters: getDomainSeparator.Parameters, +): Promise { + return readContract(client, { + ...parameters, + ...getDomainSeparator.call(parameters), + }) +} + +export namespace getDomainSeparator { + export type Parameters = ReadParameters & Args + + export type Args = { + /** Address or ID of the TIP20 token. */ + token: TokenId.TokenIdOrAddress + } + + export type ReturnValue = ReadContractReturnType< + typeof Abis.tip20, + 'DOMAIN_SEPARATOR', + never + > + + /** + * Defines a call to the `DOMAIN_SEPARATOR` function. + * + * @param args - Arguments. + * @returns The call. + */ + export function call(args: Args) { + const { token } = args + return defineCall({ + address: TokenId.toAddress(token), + abi: Abis.tip20, + functionName: 'DOMAIN_SEPARATOR', + args: [], + }) + } +} + +/** + * Gets the EIP-2612 nonce for an account on a TIP20 token. + * + * @example + * ```ts + * import { createClient, http } from 'viem' + * import { tempo } from 'viem/chains' + * import { Actions } from 'viem/tempo' + * + * const client = createClient({ + * chain: tempo.extend({ feeToken: '0x20c0000000000000000000000000000000000001' }) + * transport: http(), + * }) + * + * const nonce = await Actions.token.getNonce(client, { + * token: '0x...', + * account: '0x...', + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The nonce. + */ +export async function getNonce< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: getNonce.Parameters, +): Promise { + const { account = client.account } = parameters + const address = account ? parseAccount(account).address : undefined + if (!address) throw new Error('account is required.') + return readContract(client, { + ...parameters, + ...getNonce.call({ ...parameters, account: address }), + }) +} + +export namespace getNonce { + export type Parameters< + account extends Account | undefined = Account | undefined, + > = ReadParameters & GetAccountParameter & Omit + + export type Args = { + /** Account address. */ + account: Address + /** Address or ID of the TIP20 token. */ + token: TokenId.TokenIdOrAddress + } + + export type ReturnValue = ReadContractReturnType< + typeof Abis.tip20, + 'nonces', + never + > + + /** + * Defines a call to the `nonces` function. + * + * @param args - Arguments. + * @returns The call. + */ + export function call(args: Args) { + const { account, token } = args + return defineCall({ + address: TokenId.toAddress(token), + abi: Abis.tip20, + functionName: 'nonces', + args: [account], + }) + } +} + +/** + * Gets the opted-in supply for rewards on a TIP20 token. + * + * @example + * ```ts + * import { createClient, http } from 'viem' + * import { tempo } from 'viem/chains' + * import { Actions } from 'viem/tempo' + * + * const client = createClient({ + * chain: tempo.extend({ feeToken: '0x20c0000000000000000000000000000000000001' }) + * transport: http(), + * }) + * + * const optedInSupply = await Actions.token.getOptedInSupply(client, { + * token: '0x...', + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The opted-in supply. + */ +export async function getOptedInSupply( + client: Client, + parameters: getOptedInSupply.Parameters, +): Promise { + return readContract(client, { + ...parameters, + ...getOptedInSupply.call(parameters), + }) +} + +export namespace getOptedInSupply { + export type Parameters = ReadParameters & Args + + export type Args = { + /** Address or ID of the TIP20 token. */ + token: TokenId.TokenIdOrAddress + } + + export type ReturnValue = ReadContractReturnType< + typeof Abis.tip20, + 'optedInSupply', + never + > + + /** + * Defines a call to the `optedInSupply` function. + * + * @param args - Arguments. + * @returns The call. + */ + export function call(args: Args) { + const { token } = args + return defineCall({ + address: TokenId.toAddress(token), + abi: Abis.tip20, + functionName: 'optedInSupply', + args: [], + }) + } +} + /** * Gets TIP20 token metadata including name, symbol, currency, decimals, and total supply. *