diff --git a/.changeset/tidy-wolves-shake.md b/.changeset/tidy-wolves-shake.md new file mode 100644 index 0000000000..903b8442d7 --- /dev/null +++ b/.changeset/tidy-wolves-shake.md @@ -0,0 +1,5 @@ +--- +"viem": patch +--- + +`viem/tempo`: Supported calling token `.call` builders without a Client (restores the pre-`2.54` call signature). When the Client is omitted, `token` must be a TIP20 token id or contract address, and formatted amounts require explicit `decimals`. diff --git a/src/clients/createClient.ts b/src/clients/createClient.ts index 13cf93090a..14b62e225a 100644 --- a/src/clients/createClient.ts +++ b/src/clients/createClient.ts @@ -371,9 +371,13 @@ export function bindActionDecorators( if (Object.hasOwn(action, key)) { const helper = action[key] wrapped[key] = (args: any = {}) => { - if (helper.length >= 2) return helper(client, args) - if (helper.length === 0) return helper() - return helper(args) + // Single-parameter helpers are client-less (e.g. `dex.buy.call(args)`). + // Everything else is called with `(client, args)`: two-parameter + // helpers, rest-parameter helpers that accept `(client, args)` or + // `(args)` (`length === 0`, e.g. `token.transfer.call`), and + // zero-argument helpers, which ignore the extra arguments. + if (helper.length === 1) return helper(args) + return helper(client, args) } } for (const key of ['extractEvent', 'extractEvents'] as const) diff --git a/src/tempo/actions/token.test.ts b/src/tempo/actions/token.test.ts index 5f46389313..b9d0b169d4 100644 --- a/src/tempo/actions/token.test.ts +++ b/src/tempo/actions/token.test.ts @@ -29,6 +29,54 @@ const tokenlessClient = getClient({ tokens: undefined, }) +describe('call (without client)', () => { + test('default: builds the same call as the client form', () => { + const args = { + amount: parseUnits('1.25', 6), + to: account2.address, + token: addresses.alphaUsd, + } as const + expect(actions.token.transfer.call(args)).toEqual( + actions.token.transfer.call(client, args), + ) + + expect( + actions.token.approve.call({ + amount: 100n, + spender: account2.address, + token: TokenId.fromAddress(addresses.alphaUsd), + }).to, + ).toBe(addresses.alphaUsd) + + expect( + actions.token.getBalance.call({ + account: account2.address, + token: addresses.alphaUsd, + }).args, + ).toEqual([account2.address]) + }) + + test('amount: formatted amounts require explicit decimals', () => { + expect( + actions.token.mint.call({ + amount: { decimals: 6, formatted: '1.25' }, + to: account2.address, + token: addresses.alphaUsd, + }).args, + ).toEqual([account2.address, parseUnits('1.25', 6)]) + + expect(() => + actions.token.mint.call({ + amount: { formatted: '1.25' }, + to: account2.address, + token: addresses.alphaUsd, + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Token decimals are required. Pass \`amount.decimals\` or select a declared token.]`, + ) + }) +}) + describe('approve', () => { test('decimals: requires decimals for an undeclared formatted amount', async () => { await expect( diff --git a/src/tempo/actions/token.ts b/src/tempo/actions/token.ts index b2991cf0e1..9ef133b2e7 100644 --- a/src/tempo/actions/token.ts +++ b/src/tempo/actions/token.ts @@ -46,9 +46,11 @@ import type { WriteParameters, } from '../internal/types.js' import { + type CallParameters, defineCall, findDeclaredToken, pickWriteParameters, + resolveCallParameters, resolveToken, resolveTokenWithDecimals, } from '../internal/utils.js' @@ -130,15 +132,14 @@ export namespace approve { * `address`; `amount.decimals` is inferred from the client's declared * `tokens` when omitted. * - * @param client - Client. - * @param parameters - Parameters. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - parameters: Args, + ...parameters: CallParameters> ) { - const { amount, spender, token } = parameters + const [client, args] = resolveCallParameters(parameters) + const { amount, spender, token } = args const { address, decimals } = resolveToken(client, { token }) return defineCall({ address, @@ -389,13 +390,13 @@ export namespace burnBlocked { * * `amount.decimals` is inferred from the client's declared `tokens` when omitted. * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { from, amount, token } = args const { address, decimals } = resolveToken(client, { token }) return defineCall({ @@ -598,13 +599,13 @@ export namespace burn { * * `amount.decimals` is inferred from the client's declared `tokens` when omitted. * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { amount, memo, token } = args const { address, decimals } = resolveToken(client, { token }) const value = internal_Token.toBaseUnits(amount, decimals) @@ -812,13 +813,13 @@ export namespace changeTransferPolicy { * }) * ``` * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { token, policyId } = args return defineCall({ address: resolveToken(client, { token }).address, @@ -1049,13 +1050,13 @@ export namespace create { * }) * ``` * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { name, symbol, @@ -1247,14 +1248,13 @@ export namespace getAllowance { * other action that accepts a contract call. The token is selected by `token`, * which is either a TIP20 token id or a contract address. * - * @param client - Client. - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) return defineCall({ address: resolveToken(client, args).address, abi: Abis.tip20, @@ -1333,15 +1333,20 @@ export namespace getBalance { * other action that accepts a contract call. The token is selected by `token`, * which is either a TIP20 token id or a contract address. * - * @param client - Client. - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call< chain extends Chain | undefined, account extends Account | undefined, - >(client: Client, args: Args) { - const account_ = args.account ?? client.account + >( + ...parameters: CallParameters< + Args, + Client + > + ) { + const [client, args] = resolveCallParameters(parameters) + const account_ = args.account ?? client?.account if (!account_) throw new AccountNotFoundError() const account = parseAccount(account_).address return defineCall({ @@ -1658,14 +1663,13 @@ export namespace getTotalSupply { * other action that accepts a contract call. The token is selected by `token`, * which is either a TIP20 token id or a contract address. * - * @param client - Client. - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) return defineCall({ address: resolveToken(client, args).address, abi: Abis.tip20, @@ -1728,13 +1732,13 @@ export namespace getRoleAdmin { /** * Defines a call to the `getRoleAdmin` function. * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { role, token } = args return defineCall({ address: resolveToken(client, { token }).address, @@ -1809,13 +1813,13 @@ export namespace hasRole { /** * Defines a call to the `hasRole` function. * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { account, role, token } = args return defineCall({ address: resolveToken(client, { token }).address, @@ -1939,13 +1943,13 @@ export namespace grantRoles { * }) * ``` * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { token, to, role } = args const roleHash = TokenRole.serialize(role) return defineCall({ @@ -2149,13 +2153,13 @@ export namespace mint { * * `amount.decimals` is inferred from the client's declared `tokens` when omitted. * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { to, amount, memo, token } = args const { address, decimals } = resolveToken(client, { token }) const value = internal_Token.toBaseUnits(amount, decimals) @@ -2360,13 +2364,13 @@ export namespace pause { * }) * ``` * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { token } = args return defineCall({ address: resolveToken(client, { token }).address, @@ -2566,13 +2570,13 @@ export namespace renounceRoles { * }) * ``` * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { token, role } = args const roleHash = TokenRole.serialize(role) return defineCall({ @@ -2782,13 +2786,13 @@ export namespace revokeRoles { * }) * ``` * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { token, from, role } = args const roleHash = TokenRole.serialize(role) return defineCall({ @@ -2986,13 +2990,13 @@ export namespace setSupplyCap { * }) * ``` * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { token, supplyCap } = args return defineCall({ address: resolveToken(client, { token }).address, @@ -3189,13 +3193,13 @@ export namespace setRoleAdmin { * }) * ``` * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { token, role, adminRole } = args const roleHash = TokenRole.serialize(role) const adminRoleHash = TokenRole.serialize(adminRole) @@ -3371,17 +3375,16 @@ export namespace transfer { * `address`; `amount.decimals` is inferred from the client's declared * `tokens` when omitted. * - * @param client - Client. - * @param parameters - Parameters. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - parameters: Args, + ...parameters: CallParameters> ) { - const { from, memo, to, token } = parameters + const [client, args] = resolveCallParameters(parameters) + const { from, memo, to, token } = args const { address, decimals } = resolveToken(client, { token }) - const value = internal_Token.toBaseUnits(parameters.amount, decimals) + const value = internal_Token.toBaseUnits(args.amount, decimals) const callArgs = (() => { if (memo && from) return { @@ -3645,13 +3648,13 @@ export namespace unpause { * }) * ``` * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { token } = args return defineCall({ address: resolveToken(client, { token }).address, @@ -3843,13 +3846,13 @@ export namespace prepareUpdateQuoteToken { * }) * ``` * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { token, quoteToken } = args return defineCall({ address: resolveToken(client, { token }).address, @@ -4043,13 +4046,13 @@ export namespace updateQuoteToken { * }) * ``` * - * @param args - Arguments. + * @param parameters - Client (optional), followed by the call arguments. * @returns The call. */ export function call( - client: Client, - args: Args, + ...parameters: CallParameters> ) { + const [client, args] = resolveCallParameters(parameters) const { token } = args return defineCall({ address: resolveToken(client, { token }).address, diff --git a/src/tempo/internal/utils.ts b/src/tempo/internal/utils.ts index a9ce23cd9e..c21395b0d9 100644 --- a/src/tempo/internal/utils.ts +++ b/src/tempo/internal/utils.ts @@ -1,5 +1,6 @@ import type { Abi, AbiStateMutability, Address } from 'abitype' import { TokenId } from 'ox/tempo' +import type { Account } from '../../accounts/types.js' import { readContract } from '../../actions/public/readContract.js' import type { Client } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' @@ -26,17 +27,21 @@ import * as Abis from '../Abis.js' * from the client's declared `tokens` when the address matches a declared token, * otherwise taken from the explicit `decimals`. * - * @param client - Client. + * When `client` is omitted, `token` must be a token id or address (symbols + * cannot be resolved), and `decimals` is only taken from the explicit + * `decimals`. + * + * @param client - Client (optional). * @param parameters - Parameters. * @returns The resolved `address` and `decimals`. */ export function resolveToken( - client: Client, + client: Client | undefined, parameters: resolveToken.Parameters, ): { address: Address; decimals: number | undefined } { const { decimals, token } = parameters - if (typeof token === 'string') { + if (client && typeof token === 'string') { const declared = findDeclaredTokenBySymbol(client, token) if (declared) return { @@ -48,7 +53,7 @@ export function resolveToken( const address = TokenId.toAddress(token as TokenId.TokenIdOrAddress) return { address, - decimals: decimals ?? inferDecimals(client, address), + decimals: decimals ?? (client ? inferDecimals(client, address) : undefined), } } @@ -211,6 +216,39 @@ export function pickWriteParameters(parameters: Record) { } } +/** + * Parameters of an action's `.call` builder: the call arguments, optionally + * preceded by a Client. The Client is used to resolve token symbols declared + * on its `tokens` array and to infer token `decimals`. When the Client is + * omitted, `token` must be a TIP20 token id or contract `address`, and + * formatted `amount` values must carry explicit `decimals`. + */ +export type CallParameters< + args, + client extends Client< + Transport, + Chain | undefined, + Account | undefined + > = Client, + // Note: mutable (non-`readonly`) tuples, so bound decorator helpers can + // pattern-match these parameters (see `BoundHelper` in `../Decorator.ts`). +> = [client: client, args: args] | [args: args] + +/** + * Splits {@link CallParameters} into `[client, args]`, with an `undefined` + * client when the caller omitted it. + * @internal + */ +export function resolveCallParameters< + args, + client extends Client, +>( + parameters: CallParameters, +): readonly [client: client | undefined, args: args] { + if (parameters.length === 2) return parameters + return [undefined, parameters[0]] +} + export function defineCall< const abi extends Abi, const functionName extends ContractFunctionName,