diff --git a/.changeset/config-mapped-arguments.md b/.changeset/config-mapped-arguments.md new file mode 100644 index 000000000..dceefdb1f --- /dev/null +++ b/.changeset/config-mapped-arguments.md @@ -0,0 +1,11 @@ +--- +'@mysten/codegen': minor +--- + +Add `configArguments` codegen option for mapping function parameters and package addresses to a +runtime config object. Matched parameters become optional in generated `arguments` and are resolved +from a typed optional `config` object instead, with per-function minimal config slices, resolver +functions for generic types (receiving the matched parameter's normalized instantiation and +call-site metadata), and a generated per-package config interface in `config-arguments.ts`. +Misconfigured matchers are surfaced at generation time. Also treat +`0x2::accumulator::AccumulatorRoot` as a well-known object that is auto-injected like `Clock`. diff --git a/packages/codegen/src/cli/commands/generate/impl.ts b/packages/codegen/src/cli/commands/generate/impl.ts index 9427f19a5..8be6be22c 100644 --- a/packages/codegen/src/cli/commands/generate/impl.ts +++ b/packages/codegen/src/cli/commands/generate/impl.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { LocalContext } from '../../context.js'; -import { generateFromPackageSummary } from '../../../index.js'; +import { generateFromPackageSummary, resolvePackageRootAddress } from '../../../index.js'; import { loadConfig, type GenerateBase, type PackageGenerate } from '../../../config.js'; import { isValidNamedPackage, isValidSuiObjectId } from '@mysten/sui/utils'; import { execSync } from 'node:child_process'; @@ -49,6 +49,24 @@ export default async function generate( }) : config.packages; + // Package entries in any configArguments block must reference a package in this run — a typo'd + // name would otherwise silently never apply. + const knownPackageNames = new Set(normalizedPackages.map((p) => p.package)); + const configArgumentBlocks = [ + config.configArguments ?? {}, + ...normalizedPackages.map((p) => ('configArguments' in p ? (p.configArguments ?? {}) : {})), + ]; + for (const block of configArgumentBlocks) { + for (const [key, matcher] of Object.entries(block)) { + if ('package' in matcher && !knownPackageNames.has(matcher.package)) { + throw new Error( + `configArguments.${key} references package "${matcher.package}", which is not part of ` + + `this codegen run (packages: ${[...knownPackageNames].join(', ')})`, + ); + } + } + } + const generateSummaries = flags.noSummaries === undefined ? config.generateSummaries : !flags.noSummaries; @@ -81,6 +99,8 @@ export default async function generate( } : undefined; + // First ensure summaries exist for every package, then resolve each package's root address so + // configArguments matchers can reference any package in the run by its identifier. for (const pkg of normalizedPackages) { // Detect on-chain packages: they have 'network' field and no 'path' const isOnChainPackage = @@ -108,6 +128,18 @@ export default async function generate( stdio: 'inherit', }); } + } + + const packageAddresses: Record = {}; + for (const pkg of normalizedPackages) { + if (!pkg.path) continue; + const address = await resolvePackageRootAddress(pkg.path); + if (address !== undefined) { + packageAddresses[pkg.package] = address; + } + } + + for (const pkg of normalizedPackages) { const importExtension = flags.importExtension === undefined ? config.importExtension @@ -149,6 +181,8 @@ export default async function generate( importExtension, includePhantomTypeParameters: config.includePhantomTypeParameters, errorClass: config.errorClass, + configArguments: config.configArguments, + packageAddresses, }); } } diff --git a/packages/codegen/src/config-arguments.ts b/packages/codegen/src/config-arguments.ts new file mode 100644 index 000000000..bc4f0e603 --- /dev/null +++ b/packages/codegen/src/config-arguments.ts @@ -0,0 +1,757 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { normalizeSuiAddress } from '@mysten/sui/utils'; +import type { ConfigArguments } from './config.js'; +import type { ModuleRegistry } from './module-registry.js'; +import type { Parameter, Type } from './types/summary.js'; +import { isWellKnownObjectParameter } from './utils.js'; + +/** A parsed type tag from a `configArguments` matcher. Always fully concrete. */ +export type ParsedTypeTag = + | { prim: string } + | { vector: ParsedTypeTag } + | { datatype: { address: string; module: string; name: string; typeArguments: ParsedTypeTag[] } }; + +/** Whether an entry came from the shared global block or a package-scoped block. */ +export type ConfigArgumentSource = 'global' | 'package'; + +export interface TypeConfigArgument { + kind: 'type'; + key: string; + source: ConfigArgumentSource; + address: string; + module: string; + name: string; + /** `null` when the matcher is written without type arguments (matches every instantiation). */ + typeArguments: ParsedTypeTag[] | null; + parameterName?: string; + /** + * Whether the matched Move type is generic. Uninstantiated matchers on generic types require a + * resolver function as the config value (a static id cannot be correct across instantiations). + */ + isGeneric: boolean; + /** + * Canonical identity of the concrete type this entry binds, or `null` when it can bind many + * (an uninstantiated generic). A key whose entries span multiple identities (or any `null`) + * must be a resolver function. + */ + boundType: string | null; +} + +export interface FunctionConfigArgument { + kind: 'function'; + key: string; + source: ConfigArgumentSource; + address: string; + module: string; + functionName: string; + parameterName?: string; + /** Position in the generated function's arguments (TxContext/well-known excluded). */ + parameterIndex?: number; + /** See `TypeConfigArgument.boundType`. */ + boundType: string | null; +} + +export interface PackageConfigArgument { + kind: 'package'; + key: string; + source: ConfigArgumentSource; + package: string; +} + +export type ParsedConfigArgument = + | TypeConfigArgument + | FunctionConfigArgument + | PackageConfigArgument; + +export interface ConfigArgumentsContext { + /** Identifier (from the `packages` config) and resolved address of the package being generated. */ + package: { id: string; address: string }; + /** + * Resolved root addresses of the other packages in the codegen run, keyed by their `packages` + * identifier. Matchers can reference any of these packages' types. + */ + packageAddresses?: Record; +} + +const PRIMITIVES = new Set(['bool', 'u8', 'u16', 'u32', 'u64', 'u128', 'u256', 'address']); +const MOVE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; +const HEX_ADDRESS = /^0x[0-9a-fA-F]{1,64}$/; +/** The only packages with chain-stable addresses. Everything else must use a package identifier. */ +const FRAMEWORK_ADDRESS = /^0x0*[123]$/; + +function normalizeAddress(address: string) { + return HEX_ADDRESS.test(address) ? normalizeSuiAddress(address) : address; +} + +function resolveQualifier( + packagePart: string | undefined, + unqualified: string, + tag: string, + ctx: { scopeAddress: string | null; packageAddresses: Record; root: string }, +): string { + if (packagePart === undefined) { + if (ctx.scopeAddress === null) { + throw new Error( + `configArguments matcher "${ctx.root}": "${unqualified}" must be qualified ` + + `with a package in the global configArguments block (e.g. ` + + `"@pkg/name::${unqualified}"). Bare matchers are only supported in a package's own ` + + `configArguments block.`, + ); + } + return ctx.scopeAddress; + } + if (FRAMEWORK_ADDRESS.test(packagePart)) { + return normalizeSuiAddress(packagePart); + } + if (HEX_ADDRESS.test(packagePart)) { + throw new Error( + `Invalid package "${packagePart}" in configArguments matcher "${tag}": package addresses ` + + `are network-specific and cannot be used in matchers (only the framework addresses ` + + `0x1-0x3 are chain-stable). Reference the package by its identifier from the codegen ` + + `config instead (e.g. "@pkg/name::${unqualified}").`, + ); + } + const resolved = ctx.packageAddresses[packagePart]; + if (resolved === undefined) { + throw new Error( + `Unknown package "${packagePart}" in configArguments matcher "${tag}". Known packages in ` + + `this codegen run: ${Object.keys(ctx.packageAddresses).join(', ')}`, + ); + } + return resolved; +} + +interface ParseContext { + /** + * Address a bare `module::Type` resolves against, or `null` when there is no ambient package + * (global block), which makes the qualifier required. + */ + scopeAddress: string | null; + /** Resolved addresses by package identifier from the `packages` config. */ + packageAddresses: Record; + root: string; + isTypeArgument?: boolean; +} + +function assertBalancedBrackets(tag: string) { + let depth = 0; + for (const char of tag) { + if (char === '<') depth++; + if (char === '>') depth--; + if (depth < 0) { + throw new Error(`Invalid type in configArguments matcher: "${tag}" (unbalanced '>')`); + } + } + if (depth !== 0) { + throw new Error(`Invalid type in configArguments matcher: "${tag}" (unbalanced '<')`); + } +} + +function splitTopLevelTypeArgs(inner: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ''; + for (const char of inner) { + if (char === ',' && depth === 0) { + parts.push(current.trim()); + current = ''; + continue; + } + if (char === '<') depth++; + if (char === '>') depth--; + current += char; + } + parts.push(current.trim()); + return parts; +} + +function parseTypeTag(tag: string, ctx: ParseContext): ParsedTypeTag { + const trimmed = tag.trim(); + + if (!ctx.isTypeArgument) { + assertBalancedBrackets(trimmed); + } + + if (PRIMITIVES.has(trimmed)) { + return { prim: trimmed }; + } + + if (trimmed.startsWith('vector<') && trimmed.endsWith('>')) { + return { + vector: parseTypeTag(trimmed.slice('vector<'.length, -1), { + ...ctx, + isTypeArgument: true, + }), + }; + } + + // A bare identifier in type-argument position is a type-parameter placeholder like `Pool`. + if (ctx.isTypeArgument && MOVE_IDENTIFIER.test(trimmed)) { + throw new Error( + `configArguments matcher "${ctx.root}" contains the type parameter "${trimmed}" — partially ` + + `instantiated matchers are not supported. Use an uninstantiated matcher (no type ` + + `arguments) with a resolver function instead.`, + ); + } + + const lt = trimmed.indexOf('<'); + const base = lt === -1 ? trimmed : trimmed.slice(0, lt); + const parts = base.split('::'); + + if ((parts.length !== 2 && parts.length !== 3) || parts.some((part) => part.length === 0)) { + throw new Error( + `Invalid type in configArguments matcher: "${tag}". Expected "module::Type", optionally ` + + `qualified with a package from the codegen config ("@pkg/name::module::Type") or a Sui ` + + `framework address ("0x2::module::Type").`, + ); + } + + if (lt !== -1 && !trimmed.endsWith('>')) { + throw new Error(`Invalid type in configArguments matcher: "${tag}"`); + } + + const packagePart = parts.length === 3 ? parts[0] : undefined; + const modulePart = parts[parts.length - 2]; + const namePart = parts[parts.length - 1]; + + if (!MOVE_IDENTIFIER.test(modulePart) || !MOVE_IDENTIFIER.test(namePart)) { + throw new Error( + `Invalid type in configArguments matcher: "${tag}" ("${modulePart}::${namePart}" is not a valid module::type pair)`, + ); + } + + const address = resolveQualifier(packagePart, `${modulePart}::${namePart}`, tag, ctx); + + const typeArguments = + lt === -1 + ? [] + : splitTopLevelTypeArgs(trimmed.slice(lt + 1, -1)).map((arg) => { + if (arg.length === 0) { + throw new Error( + `Invalid type in configArguments matcher: "${tag}" (empty type argument)`, + ); + } + return parseTypeTag(arg, { ...ctx, isTypeArgument: true }); + }); + + return { + datatype: { + address: normalizeAddress(address), + module: modulePart, + name: namePart, + typeArguments, + }, + }; +} + +/** + * Check that every datatype nested in an instantiated matcher is itself fully instantiated, + * as far as the summaries can tell (unknown types are skipped). + */ +function assertFullyInstantiated( + tag: ParsedTypeTag, + registry: ModuleRegistry, + matcherType: string, +) { + if ('prim' in tag) return; + if ('vector' in tag) { + assertFullyInstantiated(tag.vector, registry, matcherType); + return; + } + + const { address, module, name, typeArguments } = tag.datatype; + const summary = registry.getSummaryByResolvedAddress(address, module); + const arity = (summary?.structs[name] ?? summary?.enums[name])?.type_parameters.length; + + if (arity !== undefined && arity !== typeArguments.length) { + throw new Error( + `configArguments matcher "${matcherType}": ${module}::${name} expects ${arity} type ` + + `argument(s), got ${typeArguments.length}. Partially instantiated matchers are not ` + + `supported — use an uninstantiated matcher (no type arguments) with a resolver function instead.`, + ); + } + + for (const argument of typeArguments) { + assertFullyInstantiated(argument, registry, matcherType); + } +} + +function isContextParameter(type: Type, resolveAddress: (address: string) => string): boolean { + if (typeof type === 'string') return false; + if ('Reference' in type) return isContextParameter(type.Reference[1], resolveAddress); + if ('Datatype' in type) { + return ( + normalizeAddress(resolveAddress(type.Datatype.module.address)) === + normalizeSuiAddress('0x2') && + type.Datatype.module.name === 'tx_context' && + type.Datatype.name === 'TxContext' + ); + } + return false; +} + +function typeHasTypeParameter(type: Type): boolean { + if (typeof type === 'string') return false; + if ('Reference' in type) return typeHasTypeParameter(type.Reference[1]); + if ('vector' in type) return typeHasTypeParameter(type.vector); + if ('Datatype' in type) { + return type.Datatype.type_arguments.some((argument) => typeHasTypeParameter(argument.argument)); + } + return 'TypeParameter' in type || 'NamedTypeParameter' in type; +} + +function tagIdentity(tag: ParsedTypeTag): string { + if ('prim' in tag) return tag.prim; + if ('vector' in tag) return `vector<${tagIdentity(tag.vector)}>`; + const { address, module, name, typeArguments } = tag.datatype; + const base = `${address}::${module}::${name}`; + return typeArguments.length ? `${base}<${typeArguments.map(tagIdentity).join(',')}>` : base; +} + +function canonicalTypeIdentity( + type: Type, + resolveAddress: (address: string) => string, +): string | null { + if (typeof type === 'string') return type === 'signer' || type === '_' ? null : type; + if ('Reference' in type) return canonicalTypeIdentity(type.Reference[1], resolveAddress); + if ('vector' in type) { + const inner = canonicalTypeIdentity(type.vector, resolveAddress); + return inner === null ? null : `vector<${inner}>`; + } + if ('Datatype' in type) { + const args = type.Datatype.type_arguments.map((argument) => + canonicalTypeIdentity(argument.argument, resolveAddress), + ); + if (args.some((argument) => argument === null)) return null; + const base = `${normalizeAddress(resolveAddress(type.Datatype.module.address))}::${type.Datatype.module.name}::${type.Datatype.name}`; + return args.length ? `${base}<${args.join(',')}>` : base; + } + return null; +} + +function parseFunctionMatcher( + key: string, + source: ConfigArgumentSource, + matcher: { function: string; parameterName?: string; parameterIndex?: number }, + { + registry, + scopeAddress, + packageAddresses, + packageId, + }: { + registry: ModuleRegistry; + scopeAddress: string | null; + packageAddresses: Record; + packageId: string; + }, +): FunctionConfigArgument | null { + const parts = matcher.function.split('::'); + if ((parts.length !== 2 && parts.length !== 3) || parts.some((part) => part.length === 0)) { + throw new Error( + `configArguments.${key}: invalid function "${matcher.function}". Expected ` + + `"module::function_name", optionally qualified with a package from the codegen config.`, + ); + } + + const packagePart = parts.length === 3 ? parts[0] : undefined; + const modulePart = parts[parts.length - 2]; + const functionPart = parts[parts.length - 1]; + + if (!MOVE_IDENTIFIER.test(modulePart) || !MOVE_IDENTIFIER.test(functionPart)) { + throw new Error(`configArguments.${key}: invalid function "${matcher.function}"`); + } + + if (matcher.parameterName !== undefined && matcher.parameterIndex !== undefined) { + throw new Error( + `configArguments.${key}: specify either parameterName or parameterIndex, not both`, + ); + } + + const address = resolveQualifier( + packagePart, + `${modulePart}::${functionPart}`, + matcher.function, + { + scopeAddress, + packageAddresses, + root: matcher.function, + }, + ); + + const summary = registry.getSummaryByResolvedAddress(address, modulePart); + const func = summary?.functions[functionPart]; + + if (!func) { + if (registry.hasResolvedAddress(address)) { + throw new Error( + `configArguments.${key}: function "${matcher.function}" was not found in its package's summaries`, + ); + } + if (source === 'package') { + console.warn( + `configArguments.${key}: function "${matcher.function}" is not part of ${packageId}'s ` + + `dependencies and will never match.`, + ); + } + return null; + } + + const resolveAddress = (target: string) => registry.resolveAddress(target); + // The same positions the generated arguments use: TxContext and auto-injected well-known + // objects are excluded. + const parameters = func.parameters.filter( + (param) => + !isContextParameter(param.type_, resolveAddress) && + !isWellKnownObjectParameter(param.type_, resolveAddress), + ); + + let bound: Parameter | undefined; + let parameterIndex = matcher.parameterIndex; + + if (matcher.parameterName !== undefined) { + bound = parameters.find((param) => param.name === matcher.parameterName); + if (!bound) { + throw new Error( + `configArguments.${key}: function "${matcher.function}" has no parameter named ` + + `"${matcher.parameterName}"${parameters.some((param) => param.name === undefined) ? " (this package's summaries do not include parameter names — use parameterIndex instead)" : ''}`, + ); + } + parameterIndex = undefined; + } else if (parameterIndex !== undefined) { + bound = parameters[parameterIndex]; + if (!bound) { + throw new Error( + `configArguments.${key}: function "${matcher.function}" has ${parameters.length} ` + + `argument(s); parameterIndex ${parameterIndex} is out of range`, + ); + } + } else { + if (parameters.length !== 1) { + throw new Error( + `configArguments.${key}: function "${matcher.function}" has ${parameters.length} ` + + `argument(s) — specify parameterName or parameterIndex`, + ); + } + bound = parameters[0]; + parameterIndex = 0; + } + + return { + kind: 'function', + key, + source, + address, + module: modulePart, + functionName: functionPart, + parameterName: matcher.parameterName, + parameterIndex, + // A parameter typed with the function's own type parameters cannot be a single static id. + boundType: typeHasTypeParameter(bound.type_) + ? null + : canonicalTypeIdentity(bound.type_, (target) => registry.resolveAddress(target)), + }; +} + +/** + * Parse and validate `configArguments` blocks against the modules loaded in `registry`. + * Per-package entries are merged over global entries (per key). + * + * Matchers identify packages network-agnostically: by the package identifiers from the codegen + * config (resolved through `context.packageAddresses`), by the ambient package for bare + * `module::Type` matchers in a package-scoped block, or by the chain-stable framework addresses + * 0x1-0x3. If a matcher's package is part of this package's summaries, the matched type must + * exist there (typos fail generation); matchers referencing run packages that aren't part of this + * dependency closure can't match anything here and are skipped. + */ +export function parseConfigArguments( + blocks: { global?: ConfigArguments; package?: ConfigArguments }, + registry: ModuleRegistry, + context: ConfigArgumentsContext, +): { entries: ParsedConfigArgument[] } { + const entries: ParsedConfigArgument[] = []; + const currentAddress = normalizeAddress(context.package.address); + const packageAddresses: Record = Object.fromEntries( + Object.entries({ + ...context.packageAddresses, + [context.package.id]: context.package.address, + }).map(([id, address]) => [id, normalizeAddress(address)]), + ); + + const merged = new Map< + string, + { matcher: NonNullable; source: ConfigArgumentSource } + >(); + for (const [key, matcher] of Object.entries(blocks.global ?? {})) { + merged.set(key, { matcher, source: 'global' }); + } + for (const [key, matcher] of Object.entries(blocks.package ?? {})) { + merged.set(key, { matcher, source: 'package' }); + } + + for (const [key, { matcher, source }] of merged) { + if (!Array.isArray(matcher) && 'package' in matcher) { + entries.push({ kind: 'package', key, source, package: matcher.package }); + continue; + } + + // One key may declare several matchers (e.g. several functions sharing one config value). + const matchers = Array.isArray(matcher) ? matcher : [matcher]; + + for (const single of matchers) { + const scopeAddress = source === 'package' ? currentAddress : null; + + if ('function' in single) { + const entry = parseFunctionMatcher(key, source, single, { + registry, + scopeAddress, + packageAddresses, + packageId: context.package.id, + }); + if (entry) { + entries.push(entry); + } + continue; + } + + const parsed = parseTypeTag(single.type, { + scopeAddress, + packageAddresses, + root: single.type, + }); + + if (!('datatype' in parsed)) { + throw new Error( + `configArguments.${key}: matcher type "${single.type}" must be a Move datatype`, + ); + } + + const { address, module, name, typeArguments } = parsed.datatype; + const summary = registry.getSummaryByResolvedAddress(address, module); + const datatype = summary?.structs[name] ?? summary?.enums[name]; + + if (!datatype) { + if (registry.hasResolvedAddress(address)) { + // The matcher's package is part of this dependency closure, so the type has to + // exist — this is a typo. + throw new Error( + `configArguments.${key}: type "${single.type}" was not found in its package's summaries`, + ); + } + // The matcher references a run package that isn't part of this package's dependency + // closure — it can't match anything here. It is validated when its own package is + // generated. + if (source === 'package') { + console.warn( + `configArguments.${key}: type "${single.type}" is not part of ${context.package.id}'s ` + + `dependencies and will never match — consider moving it to the global block or the ` + + `package it belongs to.`, + ); + } + continue; + } + + const arity = datatype.type_parameters.length; + const isGeneric = arity > 0; + // A generic type written without `<...>` matches every instantiation. + const uninstantiated = isGeneric && !single.type.includes('<'); + + if (!uninstantiated && typeArguments.length !== arity) { + throw new Error( + `configArguments.${key}: type "${single.type}" expects ${arity} type argument(s), got ${typeArguments.length}`, + ); + } + + for (const argument of typeArguments) { + assertFullyInstantiated(argument, registry, single.type); + } + + entries.push({ + kind: 'type', + key, + source, + address, + module, + name, + typeArguments: uninstantiated ? null : typeArguments, + parameterName: single.parameterName, + isGeneric, + boundType: uninstantiated + ? null + : tagIdentity({ datatype: { address, module, name, typeArguments } }), + }); + } + } + + return { entries }; +} + +function typeEqualsTag( + type: Type, + tag: ParsedTypeTag, + resolveAddress: (address: string) => string, +): boolean { + if (typeof type === 'string') { + return 'prim' in tag && tag.prim === type; + } + + if ('Reference' in type) { + return typeEqualsTag(type.Reference[1], tag, resolveAddress); + } + + if ('vector' in type) { + return 'vector' in tag && typeEqualsTag(type.vector, tag.vector, resolveAddress); + } + + if ('Datatype' in type) { + if (!('datatype' in tag)) return false; + const { Datatype } = type; + return ( + normalizeAddress(resolveAddress(Datatype.module.address)) === tag.datatype.address && + Datatype.module.name === tag.datatype.module && + Datatype.name === tag.datatype.name && + Datatype.type_arguments.length === tag.datatype.typeArguments.length && + Datatype.type_arguments.every((arg, i) => + typeEqualsTag(arg.argument, tag.datatype.typeArguments[i], resolveAddress), + ) + ); + } + + // TypeParameter / NamedTypeParameter / tuple / fun never match a concrete tag. + return false; +} + +/** + * Find the config entry matching a function parameter, or `null`. + * + * Most-specific matcher wins, decided statically: a fully instantiated matcher beats an + * uninstantiated one, and a `parameterName`-refined matcher beats a bare one at the same level. + * Ties are a hard generation-time error. + * + * A `parameterName`-refined matcher never matches a parameter without a name — but if such a + * matcher would otherwise apply (type and instantiation match) and nothing else matches the + * parameter, that is a hard error: the matcher clearly targets this parameter's type and only the + * missing parameter names (bytecode summaries don't include them) prevent matching it. + */ +export function findConfigArgumentMatch( + param: Parameter, + entries: ParsedConfigArgument[], + { + resolveAddress, + functionLabel, + functionRef, + parameterIndex, + }: { + resolveAddress: (address: string) => string; + functionLabel: string; + /** The module and function the parameter belongs to, for function matchers. */ + functionRef: { moduleAddress: string; moduleName: string; functionName: string }; + /** The parameter's position in the generated arguments. */ + parameterIndex: number; + }, +): TypeConfigArgument | FunctionConfigArgument | null { + let type = param.type_; + while (typeof type !== 'string' && 'Reference' in type) { + type = type.Reference[1]; + } + + const datatype = typeof type !== 'string' && 'Datatype' in type ? type.Datatype : null; + const paramAddress = datatype ? normalizeAddress(resolveAddress(datatype.module.address)) : null; + const moduleAddress = normalizeAddress(resolveAddress(functionRef.moduleAddress)); + + const candidates: { + entry: TypeConfigArgument | FunctionConfigArgument; + specificity: number; + }[] = []; + const blockedNameMatchers: (TypeConfigArgument | FunctionConfigArgument)[] = []; + + for (const entry of entries) { + if (entry.kind === 'function') { + if ( + entry.address !== moduleAddress || + entry.module !== functionRef.moduleName || + entry.functionName !== functionRef.functionName + ) { + continue; + } + if (entry.parameterName !== undefined) { + if (param.name === undefined) { + blockedNameMatchers.push(entry); + continue; + } + if (entry.parameterName !== param.name) { + continue; + } + } else if (entry.parameterIndex !== parameterIndex) { + continue; + } + // Function matchers are the most specific form. + candidates.push({ entry, specificity: 4 }); + continue; + } + + if (entry.kind !== 'type' || !datatype) continue; + if ( + entry.address !== paramAddress || + entry.module !== datatype.module.name || + entry.name !== datatype.name + ) { + continue; + } + + let specificity = 0; + + if (entry.typeArguments !== null) { + // Fully instantiated matcher: only matches parameters concretely typed with that + // exact instantiation in the Move signature. + if ( + datatype.type_arguments.length !== entry.typeArguments.length || + !datatype.type_arguments.every((arg, i) => + typeEqualsTag(arg.argument, entry.typeArguments![i], resolveAddress), + ) + ) { + continue; + } + specificity += 2; + } + + if (entry.parameterName) { + if (param.name === undefined) { + blockedNameMatchers.push(entry); + continue; + } + if (entry.parameterName !== param.name) { + continue; + } + specificity += 1; + } + + candidates.push({ entry, specificity }); + } + + if (candidates.length === 0) { + if (blockedNameMatchers.length > 0) { + throw new Error( + `configArguments ${blockedNameMatchers.map((entry) => entry.key).join(', ')} use ` + + `parameterName matchers that would apply to a parameter of ${functionLabel}, but its ` + + `parameters have no names (bytecode summaries do not include parameter names). Remove ` + + `the parameterName refinement, or exclude this package from the matcher's scope.`, + ); + } + return null; + } + + const best = Math.max(...candidates.map((c) => c.specificity)); + const winners = candidates.filter((c) => c.specificity === best); + + if (winners.length > 1 && !winners.every((c) => c.entry.key === winners[0].entry.key)) { + throw new Error( + `Parameter ${param.name ?? ''} of ${functionLabel} is matched by multiple configArguments entries with equal specificity: ${winners + .map((c) => c.entry.key) + .join(', ')}. Refine the matchers with type arguments or a parameter name.`, + ); + } + + return winners[0].entry; +} diff --git a/packages/codegen/src/config.ts b/packages/codegen/src/config.ts index 95aa8a56d..b8f156ddc 100644 --- a/packages/codegen/src/config.ts +++ b/packages/codegen/src/config.ts @@ -32,6 +32,83 @@ export const moduleGenerateSchema = z.object({ types: typesOptionSchema.optional(), }); +const IDENTIFIER = /^[A-Za-z_$][\w$]*$/; +/** Keys that would collide with `Object.prototype` or mutate prototypes on plain objects. */ +const FORBIDDEN_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +const typeMatcherSchema = z.strictObject({ + /** + * Move type to match function parameters against, written network-agnostically as + * `module::TypeName`. In a package's own `configArguments` block a bare `module::TypeName` + * refers to that package's type; other packages in the run are referenced by their + * `packages` identifier (`@myapp/core::pool::Pool`), and the chain-stable framework + * packages by address (`0x2::sui::SUI`). A generic type written without type arguments + * matches every instantiation and requires a resolver function as the config value; a + * fully instantiated generic (e.g. `pool::Pool<0x2::sui::SUI>`) only matches parameters + * concretely typed with that exact instantiation. + */ + type: z.string(), + /** + * Optional Move parameter-name refinement, for signatures with two parameters of the same + * matched type. Only supported for summaries generated from local packages (bytecode + * summaries do not include parameter names). + */ + parameterName: z.string().optional(), +}); + +const functionMatcherSchema = z.strictObject({ + /** + * A Move function whose parameter is configured directly, written `module::function_name` + * (scoped like type matchers: bare form in a package's own block, `@pkg::module::fn` + * otherwise). The parameter's type is derived from the signature. + */ + function: z.string(), + /** The Move name of the parameter to configure. */ + parameterName: z.string().optional(), + /** + * The position of the parameter to configure, in the generated function's arguments (the + * same positions as the tuple form of `arguments` — `TxContext` and auto-injected well-known + * objects are excluded). Use for summaries without parameter names. When both this and + * `parameterName` are omitted, the function must have exactly one argument. + */ + parameterIndex: z.number().int().nonnegative().optional(), +}); + +const packageMatcherSchema = z.strictObject({ + /** + * Package entry, keyed by the package's name/MVR name from the `packages` config. Adds an + * optional config key that overrides the package address used for generated calls. + */ + package: z.string(), +}); + +export const configArgumentMatcherSchema = z.union([ + typeMatcherSchema, + functionMatcherSchema, + packageMatcherSchema, +]); + +export const configArgumentsSchema = z.record( + z + .string() + .regex(IDENTIFIER, { + message: + 'configArguments keys become properties of the generated config interface and must be valid identifiers', + }) + .refine((key) => !FORBIDDEN_CONFIG_KEYS.has(key), { + message: 'configArguments keys must not be prototype property names', + }), + z.union([ + configArgumentMatcherSchema, + // One key may resolve multiple types/parameters; its config value must then be a + // resolver function. + z.array(z.union([typeMatcherSchema, functionMatcherSchema])), + ]), +); + +export type ConfigArgumentMatcher = z.infer; +export type ConfigArguments = z.infer; + export const packageGenerateSchema = globalGenerateSchema.extend({ modules: z .union([ @@ -49,6 +126,7 @@ export const onChainPackageSchema = z.object({ path: z.never().optional(), network: z.enum(['mainnet', 'testnet']), generate: packageGenerateSchema.optional(), + configArguments: configArgumentsSchema.optional(), }); export const localPackageSchema = z.object({ @@ -56,6 +134,7 @@ export const localPackageSchema = z.object({ package: z.string(), packageName: z.string().optional(), generate: packageGenerateSchema.optional(), + configArguments: configArgumentsSchema.optional(), }); export const packageConfigSchema = z.union([onChainPackageSchema, localPackageSchema]); @@ -68,8 +147,6 @@ export type PackageGenerate = z.infer; export type FunctionsOption = z.infer; export type TypesOption = z.infer; -const IDENTIFIER = /^[A-Za-z_$][\w$]*$/; - export const errorClassSchema = z.object({ name: z.string().regex(IDENTIFIER, { message: 'errorClass.name must start with a letter, $ or _ and contain only [A-Za-z0-9_$]', @@ -84,6 +161,12 @@ export const configSchema = z.object({ generateSummaries: z.boolean().optional().default(true), packages: z.array(packageConfigSchema), generate: globalGenerateSchema.optional(), + /** + * Maps config-object keys to Move type (or package) matchers. Matched function parameters are + * resolved from a runtime config object instead of being required arguments. Per-package + * `configArguments` entries are merged over these global entries. + */ + configArguments: configArgumentsSchema.optional(), /** @deprecated Use `generate: { functions: { private: 'entry' } }` instead */ privateMethods: z.union([z.literal('none'), z.literal('entry'), z.literal('all')]).optional(), importExtension: importExtensionSchema.optional().default('.js'), diff --git a/packages/codegen/src/generate-utils.ts b/packages/codegen/src/generate-utils.ts index 6a828a52c..ba598047d 100644 --- a/packages/codegen/src/generate-utils.ts +++ b/packages/codegen/src/generate-utils.ts @@ -28,7 +28,11 @@ import { BcsTuple, } from '@mysten/sui/bcs'; import { normalizeStructTag, normalizeSuiAddress } from '@mysten/sui/utils'; -import { type TransactionArgument, isArgument } from '@mysten/sui/transactions'; +import { + type TransactionArgument, + type TransactionObjectArgument, + isArgument, +} from '@mysten/sui/transactions'; import { type ClientWithCoreApi, type SuiClientTypes } from '@mysten/sui/client'; const MOVE_STDLIB_ADDRESS = normalizeSuiAddress('0x1'); @@ -126,6 +130,12 @@ export function normalizeMoveArguments( continue; } + if (argType === '0x2::accumulator::AccumulatorRoot') { + // Chain-wide shared singleton at a fixed address (SUI_ACCUMULATOR_ROOT_OBJECT_ID). + normalizedArgs.push((tx) => tx.object('0xacc')); + continue; + } + if (argType === '0x3::sui_system::SuiSystemState') { normalizedArgs.push((tx) => tx.object.system()); continue; @@ -144,7 +154,10 @@ export function normalizeMoveArguments( throw new __ERROR_CLASS__(\`Expected arguments to be passed as an array\`); } const name = parameterNames[index]; - arg = args[name as keyof typeof args]; + arg = + name !== undefined && Object.prototype.hasOwnProperty.call(args, name) + ? args[name as keyof typeof args] + : undefined; if (arg === undefined) { throw new __ERROR_CLASS__(\`Parameter \${name} is required\`); @@ -177,6 +190,126 @@ export function normalizeMoveArguments( return normalizedArgs; } +/* -------------------------- Config-mapped arguments -------------------------- */ + +/** Context passed to config resolver functions. */ +export interface ConfigResolverContext { + /** + * The matched parameter's own instantiated type arguments (not the whole function's type + * argument tuple), as fully-qualified type tags. Hex-addressed struct tags are normalized to + * their long form before the resolver is invoked; MVR-named tags are passed through unchanged. + */ + typeArguments: string[]; + /** The package address the generated call will be sent to. */ + packageAddress: string; + /** The Move module of the generated call. */ + moduleName: string; + /** The Move function of the generated call. */ + functionName: string; + /** The Move name of the matched parameter, when the summary includes parameter names. */ + parameterName?: string; + /** The matched parameter's position in the generated function's arguments. */ + parameterIndex: number; +} + +/** + * A value in a generated config object: a plain object id/argument, or a resolver function. + * + * Any function is treated as a resolver and called with a \`ConfigResolverContext\`. To provide a + * transaction-callback object argument dynamically, return it from a resolver: + * \`(ctx) => (tx) => ...\`. + */ +export type ConfigValue = + | string + | Exclude unknown> + | ((ctx: ConfigResolverContext) => string | TransactionObjectArgument); + +/** Normalize a hex-addressed struct tag to its long form; pass anything else through. */ +function normalizeConfigTypeTag(tag: string): string { + if (/[@/]/.test(tag) || !tag.includes('::')) { + return tag; + } + try { + return normalizeStructTag(tag); + } catch { + return tag; + } +} + +export function resolveConfigArgument( + value: ConfigValue | undefined, + ctx: ConfigResolverContext, + name: string, +): string | TransactionObjectArgument { + if (value == null) { + throw new __ERROR_CLASS__( + \`Missing config value for "\${name}": pass it explicitly in arguments, or include it in the config object\`, + ); + } + + if (typeof value !== 'function') { + return value; + } + + const resolved = value({ + ...ctx, + typeArguments: ctx.typeArguments.map(normalizeConfigTypeTag), + }); + + if (resolved == null) { + throw new __ERROR_CLASS__( + \`Config resolver for "\${name}" returned \${resolved} (\${ctx.moduleName}::\${ctx.functionName}, typeArguments: [\${ctx.typeArguments.join(', ')}])\`, + ); + } + + return resolved; +} + +/** + * Fill unset config-mapped positions in an arguments array/object with their resolved config + * values. Resolvers are only invoked for positions the caller did not pass explicitly. + */ +export function applyConfigArguments( + args: T, + defaults: readonly { index: number; name?: string; resolve: () => unknown }[], +): T { + if (Array.isArray(args)) { + const result = [...args]; + const matchedIndexes = new Set(defaults.map((entry) => entry.index)); + for (const entry of defaults) { + if (result[entry.index] === undefined) { + result[entry.index] = entry.resolve(); + } + } + // Filling a trailing config-mapped position can extend the array past positions the + // caller omitted; catch those holes here rather than failing deep inside serialization. + for (let i = 0; i < result.length; i++) { + if (result[i] === undefined && !matchedIndexes.has(i)) { + throw new __ERROR_CLASS__(\`Missing argument at position \${i}\`); + } + } + return result as T; + } + + const result: Record = { ...args }; + for (const entry of defaults) { + if (entry.name === undefined) { + continue; + } + // Own-property check so inherited properties (e.g. a key named "constructor") are never + // mistaken for explicitly passed arguments. + if (!Object.prototype.hasOwnProperty.call(result, entry.name) || result[entry.name] === undefined) { + Object.defineProperty(result, entry.name, { + value: entry.resolve(), + enumerable: true, + writable: true, + configurable: true, + }); + } + } + return result as T; +} + /* -------------------------- Move type tags -------------------------- */ /** A type argument: a type tag string, or a BCS type whose name is a Move type. */ diff --git a/packages/codegen/src/index.ts b/packages/codegen/src/index.ts index 1e3dd1015..1f0614dbe 100644 --- a/packages/codegen/src/index.ts +++ b/packages/codegen/src/index.ts @@ -6,10 +6,16 @@ import { basename, join } from 'node:path'; import { ModuleRegistry } from './module-registry.js'; import { MoveModuleBuilder } from './move-module-builder.js'; import { existsSync, statSync } from 'node:fs'; +import { normalizeSuiAddress } from '@mysten/sui/utils'; import { getUtilsContent } from './generate-utils.js'; import { parse } from 'toml'; +import { FileBuilder } from './file-builder.js'; +import { parseConfigArguments } from './config-arguments.js'; +import type { ParsedConfigArgument } from './config-arguments.js'; +import { camelCase, capitalize, parseTS } from './utils.js'; import type { RootPackageMetadata } from './types/summary.js'; import type { + ConfigArguments, ErrorClassConfig, FunctionsOption, GenerateBase, @@ -18,7 +24,11 @@ import type { PackageGenerate, TypesOption, } from './config.js'; -export { type SuiCodegenConfig } from './config.js'; +export { + type SuiCodegenConfig, + type ConfigArguments, + type ConfigArgumentMatcher, +} from './config.js'; export async function generateFromPackageSummary({ package: pkg, @@ -28,6 +38,8 @@ export async function generateFromPackageSummary({ importExtension = '.js', includePhantomTypeParameters = false, errorClass, + configArguments: globalConfigArguments, + packageAddresses, }: { package: PackageConfig; prune: boolean; @@ -36,6 +48,13 @@ export async function generateFromPackageSummary({ importExtension?: ImportExtension; includePhantomTypeParameters?: boolean; errorClass?: ErrorClassConfig; + configArguments?: ConfigArguments; + /** + * Resolved root addresses of the other packages in the codegen run, keyed by their `packages` + * identifier, so `configArguments` matchers can reference them (see + * `resolvePackageRootAddress`). The CLI builds this automatically. + */ + packageAddresses?: Record; }) { if (!pkg.path) { throw new Error(`Package path is required (got ${pkg.package})`); @@ -154,6 +173,42 @@ export async function generateFromPackageSummary({ ) ).flat(); + const currentPackageAddress = isOnChainPackage + ? rootPackageId! + : (addressMappings[mainPackageDir] ?? mainPackageDir); + + const { entries: configArgumentEntries } = + Object.keys(globalConfigArguments ?? {}).length || Object.keys(pkg.configArguments ?? {}).length + ? parseConfigArguments( + { global: globalConfigArguments, package: pkg.configArguments }, + registry, + { + package: { id: pkg.package, address: currentPackageAddress }, + packageAddresses, + }, + ) + : { entries: [] }; + + const packageEntries = configArgumentEntries.filter( + (entry): entry is ParsedConfigArgument & { kind: 'package' } => + entry.kind === 'package' && entry.package === pkg.package, + ); + if (packageEntries.length > 1) { + throw new Error( + `Multiple configArguments package entries match ${pkg.package}: ${packageEntries + .map((entry) => entry.key) + .join(', ')}`, + ); + } + const packageConfigKey = packageEntries[0]?.key; + + for (const mod of modules) { + mod.builder.setConfigArguments( + configArgumentEntries, + mod.isMainPackage ? packageConfigKey : undefined, + ); + } + const packageGenerate: PackageGenerate | undefined = 'generate' in pkg ? pkg.generate : undefined; const pkgModules = packageGenerate?.modules; const pkgTypes: TypesOption = packageGenerate?.types ?? globalGenerate?.types ?? true; @@ -219,6 +274,162 @@ export async function generateFromPackageSummary({ ); }), ); + + const usedConfigKeys = new Set(); + for (const mod of modules) { + for (const key of mod.builder.usedConfigKeys) { + usedConfigKeys.add(key); + } + } + + // Every matcher is checked in the run of the package it targets: an unused entry targeting + // this package is a misconfiguration (wrong parameterName, wrong instantiation, or a function + // filtered out of generation). Entries targeting other packages are checked in their own runs. + const normalizedCurrentAddress = normalizePackageAddress(currentPackageAddress); + const unusedOwnEntries = configArgumentEntries.filter( + (entry) => + entry.kind !== 'package' && + entry.address === normalizedCurrentAddress && + !usedConfigKeys.has(entry.key), + ); + if (unusedOwnEntries.length > 0) { + console.warn( + `configArguments keys that matched no generated function parameters in ${pkg.package}: ${unusedOwnEntries + .map((entry) => entry.key) + .join(', ')}`, + ); + } + + if (configArgumentEntries.length > 0) { + await generateConfigInterface({ + packageOutputDir, + outputDir, + packageName, + entries: configArgumentEntries.filter( + (entry) => entry.kind !== 'package' || entry.package === pkg.package, + ), + importExtension, + }); + } +} + +const HEX_ADDRESS = /^0x[0-9a-fA-F]{1,64}$/; + +function normalizePackageAddress(address: string) { + return HEX_ADDRESS.test(address) ? normalizeSuiAddress(address) : address; +} + +/** + * Resolve a package's root address from its summaries directory, for the `packageAddresses` map + * passed to `generateFromPackageSummary`. Requires summaries to already exist at `pkgPath`. + */ +export async function resolvePackageRootAddress(pkgPath: string): Promise { + const metadataPath = join(pkgPath, 'root_package_metadata.json'); + if (existsSync(metadataPath)) { + const metadata: RootPackageMetadata = JSON.parse(await readFile(metadataPath, 'utf-8')); + return metadata.root_package_original_id ?? metadata.root_package_id; + } + + const summaryDir = join(pkgPath, 'package_summaries'); + if (!existsSync(join(summaryDir, 'address_mapping.json'))) { + return undefined; + } + const addressMappings: Record = JSON.parse( + await readFile(join(summaryDir, 'address_mapping.json'), 'utf-8'), + ); + + let localAddressLabels: string[] = []; + let packageName = ''; + try { + const parsedToml: { package?: { name?: unknown }; addresses?: Record } = parse( + await readFile(join(pkgPath, 'Move.toml'), 'utf-8'), + ); + localAddressLabels = Object.keys(parsedToml.addresses ?? {}); + if (typeof parsedToml.package?.name === 'string') { + packageName = parsedToml.package.name.toLowerCase(); + } + } catch { + // fall through to summary-directory-based resolution + } + + const packages = (await readdir(summaryDir)).filter((file) => + statSync(join(summaryDir, file)).isDirectory(), + ); + + try { + const mainDir = resolveLocalMainPackageDir(localAddressLabels, packages, packageName, pkgPath); + return addressMappings[mainDir] ?? mainDir; + } catch { + return undefined; + } +} + +/** + * Emit `//config-arguments.ts` with a convenience interface covering this + * package's resolvable config keys (and its own package-address key), for `satisfies` on the user + * side. With a global block spanning multiple packages, intersect the per-package interfaces + * (`CoreConfig & MarginConfig`). The hyphenated filename can never collide with a generated Move + * module file. + */ +async function generateConfigInterface({ + packageOutputDir, + outputDir, + packageName, + entries, + importExtension, +}: { + packageOutputDir: string; + outputDir: string; + packageName: string; + entries: ParsedConfigArgument[]; + importExtension: ImportExtension; +}) { + const builder = new FileBuilder(); + const utilsModule = `~outputRoot/utils/index${importExtension}`; + + const interfaceName = `${capitalize( + camelCase(packageName.replaceAll(/[^A-Za-z0-9_$]+/g, '_').replace(/^(\d)/, '_$1')), + )}Config`; + + const fieldEntries = new Map(); + for (const entry of entries) { + fieldEntries.set(entry.key, [...(fieldEntries.get(entry.key) ?? []), entry]); + } + + const fields = [...fieldEntries.entries()].map(([key, group]) => { + if (group[0].kind === 'package') { + return `${key}?: string`; + } + + // A key binding multiple distinct types (or a generic) must be a resolver function. + const boundTypes = new Set( + group.flatMap((entry) => (entry.kind !== 'package' ? [entry.boundType] : [])), + ); + const requiresResolver = boundTypes.size > 1 || boundTypes.has(null); + + if (requiresResolver) { + const ctxName = builder.addImport(utilsModule, 'type ConfigResolverContext'); + const objArgName = builder.addImport( + '@mysten/sui/transactions', + 'type TransactionObjectArgument', + ); + return `${key}: (ctx: ${ctxName}) => string | ${objArgName}`; + } + + return `${key}: ${builder.addImport(utilsModule, 'type ConfigValue')}`; + }); + + builder.statements.push( + ...parseTS /* ts */ `export interface ${interfaceName} { + ${fields.join(';\n')} + }`, + ); + + await mkdir(packageOutputDir, { recursive: true }); + await writeFile( + join(packageOutputDir, 'config-arguments.ts'), + await builder.toString(packageOutputDir, 'config-arguments.ts', outputDir), + ); } async function generateUtils({ diff --git a/packages/codegen/src/module-registry.ts b/packages/codegen/src/module-registry.ts index a6945c6b1..f4593bfa8 100644 --- a/packages/codegen/src/module-registry.ts +++ b/packages/codegen/src/module-registry.ts @@ -1,9 +1,16 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +import { normalizeSuiAddress } from '@mysten/sui/utils'; import type { MoveModuleBuilder } from './move-module-builder.js'; import type { Ability, ModuleSummary } from './types/summary.js'; +const HEX_ADDRESS = /^0x[0-9a-fA-F]{1,64}$/; + +function normalizeAddress(address: string) { + return HEX_ADDRESS.test(address) ? normalizeSuiAddress(address) : address; +} + export class ModuleRegistry { readonly addressMappings: Record; readonly #builders = new Map(); @@ -31,11 +38,43 @@ export class ModuleRegistry { return this.getBuilder(address, module)?.summary; } + /** + * Look up a module by its resolved (canonical) address. Builders are keyed by the address + * used in their summaries, which may be a named address — this scans by resolving each + * builder's address instead. + */ + getSummaryByResolvedAddress(resolvedAddress: string, module: string): ModuleSummary | undefined { + const direct = this.getSummary(resolvedAddress, module); + if (direct) return direct; + + for (const builder of this.#builders.values()) { + if ( + builder.summary.id.name === module && + normalizeAddress(this.resolveAddress(builder.summary.id.address)) === + normalizeAddress(resolvedAddress) + ) { + return builder.summary; + } + } + return undefined; + } + getAbilities(address: string, module: string, name: string): Ability[] | undefined { const summary = this.getSummary(address, module); return summary?.structs[name]?.abilities ?? summary?.enums[name]?.abilities; } + /** Whether any loaded module belongs to a package with this resolved address. */ + hasResolvedAddress(resolvedAddress: string): boolean { + const normalized = normalizeAddress(resolvedAddress); + for (const builder of this.#builders.values()) { + if (normalizeAddress(this.resolveAddress(builder.summary.id.address)) === normalized) { + return true; + } + } + return false; + } + #keyOf(address: string, module: string): string { return `${address}::${module}`; } diff --git a/packages/codegen/src/move-module-builder.ts b/packages/codegen/src/move-module-builder.ts index cce27b1ef..5bb312733 100644 --- a/packages/codegen/src/move-module-builder.ts +++ b/packages/codegen/src/move-module-builder.ts @@ -7,10 +7,17 @@ import { ModuleRegistry } from './module-registry.js'; import { getSafeName, isSupportedRawTransactionInput, + renderResolverTypeTag, renderTypeSignature, SUI_FRAMEWORK_ADDRESS, SUI_SYSTEM_ADDRESS, } from './render-types.js'; +import { findConfigArgumentMatch } from './config-arguments.js'; +import type { + FunctionConfigArgument, + ParsedConfigArgument, + TypeConfigArgument, +} from './config-arguments.js'; import { camelCase, capitalize, @@ -20,7 +27,7 @@ import { parseTS, withComment, } from './utils.js'; -import type { Fields, ModuleSummary, Type, TypeParameter } from './types/summary.js'; +import type { Datatype, Fields, ModuleSummary, Type, TypeParameter } from './types/summary.js'; import type { FunctionsOption, ImportExtension, TypesOption } from './config.js'; import { join } from 'node:path'; import { isValidSuiObjectId } from '@mysten/sui/utils'; @@ -35,6 +42,11 @@ const IMPORT_MAP = { MoveEnum: { module: '~outputRoot/utils/index', isType: false }, normalizeMoveArguments: { module: '~outputRoot/utils/index', isType: false }, RawTransactionArgument: { module: '~outputRoot/utils/index', isType: true }, + resolveConfigArgument: { module: '~outputRoot/utils/index', isType: false }, + applyConfigArguments: { module: '~outputRoot/utils/index', isType: false }, + ConfigValue: { module: '~outputRoot/utils/index', isType: true }, + ConfigResolverContext: { module: '~outputRoot/utils/index', isType: true }, + TransactionObjectArgument: { module: '@mysten/sui/transactions', isType: true }, } as const; type ImportName = keyof typeof IMPORT_MAP; @@ -52,6 +64,10 @@ export class MoveModuleBuilder extends FileBuilder { #importNames: Partial> = {}; #importExtension: ImportExtension; #includePhantomTypeParameters: boolean; + #configArguments: ParsedConfigArgument[] = []; + #packageConfigKey?: string; + /** Config keys that matched at least one parameter of a rendered function. */ + readonly usedConfigKeys = new Set(); constructor({ mvrNameOrAddress, @@ -144,6 +160,44 @@ export class MoveModuleBuilder extends FileBuilder { return this.#importNames[name]!; } + /** + * Configure parsed `configArguments` entries for this module's generated functions. Must be + * called before `renderFunctions`. `packageConfigKey` is the config key (if any) that supplies + * this package's call address. + */ + setConfigArguments(entries: ParsedConfigArgument[], packageConfigKey?: string) { + this.#configArguments = entries; + this.#packageConfigKey = packageConfigKey; + } + + /** + * The address this module's generated type tags use for `name`: the type-origin address when + * known, otherwise the same address BCS type names use (MVR name / root package id / resolved + * summary address). + */ + getTypeTagAddress(name: string): string { + const origin = this.#typeOrigins?.[name]; + if (origin) { + return origin; + } + const moduleTypeName = this.#getModuleTypeName(); + return moduleTypeName.startsWith('0x') || /[@/]/.test(moduleTypeName) + ? moduleTypeName + : this.#resolveAddress(moduleTypeName); + } + + /** + * Address for a datatype appearing in a resolver-context type tag: delegate to the defining + * module's builder so origins and MVR names match generated BCS type names. + */ + #getResolverTagAddress(datatype: Datatype): string { + const builder = this.registry.getBuilder(datatype.module.address, datatype.module.name); + if (builder) { + return builder.getTypeTagAddress(datatype.name); + } + return this.#resolveAddress(datatype.module.address); + } + override async getHeader() { if (!this.summary.doc) { return super.getHeader(); @@ -554,6 +608,31 @@ export class MoveModuleBuilder extends FileBuilder { !isWellKnownObjectParameter(param.type_, (address) => this.#resolveAddress(address)), ); + const functionLabel = `${this.summary.id.address}::${this.summary.id.name}::${name}`; + // Parameters (by index into `requiredParameters`) resolved from the runtime config + // object instead of being required arguments. + const configMatches = new Map(); + if (this.#configArguments.length > 0) { + requiredParameters.forEach((param, i) => { + const match = findConfigArgumentMatch(param, this.#configArguments, { + resolveAddress: (address) => this.#resolveAddress(address), + functionLabel, + functionRef: { + moduleAddress: this.summary.id.address, + moduleName: this.summary.id.name, + functionName: name, + }, + parameterIndex: i, + }); + if (!match) return; + configMatches.set(i, match); + }); + for (const match of configMatches.values()) { + this.usedConfigKeys.add(match.key); + } + } + const hasConfigMatches = configMatches.size > 0; + const normalizeName = parameters.length > 0 ? this.#getImportName('normalizeMoveArguments') : null; @@ -588,14 +667,33 @@ export class MoveModuleBuilder extends FileBuilder { const wrap = (type: string | null): string => type === null ? transactionArgName! : `${rawTxArgName}<${type}>`; - const argumentsTypes = renderedArgTypes + // Interface fields: config-matched parameters become optional properties. + const argumentFields = renderedArgTypes .map((type, i) => requiredParameters[i].name - ? `${camelCase(requiredParameters[i].name)}: ${wrap(type)}` + ? `${camelCase(requiredParameters[i].name)}${configMatches.has(i) ? '?' : ''}: ${wrap(type)}` : wrap(type), ) .join(',\n'); + // Tuple items: a config-matched suffix becomes genuinely optional tuple elements. + // Optional elements can't precede required ones, so matched positions followed by a + // required one accept an explicit `undefined` instead. + const lastUnmatchedIndex = renderedArgTypes.reduce( + (last, _, i) => (configMatches.has(i) ? last : i), + -1, + ); + const argumentTupleItems = renderedArgTypes + .map((type, i) => { + const paramName = requiredParameters[i].name; + if (configMatches.has(i) && i > lastUnmatchedIndex) { + return paramName ? `${camelCase(paramName)}?: ${wrap(type)}` : `${wrap(type)}?`; + } + const itemType = configMatches.has(i) ? `${wrap(type)} | undefined` : wrap(type); + return paramName ? `${camelCase(paramName)}: ${itemType}` : itemType; + }) + .join(',\n'); + const bcsTypeName = usedTypeParameters.size > 0 ? this.#getImportName('BcsType') : null; const filteredTypeParameters = func.type_parameters @@ -621,24 +719,61 @@ export class MoveModuleBuilder extends FileBuilder { if (hasAllParameterNames) { this.statements.push( ...parseTS /* ts */ `export interface ${argumentsInterface}${genericTypes} { - ${argumentsTypes} + ${argumentFields} }`, ); } const optionsInterface = this.getUnusedName(`${capitalize(fnName.replace(/^_/, ''))}Options`); const packageIsRequired = !this.#mvrNameOrAddress; + // The package-address config key only applies when a generated default exists — + // otherwise `package` stays required and always takes precedence. + const packageConfigKey = packageIsRequired ? undefined : this.#packageConfigKey; const requiresOptions = - packageIsRequired || argumentsTypes.length > 0 || func.type_parameters.length > 0; + packageIsRequired || requiredParameters.length > 0 || func.type_parameters.length > 0; + + // The minimal structural config slice for this function: only the keys whose matchers + // hit its parameters, plus this package's own package key if declared. + const configSliceFields: string[] = []; + const seenConfigKeys = new Set(); + for (const match of configMatches.values()) { + if (seenConfigKeys.has(match.key)) continue; + seenConfigKeys.add(match.key); + // A key must be a resolver function when it can bind more than one distinct type + // (or a generic). Multiple matchers sharing one concrete type share a plain value. + const boundTypes = new Set( + this.#configArguments.flatMap((entry) => + entry.kind !== 'package' && entry.key === match.key ? [entry.boundType] : [], + ), + ); + const requiresResolver = boundTypes.size > 1 || boundTypes.has(null); + configSliceFields.push( + requiresResolver + ? `${match.key}: (ctx: ${this.#getImportName('ConfigResolverContext')}) => string | ${this.#getImportName('TransactionObjectArgument')}` + : `${match.key}: ${this.#getImportName('ConfigValue')}`, + ); + } + if (packageConfigKey) { + configSliceFields.push(`${packageConfigKey}?: string`); + } + + const argumentsOptional = requiredParameters.every((_, i) => configMatches.has(i)); this.statements.push( ...parseTS /* ts */ `export interface ${optionsInterface}${genericTypes} { package${packageIsRequired ? ': string' : '?: string'} - ${argumentsTypes.length > 0 ? 'arguments: ' : 'arguments?: '}${ + arguments${argumentsOptional ? '?' : ''}: ${ hasAllParameterNames - ? `${argumentsInterface}${genericTypeArgs} | [${argumentsTypes}]` - : `[${argumentsTypes}]` + ? `${argumentsInterface}${genericTypeArgs} | [${argumentTupleItems}]` + : `[${argumentTupleItems}]` }, + ${ + configSliceFields.length > 0 + ? `config?: { + ${configSliceFields.join(',\n')} + },` + : '' + } ${ func.type_parameters.length ? `typeArguments: [${func.type_parameters.map(() => 'string').join(', ')}]` @@ -647,11 +782,55 @@ export class MoveModuleBuilder extends FileBuilder { }`, ); + // Config-matched positions are resolved lazily — only when the caller did not pass the + // argument explicitly. + const configDefaults = [...configMatches.entries()].map(([i, match]) => { + const param = requiredParameters[i]; + let paramType = param.type_; + while (typeof paramType !== 'string' && 'Reference' in paramType) { + paramType = paramType.Reference[1]; + } + const paramTypeArguments = + typeof paramType !== 'string' && 'Datatype' in paramType + ? paramType.Datatype.type_arguments + : []; + const ctxTags = paramTypeArguments.map((arg) => { + const tag = renderResolverTypeTag(arg.argument, { + summary: this.summary, + typeParameters: func.type_parameters, + registry: this.registry, + getDatatypeTagAddress: (datatype) => this.#getResolverTagAddress(datatype), + }); + return tag.includes('${') ? `\`${tag}\`` : `'${tag}'`; + }); + const ctx = `{ typeArguments: [${ctxTags.join(', ')}], packageAddress, moduleName: '${this.summary.id.name}', functionName: '${name}', parameterIndex: ${i}${ + param.name ? `, parameterName: ${JSON.stringify(param.name)}` : '' + } }`; + return `{ index: ${i}, ${param.name ? `name: ${JSON.stringify(camelCase(param.name))}, ` : ''}resolve: () => ${this.#getImportName('resolveConfigArgument')}(options.config?.${match.key}, ${ctx}, ${JSON.stringify(match.key)}) }`; + }); + + const baseArgumentsExpr = `options.arguments${ + requiredParameters.length === 0 + ? ' ?? []' + : argumentsOptional + ? hasAllParameterNames + ? ' ?? {}' + : ' ?? []' + : '' + }`; + const argumentsExpr = hasConfigMatches + ? `${this.#getImportName('applyConfigArguments')}(${baseArgumentsExpr}, [${configDefaults.join(', ')}])` + : baseArgumentsExpr; + + const packageAddressExpr = `options.package${ + packageConfigKey ? ` ?? options.config?.${packageConfigKey}` : '' + }${packageIsRequired ? '' : ` ?? '${this.#mvrNameOrAddress}'`}`; + this.statements.push( ...(await withComment( func, parseTS /* ts */ `export function ${fnName}${genericTypes}(options: ${optionsInterface}${genericTypeArgs}${requiresOptions ? '' : ' = {}'}) { - const packageAddress = options.package${packageIsRequired ? '' : ` ?? '${this.#mvrNameOrAddress}'`}; + const packageAddress = ${packageAddressExpr}; ${ parameters.length > 0 ? `const argumentsTypes = [ @@ -676,7 +855,7 @@ export class MoveModuleBuilder extends FileBuilder { package: packageAddress, module: '${this.summary.id.name}', function: '${name}', - ${parameters.length > 0 ? `arguments: ${normalizeName}(options.arguments${argumentsTypes.length > 0 ? '' : ' ?? []'} , argumentsTypes${hasAllParameterNames ? `, parameterNames` : ''}),` : ''} + ${parameters.length > 0 ? `arguments: ${normalizeName}(${argumentsExpr}, argumentsTypes${hasAllParameterNames ? `, parameterNames` : ''}),` : ''} ${func.type_parameters.length ? 'typeArguments: options.typeArguments' : ''} }) }`, diff --git a/packages/codegen/src/render-types.ts b/packages/codegen/src/render-types.ts index 484cd48e7..89ad00f00 100644 --- a/packages/codegen/src/render-types.ts +++ b/packages/codegen/src/render-types.ts @@ -185,6 +185,67 @@ export function renderTypeSignature(type: Type, options: RenderTypeSignatureOpti throw new Error(`Unknown type signature: ${JSON.stringify(type, null, 2)}`); } +/** + * Render a type as a full type-tag string for config resolver contexts. Unlike the `typeTag` + * format (which emits `null` for non-pure datatypes because the runtime doesn't need those tags), + * this always produces a real tag. Function type parameters are interpolated from the generated + * function's `options.typeArguments`, so the result may be a template-literal fragment. + */ +export function renderResolverTypeTag( + type: Type, + options: Pick & { + /** + * Overrides the address used for a concrete datatype (e.g. to use type-origin addresses or + * MVR names consistent with generated BCS type names). Falls back to the registry's + * address mapping. + */ + getDatatypeTagAddress?: (datatype: Datatype) => string; + }, +): string { + if (typeof type === 'string') { + if (type === 'signer' || type === '_') { + throw new Error(`${type} is not supported in type arguments`); + } + return type; + } + + if ('Reference' in type) { + return renderResolverTypeTag(type.Reference[1], options); + } + + if ('vector' in type) { + return `vector<${renderResolverTypeTag(type.vector, options)}>`; + } + + if ('TypeParameter' in type) { + return `\${options.typeArguments[${type.TypeParameter}]}`; + } + + if ('NamedTypeParameter' in type) { + const originalIndex = + options.typeParameters?.findIndex((p) => p.name === type.NamedTypeParameter) ?? -1; + if (originalIndex === -1) { + throw new Error(`Named type parameter ${type.NamedTypeParameter} not found`); + } + return `\${options.typeArguments[${originalIndex}]}`; + } + + if ('Datatype' in type) { + const { Datatype } = type; + const address = + options.getDatatypeTagAddress?.(Datatype) ?? resolveAddress(options, Datatype.module.address); + const base = `${address}::${Datatype.module.name}::${Datatype.name}`; + if (Datatype.type_arguments.length === 0) { + return base; + } + return `${base}<${Datatype.type_arguments + .map((arg) => renderResolverTypeTag(arg.argument, options)) + .join(', ')}>`; + } + + throw new Error(`Unknown type signature: ${JSON.stringify(type, null, 2)}`); +} + function getDatatypeAbilities( type: Datatype, options: Pick, @@ -260,6 +321,8 @@ function renderDataType(type: Datatype, options: RenderTypeSignatureOptions): st if (type.module.name === 'random' && type.name === 'Random') return '0x2::random::Random'; if (type.module.name === 'deny_list' && type.name === 'DenyList') return '0x2::deny_list::DenyList'; + if (type.module.name === 'accumulator' && type.name === 'AccumulatorRoot') + return '0x2::accumulator::AccumulatorRoot'; if (type.module.name === 'object' && (type.name === 'ID' || type.name === 'UID')) return '0x2::object::ID'; } diff --git a/packages/codegen/src/utils.ts b/packages/codegen/src/utils.ts index d37b58913..679e94e28 100644 --- a/packages/codegen/src/utils.ts +++ b/packages/codegen/src/utils.ts @@ -168,6 +168,10 @@ export function isWellKnownObjectParameter( if (Datatype.module.name === 'clock') { return Datatype.name === 'Clock'; } + + if (Datatype.module.name === 'accumulator') { + return Datatype.name === 'AccumulatorRoot'; + } } if (address === SUI_SYSTEM_ADDRESS) { diff --git a/packages/codegen/tests/codegen-output.test.ts b/packages/codegen/tests/codegen-output.test.ts index 691759ad6..12a964ae5 100644 --- a/packages/codegen/tests/codegen-output.test.ts +++ b/packages/codegen/tests/codegen-output.test.ts @@ -586,6 +586,68 @@ describe('function codegen output', () => { expect(fnBody?.[0]).toContain("'0x2::clock::Clock'"); }); + it('function with well-known AccumulatorRoot parameter', async () => { + const summary = { + id: { address: 'testpkg', name: 'settlement' }, + doc: '', + immediate_dependencies: [], + attributes: [], + functions: { + settle: { + source_index: 0, + index: 0, + doc: '', + attributes: [], + visibility: 'Public', + entry: false, + macro_: false, + type_parameters: [], + parameters: [ + { + name: 'root', + type_: { + Reference: [ + true, + { + Datatype: { + module: { address: 'sui', name: 'accumulator' }, + name: 'AccumulatorRoot', + type_arguments: [], + }, + }, + ], + }, + }, + { name: 'amount', type_: 'u64' }, + ], + return_: [], + }, + }, + structs: {}, + enums: {}, + }; + + const builder = new MoveModuleBuilder({ + summary: summary as any, + registry: new ModuleRegistry(ADDRESS_MAPPINGS), + mvrNameOrAddress: '@test/testpkg', + importExtension: '.js', + }); + builder.includeFunctions(); + const output = await render(builder, { functions: true }); + + // AccumulatorRoot should be auto-injected, not in the arguments interface + const argInterface = output.match(/export interface SettleArguments[\s\S]*?^}/m); + expect(argInterface?.[0]).toMatchInlineSnapshot(` + "export interface SettleArguments { + amount: RawTransactionArgument; + }" + `); + + const fnBody = output.match(/export function settle[\s\S]*?^}/m); + expect(fnBody?.[0]).toContain("'0x2::accumulator::AccumulatorRoot'"); + }); + it('function with enum parameter (is_active)', async () => { const { registry } = await createBuilders(); registry.includeTypes(); diff --git a/packages/codegen/tests/config-arguments.test.ts b/packages/codegen/tests/config-arguments.test.ts new file mode 100644 index 000000000..2bc8ce897 --- /dev/null +++ b/packages/codegen/tests/config-arguments.test.ts @@ -0,0 +1,1288 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { readFile, readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import ts from 'typescript'; +import { Transaction } from '@mysten/sui/transactions'; +import { ModuleRegistry } from '../src/module-registry.js'; +import { MoveModuleBuilder } from '../src/move-module-builder.js'; +import { parseConfigArguments } from '../src/config-arguments.js'; +import { generateFromPackageSummary } from '../src/index.js'; +import { configArgumentsSchema } from '../src/config.js'; +import type { ConfigArguments } from '../src/config.js'; + +const FIXTURE_PATH = join(__dirname, 'move/testpkg'); +const SUMMARIES_DIR = join(FIXTURE_PATH, 'package_summaries'); +const GENERATED_DIR = join(__dirname, 'generated-config'); + +const ADDRESS_MAPPINGS = { + std: '0x0000000000000000000000000000000000000000000000000000000000000001', + sui: '0x0000000000000000000000000000000000000000000000000000000000000002', + testpkg: '0x0000000000000000000000000000000000000000000000000000000000000000', +}; + +const TESTPKG_CONTEXT = { + package: { id: '@test/testpkg', address: ADDRESS_MAPPINGS.testpkg }, +}; + +async function createBuilders(configArguments: ConfigArguments, packageConfigKey?: string) { + const registry = new ModuleRegistry(ADDRESS_MAPPINGS); + const counter = await MoveModuleBuilder.fromSummaryFile( + join(SUMMARIES_DIR, 'testpkg', 'counter.json'), + registry, + '@test/testpkg', + ); + const registryBuilder = await MoveModuleBuilder.fromSummaryFile( + join(SUMMARIES_DIR, 'testpkg', 'registry.json'), + registry, + '@test/testpkg', + ); + + const { entries } = parseConfigArguments({ global: configArguments }, registry, TESTPKG_CONTEXT); + counter.setConfigArguments(entries, packageConfigKey); + registryBuilder.setConfigArguments(entries, packageConfigKey); + + return { counter, registry: registryBuilder, moduleRegistry: registry }; +} + +async function render(builder: MoveModuleBuilder) { + await builder.renderFunctions(); + return builder.toString('./', './testpkg/test.ts'); +} + +/** + * A synthetic module with a generic `Pool` type used by functions in generic, + * concretely-instantiated, and same-type-twice positions, plus a `Coin` type used as a concrete + * own-package type argument. + */ +function poolsSummary({ parameterNames = true }: { parameterNames?: boolean } = {}) { + const poolType = (typeArgument: unknown) => ({ + Reference: [ + false, + { + Datatype: { + module: { address: 'testpkg', name: 'pools' }, + name: 'Pool', + type_arguments: [{ phantom: true, argument: typeArgument }], + }, + }, + ], + }); + const suiType = { + Datatype: { module: { address: 'sui', name: 'sui' }, name: 'SUI', type_arguments: [] }, + }; + const ownCoinType = { + Datatype: { module: { address: 'testpkg', name: 'pools' }, name: 'Coin', type_arguments: [] }, + }; + const param = (name: string, type_: unknown) => (parameterNames ? { name, type_ } : { type_ }); + const fn = (parameters: unknown[], type_parameters: unknown[] = []) => ({ + source_index: 0, + index: 0, + doc: '', + attributes: [], + visibility: 'Public', + entry: false, + macro_: false, + type_parameters, + parameters, + return_: [], + }); + + return { + id: { address: 'testpkg', name: 'pools' }, + doc: '', + immediate_dependencies: [], + attributes: [], + functions: { + use_generic: fn( + [param('pool', poolType({ TypeParameter: 0 })), param('amount', 'u64')], + [{ name: 'T', phantom: false, constraints: [] }], + ), + use_concrete: fn([param('pool', poolType(suiType)), param('amount', 'u64')]), + use_own_coin: fn([param('pool', poolType(ownCoinType)), param('amount', 'u64')]), + swap: fn( + [ + param('base_pool', poolType({ TypeParameter: 0 })), + param('quote_pool', poolType({ TypeParameter: 1 })), + ], + [ + { name: 'Base', phantom: false, constraints: [] }, + { name: 'Quote', phantom: false, constraints: [] }, + ], + ), + }, + structs: { + Pool: { + index: 0, + doc: '', + attributes: [], + abilities: ['Key'], + type_parameters: [{ name: 'T', phantom: true, constraints: [] }], + fields: { + positional_fields: false, + fields: { id: { index: 0, doc: null, type_: 'address' } }, + }, + }, + Coin: { + index: 1, + doc: '', + attributes: [], + abilities: ['Store'], + type_parameters: [], + fields: { + positional_fields: false, + fields: { value: { index: 0, doc: null, type_: 'u64' } }, + }, + }, + }, + enums: {}, + }; +} + +function createPoolsBuilder( + configArguments: ConfigArguments, + options: { parameterNames?: boolean; typeOrigins?: Record } = {}, +) { + const registry = new ModuleRegistry(ADDRESS_MAPPINGS); + const builder = new MoveModuleBuilder({ + summary: poolsSummary(options) as any, + registry, + mvrNameOrAddress: '@test/testpkg', + importExtension: '.js', + typeOrigins: options.typeOrigins, + }); + const { entries } = parseConfigArguments({ global: configArguments }, registry, TESTPKG_CONTEXT); + builder.setConfigArguments(entries); + return builder; +} + +describe('configArguments schema', () => { + it('rejects prototype-polluting keys', () => { + // zod itself drops `__proto__` record keys; the others are rejected by the key schema. + expect( + Object.keys(configArgumentsSchema.parse({ ['__proto__']: { type: '0x2::sui::SUI' } })), + ).toEqual([]); + expect(() => + configArgumentsSchema.parse({ constructor: { type: '0x2::sui::SUI' } }), + ).toThrowError(/prototype property names/); + expect(() => + configArgumentsSchema.parse({ prototype: { type: '0x2::sui::SUI' } }), + ).toThrowError(/prototype property names/); + }); + + it('rejects matchers mixing type and package', () => { + expect(() => + configArgumentsSchema.parse({ both: { type: '0x2::sui::SUI', package: '@x/y' } }), + ).toThrowError(); + }); +}); + +describe('parseConfigArguments', () => { + function createRegistry() { + const registry = new ModuleRegistry(ADDRESS_MAPPINGS); + new MoveModuleBuilder({ + summary: poolsSummary() as any, + registry, + mvrNameOrAddress: '@test/testpkg', + importExtension: '.js', + }); + return registry; + } + + it('parses package-qualified, framework-qualified, and package matchers', async () => { + const { entries } = parseConfigArguments( + { + global: { + pool: { type: '@test/testpkg::pools::Pool' }, + suiPool: { type: '@test/testpkg::pools::Pool<0x2::sui::SUI>' }, + pkg: { package: '@test/testpkg' }, + }, + }, + createRegistry(), + TESTPKG_CONTEXT, + ); + + expect(entries).toMatchObject([ + { + kind: 'type', + key: 'pool', + source: 'global', + address: '0x0000000000000000000000000000000000000000000000000000000000000000', + module: 'pools', + name: 'Pool', + typeArguments: null, + isGeneric: true, + }, + { + kind: 'type', + key: 'suiPool', + typeArguments: [ + { + datatype: { + address: '0x0000000000000000000000000000000000000000000000000000000000000002', + module: 'sui', + name: 'SUI', + typeArguments: [], + }, + }, + ], + isGeneric: true, + }, + { kind: 'package', key: 'pkg', package: '@test/testpkg' }, + ]); + }); + + it('resolves bare module::Type matchers against the declaring package', async () => { + const { entries } = parseConfigArguments( + { + package: { + pool: { type: 'pools::Pool' }, + coinPool: { type: 'pools::Pool' }, + }, + }, + createRegistry(), + TESTPKG_CONTEXT, + ); + + expect(entries).toMatchObject([ + { + key: 'pool', + address: '0x0000000000000000000000000000000000000000000000000000000000000000', + module: 'pools', + name: 'Pool', + typeArguments: null, + }, + { + key: 'coinPool', + typeArguments: [ + { + datatype: { + address: '0x0000000000000000000000000000000000000000000000000000000000000000', + module: 'pools', + name: 'Coin', + }, + }, + ], + }, + ]); + }); + + it('requires package qualification for global matchers', async () => { + expect(() => + parseConfigArguments( + { global: { pool: { type: 'pools::Pool' } } }, + createRegistry(), + TESTPKG_CONTEXT, + ), + ).toThrowError(/must be qualified with a package in the global configArguments block/); + }); + + it('resolves other run packages through the packageAddresses map', async () => { + const { entries } = parseConfigArguments( + { global: { pool: { type: '@other/pkg::pools::Pool' } } }, + createRegistry(), + { + ...TESTPKG_CONTEXT, + // @other/pkg resolves to the same summary package in this test closure. + packageAddresses: { '@other/pkg': ADDRESS_MAPPINGS.testpkg }, + }, + ); + + expect(entries).toMatchObject([{ key: 'pool', module: 'pools', name: 'Pool' }]); + }); + + it('merges package-scoped entries over global entries per key', async () => { + const { entries } = parseConfigArguments( + { + global: { + pool: { type: '@test/testpkg::pools::Pool' }, + coin: { type: '@test/testpkg::pools::Coin' }, + }, + package: { + pool: { type: 'pools::Pool<0x2::sui::SUI>' }, + }, + }, + createRegistry(), + TESTPKG_CONTEXT, + ); + + // Map insertion order keeps the global position for overridden keys. + expect(entries).toMatchObject([ + { key: 'pool', source: 'package', typeArguments: [{ datatype: { name: 'SUI' } }] }, + { key: 'coin', source: 'global', typeArguments: [] }, + ]); + }); + + it('skips matchers for run packages outside this dependency closure', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + // Global entries for other packages are silently inapplicable here (they are + // validated when their own package is generated). + const { entries } = parseConfigArguments( + { global: { other: { type: '@other/pkg::vault::Vault' } } }, + createRegistry(), + { + ...TESTPKG_CONTEXT, + packageAddresses: { + '@other/pkg': '0x0000000000000000000000000000000000000000000000000000000000000042', + }, + }, + ); + expect(entries).toEqual([]); + expect(warn).not.toHaveBeenCalled(); + + // A package-scoped entry referencing a package that is not a dependency can never + // match, which deserves a warning. + parseConfigArguments( + { package: { other: { type: '@other/pkg::vault::Vault' } } }, + createRegistry(), + { + ...TESTPKG_CONTEXT, + packageAddresses: { + '@other/pkg': '0x0000000000000000000000000000000000000000000000000000000000000042', + }, + }, + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('will never match')); + } finally { + warn.mockRestore(); + } + }); + + it('errors when the matched type does not exist in a package that is part of the closure', async () => { + expect(() => + parseConfigArguments( + { global: { missing: { type: '@test/testpkg::pools::DoesNotExist' } } }, + createRegistry(), + TESTPKG_CONTEXT, + ), + ).toThrowError(/was not found in its package's summaries/); + }); + + it('rejects malformed matcher types', async () => { + const registry = createRegistry(); + const parse = (type: string) => + parseConfigArguments({ global: { bad: { type } } }, registry, TESTPKG_CONTEXT); + + expect(() => parse('Pool')).toThrowError(/Expected "module::Type"/); + expect(() => parse('u64')).toThrowError(/must be a Move datatype/); + expect(() => parse('@test/testpkg::pools::Pool<0x2::sui::SUI>>')).toThrowError( + /unbalanced '>'/, + ); + expect(() => parse('@test/testpkg::pools::Pool<0x2::sui::SUI')).toThrowError(/unbalanced ' parse('@test/testpkg::pools::Coin<>')).toThrowError(/empty type argument/); + expect(() => parse('@test/testpkg::pools::Pool <0x2::sui::SUI>')).toThrowError( + /is not a valid module::type pair/, + ); + expect(() => parse('2::sui::SUI')).toThrowError(/Unknown package "2"/); + expect(() => parse('@unknown/pkg::pools::Pool')).toThrowError( + /Unknown package "@unknown\/pkg"/, + ); + }); + + it('rejects non-framework package addresses', async () => { + const registry = createRegistry(); + const parse = (type: string) => + parseConfigArguments({ global: { bad: { type } } }, registry, TESTPKG_CONTEXT); + + expect(() => parse('0x999::vault::Vault')).toThrowError( + /package addresses are network-specific/, + ); + expect(() => parse('@test/testpkg::pools::Pool<0xabc123::coin::COIN>')).toThrowError( + /package addresses are network-specific/, + ); + }); + + it('rejects partially instantiated matchers with a dedicated error', async () => { + const registry = createRegistry(); + + expect(() => + parseConfigArguments( + { global: { pool: { type: '@test/testpkg::pools::Pool' } } }, + registry, + TESTPKG_CONTEXT, + ), + ).toThrowError(/partially instantiated matchers are not supported/); + + // A nested uninstantiated generic is also a partial instantiation. + expect(() => + parseConfigArguments( + { global: { pool: { type: '@test/testpkg::pools::Pool<@test/testpkg::pools::Pool>' } } }, + registry, + TESTPKG_CONTEXT, + ), + ).toThrowError(/Partially instantiated matchers are not supported/); + }); + + it('rejects instantiated matchers with the wrong arity', async () => { + expect(() => + parseConfigArguments( + { global: { pool: { type: '@test/testpkg::pools::Pool<0x2::sui::SUI, u64>' } } }, + createRegistry(), + TESTPKG_CONTEXT, + ), + ).toThrowError(/expects 1 type argument\(s\), got 2/); + }); +}); + +describe('config-driven function codegen', () => { + it('non-generic matcher: matched parameter becomes optional with an optional config slice', async () => { + const { registry } = await createBuilders({ + registryObj: { type: '@test/testpkg::registry::Registry' }, + }); + registry.includeFunctions(['register']); + const output = await render(registry); + + const argInterface = output.match(/export interface RegisterArguments[\s\S]*?^}/m); + expect(argInterface?.[0]).toMatchInlineSnapshot(` + "export interface RegisterArguments { + registry?: RawTransactionArgument; + name: RawTransactionArgument; + tags: RawTransactionArgument>; + }" + `); + + const optionsInterface = output.match(/export interface RegisterOptions[\s\S]*?^}/m); + expect(optionsInterface?.[0]).toMatchInlineSnapshot(` + "export interface RegisterOptions { + package?: string; + arguments: RegisterArguments | [ + registry: RawTransactionArgument | undefined, + name: RawTransactionArgument, + tags: RawTransactionArgument> + ]; + config?: { + registryObj: ConfigValue; + }; + }" + `); + + const fnBody = output.match(/export function register[\s\S]*?^}/m); + expect(fnBody?.[0]).toMatchInlineSnapshot(` + "export function register(options: RegisterOptions) { + const packageAddress = options.package ?? '@test/testpkg'; + const argumentsTypes = [ + null, + '0x1::string::String', + 'vector<0x1::string::String>' + ] satisfies (string | null)[]; + const parameterNames = ["registry", "name", "tags"]; + return (tx: Transaction) => tx.moveCall({ + package: packageAddress, + module: 'registry', + function: 'register', + arguments: normalizeMoveArguments(applyConfigArguments(options.arguments, [{ index: 0, name: "registry", resolve: () => resolveConfigArgument(options.config?.registryObj, { typeArguments: [], packageAddress, moduleName: 'registry', functionName: 'register', parameterIndex: 0, parameterName: "registry" }, "registryObj") }]), argumentsTypes, parameterNames), + }); + }" + `); + }); + + it('makes arguments optional and the tuple suffix optional when every parameter is config-matched', async () => { + const { registry } = await createBuilders({ + registryObj: { type: '@test/testpkg::registry::Registry' }, + }); + registry.includeFunctions(['lookup']); + const output = await render(registry); + + const optionsInterface = output.match(/export interface LookupOptions[\s\S]*?^}/m); + expect(optionsInterface?.[0]).toMatchInlineSnapshot(` + "export interface LookupOptions { + package?: string; + arguments?: LookupArguments | [ + registry?: RawTransactionArgument + ]; + config?: { + registryObj: ConfigValue; + }; + }" + `); + + const fnBody = output.match(/export function lookup[\s\S]*?^}/m); + expect(fnBody?.[0]).toMatchInlineSnapshot(` + "export function lookup(options: LookupOptions) { + const packageAddress = options.package ?? '@test/testpkg'; + const argumentsTypes = [ + null + ] satisfies (string | null)[]; + const parameterNames = ["registry"]; + return (tx: Transaction) => tx.moveCall({ + package: packageAddress, + module: 'registry', + function: 'lookup', + arguments: normalizeMoveArguments(applyConfigArguments(options.arguments ?? {}, [{ index: 0, name: "registry", resolve: () => resolveConfigArgument(options.config?.registryObj, { typeArguments: [], packageAddress, moduleName: 'registry', functionName: 'lookup', parameterIndex: 0, parameterName: "registry" }, "registryObj") }]), argumentsTypes, parameterNames), + }); + }" + `); + }); + + it('uninstantiated generic matcher: config value requires a resolver and receives the parameter instantiation', async () => { + const { registry } = await createBuilders({ + container: { type: '@test/testpkg::registry::Container' }, + }); + registry.includeFunctions(['container_size']); + const output = await render(registry); + + const optionsInterface = output.match(/export interface ContainerSizeOptions[\s\S]*?^}/m); + expect(optionsInterface?.[0]).toMatchInlineSnapshot(` + "export interface ContainerSizeOptions { + package?: string; + arguments?: ContainerSizeArguments | [ + container?: RawTransactionArgument + ]; + config?: { + container: (ctx: ConfigResolverContext) => string | TransactionObjectArgument; + }; + typeArguments: [ + string + ]; + }" + `); + + const fnBody = output.match(/export function containerSize[\s\S]*?^}/m); + expect(fnBody?.[0]).toMatchInlineSnapshot(` + "export function containerSize(options: ContainerSizeOptions) { + const packageAddress = options.package ?? '@test/testpkg'; + const argumentsTypes = [ + null + ] satisfies (string | null)[]; + const parameterNames = ["container"]; + return (tx: Transaction) => tx.moveCall({ + package: packageAddress, + module: 'registry', + function: 'container_size', + arguments: normalizeMoveArguments(applyConfigArguments(options.arguments ?? {}, [{ index: 0, name: "container", resolve: () => resolveConfigArgument(options.config?.container, { typeArguments: [\`\${options.typeArguments[0]}\`], packageAddress, moduleName: 'registry', functionName: 'container_size', parameterIndex: 0, parameterName: "container" }, "container") }]), argumentsTypes, parameterNames), + typeArguments: options.typeArguments + }); + }" + `); + }); + + it('instantiated matcher only matches concrete instantiations and wins over the uninstantiated matcher', async () => { + const builder = createPoolsBuilder({ + pool: { type: '@test/testpkg::pools::Pool' }, + suiPool: { type: '@test/testpkg::pools::Pool<0x2::sui::SUI>' }, + }); + builder.includeFunctions(['use_generic', 'use_concrete']); + const output = await render(builder); + + // use_concrete is concretely typed Pool in the Move signature: the instantiated + // matcher wins and a plain value is allowed. + const concreteOptions = output.match(/export interface UseConcreteOptions[\s\S]*?^}/m); + expect(concreteOptions?.[0]).toMatchInlineSnapshot(` + "export interface UseConcreteOptions { + package?: string; + arguments: UseConcreteArguments | [ + pool: RawTransactionArgument | undefined, + amount: RawTransactionArgument + ]; + config?: { + suiPool: ConfigValue; + }; + }" + `); + + const concreteBody = output.match(/export function useConcrete[\s\S]*?^}/m); + expect(concreteBody?.[0]).toMatchInlineSnapshot(` + "export function useConcrete(options: UseConcreteOptions) { + const packageAddress = options.package ?? '@test/testpkg'; + const argumentsTypes = [ + null, + 'u64' + ] satisfies (string | null)[]; + const parameterNames = ["pool", "amount"]; + return (tx: Transaction) => tx.moveCall({ + package: packageAddress, + module: 'pools', + function: 'use_concrete', + arguments: normalizeMoveArguments(applyConfigArguments(options.arguments, [{ index: 0, name: "pool", resolve: () => resolveConfigArgument(options.config?.suiPool, { typeArguments: ['0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI'], packageAddress, moduleName: 'pools', functionName: 'use_concrete', parameterIndex: 0, parameterName: "pool" }, "suiPool") }]), argumentsTypes, parameterNames), + }); + }" + `); + + // use_generic is typed Pool: it always binds to the uninstantiated matcher, which + // requires a resolver function. + const genericOptions = output.match(/export interface UseGenericOptions[\s\S]*?^}/m); + expect(genericOptions?.[0]).toMatchInlineSnapshot(` + "export interface UseGenericOptions { + package?: string; + arguments: UseGenericArguments | [ + pool: RawTransactionArgument | undefined, + amount: RawTransactionArgument + ]; + config?: { + pool: (ctx: ConfigResolverContext) => string | TransactionObjectArgument; + }; + typeArguments: [ + string + ]; + }" + `); + + const genericBody = output.match(/export function useGeneric[\s\S]*?^}/m); + expect(genericBody?.[0]).toMatchInlineSnapshot(` + "export function useGeneric(options: UseGenericOptions) { + const packageAddress = options.package ?? '@test/testpkg'; + const argumentsTypes = [ + null, + 'u64' + ] satisfies (string | null)[]; + const parameterNames = ["pool", "amount"]; + return (tx: Transaction) => tx.moveCall({ + package: packageAddress, + module: 'pools', + function: 'use_generic', + arguments: normalizeMoveArguments(applyConfigArguments(options.arguments, [{ index: 0, name: "pool", resolve: () => resolveConfigArgument(options.config?.pool, { typeArguments: [\`\${options.typeArguments[0]}\`], packageAddress, moduleName: 'pools', functionName: 'use_generic', parameterIndex: 0, parameterName: "pool" }, "pool") }]), argumentsTypes, parameterNames), + typeArguments: options.typeArguments + }); + }" + `); + }); + + it('resolver context tags for own-package types use the package name, not the placeholder address', async () => { + const builder = createPoolsBuilder({ + pool: { type: '@test/testpkg::pools::Pool' }, + }); + builder.includeFunctions(['use_own_coin']); + const output = await render(builder); + + const fnBody = output.match(/export function useOwnCoin[\s\S]*?^}/m); + expect(fnBody?.[0]).toContain("typeArguments: ['@test/testpkg::pools::Coin']"); + }); + + it('resolver context tags use origin addresses for upgraded packages', async () => { + const ORIGIN_V1 = '0x000000000000000000000000000000000000000000000000000000000000aaaa'; + const builder = createPoolsBuilder( + { pool: { type: '@test/testpkg::pools::Pool' } }, + { typeOrigins: { Coin: ORIGIN_V1 } }, + ); + builder.includeFunctions(['use_own_coin']); + const output = await render(builder); + + const fnBody = output.match(/export function useOwnCoin[\s\S]*?^}/m); + expect(fnBody?.[0]).toContain(`typeArguments: ['${ORIGIN_V1}::pools::Coin']`); + }); + + it('allows one key to match multiple parameters, forcing a resolver-typed config value', async () => { + const builder = createPoolsBuilder({ + pool: { type: '@test/testpkg::pools::Pool' }, + }); + builder.includeFunctions(['swap']); + const output = await render(builder); + + // Both base_pool and quote_pool resolve through config.pool; the resolver + // disambiguates via ctx (parameterName/parameterIndex/typeArguments). + const optionsInterface = output.match(/export interface SwapOptions[\s\S]*?^}/m); + expect(optionsInterface?.[0]).toContain( + 'pool: (ctx: ConfigResolverContext) => string | TransactionObjectArgument', + ); + + const fnBody = output.match(/export function swap[\s\S]*?^}/m); + expect(fnBody?.[0]).toContain('parameterIndex: 0'); + expect(fnBody?.[0]).toContain('parameterIndex: 1'); + }); + + it('allows one key to declare matchers for multiple types, forcing a resolver-typed config value', async () => { + const builder = createPoolsBuilder({ + objects: [ + { type: '@test/testpkg::pools::Coin' }, + { type: '@test/testpkg::pools::Pool<0x2::sui::SUI>' }, + ], + }); + builder.includeFunctions(['use_concrete']); + const output = await render(builder); + + const optionsInterface = output.match(/export interface UseConcreteOptions[\s\S]*?^}/m); + expect(optionsInterface?.[0]).toContain( + 'objects: (ctx: ConfigResolverContext) => string | TransactionObjectArgument', + ); + }); + + it('function matchers configure a single function parameter directly', async () => { + const { registry } = await createBuilders({ + // lookup has exactly one argument, so the parameter can be inferred. + lookupRegistry: { function: '@test/testpkg::registry::lookup' }, + registerRegistry: { + function: '@test/testpkg::registry::register', + parameterName: 'registry', + }, + }); + registry.includeFunctions(['lookup', 'register']); + const output = await render(registry); + + const lookupOptions = output.match(/export interface LookupOptions[\s\S]*?^}/m); + expect(lookupOptions?.[0]).toContain('lookupRegistry: ConfigValue'); + + const registerOptions = output.match(/export interface RegisterOptions[\s\S]*?^}/m); + expect(registerOptions?.[0]).toContain('registerRegistry: ConfigValue'); + // The function matcher only applies to its own function. + expect(registerOptions?.[0]).not.toContain('lookupRegistry'); + }); + + it('multiple matchers binding the same concrete type keep a plain config value', async () => { + const { registry } = await createBuilders({ + reg: [ + { function: '@test/testpkg::registry::lookup' }, + { function: '@test/testpkg::registry::register', parameterName: 'registry' }, + ], + }); + registry.includeFunctions(['lookup', 'register']); + const output = await render(registry); + + // Both bindings are `registry::Registry`, so one static id serves both functions. + const lookupOptions = output.match(/export interface LookupOptions[\s\S]*?^}/m); + expect(lookupOptions?.[0]).toContain('reg: ConfigValue'); + const registerOptions = output.match(/export interface RegisterOptions[\s\S]*?^}/m); + expect(registerOptions?.[0]).toContain('reg: ConfigValue'); + }); + + it('function matchers win over type matchers and support parameterIndex', async () => { + const builder = createPoolsBuilder({ + pool: { type: '@test/testpkg::pools::Pool' }, + concretePool: { function: '@test/testpkg::pools::use_concrete', parameterIndex: 0 }, + }); + builder.includeFunctions(['use_concrete', 'use_generic']); + const output = await render(builder); + + const concreteOptions = output.match(/export interface UseConcreteOptions[\s\S]*?^}/m); + expect(concreteOptions?.[0]).toContain('concretePool: ConfigValue'); + const concreteSlice = concreteOptions?.[0].match(/config\?: \{[\s\S]*?\}/); + expect(concreteSlice?.[0]).not.toContain('pool: (ctx'); + + const genericOptions = output.match(/export interface UseGenericOptions[\s\S]*?^}/m); + expect(genericOptions?.[0]).toContain('pool:'); + }); + + it('validates function matchers at parse time', async () => { + const registry = new ModuleRegistry(ADDRESS_MAPPINGS); + new MoveModuleBuilder({ + summary: poolsSummary() as any, + registry, + mvrNameOrAddress: '@test/testpkg', + importExtension: '.js', + }); + const parse = (matcher: object) => + parseConfigArguments({ global: { key: matcher as never } }, registry, TESTPKG_CONTEXT); + + expect(() => parse({ function: '@test/testpkg::pools::nope' })).toThrowError( + /was not found in its package's summaries/, + ); + expect(() => parse({ function: '@test/testpkg::pools::swap' })).toThrowError( + /has 2 argument\(s\) — specify parameterName or parameterIndex/, + ); + expect(() => + parse({ function: '@test/testpkg::pools::swap', parameterName: 'nope' }), + ).toThrowError(/has no parameter named "nope"/); + expect(() => parse({ function: '@test/testpkg::pools::swap', parameterIndex: 5 })).toThrowError( + /parameterIndex 5 is out of range/, + ); + expect(() => + parse({ + function: '@test/testpkg::pools::swap', + parameterName: 'base_pool', + parameterIndex: 0, + }), + ).toThrowError(/not both/); + }); + + it('generates tuple-only bindings for nameless summaries with a matched parameter', async () => { + const builder = createPoolsBuilder( + { pool: { type: '@test/testpkg::pools::Pool' } }, + { parameterNames: false }, + ); + builder.includeFunctions(['use_generic']); + const output = await render(builder); + + const optionsInterface = output.match(/export interface UseGenericOptions[\s\S]*?^}/m); + expect(optionsInterface?.[0]).toMatchInlineSnapshot(` + "export interface UseGenericOptions { + package?: string; + arguments: [ + RawTransactionArgument | undefined, + RawTransactionArgument + ]; + config?: { + pool: (ctx: ConfigResolverContext) => string | TransactionObjectArgument; + }; + typeArguments: [ + string + ]; + }" + `); + + const fnBody = output.match(/export function useGeneric[\s\S]*?^}/m); + expect(fnBody?.[0]).toMatchInlineSnapshot(` + "export function useGeneric(options: UseGenericOptions) { + const packageAddress = options.package ?? '@test/testpkg'; + const argumentsTypes = [ + null, + 'u64' + ] satisfies (string | null)[]; + return (tx: Transaction) => tx.moveCall({ + package: packageAddress, + module: 'pools', + function: 'use_generic', + arguments: normalizeMoveArguments(applyConfigArguments(options.arguments, [{ index: 0, resolve: () => resolveConfigArgument(options.config?.pool, { typeArguments: [\`\${options.typeArguments[0]}\`], packageAddress, moduleName: 'pools', functionName: 'use_generic', parameterIndex: 0 }, "pool") }]), argumentsTypes), + typeArguments: options.typeArguments + }); + }" + `); + }); + + it('name refinement disambiguates two parameters of the same type', async () => { + const builder = createPoolsBuilder({ + basePool: { type: '@test/testpkg::pools::Pool', parameterName: 'base_pool' }, + quotePool: { type: '@test/testpkg::pools::Pool', parameterName: 'quote_pool' }, + }); + builder.includeFunctions(['swap']); + const output = await render(builder); + + const optionsInterface = output.match(/export interface SwapOptions[\s\S]*?^}/m); + expect(optionsInterface?.[0]).toMatchInlineSnapshot(` + "export interface SwapOptions { + package?: string; + arguments?: SwapArguments | [ + basePool?: RawTransactionArgument, + quotePool?: RawTransactionArgument + ]; + config?: { + basePool: (ctx: ConfigResolverContext) => string | TransactionObjectArgument; + quotePool: (ctx: ConfigResolverContext) => string | TransactionObjectArgument; + }; + typeArguments: [ + string, + string + ]; + }" + `); + + const fnBody = output.match(/export function swap[\s\S]*?^}/m); + expect(fnBody?.[0]).toMatchInlineSnapshot(` + "export function swap(options: SwapOptions) { + const packageAddress = options.package ?? '@test/testpkg'; + const argumentsTypes = [ + null, + null + ] satisfies (string | null)[]; + const parameterNames = ["basePool", "quotePool"]; + return (tx: Transaction) => tx.moveCall({ + package: packageAddress, + module: 'pools', + function: 'swap', + arguments: normalizeMoveArguments(applyConfigArguments(options.arguments ?? {}, [{ index: 0, name: "basePool", resolve: () => resolveConfigArgument(options.config?.basePool, { typeArguments: [\`\${options.typeArguments[0]}\`], packageAddress, moduleName: 'pools', functionName: 'swap', parameterIndex: 0, parameterName: "base_pool" }, "basePool") }, { index: 1, name: "quotePool", resolve: () => resolveConfigArgument(options.config?.quotePool, { typeArguments: [\`\${options.typeArguments[1]}\`], packageAddress, moduleName: 'pools', functionName: 'swap', parameterIndex: 1, parameterName: "quote_pool" }, "quotePool") }]), argumentsTypes, parameterNames), + typeArguments: options.typeArguments + }); + }" + `); + }); + + it('name-refined matchers win over a bare matcher for the same type', async () => { + const builder = createPoolsBuilder({ + pool: { type: '@test/testpkg::pools::Pool' }, + basePool: { type: '@test/testpkg::pools::Pool', parameterName: 'base_pool' }, + quotePool: { type: '@test/testpkg::pools::Pool', parameterName: 'quote_pool' }, + }); + builder.includeFunctions(['swap', 'use_generic']); + const output = await render(builder); + + // swap binds base/quote to the name-refined keys; use_generic still binds `pool`. + const swapOptions = output.match(/export interface SwapOptions[\s\S]*?^}/m); + expect(swapOptions?.[0]).toContain('basePool'); + expect(swapOptions?.[0]).toContain('quotePool'); + expect(swapOptions?.[0]).not.toContain('pool:'); + + const genericOptions = output.match(/export interface UseGenericOptions[\s\S]*?^}/m); + expect(genericOptions?.[0]).toContain('pool:'); + }); + + it('errors when two matchers of equal specificity hit the same parameter', async () => { + const builder = createPoolsBuilder({ + poolA: { type: '@test/testpkg::pools::Pool' }, + poolB: { type: '@test/testpkg::pools::Pool' }, + }); + builder.includeFunctions(['use_generic']); + + await expect(render(builder)).rejects.toThrowError( + /matched by multiple configArguments entries with equal specificity: poolA, poolB/, + ); + }); + + it('errors when a name matcher would apply to a nameless parameter and nothing else matches', async () => { + const builder = createPoolsBuilder( + { basePool: { type: '@test/testpkg::pools::Pool', parameterName: 'base_pool' } }, + { parameterNames: false }, + ); + builder.includeFunctions(['swap']); + + await expect(render(builder)).rejects.toThrowError( + /parameters have no names \(bytecode summaries do not include parameter names\)/, + ); + }); + + it('does not error for a name matcher whose instantiation cannot match the nameless parameter', async () => { + // The instantiated+named matcher targets Pool; use_own_coin's parameter is + // Pool, so the matcher is filtered by instantiation before the nameless check. + const builder = createPoolsBuilder( + { + suiPool: { type: '@test/testpkg::pools::Pool<0x2::sui::SUI>', parameterName: 'sui_pool' }, + }, + { parameterNames: false }, + ); + builder.includeFunctions(['use_own_coin']); + const output = await render(builder); + + const optionsInterface = output.match(/export interface UseOwnCoinOptions[\s\S]*?^}/m); + expect(optionsInterface?.[0]).not.toContain('config'); + }); + + it('falls back to a bare matcher instead of erroring when a name matcher hits a nameless parameter', async () => { + const builder = createPoolsBuilder( + { + pool: { type: '@test/testpkg::pools::Pool' }, + basePool: { type: '@test/testpkg::pools::Pool', parameterName: 'base_pool' }, + }, + { parameterNames: false }, + ); + builder.includeFunctions(['use_generic']); + const output = await render(builder); + + const optionsInterface = output.match(/export interface UseGenericOptions[\s\S]*?^}/m); + expect(optionsInterface?.[0]).toContain('pool:'); + }); + + it('package entries are added to the package-address precedence chain', async () => { + const { registry } = await createBuilders( + { + registryObj: { type: '@test/testpkg::registry::Registry' }, + testpkgAddress: { package: '@test/testpkg' }, + }, + 'testpkgAddress', + ); + registry.includeFunctions(['lookup']); + const output = await render(registry); + + const fnBody = output.match(/export function lookup[\s\S]*?^}/m); + expect(fnBody?.[0]).toContain( + "const packageAddress = options.package ?? options.config?.testpkgAddress ?? '@test/testpkg';", + ); + }); + + it('package entries alone produce an optional config slice', async () => { + const { registry } = await createBuilders( + { testpkgAddress: { package: '@test/testpkg' } }, + 'testpkgAddress', + ); + registry.includeFunctions(['lookup']); + const output = await render(registry); + + const optionsInterface = output.match(/export interface LookupOptions[\s\S]*?^}/m); + expect(optionsInterface?.[0]).toMatchInlineSnapshot(` + "export interface LookupOptions { + package?: string; + arguments: LookupArguments | [ + registry: RawTransactionArgument + ]; + config?: { + testpkgAddress?: string; + }; + }" + `); + + const fnBody = output.match(/export function lookup[\s\S]*?^}/m); + expect(fnBody?.[0]).toContain( + "const packageAddress = options.package ?? options.config?.testpkgAddress ?? '@test/testpkg';", + ); + }); + + it('keeps origin-addressed type tags while calls use the config-supplied package address', async () => { + const ORIGIN_V1 = '0x000000000000000000000000000000000000000000000000000000000000aaaa'; + const registry = new ModuleRegistry(ADDRESS_MAPPINGS); + const builder = await MoveModuleBuilder.fromSummaryFile( + join(SUMMARIES_DIR, 'testpkg', 'registry.json'), + registry, + '@test/testpkg', + '.js', + false, + { Registry: ORIGIN_V1, Container: ORIGIN_V1 }, + ); + const { entries } = parseConfigArguments( + { + global: { + registryObj: { type: '@test/testpkg::registry::Registry' }, + testpkgAddress: { package: '@test/testpkg' }, + }, + }, + registry, + TESTPKG_CONTEXT, + ); + builder.setConfigArguments(entries, 'testpkgAddress'); + builder.includeTypes(['Registry']); + builder.includeFunctions(['lookup']); + await builder.renderBCSTypes(); + const output = await render(builder); + + // BCS type names keep the origin address; the call package comes from the config chain. + expect(output).toContain(`name: \`${ORIGIN_V1}::registry::Registry\``); + expect(output).toContain( + "const packageAddress = options.package ?? options.config?.testpkgAddress ?? '@test/testpkg';", + ); + }); +}); + +describe('generateFromPackageSummary with configArguments', () => { + afterAll(async () => { + await rm(GENERATED_DIR, { recursive: true, force: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function generate() { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await generateFromPackageSummary({ + package: { + package: '@test/testpkg', + path: FIXTURE_PATH, + }, + prune: true, + outputDir: GENERATED_DIR, + configArguments: { + registryObj: { type: '@test/testpkg::registry::Registry' }, + container: { type: '@test/testpkg::registry::Container' }, + testpkgAddress: { package: '@test/testpkg' }, + unusedEntry: { type: '@test/testpkg::registry::Entry' }, + }, + }); + return warn; + } + + it('emits config-arguments.ts and config-driven bindings, warning on unused keys', async () => { + const warn = await generate(); + + // Entry is a plain store struct that no generated function takes as a parameter. + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'configArguments keys that matched no generated function parameters in @test/testpkg: unusedEntry', + ), + ); + + const configArgs = await readFile( + join(GENERATED_DIR, 'testpkg', 'config-arguments.ts'), + 'utf-8', + ); + expect(configArgs).toMatchInlineSnapshot(` + "/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + import { type ConfigValue, type ConfigResolverContext } from '../utils/index.js'; + import { type TransactionObjectArgument } from '@mysten/sui/transactions'; + export interface TestpkgConfig { + registryObj: ConfigValue; + container: (ctx: ConfigResolverContext) => string | TransactionObjectArgument; + testpkgAddress?: string; + unusedEntry: ConfigValue; + }" + `); + + const registryModule = await readFile(join(GENERATED_DIR, 'testpkg', 'registry.ts'), 'utf-8'); + expect(registryModule).toContain('applyConfigArguments'); + expect(registryModule).toContain('resolveConfigArgument'); + }); + + it('errors when a matcher references a type missing from its package', async () => { + await expect( + generateFromPackageSummary({ + package: { + package: '@test/testpkg', + path: FIXTURE_PATH, + configArguments: { + missing: { type: '@test/testpkg::registry::DoesNotExist' }, + }, + }, + prune: true, + outputDir: GENERATED_DIR, + }), + ).rejects.toThrowError(/was not found in its package's summaries/); + }); + + it('warns for unused keys in a package-scoped block', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await generateFromPackageSummary({ + package: { + package: '@test/testpkg', + path: FIXTURE_PATH, + configArguments: { + unusedEntry: { type: '@test/testpkg::registry::Entry' }, + }, + }, + prune: true, + outputDir: GENERATED_DIR, + }); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'configArguments keys that matched no generated function parameters in @test/testpkg: unusedEntry', + ), + ); + }); + + it('generated output typechecks under strict settings', { timeout: 60_000 }, async () => { + await generate(); + + const files: string[] = []; + const walk = async (dir: string) => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path); + } else if (entry.name.endsWith('.ts')) { + files.push(path); + } + } + }; + await walk(GENERATED_DIR); + + const program = ts.createProgram({ + rootNames: files, + options: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + strict: true, + noUncheckedIndexedAccess: true, + noEmit: true, + skipLibCheck: true, + esModuleInterop: true, + lib: ['lib.es2020.d.ts', 'lib.dom.d.ts'], + }, + }); + + const diagnostics = ts + .getPreEmitDiagnostics(program) + .filter((diagnostic) => diagnostic.file && files.includes(diagnostic.file.fileName)); + + const messages = diagnostics.map((diagnostic) => { + const text = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); + if (diagnostic.file && diagnostic.start !== undefined) { + const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + return `[${diagnostic.file.fileName}:${line + 1}:${character + 1}] ${text}`; + } + return text; + }); + + expect(messages, `Generated output has type errors:\n${messages.join('\n')}`).toEqual([]); + }); + + it('config values are applied at runtime, with explicit arguments overriding', async () => { + await generate(); + + const mod = await import(join(GENERATED_DIR, 'testpkg', 'registry.js')); + const PACKAGE_ID = '0x00000000000000000000000000000000000000000000000000000000000000ee'; + const REGISTRY_ID = '0x0000000000000000000000000000000000000000000000000000000000000123'; + + // Config-provided object id and package address. + const tx = new Transaction(); + tx.add( + mod.lookup({ + config: { registryObj: REGISTRY_ID, testpkgAddress: PACKAGE_ID }, + }), + ); + const json = JSON.parse(await tx.toJSON()); + expect(json.inputs).toEqual([{ UnresolvedObject: { objectId: REGISTRY_ID } }]); + expect(json.commands[0].MoveCall.package).toBe(PACKAGE_ID); + + // An explicitly passed argument overrides config resolution (object form). + const OVERRIDE_ID = '0x0000000000000000000000000000000000000000000000000000000000000456'; + const tx2 = new Transaction(); + tx2.add( + mod.lookup({ + arguments: { registry: OVERRIDE_ID }, + config: { + registryObj: () => { + throw new Error('should not be called'); + }, + testpkgAddress: PACKAGE_ID, + }, + }), + ); + const json2 = JSON.parse(await tx2.toJSON()); + expect(json2.inputs).toEqual([{ UnresolvedObject: { objectId: OVERRIDE_ID } }]); + + // Tuple form: an empty tuple resolves from config, an explicit tuple element overrides. + const tx3 = new Transaction(); + tx3.add( + mod.lookup({ + arguments: [], + config: { registryObj: REGISTRY_ID, testpkgAddress: PACKAGE_ID }, + }), + ); + const json3 = JSON.parse(await tx3.toJSON()); + expect(json3.inputs).toEqual([{ UnresolvedObject: { objectId: REGISTRY_ID } }]); + + const tx4 = new Transaction(); + tx4.add( + mod.lookup({ + arguments: [OVERRIDE_ID], + config: { + registryObj: () => { + throw new Error('should not be called'); + }, + testpkgAddress: PACKAGE_ID, + }, + }), + ); + const json4 = JSON.parse(await tx4.toJSON()); + expect(json4.inputs).toEqual([{ UnresolvedObject: { objectId: OVERRIDE_ID } }]); + }); + + it('omitting both the argument and the config value fails with a descriptive error', async () => { + await generate(); + + const mod = await import(join(GENERATED_DIR, 'testpkg', 'registry.js')); + const tx = new Transaction(); + + expect(() => tx.add(mod.lookup({}))).toThrowError( + 'Missing config value for "registryObj": pass it explicitly in arguments, or include it in the config object', + ); + }); + + it('resolvers receive the normalized matched parameter instantiation and call-site metadata', async () => { + await generate(); + + const mod = await import(join(GENERATED_DIR, 'testpkg', 'registry.js')); + const PACKAGE_ID = '0x00000000000000000000000000000000000000000000000000000000000000ee'; + const CONTAINER_ID = '0x0000000000000000000000000000000000000000000000000000000000000789'; + const contexts: unknown[] = []; + + const tx = new Transaction(); + tx.add( + mod.containerSize({ + typeArguments: ['0x2::sui::SUI'], + config: { + container: (ctx: unknown) => { + contexts.push(ctx); + return CONTAINER_ID; + }, + testpkgAddress: PACKAGE_ID, + }, + }), + ); + const json = JSON.parse(await tx.toJSON()); + + expect(contexts).toEqual([ + { + typeArguments: [ + '0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI', + ], + packageAddress: PACKAGE_ID, + moduleName: 'registry', + functionName: 'container_size', + parameterName: 'container', + parameterIndex: 0, + }, + ]); + expect(json.inputs).toEqual([{ UnresolvedObject: { objectId: CONTAINER_ID } }]); + }); +}); diff --git a/packages/codegen/tests/utils.test.ts b/packages/codegen/tests/utils.test.ts index 94ec69ada..8b37038e4 100644 --- a/packages/codegen/tests/utils.test.ts +++ b/packages/codegen/tests/utils.test.ts @@ -13,6 +13,26 @@ let normalizeMoveArguments: ( argTypes: readonly (string | null)[], parameterNames?: string[], ) => any; +interface TestResolverContext { + typeArguments: string[]; + packageAddress: string; + moduleName: string; + functionName: string; + parameterName?: string; + parameterIndex: number; +} +const TEST_CTX: TestResolverContext = { + typeArguments: [], + packageAddress: '0x0', + moduleName: 'test', + functionName: 'test', + parameterIndex: 0, +}; +let resolveConfigArgument: (value: unknown, ctx: TestResolverContext, name: string) => unknown; +let applyConfigArguments: ( + args: unknown[] | object, + defaults: readonly { index: number; name?: string; resolve: () => unknown }[], +) => unknown[] | object; beforeAll(async () => { await mkdir(join(GENERATED_DIR, 'utils'), { recursive: true }); @@ -20,6 +40,8 @@ beforeAll(async () => { const modPath = join(GENERATED_DIR, 'utils', 'index.js'); const mod = await import(modPath); normalizeMoveArguments = mod.normalizeMoveArguments; + resolveConfigArgument = mod.resolveConfigArgument; + applyConfigArguments = mod.applyConfigArguments; }); afterAll(async () => { @@ -399,3 +421,183 @@ describe('normalizeMoveArguments', () => { ]); }); }); + +describe('well-known AccumulatorRoot injection', () => { + it('injects the accumulator root object without consuming arguments', async () => { + const tx = new Transaction(); + tx.moveCall({ + target: '0x0::test::test', + arguments: normalizeMoveArguments( + { arbitraryValue: 42 }, + ['u32', '0x2::accumulator::AccumulatorRoot'], + ['arbitraryValue'], + ), + }); + + const json = JSON.parse(await tx.toJSON()); + expect(json.inputs).toEqual([ + { Pure: { bytes: 'KgAAAA==' } }, + { + UnresolvedObject: { + objectId: '0x0000000000000000000000000000000000000000000000000000000000000acc', + }, + }, + ]); + }); +}); + +describe('resolveConfigArgument', () => { + it('returns plain values as-is', () => { + expect(resolveConfigArgument('0x123', TEST_CTX, 'pool')).toBe('0x123'); + }); + + it('invokes resolver functions with the context, normalizing hex struct tags', () => { + const contexts: unknown[] = []; + const value = (ctx: unknown) => { + contexts.push(ctx); + return '0x456'; + }; + + expect( + resolveConfigArgument( + value, + { ...TEST_CTX, typeArguments: ['0x2::sui::SUI', '@mvr/name::a::B', 'u64'] }, + 'pool', + ), + ).toBe('0x456'); + expect(contexts).toEqual([ + { + ...TEST_CTX, + typeArguments: [ + '0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI', + '@mvr/name::a::B', + 'u64', + ], + }, + ]); + }); + + it('throws a descriptive error for missing values', () => { + expect(() => resolveConfigArgument(undefined, TEST_CTX, 'pool')).toThrowError( + 'Missing config value for "pool": pass it explicitly in arguments, or include it in the config object', + ); + }); + + it('throws a descriptive error when a resolver returns undefined', () => { + expect(() => + resolveConfigArgument( + () => undefined, + { ...TEST_CTX, typeArguments: ['0x2::sui::SUI'] }, + 'pool', + ), + ).toThrowError( + 'Config resolver for "pool" returned undefined (test::test, typeArguments: [0x2::sui::SUI])', + ); + }); +}); + +describe('applyConfigArguments', () => { + it('fills missing named arguments and leaves explicit ones untouched', () => { + const resolved: string[] = []; + const result = applyConfigArguments({ amount: 42n }, [ + { + index: 0, + name: 'pool', + resolve: () => { + resolved.push('pool'); + return '0x123'; + }, + }, + ]); + + expect(result).toEqual({ amount: 42n, pool: '0x123' }); + expect(resolved).toEqual(['pool']); + }); + + it('does not invoke resolvers for explicitly passed arguments', () => { + const result = applyConfigArguments({ pool: '0xabc', amount: 42n }, [ + { + index: 0, + name: 'pool', + resolve: () => { + throw new Error('should not be called'); + }, + }, + ]); + + expect(result).toEqual({ pool: '0xabc', amount: 42n }); + }); + + it('fills undefined positions in array arguments', () => { + const result = applyConfigArguments( + [undefined, 42n], + [{ index: 0, name: 'pool', resolve: () => '0x123' }], + ); + + expect(result).toEqual(['0x123', 42n]); + }); + + it('fills trailing omitted positions in array arguments', () => { + const result = applyConfigArguments( + [42n], + [{ index: 1, name: 'pool', resolve: () => '0x123' }], + ); + + expect(result).toEqual([42n, '0x123']); + }); + + it('rejects array arguments with holes at non-matched positions', () => { + // Filling a trailing matched position must not mask an omitted required middle argument. + expect(() => + applyConfigArguments(['0xaa'], [{ index: 2, name: 'pool', resolve: () => '0x123' }]), + ).toThrowError('Missing argument at position 1'); + }); + + it('does not treat inherited object properties as explicitly passed arguments', () => { + // `constructor` is a valid Move identifier; the inherited Object.prototype.constructor + // must not be mistaken for an explicit argument. + const result = applyConfigArguments({ amount: 1n }, [ + { index: 0, name: 'constructor', resolve: () => '0x123' }, + ]); + + expect((result as Record)['constructor']).toBe('0x123'); + }); +}); + +describe('well-known and config-matched parameters combined', () => { + it('aligns config-filled positions with well-known injection at runtime', async () => { + // Mimics a generated body for `fn(registry: &Registry, clock: &Clock, amount: u64)`: + // clock is elided from arguments, so the config-matched registry is index 0 and amount is + // index 1 while argumentsTypes still includes the clock tag between them. + const tx = new Transaction(); + tx.moveCall({ + target: '0x0::test::test', + arguments: normalizeMoveArguments( + applyConfigArguments({ amount: 42 }, [ + { index: 0, name: 'registry', resolve: () => '0x123' }, + ]), + [null, '0x2::clock::Clock', 'u32'], + ['registry', 'amount'], + ), + }); + + const json = JSON.parse(await tx.toJSON()); + expect(json.inputs).toEqual([ + { + UnresolvedObject: { + objectId: '0x0000000000000000000000000000000000000000000000000000000000000123', + }, + }, + { + Object: { + SharedObject: { + objectId: '0x0000000000000000000000000000000000000000000000000000000000000006', + initialSharedVersion: 1, + mutable: false, + }, + }, + }, + { Pure: { bytes: 'KgAAAA==' } }, + ]); + }); +}); diff --git a/packages/create-dapp/templates/react-e2e-counter/src/contracts/utils/index.ts b/packages/create-dapp/templates/react-e2e-counter/src/contracts/utils/index.ts index 9a6624c19..20e68d294 100644 --- a/packages/create-dapp/templates/react-e2e-counter/src/contracts/utils/index.ts +++ b/packages/create-dapp/templates/react-e2e-counter/src/contracts/utils/index.ts @@ -9,7 +9,10 @@ import { } from "@mysten/sui/bcs"; import { normalizeSuiAddress } from "@mysten/sui/utils"; import { type TransactionArgument, isArgument } from "@mysten/sui/transactions"; -import { type ClientWithCoreApi, type SuiClientTypes } from "@mysten/sui/client"; +import { + type ClientWithCoreApi, + type SuiClientTypes, +} from "@mysten/sui/client"; const MOVE_STDLIB_ADDRESS = normalizeSuiAddress("0x1"); const SUI_FRAMEWORK_ADDRESS = normalizeSuiAddress("0x2"); diff --git a/packages/docs/content/codegen/index.mdx b/packages/docs/content/codegen/index.mdx index 7aae1b3f4..1eacf84b9 100644 --- a/packages/docs/content/codegen/index.mdx +++ b/packages/docs/content/codegen/index.mdx @@ -92,6 +92,7 @@ The `SuiCodegenConfig` type supports the following options: | `prune` | `boolean` | `true` | When enabled, only generates code for the main package and omits dependency modules (dependency types referenced by included types are still generated under `deps/`) | | `generateSummaries` | `boolean` | `true` | Automatically run `sui move summary` before generating code. Creates a `package_summaries` directory in your Move package which can be added to `.gitignore` | | `generate` | `GenerateOptions` | - | Default [generate options](#the-generate-option) (types, functions) for all packages | +| `configArguments` | `ConfigArguments` | - | Map function parameters and package addresses to a [runtime config object](#the-configarguments-option), shared by all packages | | `importExtension` | `'.js' \| '.ts' \| ''` | `'.js'` | File extension used in generated import statements | | `includePhantomTypeParameters` | `boolean` | `false` | Include [phantom type parameters](#phantom-types) as function arguments in generated BCS types | @@ -102,12 +103,13 @@ local (from source) or onchain (fetched from a network). #### Local packages -| Option | Type | Required | Description | -| ------------- | ------------------------ | -------- | --------------------------------------------------------- | -| `package` | `string` | yes | Package identifier (for example, `@local-pkg/my-package`) | -| `path` | `string` | yes | Path to the Move package directory | -| `packageName` | `string` | no | Custom name for generated code directory | -| `generate` | `PackageGenerateOptions` | no | Control what gets generated from this package | +| Option | Type | Required | Description | +| ----------------- | ------------------------ | -------- | ---------------------------------------------------------------------- | +| `package` | `string` | yes | Package identifier (for example, `@local-pkg/my-package`) | +| `path` | `string` | yes | Path to the Move package directory | +| `packageName` | `string` | no | Custom name for generated code directory | +| `generate` | `PackageGenerateOptions` | no | Control what gets generated from this package | +| `configArguments` | `ConfigArguments` | no | Package-scoped [config argument matchers](#the-configarguments-option) | ```typescript { @@ -121,12 +123,13 @@ local (from source) or onchain (fetched from a network). For packages already deployed onchain, generate code directly from a package ID or MVR name without needing local source code: -| Option | Type | Required | Description | -| ------------- | ------------------------ | -------- | --------------------------------------------- | -| `package` | `string` | yes | Package ID or MVR name | -| `packageName` | `string` | yes | Name for the generated code directory | -| `network` | `'mainnet' \| 'testnet'` | yes | Network to fetch the package from | -| `generate` | `PackageGenerateOptions` | no | Control what gets generated from this package | +| Option | Type | Required | Description | +| ----------------- | ------------------------ | -------- | ---------------------------------------------------------------------- | +| `package` | `string` | yes | Package ID or MVR name | +| `packageName` | `string` | yes | Name for the generated code directory | +| `network` | `'mainnet' \| 'testnet'` | yes | Network to fetch the package from | +| `generate` | `PackageGenerateOptions` | no | Control what gets generated from this package | +| `configArguments` | `ConfigArguments` | no | Package-scoped [config argument matchers](#the-configarguments-option) | ```typescript { @@ -285,6 +288,190 @@ src/contracts/ Set `prune: false` to generate all dependency modules with their full types and functions. +## The `configArguments` option + +Many SDKs built on generated bindings spend most of their wrapper code mapping values from a +per-network config object (package IDs, registry or treasury object IDs, pool addresses) into +arguments of generated functions. The `configArguments` option moves that plumbing into codegen: you +declare which Move types (or package addresses) come from a config object, and the generated +functions accept that config object directly instead of requiring those arguments on every call. + +`configArguments` maps author-chosen keys to matchers. It can be declared globally (shared by all +packages) or per package entry (merged over the global block, per key). Because codegen output is +network-agnostic, matchers never contain package addresses: types are identified by +`module::TypeName`, qualified with the package identifier you already use in the `packages` config: + +```typescript +const config: SuiCodegenConfig = { + output: './src/contracts', + packages: [ + { + package: '@myapp/core', + path: './move/core', + configArguments: { + // In a package's own block, a bare module::Type refers to that package's type. + // Non-generic type: parameters of this type resolve from `config.registry` + registry: { type: 'registry::Registry' }, + // Generic type without type arguments: matches every instantiation, and the + // config value must be a resolver function + pool: { type: 'pool::Pool' }, + // Fully instantiated generic: only matches parameters concretely typed with + // this exact instantiation in the Move signature. Framework packages (0x1-0x3) + // have chain-stable addresses and can be referenced directly. + suiPool: { type: 'pool::Pool<0x2::sui::SUI>' }, + // Function matcher: configure one function's parameter directly (opt-in). + // The type is derived from the signature. Use parameterIndex for onchain + // packages without parameter names; omit both when the function has exactly + // one argument. + adminCap: { function: 'admin::set_fees', parameterName: 'cap' }, + // One key may declare several matchers. If they all bind the same concrete + // type, a plain value still works; if they span multiple types, the config + // value must be a resolver function. + registries: [{ type: 'registry::Registry' }, { type: 'config::GlobalConfig' }], + // Package entry (keyed by the `package` value of a `packages` entry): adds an + // optional config key that overrides the package address used for calls + corePackageId: { package: '@myapp/core' }, + }, + }, + { + package: '@myapp/vaults', + path: './move/vaults', + configArguments: { + // Types from other packages in the run are referenced by their identifier. + vaultPool: { type: '@myapp/core::pool::Pool' }, + }, + }, + ], +}; +``` + +In the global `configArguments` block there is no ambient package, so type matchers there must be +package-qualified (`@myapp/core::pool::Pool`). Partially instantiated matchers (for example, +`Pool` or a nested uninstantiated generic) are not supported. Use an uninstantiated matcher with +a resolver function instead. + +Misconfiguration is surfaced at generation time: malformed types, wrong arity, unknown package +identifiers, and non-framework package addresses are hard errors, and every matcher's type is +checked for existence when the package it references is part of the generated package's dependency +closure. Matchers referencing run packages outside that closure can't match anything there and are +skipped (they are validated when their own package is generated). Keys that never match any +generated parameter of their own package produce a warning. + +### Generated output + +For each function with matched parameters, the generated options gain an optional `config` property +typed as the minimal slice of keys that function actually uses. Matched parameters become optional +in `arguments`. Passing one explicitly overrides config resolution, and resolvers are only invoked +for arguments the caller did not pass. If a matched argument is omitted and no config value is +available, the call fails with a descriptive runtime error: + +```typescript +export interface BorrowOptions { + package?: string; + arguments: + | BorrowArguments + | [ + pool: RawTransactionArgument | undefined, + amount: RawTransactionArgument, + ]; + config?: { + pool: (ctx: ConfigResolverContext) => string | TransactionObjectArgument; + corePackageId?: string; + }; + typeArguments: [string]; +} +``` + +In the tuple form of `arguments`, a matched position followed by a required one accepts an explicit +`undefined`; a matched suffix (the common case, with registry-style objects last) becomes genuinely +optional trailing elements. + +Config values can be a plain object ID, a transaction argument, or a resolver function. Any function +value is treated as a resolver. To provide a transaction-callback object argument dynamically, +return it from a resolver: `(ctx) => (tx) => ...`. Resolvers receive the matched parameter's own +instantiated type arguments (not the whole function's type argument tuple), plus static call-site +metadata: + +```typescript +export interface ConfigResolverContext { + typeArguments: string[]; // hex-addressed struct tags are normalized to their long form + packageAddress: string; + moduleName: string; + functionName: string; + parameterName?: string; // Move parameter name, when the summary includes names + parameterIndex: number; // position in the generated function's arguments +} +``` + +This makes resolvers reusable across functions that use the type in different positions: + +```typescript +const myConfig = { + registry: '0x123...', + pool: (ctx: ConfigResolverContext) => poolsByCoinType[ctx.typeArguments[0]], + corePackageId: '0xabc...', +} satisfies CoreConfig; + +tx.add( + borrow({ + arguments: { amount: 100n }, + config: myConfig, + typeArguments: ['0x2::sui::SUI'], + }), +); +``` + +For generic types matched without type arguments, a resolver function is required, because a static +ID can't be correct across instantiations. A parameter typed with the function's own type parameter +(for example, `Pool`) always binds to the uninstantiated matcher, even when a fully instantiated +matcher also exists; only parameters concretely instantiated in the Move signature bind to +instantiated matchers. + +Each package output also includes a `config-arguments.ts` file with an interface covering the +package's resolvable keys and its own package-address key, for use with `satisfies` when defining +your config object. The interface is named after the package's `packageName` (for example, +`packageName: 'core'` produces `CoreConfig`). When a global block spans multiple packages, define +one shared config object and check it against the intersection of the per-package interfaces: + +```typescript +const myConfig = { ... } satisfies CoreConfig & MarginConfig; +``` + +### Name refinement + +When a signature has two parameters of the same matched type (for example, `base_pool` and +`quote_pool`, both `Pool`), a bare type matcher matches both: the key's config value must then be +a resolver function, which receives each parameter's own context. To give each parameter its own +config key instead, refine the matchers with the Move parameter names: + +```typescript +configArguments: { + basePool: { type: 'pool::Pool', parameterName: 'base_pool' }, + quotePool: { type: 'pool::Pool', parameterName: 'quote_pool' }, +}, +``` + +Parameter names are only available in summaries generated from local packages. A `parameterName` +matcher never matches a parameter without a name; if such a matcher would otherwise apply to a +nameless parameter (same type and instantiation) and nothing else matches it, codegen fails with a +clear error. For nameless (onchain bytecode) signatures, use function matchers with `parameterIndex` +to target individual parameters. + +### Package address precedence + +For package entries, the address used for a generated call is resolved in this order: + +1. An explicit `options.package` argument +2. The config key declared by the package entry (for example, `config.corePackageId`) +3. The generated default (the package's MVR name or address) + +On Mainnet with MVR names the config entry can be omitted entirely; on networks where the MVR name +doesn't resolve, supply the deployed package ID through the config object. Two caveats: package +entries only apply to the main package's generated modules (dependency modules under `deps/` never +consult them), and they only apply where a generated default address exists. For packages generated +without an MVR name or address, `package` stays required and always takes precedence. The CLI +validates that every package entry references a package that is part of the codegen run. + ## Phantom types In Move, phantom type parameters are type parameters that only appear at the type level and don't diff --git a/packages/hashi/src/client.ts b/packages/hashi/src/client.ts index 267f69685..96cc7db29 100644 --- a/packages/hashi/src/client.ts +++ b/packages/hashi/src/client.ts @@ -9,13 +9,14 @@ import type { Signer } from '@mysten/sui/cryptography'; import { bcs, TypeTagSerializer } from '@mysten/sui/bcs'; import { fromHex, deriveDynamicFieldID, normalizeSuiAddress } from '@mysten/sui/utils'; import { base58 } from '@scure/base'; -import { Transaction } from '@mysten/sui/transactions'; +import { Transaction, type TransactionArgument } from '@mysten/sui/transactions'; import { Hashi } from './contracts/hashi/hashi.js'; import { BitcoinState, BitcoinStateKey } from './contracts/hashi/bitcoin_state.js'; import { DepositRequest } from './contracts/hashi/deposit_queue.js'; import { Bag } from './contracts/hashi/deps/sui/bag.js'; import { WithdrawalRequest, WithdrawalTransaction } from './contracts/hashi/withdrawal_queue.js'; import { UtxoId as UtxoIdBcs } from './contracts/hashi/utxo.js'; +import type { HashiConfig } from './contracts/hashi/config-arguments.js'; import * as depositModule from './contracts/hashi/deposit.js'; import * as withdrawModule from './contracts/hashi/withdraw.js'; import * as utxoModule from './contracts/hashi/utxo.js'; @@ -165,6 +166,7 @@ export class HashiClient { #client: ClientWithCoreApi; #hashiObjectId: string; #packageId: string; + #contractConfig: HashiConfig; #bitcoinNetwork: BitcoinNetwork; #btcRpcUrl: string | undefined; #graphql: SuiGraphQLClient; @@ -206,6 +208,10 @@ export class HashiClient { this.#client = client; this.#hashiObjectId = resolvedObjectId; this.#packageId = resolvedPackageId; + this.#contractConfig = { + hashiObjectId: resolvedObjectId, + packageId: resolvedPackageId, + }; this.#bitcoinNetwork = bitcoinNetwork ?? config?.bitcoinNetwork ?? 'testnet'; this.#btcRpcUrl = btcRpcUrl; this.#graphql = new SuiGraphQLClient({ @@ -510,13 +516,13 @@ export class HashiClient { for (const { vout, amountSats } of params.utxos) { const utxoId = tx.add( utxoModule.utxoId({ - package: this.#packageId, + config: this.#contractConfig, arguments: { txid: internalTxid, vout }, }), ); const utxo = tx.add( utxoModule.utxo({ - package: this.#packageId, + config: this.#contractConfig, arguments: { utxoId, amount: amountSats, @@ -596,18 +602,17 @@ export class HashiClient { call = { deposit: (options: { utxo: RawTransactionArgument }) => depositModule.deposit({ - package: this.#packageId, - arguments: { hashi: this.#hashiObjectId, utxo: options.utxo }, + config: this.#contractConfig, + arguments: { utxo: options.utxo as TransactionArgument }, }), requestWithdrawal: (options: { btc: RawTransactionArgument; bitcoinAddress: RawTransactionArgument; }) => withdrawModule.requestWithdrawal({ - package: this.#packageId, + config: this.#contractConfig, arguments: { - hashi: this.#hashiObjectId, - btc: options.btc, + btc: options.btc as TransactionArgument, bitcoinAddress: options.bitcoinAddress, }, }), @@ -618,8 +623,8 @@ export class HashiClient { */ cancelWithdrawal: (options: { requestId: RawTransactionArgument }) => withdrawModule.cancelWithdrawal({ - package: this.#packageId, - arguments: { hashi: this.#hashiObjectId, requestId: options.requestId }, + config: this.#contractConfig, + arguments: { requestId: options.requestId }, }), }; diff --git a/packages/hashi/src/contracts/hashi/abort_reconfig.ts b/packages/hashi/src/contracts/hashi/abort_reconfig.ts index 68ab52556..15d1d730a 100644 --- a/packages/hashi/src/contracts/hashi/abort_reconfig.ts +++ b/packages/hashi/src/contracts/hashi/abort_reconfig.ts @@ -11,9 +11,16 @@ * on-chain voting power. */ -import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { + MoveStruct, + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; -import { type Transaction } from '@mysten/sui/transactions'; +import { type Transaction, type TransactionArgument } from '@mysten/sui/transactions'; const $moduleName = '@local-pkg/hashi::abort_reconfig'; export const AbortReconfig = new MoveStruct({ name: `${$moduleName}::AbortReconfig`, @@ -22,24 +29,28 @@ export const AbortReconfig = new MoveStruct({ }, }); export interface ProposeArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; validatorAddress: RawTransactionArgument; epoch: RawTransactionArgument; - metadata: RawTransactionArgument; + metadata: TransactionArgument; } export interface ProposeOptions { package?: string; arguments: | ProposeArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, validatorAddress: RawTransactionArgument, epoch: RawTransactionArgument, - metadata: RawTransactionArgument, + metadata: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function propose(options: ProposeOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', 'u64', null, '0x2::clock::Clock'] satisfies ( | string | null @@ -50,21 +61,50 @@ export function propose(options: ProposeOptions) { package: packageAddress, module: 'abort_reconfig', function: 'propose', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'abort_reconfig', + functionName: 'propose', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface ExecuteArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; proposalId: RawTransactionArgument; } export interface ExecuteOptions { package?: string; arguments: | ExecuteArguments - | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; + | [ + hashi: RawTransactionArgument | undefined, + proposalId: RawTransactionArgument, + ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function execute(options: ExecuteOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'proposalId']; return (tx: Transaction) => @@ -72,6 +112,28 @@ export function execute(options: ExecuteOptions) { package: packageAddress, module: 'abort_reconfig', function: 'execute', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'abort_reconfig', + functionName: 'execute', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } diff --git a/packages/hashi/src/contracts/hashi/cert_submission.ts b/packages/hashi/src/contracts/hashi/cert_submission.ts index 62d2ff49a..a1def6464 100644 --- a/packages/hashi/src/contracts/hashi/cert_submission.ts +++ b/packages/hashi/src/contracts/hashi/cert_submission.ts @@ -10,29 +10,39 @@ * old enough. */ -import { type Transaction } from '@mysten/sui/transactions'; -import { normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { type Transaction, type TransactionArgument } from '@mysten/sui/transactions'; +import { + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; export interface SubmitDkgCertArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; epoch: RawTransactionArgument; dealer: RawTransactionArgument; - messagesHash: RawTransactionArgument; - cert: RawTransactionArgument; + messagesHash: RawTransactionArgument>; + cert: TransactionArgument; } export interface SubmitDkgCertOptions { package?: string; arguments: | SubmitDkgCertArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, epoch: RawTransactionArgument, dealer: RawTransactionArgument, - messagesHash: RawTransactionArgument, - cert: RawTransactionArgument, + messagesHash: RawTransactionArgument>, + cert: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function submitDkgCert(options: SubmitDkgCertOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'u64', 'address', 'vector', null] satisfies (string | null)[]; const parameterNames = ['hashi', 'epoch', 'dealer', 'messagesHash', 'cert']; return (tx: Transaction) => @@ -40,30 +50,56 @@ export function submitDkgCert(options: SubmitDkgCertOptions) { package: packageAddress, module: 'cert_submission', function: 'submit_dkg_cert', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'cert_submission', + functionName: 'submit_dkg_cert', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface SubmitRotationCertArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; epoch: RawTransactionArgument; dealer: RawTransactionArgument; - messagesHash: RawTransactionArgument; - cert: RawTransactionArgument; + messagesHash: RawTransactionArgument>; + cert: TransactionArgument; } export interface SubmitRotationCertOptions { package?: string; arguments: | SubmitRotationCertArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, epoch: RawTransactionArgument, dealer: RawTransactionArgument, - messagesHash: RawTransactionArgument, - cert: RawTransactionArgument, + messagesHash: RawTransactionArgument>, + cert: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function submitRotationCert(options: SubmitRotationCertOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'u64', 'address', 'vector', null] satisfies (string | null)[]; const parameterNames = ['hashi', 'epoch', 'dealer', 'messagesHash', 'cert']; return (tx: Transaction) => @@ -71,32 +107,58 @@ export function submitRotationCert(options: SubmitRotationCertOptions) { package: packageAddress, module: 'cert_submission', function: 'submit_rotation_cert', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'cert_submission', + functionName: 'submit_rotation_cert', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface SubmitNonceCertArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; epoch: RawTransactionArgument; batchIndex: RawTransactionArgument; dealer: RawTransactionArgument; - messagesHash: RawTransactionArgument; - cert: RawTransactionArgument; + messagesHash: RawTransactionArgument>; + cert: TransactionArgument; } export interface SubmitNonceCertOptions { package?: string; arguments: | SubmitNonceCertArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, epoch: RawTransactionArgument, batchIndex: RawTransactionArgument, dealer: RawTransactionArgument, - messagesHash: RawTransactionArgument, - cert: RawTransactionArgument, + messagesHash: RawTransactionArgument>, + cert: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function submitNonceCert(options: SubmitNonceCertOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'u64', 'u32', 'address', 'vector', null] satisfies ( | string | null @@ -107,25 +169,51 @@ export function submitNonceCert(options: SubmitNonceCertOptions) { package: packageAddress, module: 'cert_submission', function: 'submit_nonce_cert', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'cert_submission', + functionName: 'submit_nonce_cert', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface DestroyAllCertsArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; epoch: RawTransactionArgument; batchIndex: RawTransactionArgument; - protocolType: RawTransactionArgument; + protocolType: TransactionArgument; } export interface DestroyAllCertsOptions { package?: string; arguments: | DestroyAllCertsArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, epoch: RawTransactionArgument, batchIndex: RawTransactionArgument, - protocolType: RawTransactionArgument, + protocolType: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } /** * Garbage collection: deliberately NOT gated on pause/reconfig — cert buckets old @@ -133,7 +221,7 @@ export interface DestroyAllCertsOptions { * callable during an emergency pause. */ export function destroyAllCerts(options: DestroyAllCertsOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'u64', '0x1::option::Option', null] satisfies ( | string | null @@ -144,6 +232,28 @@ export function destroyAllCerts(options: DestroyAllCertsOptions) { package: packageAddress, module: 'cert_submission', function: 'destroy_all_certs', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'cert_submission', + functionName: 'destroy_all_certs', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } diff --git a/packages/hashi/src/contracts/hashi/committee.ts b/packages/hashi/src/contracts/hashi/committee.ts index 953e86893..6129805ad 100644 --- a/packages/hashi/src/contracts/hashi/committee.ts +++ b/packages/hashi/src/contracts/hashi/committee.ts @@ -63,8 +63,8 @@ export function CertifiedMessage>(...typeParameters: [T]) } export interface NewCommitteeSignatureArguments { epoch: RawTransactionArgument; - signature: RawTransactionArgument; - signersBitmap: RawTransactionArgument; + signature: RawTransactionArgument>; + signersBitmap: RawTransactionArgument>; } export interface NewCommitteeSignatureOptions { package?: string; @@ -72,12 +72,15 @@ export interface NewCommitteeSignatureOptions { | NewCommitteeSignatureArguments | [ epoch: RawTransactionArgument, - signature: RawTransactionArgument, - signersBitmap: RawTransactionArgument, + signature: RawTransactionArgument>, + signersBitmap: RawTransactionArgument>, ]; + config?: { + packageId?: string; + }; } export function newCommitteeSignature(options: NewCommitteeSignatureOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = ['u64', 'vector', 'vector'] satisfies (string | null)[]; const parameterNames = ['epoch', 'signature', 'signersBitmap']; return (tx: Transaction) => diff --git a/packages/hashi/src/contracts/hashi/committee_set.ts b/packages/hashi/src/contracts/hashi/committee_set.ts index cdaa0daea..0f1bba430 100644 --- a/packages/hashi/src/contracts/hashi/committee_set.ts +++ b/packages/hashi/src/contracts/hashi/committee_set.ts @@ -15,8 +15,6 @@ import { MoveStruct } from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; import * as committee from './committee.js'; import * as bag from './deps/sui/bag.js'; -import * as bag_1 from './deps/sui/bag.js'; -import * as committee_1 from './committee.js'; import * as group_ops from './deps/sui/group_ops.js'; import * as config from './config.js'; const $moduleName = '@local-pkg/hashi::committee_set'; @@ -33,7 +31,7 @@ export const CommitteeSet = new MoveStruct({ members: bag.Bag, /** The current epoch. */ epoch: bcs.u64(), - committees: bag_1.Bag, + committees: bag.Bag, pending_epoch_change: bcs.option(PendingEpochChange), /** The MPC committee's threshold public key. */ mpc_public_key: bcs.vector(bcs.u8()), @@ -49,7 +47,7 @@ export const CommitteeHandoff = new MoveStruct({ name: `${$moduleName}::CommitteeHandoff`, fields: { next_epoch: bcs.u64(), - cert: committee_1.CommitteeSignature, + cert: committee.CommitteeSignature, }, }); export const MemberInfo = new MoveStruct({ diff --git a/packages/hashi/src/contracts/hashi/config-arguments.ts b/packages/hashi/src/contracts/hashi/config-arguments.ts new file mode 100644 index 000000000..ad8047cfa --- /dev/null +++ b/packages/hashi/src/contracts/hashi/config-arguments.ts @@ -0,0 +1,8 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ +import { type ConfigValue } from '../utils/index.js'; +export interface HashiConfig { + hashiObjectId: ConfigValue; + packageId?: string; +} diff --git a/packages/hashi/src/contracts/hashi/config_value.ts b/packages/hashi/src/contracts/hashi/config_value.ts index 8b48960d7..0d069b75f 100644 --- a/packages/hashi/src/contracts/hashi/config_value.ts +++ b/packages/hashi/src/contracts/hashi/config_value.ts @@ -33,9 +33,12 @@ export interface NewU64Arguments { export interface NewU64Options { package?: string; arguments: NewU64Arguments | [value: RawTransactionArgument]; + config?: { + packageId?: string; + }; } export function newU64(options: NewU64Options) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = ['u64'] satisfies (string | null)[]; const parameterNames = ['value']; return (tx: Transaction) => @@ -52,9 +55,12 @@ export interface NewAddressArguments { export interface NewAddressOptions { package?: string; arguments: NewAddressArguments | [value: RawTransactionArgument]; + config?: { + packageId?: string; + }; } export function newAddress(options: NewAddressOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = ['address'] satisfies (string | null)[]; const parameterNames = ['value']; return (tx: Transaction) => @@ -71,9 +77,12 @@ export interface NewStringArguments { export interface NewStringOptions { package?: string; arguments: NewStringArguments | [value: RawTransactionArgument]; + config?: { + packageId?: string; + }; } export function newString(options: NewStringOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = ['0x1::string::String'] satisfies (string | null)[]; const parameterNames = ['value']; return (tx: Transaction) => @@ -90,9 +99,12 @@ export interface NewBoolArguments { export interface NewBoolOptions { package?: string; arguments: NewBoolArguments | [value: RawTransactionArgument]; + config?: { + packageId?: string; + }; } export function newBool(options: NewBoolOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = ['bool'] satisfies (string | null)[]; const parameterNames = ['value']; return (tx: Transaction) => @@ -104,14 +116,17 @@ export function newBool(options: NewBoolOptions) { }); } export interface NewBytesArguments { - value: RawTransactionArgument; + value: RawTransactionArgument>; } export interface NewBytesOptions { package?: string; - arguments: NewBytesArguments | [value: RawTransactionArgument]; + arguments: NewBytesArguments | [value: RawTransactionArgument>]; + config?: { + packageId?: string; + }; } export function newBytes(options: NewBytesOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = ['vector'] satisfies (string | null)[]; const parameterNames = ['value']; return (tx: Transaction) => @@ -128,9 +143,12 @@ export interface NewU128Arguments { export interface NewU128Options { package?: string; arguments: NewU128Arguments | [value: RawTransactionArgument]; + config?: { + packageId?: string; + }; } export function newU128(options: NewU128Options) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = ['u128'] satisfies (string | null)[]; const parameterNames = ['value']; return (tx: Transaction) => @@ -147,9 +165,12 @@ export interface NewU256Arguments { export interface NewU256Options { package?: string; arguments: NewU256Arguments | [value: RawTransactionArgument]; + config?: { + packageId?: string; + }; } export function newU256(options: NewU256Options) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = ['u256'] satisfies (string | null)[]; const parameterNames = ['value']; return (tx: Transaction) => diff --git a/packages/hashi/src/contracts/hashi/deposit.ts b/packages/hashi/src/contracts/hashi/deposit.ts index 85ad8a594..f036b7455 100644 --- a/packages/hashi/src/contracts/hashi/deposit.ts +++ b/packages/hashi/src/contracts/hashi/deposit.ts @@ -12,14 +12,18 @@ * garbage-collected once they expire. */ -import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { + MoveStruct, + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; -import { type Transaction } from '@mysten/sui/transactions'; +import { type Transaction, type TransactionArgument } from '@mysten/sui/transactions'; import * as utxo from './utxo.js'; -import * as utxo_1 from './utxo.js'; -import * as utxo_2 from './utxo.js'; import * as committee from './committee.js'; -import * as utxo_3 from './utxo.js'; const $moduleName = '@local-pkg/hashi::deposit'; export const DepositConfirmationMessage = new MoveStruct({ name: `${$moduleName}::DepositConfirmationMessage`, @@ -32,7 +36,7 @@ export const DepositRequested = new MoveStruct({ name: `${$moduleName}::DepositRequested`, fields: { request_id: bcs.Address, - utxo_id: utxo_1.UtxoId, + utxo_id: utxo.UtxoId, amount: bcs.u64(), derivation_path: bcs.option(bcs.Address), timestamp_ms: bcs.u64(), @@ -44,7 +48,7 @@ export const DepositApproved = new MoveStruct({ name: `${$moduleName}::DepositApproved`, fields: { request_id: bcs.Address, - utxo: utxo_2.Utxo, + utxo: utxo.Utxo, cert: committee.CommitteeSignature, approval_timestamp_ms: bcs.u64(), }, @@ -53,7 +57,7 @@ export const DepositConfirmed = new MoveStruct({ name: `${$moduleName}::DepositConfirmed`, fields: { request_id: bcs.Address, - utxo: utxo_3.Utxo, + utxo: utxo.Utxo, }, }); export const ExpiredDepositDeleted = new MoveStruct({ @@ -63,17 +67,21 @@ export const ExpiredDepositDeleted = new MoveStruct({ }, }); export interface DepositArguments { - hashi: RawTransactionArgument; - utxo: RawTransactionArgument; + hashi?: RawTransactionArgument; + utxo: TransactionArgument; } export interface DepositOptions { package?: string; arguments: | DepositArguments - | [hashi: RawTransactionArgument, utxo: RawTransactionArgument]; + | [hashi: RawTransactionArgument | undefined, utxo: TransactionArgument]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function deposit(options: DepositOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, null, '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'utxo']; return (tx: Transaction) => @@ -81,23 +89,49 @@ export function deposit(options: DepositOptions) { package: packageAddress, module: 'deposit', function: 'deposit', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'deposit', + functionName: 'deposit', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface ApproveDepositArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; requestId: RawTransactionArgument; - cert: RawTransactionArgument; + cert: TransactionArgument; } export interface ApproveDepositOptions { package?: string; arguments: | ApproveDepositArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, requestId: RawTransactionArgument, - cert: RawTransactionArgument, + cert: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } /** * First phase of deposit confirmation. Records a committee certificate over @@ -113,7 +147,7 @@ export interface ApproveDepositOptions { * committee. */ export function approveDeposit(options: ApproveDepositOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', null, '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'requestId', 'cert']; return (tx: Transaction) => @@ -121,18 +155,47 @@ export function approveDeposit(options: ApproveDepositOptions) { package: packageAddress, module: 'deposit', function: 'approve_deposit', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'deposit', + functionName: 'approve_deposit', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface ConfirmDepositArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; requestId: RawTransactionArgument; } export interface ConfirmDepositOptions { package?: string; arguments: | ConfirmDepositArguments - | [hashi: RawTransactionArgument, requestId: RawTransactionArgument]; + | [ + hashi: RawTransactionArgument | undefined, + requestId: RawTransactionArgument, + ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } /** * Second phase of deposit confirmation. Re-verifies the stored committee @@ -146,7 +209,7 @@ export interface ConfirmDepositOptions { * verifies (committee rotated), or the time-delay window has not yet elapsed. */ export function confirmDeposit(options: ConfirmDepositOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'requestId']; return (tx: Transaction) => @@ -154,18 +217,47 @@ export function confirmDeposit(options: ConfirmDepositOptions) { package: packageAddress, module: 'deposit', function: 'confirm_deposit', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'deposit', + functionName: 'confirm_deposit', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface DeleteExpiredDepositArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; requestId: RawTransactionArgument; } export interface DeleteExpiredDepositOptions { package?: string; arguments: | DeleteExpiredDepositArguments - | [hashi: RawTransactionArgument, requestId: RawTransactionArgument]; + | [ + hashi: RawTransactionArgument | undefined, + requestId: RawTransactionArgument, + ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } /** * Garbage collection: deliberately NOT gated on pause/reconfig — expiry refunds @@ -173,7 +265,7 @@ export interface DeleteExpiredDepositOptions { * emergency pause. */ export function deleteExpiredDeposit(options: DeleteExpiredDepositOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'requestId']; return (tx: Transaction) => @@ -181,6 +273,28 @@ export function deleteExpiredDeposit(options: DeleteExpiredDepositOptions) { package: packageAddress, module: 'deposit', function: 'delete_expired_deposit', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'deposit', + functionName: 'delete_expired_deposit', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } diff --git a/packages/hashi/src/contracts/hashi/deposit_queue.ts b/packages/hashi/src/contracts/hashi/deposit_queue.ts index b42f722b4..31ce03ce8 100644 --- a/packages/hashi/src/contracts/hashi/deposit_queue.ts +++ b/packages/hashi/src/contracts/hashi/deposit_queue.ts @@ -13,7 +13,6 @@ import { MoveStruct } from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; import * as object_bag from './deps/sui/object_bag.js'; -import * as object_bag_1 from './deps/sui/object_bag.js'; import * as utxo from './utxo.js'; import * as committee from './committee.js'; const $moduleName = '@local-pkg/hashi::deposit_queue'; @@ -26,7 +25,7 @@ export const DepositRequestQueue = new MoveStruct({ */ requests: object_bag.ObjectBag, /** Completed deposits (confirmed or expired). */ - processed: object_bag_1.ObjectBag, + processed: object_bag.ObjectBag, }, }); export const DepositRequest = new MoveStruct({ diff --git a/packages/hashi/src/contracts/hashi/disable_version.ts b/packages/hashi/src/contracts/hashi/disable_version.ts index 6b7b5bb59..445c33f6e 100644 --- a/packages/hashi/src/contracts/hashi/disable_version.ts +++ b/packages/hashi/src/contracts/hashi/disable_version.ts @@ -9,9 +9,16 @@ * version — the recovery lever if an upgraded package turns out to be broken. */ -import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { + MoveStruct, + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; -import { type Transaction } from '@mysten/sui/transactions'; +import { type Transaction, type TransactionArgument } from '@mysten/sui/transactions'; const $moduleName = '@local-pkg/hashi::disable_version'; export const DisableVersion = new MoveStruct({ name: `${$moduleName}::DisableVersion`, @@ -20,24 +27,28 @@ export const DisableVersion = new MoveStruct({ }, }); export interface ProposeArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; validatorAddress: RawTransactionArgument; version: RawTransactionArgument; - metadata: RawTransactionArgument; + metadata: TransactionArgument; } export interface ProposeOptions { package?: string; arguments: | ProposeArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, validatorAddress: RawTransactionArgument, version: RawTransactionArgument, - metadata: RawTransactionArgument, + metadata: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function propose(options: ProposeOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', 'u64', null, '0x2::clock::Clock'] satisfies ( | string | null @@ -48,21 +59,50 @@ export function propose(options: ProposeOptions) { package: packageAddress, module: 'disable_version', function: 'propose', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'disable_version', + functionName: 'propose', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface ExecuteArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; proposalId: RawTransactionArgument; } export interface ExecuteOptions { package?: string; arguments: | ExecuteArguments - | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; + | [ + hashi: RawTransactionArgument | undefined, + proposalId: RawTransactionArgument, + ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function execute(options: ExecuteOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'proposalId']; return (tx: Transaction) => @@ -70,6 +110,28 @@ export function execute(options: ExecuteOptions) { package: packageAddress, module: 'disable_version', function: 'execute', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'disable_version', + functionName: 'execute', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } diff --git a/packages/hashi/src/contracts/hashi/emergency_pause.ts b/packages/hashi/src/contracts/hashi/emergency_pause.ts index cede33d3c..6b6311b5b 100644 --- a/packages/hashi/src/contracts/hashi/emergency_pause.ts +++ b/packages/hashi/src/contracts/hashi/emergency_pause.ts @@ -9,9 +9,16 @@ * a low quorum for fast response; unpausing requires supermajority. */ -import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { + MoveStruct, + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; -import { type Transaction } from '@mysten/sui/transactions'; +import { type Transaction, type TransactionArgument } from '@mysten/sui/transactions'; const $moduleName = '@local-pkg/hashi::emergency_pause'; export const EmergencyPause = new MoveStruct({ name: `${$moduleName}::EmergencyPause`, @@ -20,24 +27,28 @@ export const EmergencyPause = new MoveStruct({ }, }); export interface ProposeArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; validatorAddress: RawTransactionArgument; pause: RawTransactionArgument; - metadata: RawTransactionArgument; + metadata: TransactionArgument; } export interface ProposeOptions { package?: string; arguments: | ProposeArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, validatorAddress: RawTransactionArgument, pause: RawTransactionArgument, - metadata: RawTransactionArgument, + metadata: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function propose(options: ProposeOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', 'bool', null, '0x2::clock::Clock'] satisfies ( | string | null @@ -48,21 +59,50 @@ export function propose(options: ProposeOptions) { package: packageAddress, module: 'emergency_pause', function: 'propose', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'emergency_pause', + functionName: 'propose', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface ExecuteArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; proposalId: RawTransactionArgument; } export interface ExecuteOptions { package?: string; arguments: | ExecuteArguments - | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; + | [ + hashi: RawTransactionArgument | undefined, + proposalId: RawTransactionArgument, + ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function execute(options: ExecuteOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'proposalId']; return (tx: Transaction) => @@ -70,6 +110,28 @@ export function execute(options: ExecuteOptions) { package: packageAddress, module: 'emergency_pause', function: 'execute', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'emergency_pause', + functionName: 'execute', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } diff --git a/packages/hashi/src/contracts/hashi/enable_version.ts b/packages/hashi/src/contracts/hashi/enable_version.ts index 58623f19c..050226b49 100644 --- a/packages/hashi/src/contracts/hashi/enable_version.ts +++ b/packages/hashi/src/contracts/hashi/enable_version.ts @@ -10,9 +10,16 @@ * re-activating a previously disabled version. */ -import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { + MoveStruct, + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; -import { type Transaction } from '@mysten/sui/transactions'; +import { type Transaction, type TransactionArgument } from '@mysten/sui/transactions'; const $moduleName = '@local-pkg/hashi::enable_version'; export const EnableVersion = new MoveStruct({ name: `${$moduleName}::EnableVersion`, @@ -21,24 +28,28 @@ export const EnableVersion = new MoveStruct({ }, }); export interface ProposeArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; validatorAddress: RawTransactionArgument; version: RawTransactionArgument; - metadata: RawTransactionArgument; + metadata: TransactionArgument; } export interface ProposeOptions { package?: string; arguments: | ProposeArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, validatorAddress: RawTransactionArgument, version: RawTransactionArgument, - metadata: RawTransactionArgument, + metadata: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function propose(options: ProposeOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', 'u64', null, '0x2::clock::Clock'] satisfies ( | string | null @@ -49,21 +60,50 @@ export function propose(options: ProposeOptions) { package: packageAddress, module: 'enable_version', function: 'propose', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'enable_version', + functionName: 'propose', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface ExecuteArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; proposalId: RawTransactionArgument; } export interface ExecuteOptions { package?: string; arguments: | ExecuteArguments - | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; + | [ + hashi: RawTransactionArgument | undefined, + proposalId: RawTransactionArgument, + ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function execute(options: ExecuteOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'proposalId']; return (tx: Transaction) => @@ -71,6 +111,28 @@ export function execute(options: ExecuteOptions) { package: packageAddress, module: 'enable_version', function: 'execute', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'enable_version', + functionName: 'execute', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } diff --git a/packages/hashi/src/contracts/hashi/hashi.ts b/packages/hashi/src/contracts/hashi/hashi.ts index 58bccfd83..484251a6e 100644 --- a/packages/hashi/src/contracts/hashi/hashi.ts +++ b/packages/hashi/src/contracts/hashi/hashi.ts @@ -12,7 +12,14 @@ * the package `UpgradeCap` into on-chain custody. */ -import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { + MoveStruct, + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; import { type Transaction } from '@mysten/sui/transactions'; import * as committee_set from './committee_set.js'; @@ -41,11 +48,11 @@ export const Hashi = new MoveStruct({ }, }); export interface FinishPublishArguments { - self: RawTransactionArgument; + self?: RawTransactionArgument; upgradeCap: RawTransactionArgument; bitcoinChainId: RawTransactionArgument; guardianUrl: RawTransactionArgument; - guardianBtcPublicKey: RawTransactionArgument; + guardianBtcPublicKey: RawTransactionArgument>; bitcoinConfirmationThreshold: RawTransactionArgument; bitcoinDepositTimeDelayMs: RawTransactionArgument; coinRegistry: RawTransactionArgument; @@ -55,18 +62,22 @@ export interface FinishPublishOptions { arguments: | FinishPublishArguments | [ - self: RawTransactionArgument, + self: RawTransactionArgument | undefined, upgradeCap: RawTransactionArgument, bitcoinChainId: RawTransactionArgument, guardianUrl: RawTransactionArgument, - guardianBtcPublicKey: RawTransactionArgument, + guardianBtcPublicKey: RawTransactionArgument>, bitcoinConfirmationThreshold: RawTransactionArgument, bitcoinDepositTimeDelayMs: RawTransactionArgument, coinRegistry: RawTransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function finishPublish(options: FinishPublishOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [ null, null, @@ -92,6 +103,28 @@ export function finishPublish(options: FinishPublishOptions) { package: packageAddress, module: 'hashi', function: 'finish_publish', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'self', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'hashi', + functionName: 'finish_publish', + parameterIndex: 0, + parameterName: 'self', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } diff --git a/packages/hashi/src/contracts/hashi/proposal.ts b/packages/hashi/src/contracts/hashi/proposal.ts index e8242beea..f3d189cd7 100644 --- a/packages/hashi/src/contracts/hashi/proposal.ts +++ b/packages/hashi/src/contracts/hashi/proposal.ts @@ -13,7 +13,14 @@ */ import { type BcsType, bcs } from '@mysten/sui/bcs'; -import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { + MoveStruct, + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; import { type Transaction } from '@mysten/sui/transactions'; import * as vec_map from './deps/sui/vec_map.js'; const $moduleName = '@local-pkg/hashi::proposal'; @@ -76,7 +83,7 @@ export const QuorumReached = new MoveStruct({ }, }); export interface VoteArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; validatorAddress: RawTransactionArgument; proposalId: RawTransactionArgument; } @@ -85,14 +92,18 @@ export interface VoteOptions { arguments: | VoteArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, validatorAddress: RawTransactionArgument, proposalId: RawTransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; typeArguments: [string]; } export function vote(options: VoteOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', '0x2::object::ID', '0x2::clock::Clock'] satisfies ( | string | null @@ -103,12 +114,34 @@ export function vote(options: VoteOptions) { package: packageAddress, module: 'proposal', function: 'vote', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'proposal', + functionName: 'vote', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), typeArguments: options.typeArguments, }); } export interface RemoveVoteArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; validatorAddress: RawTransactionArgument; proposalId: RawTransactionArgument; } @@ -117,14 +150,18 @@ export interface RemoveVoteOptions { arguments: | RemoveVoteArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, validatorAddress: RawTransactionArgument, proposalId: RawTransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; typeArguments: [string]; } export function removeVote(options: RemoveVoteOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', '0x2::object::ID'] satisfies (string | null)[]; const parameterNames = ['hashi', 'validatorAddress', 'proposalId']; return (tx: Transaction) => @@ -132,23 +169,52 @@ export function removeVote(options: RemoveVoteOptions) { package: packageAddress, module: 'proposal', function: 'remove_vote', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'proposal', + functionName: 'remove_vote', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), typeArguments: options.typeArguments, }); } export interface DeleteExpiredArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; proposalId: RawTransactionArgument; } export interface DeleteExpiredOptions { package?: string; arguments: | DeleteExpiredArguments - | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; + | [ + hashi: RawTransactionArgument | undefined, + proposalId: RawTransactionArgument, + ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; typeArguments: [string]; } export function deleteExpired(options: DeleteExpiredOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'proposalId']; return (tx: Transaction) => @@ -156,7 +222,29 @@ export function deleteExpired(options: DeleteExpiredOptions) { package: packageAddress, module: 'proposal', function: 'delete_expired', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'proposal', + functionName: 'delete_expired', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), typeArguments: options.typeArguments, }); } diff --git a/packages/hashi/src/contracts/hashi/proposal_events.ts b/packages/hashi/src/contracts/hashi/proposal_events.ts deleted file mode 100644 index 23b4b3ee9..000000000 --- a/packages/hashi/src/contracts/hashi/proposal_events.ts +++ /dev/null @@ -1,55 +0,0 @@ -/************************************************************** - * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * - **************************************************************/ -import { MoveStruct } from '../utils/index.js'; -import { bcs, type BcsType } from '@mysten/sui/bcs'; -const $moduleName = '@local-pkg/hashi::proposal_events'; -export const ProposalCreatedEvent = new MoveStruct({ - name: `${$moduleName}::ProposalCreatedEvent`, - fields: { - proposal_id: bcs.Address, - timestamp_ms: bcs.u64(), - }, -}); -export const VoteCastEvent = new MoveStruct({ - name: `${$moduleName}::VoteCastEvent`, - fields: { - proposal_id: bcs.Address, - voter: bcs.Address, - }, -}); -export const VoteRemovedEvent = new MoveStruct({ - name: `${$moduleName}::VoteRemovedEvent`, - fields: { - proposal_id: bcs.Address, - voter: bcs.Address, - }, -}); -export const ProposalDeletedEvent = new MoveStruct({ - name: `${$moduleName}::ProposalDeletedEvent`, - fields: { - proposal_id: bcs.Address, - }, -}); -export function ProposalExecutedEvent>(...typeParameters: [T]) { - return new MoveStruct({ - name: `${$moduleName}::ProposalExecutedEvent<${typeParameters[0].name as T['name']}>`, - fields: { - proposal_id: bcs.Address, - data: typeParameters[0], - }, - }); -} -export const QuorumReachedEvent = new MoveStruct({ - name: `${$moduleName}::QuorumReachedEvent`, - fields: { - proposal_id: bcs.Address, - }, -}); -export const PackageUpgradedEvent = new MoveStruct({ - name: `${$moduleName}::PackageUpgradedEvent`, - fields: { - package: bcs.Address, - version: bcs.u64(), - }, -}); diff --git a/packages/hashi/src/contracts/hashi/proposals.ts b/packages/hashi/src/contracts/hashi/proposals.ts index 4243145b4..a187a9bff 100644 --- a/packages/hashi/src/contracts/hashi/proposals.ts +++ b/packages/hashi/src/contracts/hashi/proposals.ts @@ -11,7 +11,6 @@ import { MoveStruct } from '../utils/index.js'; import * as object_bag from './deps/sui/object_bag.js'; -import * as object_bag_1 from './deps/sui/object_bag.js'; const $moduleName = '@local-pkg/hashi::proposals'; export const Proposals = new MoveStruct({ name: `${$moduleName}::Proposals`, @@ -22,6 +21,6 @@ export const Proposals = new MoveStruct({ * Proposals that have executed successfully. Kept indefinitely so historical * governance actions remain inspectable. */ - executed: object_bag_1.ObjectBag, + executed: object_bag.ObjectBag, }, }); diff --git a/packages/hashi/src/contracts/hashi/reconfig.ts b/packages/hashi/src/contracts/hashi/reconfig.ts index 1ebb16944..bb5f0d54f 100644 --- a/packages/hashi/src/contracts/hashi/reconfig.ts +++ b/packages/hashi/src/contracts/hashi/reconfig.ts @@ -13,9 +13,16 @@ * (`hashi::finish_publish`). */ -import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { + MoveStruct, + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; -import { type Transaction } from '@mysten/sui/transactions'; +import { type Transaction, type TransactionArgument } from '@mysten/sui/transactions'; import * as committee from './committee.js'; const $moduleName = '@local-pkg/hashi::reconfig'; export const ReconfigCompletionMessage = new MoveStruct({ @@ -49,14 +56,18 @@ export const ReconfigEnded = new MoveStruct({ }, }); export interface StartReconfigArguments { - self: RawTransactionArgument; + self?: RawTransactionArgument; } export interface StartReconfigOptions { package?: string; - arguments: StartReconfigArguments | [self: RawTransactionArgument]; + arguments?: StartReconfigArguments | [self?: RawTransactionArgument]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function startReconfig(options: StartReconfigOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, '0x3::sui_system::SuiSystemState'] satisfies (string | null)[]; const parameterNames = ['self']; return (tx: Transaction) => @@ -64,26 +75,52 @@ export function startReconfig(options: StartReconfigOptions) { package: packageAddress, module: 'reconfig', function: 'start_reconfig', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments ?? {}, [ + { + index: 0, + name: 'self', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'reconfig', + functionName: 'start_reconfig', + parameterIndex: 0, + parameterName: 'self', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface EndReconfigArguments { - self: RawTransactionArgument; - mpcPublicKey: RawTransactionArgument; - mpcCert: RawTransactionArgument; + self?: RawTransactionArgument; + mpcPublicKey: RawTransactionArgument>; + mpcCert: TransactionArgument; } export interface EndReconfigOptions { package?: string; arguments: | EndReconfigArguments | [ - self: RawTransactionArgument, - mpcPublicKey: RawTransactionArgument, - mpcCert: RawTransactionArgument, + self: RawTransactionArgument | undefined, + mpcPublicKey: RawTransactionArgument>, + mpcCert: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function endReconfig(options: EndReconfigOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'vector', null] satisfies (string | null)[]; const parameterNames = ['self', 'mpcPublicKey', 'mpcCert']; return (tx: Transaction) => @@ -91,21 +128,47 @@ export function endReconfig(options: EndReconfigOptions) { package: packageAddress, module: 'reconfig', function: 'end_reconfig', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'self', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'reconfig', + functionName: 'end_reconfig', + parameterIndex: 0, + parameterName: 'self', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface SubmitCommitteeHandoffArguments { - self: RawTransactionArgument; - committeeHandoffCert: RawTransactionArgument; + self?: RawTransactionArgument; + committeeHandoffCert: TransactionArgument; } export interface SubmitCommitteeHandoffOptions { package?: string; arguments: | SubmitCommitteeHandoffArguments - | [self: RawTransactionArgument, committeeHandoffCert: RawTransactionArgument]; + | [self: RawTransactionArgument | undefined, committeeHandoffCert: TransactionArgument]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function submitCommitteeHandoff(options: SubmitCommitteeHandoffOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, null] satisfies (string | null)[]; const parameterNames = ['self', 'committeeHandoffCert']; return (tx: Transaction) => @@ -113,6 +176,28 @@ export function submitCommitteeHandoff(options: SubmitCommitteeHandoffOptions) { package: packageAddress, module: 'reconfig', function: 'submit_committee_handoff', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'self', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'reconfig', + functionName: 'submit_committee_handoff', + parameterIndex: 0, + parameterName: 'self', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } diff --git a/packages/hashi/src/contracts/hashi/update_config.ts b/packages/hashi/src/contracts/hashi/update_config.ts index cf0256358..64d4032f8 100644 --- a/packages/hashi/src/contracts/hashi/update_config.ts +++ b/packages/hashi/src/contracts/hashi/update_config.ts @@ -10,9 +10,16 @@ * unknown keys or change an entry's type. */ -import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { + MoveStruct, + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; -import { type Transaction } from '@mysten/sui/transactions'; +import { type Transaction, type TransactionArgument } from '@mysten/sui/transactions'; import * as vec_map from './deps/sui/vec_map.js'; import * as config_value from './config_value.js'; const $moduleName = '@local-pkg/hashi::update_config'; @@ -23,24 +30,28 @@ export const UpdateConfig = new MoveStruct({ }, }); export interface ProposeArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; validatorAddress: RawTransactionArgument; - entries: RawTransactionArgument; - metadata: RawTransactionArgument; + entries: TransactionArgument; + metadata: TransactionArgument; } export interface ProposeOptions { package?: string; arguments: | ProposeArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, validatorAddress: RawTransactionArgument, - entries: RawTransactionArgument, - metadata: RawTransactionArgument, + entries: TransactionArgument, + metadata: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function propose(options: ProposeOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', null, null, '0x2::clock::Clock'] satisfies ( | string | null @@ -51,21 +62,50 @@ export function propose(options: ProposeOptions) { package: packageAddress, module: 'update_config', function: 'propose', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'update_config', + functionName: 'propose', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface ExecuteArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; proposalId: RawTransactionArgument; } export interface ExecuteOptions { package?: string; arguments: | ExecuteArguments - | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; + | [ + hashi: RawTransactionArgument | undefined, + proposalId: RawTransactionArgument, + ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function execute(options: ExecuteOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'proposalId']; return (tx: Transaction) => @@ -73,6 +113,28 @@ export function execute(options: ExecuteOptions) { package: packageAddress, module: 'update_config', function: 'execute', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'update_config', + functionName: 'execute', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } diff --git a/packages/hashi/src/contracts/hashi/update_guardian.ts b/packages/hashi/src/contracts/hashi/update_guardian.ts index 228f2f928..b838ca623 100644 --- a/packages/hashi/src/contracts/hashi/update_guardian.ts +++ b/packages/hashi/src/contracts/hashi/update_guardian.ts @@ -10,9 +10,16 @@ * guardian over TLS plus the immutable BTC key. */ -import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { + MoveStruct, + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; -import { type Transaction } from '@mysten/sui/transactions'; +import { type Transaction, type TransactionArgument } from '@mysten/sui/transactions'; const $moduleName = '@local-pkg/hashi::update_guardian'; export const UpdateGuardian = new MoveStruct({ name: `${$moduleName}::UpdateGuardian`, @@ -21,24 +28,28 @@ export const UpdateGuardian = new MoveStruct({ }, }); export interface ProposeArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; validatorAddress: RawTransactionArgument; url: RawTransactionArgument; - metadata: RawTransactionArgument; + metadata: TransactionArgument; } export interface ProposeOptions { package?: string; arguments: | ProposeArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, validatorAddress: RawTransactionArgument, url: RawTransactionArgument, - metadata: RawTransactionArgument, + metadata: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function propose(options: ProposeOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [ null, 'address', @@ -52,21 +63,50 @@ export function propose(options: ProposeOptions) { package: packageAddress, module: 'update_guardian', function: 'propose', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'update_guardian', + functionName: 'propose', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface ExecuteArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; proposalId: RawTransactionArgument; } export interface ExecuteOptions { package?: string; arguments: | ExecuteArguments - | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; + | [ + hashi: RawTransactionArgument | undefined, + proposalId: RawTransactionArgument, + ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function execute(options: ExecuteOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'proposalId']; return (tx: Transaction) => @@ -74,6 +114,28 @@ export function execute(options: ExecuteOptions) { package: packageAddress, module: 'update_guardian', function: 'execute', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'update_guardian', + functionName: 'execute', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } diff --git a/packages/hashi/src/contracts/hashi/upgrade.ts b/packages/hashi/src/contracts/hashi/upgrade.ts index 9c6cd5789..a4a75186e 100644 --- a/packages/hashi/src/contracts/hashi/upgrade.ts +++ b/packages/hashi/src/contracts/hashi/upgrade.ts @@ -17,9 +17,16 @@ * - Commits the upgrade to the `UpgradeCap` and auto-enables the new version */ -import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { + MoveStruct, + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; -import { type Transaction } from '@mysten/sui/transactions'; +import { type Transaction, type TransactionArgument } from '@mysten/sui/transactions'; const $moduleName = '@local-pkg/hashi::upgrade'; export const Upgrade = new MoveStruct({ name: `${$moduleName}::Upgrade`, @@ -35,24 +42,28 @@ export const PackageUpgraded = new MoveStruct({ }, }); export interface ProposeArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; validatorAddress: RawTransactionArgument; - digest: RawTransactionArgument; - metadata: RawTransactionArgument; + digest: RawTransactionArgument>; + metadata: TransactionArgument; } export interface ProposeOptions { package?: string; arguments: | ProposeArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, validatorAddress: RawTransactionArgument, - digest: RawTransactionArgument, - metadata: RawTransactionArgument, + digest: RawTransactionArgument>, + metadata: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function propose(options: ProposeOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', 'vector', null, '0x2::clock::Clock'] satisfies ( | string | null @@ -63,18 +74,47 @@ export function propose(options: ProposeOptions) { package: packageAddress, module: 'upgrade', function: 'propose', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'upgrade', + functionName: 'propose', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface ExecuteArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; proposalId: RawTransactionArgument; } export interface ExecuteOptions { package?: string; arguments: | ExecuteArguments - | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; + | [ + hashi: RawTransactionArgument | undefined, + proposalId: RawTransactionArgument, + ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } /** * Executes an approved upgrade proposal. @@ -84,7 +124,7 @@ export interface ExecuteOptions { * be passed to `finalize_upgrade()` to finalize the upgrade. */ export function execute(options: ExecuteOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'proposalId']; return (tx: Transaction) => @@ -92,21 +132,47 @@ export function execute(options: ExecuteOptions) { package: packageAddress, module: 'upgrade', function: 'execute', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'upgrade', + functionName: 'execute', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface FinalizeUpgradeArguments { - hashi: RawTransactionArgument; - receipt: RawTransactionArgument; + hashi?: RawTransactionArgument; + receipt: TransactionArgument; } export interface FinalizeUpgradeOptions { package?: string; arguments: | FinalizeUpgradeArguments - | [hashi: RawTransactionArgument, receipt: RawTransactionArgument]; + | [hashi: RawTransactionArgument | undefined, receipt: TransactionArgument]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function finalizeUpgrade(options: FinalizeUpgradeOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, null] satisfies (string | null)[]; const parameterNames = ['hashi', 'receipt']; return (tx: Transaction) => @@ -114,6 +180,28 @@ export function finalizeUpgrade(options: FinalizeUpgradeOptions) { package: packageAddress, module: 'upgrade', function: 'finalize_upgrade', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'upgrade', + functionName: 'finalize_upgrade', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } diff --git a/packages/hashi/src/contracts/hashi/utxo.ts b/packages/hashi/src/contracts/hashi/utxo.ts index d25ac02e6..d93045996 100644 --- a/packages/hashi/src/contracts/hashi/utxo.ts +++ b/packages/hashi/src/contracts/hashi/utxo.ts @@ -12,7 +12,7 @@ import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; -import { type Transaction } from '@mysten/sui/transactions'; +import { type Transaction, type TransactionArgument } from '@mysten/sui/transactions'; const $moduleName = '@local-pkg/hashi::utxo'; export const UtxoId = new MoveStruct({ name: `${$moduleName}::UtxoId`, @@ -38,9 +38,12 @@ export interface UtxoIdOptions { arguments: | UtxoIdArguments | [txid: RawTransactionArgument, vout: RawTransactionArgument]; + config?: { + packageId?: string; + }; } export function utxoId(options: UtxoIdOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = ['address', 'u32'] satisfies (string | null)[]; const parameterNames = ['txid', 'vout']; return (tx: Transaction) => @@ -52,7 +55,7 @@ export function utxoId(options: UtxoIdOptions) { }); } export interface UtxoArguments { - utxoId: RawTransactionArgument; + utxoId: TransactionArgument; amount: RawTransactionArgument; derivationPath: RawTransactionArgument; } @@ -61,13 +64,16 @@ export interface UtxoOptions { arguments: | UtxoArguments | [ - utxoId: RawTransactionArgument, + utxoId: TransactionArgument, amount: RawTransactionArgument, derivationPath: RawTransactionArgument, ]; + config?: { + packageId?: string; + }; } export function utxo(options: UtxoOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'u64', '0x1::option::Option
'] satisfies (string | null)[]; const parameterNames = ['utxoId', 'amount', 'derivationPath']; return (tx: Transaction) => diff --git a/packages/hashi/src/contracts/hashi/utxo_pool.ts b/packages/hashi/src/contracts/hashi/utxo_pool.ts index 85baff386..86cf4f330 100644 --- a/packages/hashi/src/contracts/hashi/utxo_pool.ts +++ b/packages/hashi/src/contracts/hashi/utxo_pool.ts @@ -13,15 +13,13 @@ import { MoveStruct } from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; import * as bag from './deps/sui/bag.js'; -import * as bag_1 from './deps/sui/bag.js'; import * as utxo from './utxo.js'; -import * as utxo_1 from './utxo.js'; const $moduleName = '@local-pkg/hashi::utxo_pool'; export const UtxoPool = new MoveStruct({ name: `${$moduleName}::UtxoPool`, fields: { utxo_records: bag.Bag, - spent_utxos: bag_1.Bag, + spent_utxos: bag.Bag, }, }); export const UtxoRecord = new MoveStruct({ @@ -36,7 +34,7 @@ export const UtxoRecord = new MoveStruct({ export const UtxoSpent = new MoveStruct({ name: `${$moduleName}::UtxoSpent`, fields: { - utxo_id: utxo_1.UtxoId, + utxo_id: utxo.UtxoId, spent_epoch: bcs.u64(), }, }); diff --git a/packages/hashi/src/contracts/hashi/validator.ts b/packages/hashi/src/contracts/hashi/validator.ts index 22a361e46..96ffbd4ff 100644 --- a/packages/hashi/src/contracts/hashi/validator.ts +++ b/packages/hashi/src/contracts/hashi/validator.ts @@ -9,7 +9,14 @@ * Every mutation emits an event for off-chain watchers. */ -import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { + MoveStruct, + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; import { type Transaction } from '@mysten/sui/transactions'; const $moduleName = '@local-pkg/hashi::validator'; @@ -26,11 +33,15 @@ export const ValidatorUpdated = new MoveStruct({ }, }); export interface RegisterArguments { - self: RawTransactionArgument; + self?: RawTransactionArgument; } export interface RegisterOptions { package?: string; - arguments: RegisterArguments | [self: RawTransactionArgument]; + arguments?: RegisterArguments | [self?: RawTransactionArgument]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } /** * Registration and key/metadata updates (below) are deliberately NOT gated on @@ -39,7 +50,7 @@ export interface RegisterOptions { * reconfig freeze operator maintenance. */ export function register(options: RegisterOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, '0x3::sui_system::SuiSystemState'] satisfies (string | null)[]; const parameterNames = ['self']; return (tx: Transaction) => @@ -47,28 +58,54 @@ export function register(options: RegisterOptions) { package: packageAddress, module: 'validator', function: 'register', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments ?? {}, [ + { + index: 0, + name: 'self', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'validator', + functionName: 'register', + parameterIndex: 0, + parameterName: 'self', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface UpdateNextEpochPublicKeyArguments { - self: RawTransactionArgument; + self?: RawTransactionArgument; validator: RawTransactionArgument; - nextEpochPublicKey: RawTransactionArgument; - proofOfPossessionSignature: RawTransactionArgument; + nextEpochPublicKey: RawTransactionArgument>; + proofOfPossessionSignature: RawTransactionArgument>; } export interface UpdateNextEpochPublicKeyOptions { package?: string; arguments: | UpdateNextEpochPublicKeyArguments | [ - self: RawTransactionArgument, + self: RawTransactionArgument | undefined, validator: RawTransactionArgument, - nextEpochPublicKey: RawTransactionArgument, - proofOfPossessionSignature: RawTransactionArgument, + nextEpochPublicKey: RawTransactionArgument>, + proofOfPossessionSignature: RawTransactionArgument>, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function updateNextEpochPublicKey(options: UpdateNextEpochPublicKeyOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', 'vector', 'vector'] satisfies (string | null)[]; const parameterNames = ['self', 'validator', 'nextEpochPublicKey', 'proofOfPossessionSignature']; return (tx: Transaction) => @@ -76,11 +113,33 @@ export function updateNextEpochPublicKey(options: UpdateNextEpochPublicKeyOption package: packageAddress, module: 'validator', function: 'update_next_epoch_public_key', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'self', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'validator', + functionName: 'update_next_epoch_public_key', + parameterIndex: 0, + parameterName: 'self', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface UpdateOperatorAddressArguments { - self: RawTransactionArgument; + self?: RawTransactionArgument; validator: RawTransactionArgument; operator: RawTransactionArgument; } @@ -89,13 +148,17 @@ export interface UpdateOperatorAddressOptions { arguments: | UpdateOperatorAddressArguments | [ - self: RawTransactionArgument, + self: RawTransactionArgument | undefined, validator: RawTransactionArgument, operator: RawTransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function updateOperatorAddress(options: UpdateOperatorAddressOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', 'address'] satisfies (string | null)[]; const parameterNames = ['self', 'validator', 'operator']; return (tx: Transaction) => @@ -103,11 +166,33 @@ export function updateOperatorAddress(options: UpdateOperatorAddressOptions) { package: packageAddress, module: 'validator', function: 'update_operator_address', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'self', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'validator', + functionName: 'update_operator_address', + parameterIndex: 0, + parameterName: 'self', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface UpdateEndpointUrlArguments { - self: RawTransactionArgument; + self?: RawTransactionArgument; validator: RawTransactionArgument; endpointUrl: RawTransactionArgument; } @@ -116,13 +201,17 @@ export interface UpdateEndpointUrlOptions { arguments: | UpdateEndpointUrlArguments | [ - self: RawTransactionArgument, + self: RawTransactionArgument | undefined, validator: RawTransactionArgument, endpointUrl: RawTransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function updateEndpointUrl(options: UpdateEndpointUrlOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', '0x1::string::String'] satisfies (string | null)[]; const parameterNames = ['self', 'validator', 'endpointUrl']; return (tx: Transaction) => @@ -130,26 +219,52 @@ export function updateEndpointUrl(options: UpdateEndpointUrlOptions) { package: packageAddress, module: 'validator', function: 'update_endpoint_url', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'self', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'validator', + functionName: 'update_endpoint_url', + parameterIndex: 0, + parameterName: 'self', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface UpdateTlsPublicKeyArguments { - self: RawTransactionArgument; + self?: RawTransactionArgument; validator: RawTransactionArgument; - tlsPublicKey: RawTransactionArgument; + tlsPublicKey: RawTransactionArgument>; } export interface UpdateTlsPublicKeyOptions { package?: string; arguments: | UpdateTlsPublicKeyArguments | [ - self: RawTransactionArgument, + self: RawTransactionArgument | undefined, validator: RawTransactionArgument, - tlsPublicKey: RawTransactionArgument, + tlsPublicKey: RawTransactionArgument>, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function updateTlsPublicKey(options: UpdateTlsPublicKeyOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', 'vector'] satisfies (string | null)[]; const parameterNames = ['self', 'validator', 'tlsPublicKey']; return (tx: Transaction) => @@ -157,28 +272,54 @@ export function updateTlsPublicKey(options: UpdateTlsPublicKeyOptions) { package: packageAddress, module: 'validator', function: 'update_tls_public_key', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'self', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'validator', + functionName: 'update_tls_public_key', + parameterIndex: 0, + parameterName: 'self', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface UpdateNextEpochEncryptionPublicKeyArguments { - self: RawTransactionArgument; + self?: RawTransactionArgument; validator: RawTransactionArgument; - nextEpochEncryptionPublicKey: RawTransactionArgument; + nextEpochEncryptionPublicKey: RawTransactionArgument>; } export interface UpdateNextEpochEncryptionPublicKeyOptions { package?: string; arguments: | UpdateNextEpochEncryptionPublicKeyArguments | [ - self: RawTransactionArgument, + self: RawTransactionArgument | undefined, validator: RawTransactionArgument, - nextEpochEncryptionPublicKey: RawTransactionArgument, + nextEpochEncryptionPublicKey: RawTransactionArgument>, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function updateNextEpochEncryptionPublicKey( options: UpdateNextEpochEncryptionPublicKeyOptions, ) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', 'vector'] satisfies (string | null)[]; const parameterNames = ['self', 'validator', 'nextEpochEncryptionPublicKey']; return (tx: Transaction) => @@ -186,6 +327,28 @@ export function updateNextEpochEncryptionPublicKey( package: packageAddress, module: 'validator', function: 'update_next_epoch_encryption_public_key', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'self', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'validator', + functionName: 'update_next_epoch_encryption_public_key', + parameterIndex: 0, + parameterName: 'self', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } diff --git a/packages/hashi/src/contracts/hashi/withdraw.ts b/packages/hashi/src/contracts/hashi/withdraw.ts index aa02726fc..5c0391571 100644 --- a/packages/hashi/src/contracts/hashi/withdraw.ts +++ b/packages/hashi/src/contracts/hashi/withdraw.ts @@ -12,9 +12,16 @@ * cooldown, refunding the hBTC. */ -import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { + MoveStruct, + normalizeMoveArguments, + type RawTransactionArgument, + type ConfigValue, + resolveConfigArgument, + applyConfigArguments, +} from '../utils/index.js'; import { bcs } from '@mysten/sui/bcs'; -import { type Transaction } from '@mysten/sui/transactions'; +import { type Transaction, type TransactionArgument } from '@mysten/sui/transactions'; import * as utxo from './utxo.js'; import * as withdrawal_queue from './withdrawal_queue.js'; const $moduleName = '@local-pkg/hashi::withdraw'; @@ -57,22 +64,26 @@ export const WithdrawalConfirmationMessage = new MoveStruct({ }, }); export interface ApproveRequestArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; requestId: RawTransactionArgument; - cert: RawTransactionArgument; + cert: TransactionArgument; } export interface ApproveRequestOptions { package?: string; arguments: | ApproveRequestArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, requestId: RawTransactionArgument, - cert: RawTransactionArgument, + cert: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function approveRequest(options: ApproveRequestOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', null, '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'requestId', 'cert']; return (tx: Transaction) => @@ -80,32 +91,58 @@ export function approveRequest(options: ApproveRequestOptions) { package: packageAddress, module: 'withdraw', function: 'approve_request', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'withdraw', + functionName: 'approve_request', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface CommitWithdrawalTxArguments { - hashi: RawTransactionArgument; - requestIds: RawTransactionArgument; - selectedUtxos: RawTransactionArgument; - outputs: RawTransactionArgument; + hashi?: RawTransactionArgument; + requestIds: RawTransactionArgument>; + selectedUtxos: TransactionArgument; + outputs: TransactionArgument; txid: RawTransactionArgument; - cert: RawTransactionArgument; + cert: TransactionArgument; } export interface CommitWithdrawalTxOptions { package?: string; arguments: | CommitWithdrawalTxArguments | [ - hashi: RawTransactionArgument, - requestIds: RawTransactionArgument, - selectedUtxos: RawTransactionArgument, - outputs: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, + requestIds: RawTransactionArgument>, + selectedUtxos: TransactionArgument, + outputs: TransactionArgument, txid: RawTransactionArgument, - cert: RawTransactionArgument, + cert: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function commitWithdrawalTx(options: CommitWithdrawalTxOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [ null, 'vector
', @@ -122,27 +159,53 @@ export function commitWithdrawalTx(options: CommitWithdrawalTxOptions) { package: packageAddress, module: 'withdraw', function: 'commit_withdrawal_tx', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'withdraw', + functionName: 'commit_withdrawal_tx', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface CommitInputSignaturesArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; withdrawalId: RawTransactionArgument; - indices: RawTransactionArgument; - signatures: RawTransactionArgument; - cert: RawTransactionArgument; + indices: RawTransactionArgument>; + signatures: RawTransactionArgument>>; + cert: TransactionArgument; } export interface CommitInputSignaturesOptions { package?: string; arguments: | CommitInputSignaturesArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, withdrawalId: RawTransactionArgument, - indices: RawTransactionArgument, - signatures: RawTransactionArgument, - cert: RawTransactionArgument, + indices: RawTransactionArgument>, + signatures: RawTransactionArgument>>, + cert: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } /** * Record a chunk of completed per-input MPC signatures into the withdrawal's @@ -152,7 +215,7 @@ export interface CommitInputSignaturesOptions { * bundle a final chunk + `finalize_withdrawal` in one PTB for small txns. */ export function commitInputSignatures(options: CommitInputSignaturesOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', 'vector', 'vector>', null] satisfies ( | string | null @@ -163,27 +226,53 @@ export function commitInputSignatures(options: CommitInputSignaturesOptions) { package: packageAddress, module: 'withdraw', function: 'commit_input_signatures', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'withdraw', + functionName: 'commit_input_signatures', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface FinalizeWithdrawalArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; withdrawalId: RawTransactionArgument; - requestIds: RawTransactionArgument; - guardianSignatures: RawTransactionArgument; - cert: RawTransactionArgument; + requestIds: RawTransactionArgument>; + guardianSignatures: RawTransactionArgument>>; + cert: TransactionArgument; } export interface FinalizeWithdrawalOptions { package?: string; arguments: | FinalizeWithdrawalArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, withdrawalId: RawTransactionArgument, - requestIds: RawTransactionArgument, - guardianSignatures: RawTransactionArgument, - cert: RawTransactionArgument, + requestIds: RawTransactionArgument>, + guardianSignatures: RawTransactionArgument>>, + cert: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } /** * Finalize a withdrawal once all MPC signatures are in: attach the one-shot @@ -192,7 +281,7 @@ export interface FinalizeWithdrawalOptions { * malicious leader cannot pair valid MPC sigs with garbage guardian sigs. */ export function finalizeWithdrawal(options: FinalizeWithdrawalOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [ null, 'address', @@ -207,26 +296,52 @@ export function finalizeWithdrawal(options: FinalizeWithdrawalOptions) { package: packageAddress, module: 'withdraw', function: 'finalize_withdrawal', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'withdraw', + functionName: 'finalize_withdrawal', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface ConfirmWithdrawalArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; withdrawalId: RawTransactionArgument; - cert: RawTransactionArgument; + cert: TransactionArgument; } export interface ConfirmWithdrawalOptions { package?: string; arguments: | ConfirmWithdrawalArguments | [ - hashi: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, withdrawalId: RawTransactionArgument, - cert: RawTransactionArgument, + cert: TransactionArgument, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } export function confirmWithdrawal(options: ConfirmWithdrawalOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', null, '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'withdrawalId', 'cert']; return (tx: Transaction) => @@ -234,18 +349,47 @@ export function confirmWithdrawal(options: ConfirmWithdrawalOptions) { package: packageAddress, module: 'withdraw', function: 'confirm_withdrawal', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'withdraw', + functionName: 'confirm_withdrawal', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface ReallocatePresigsArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; withdrawalId: RawTransactionArgument; } export interface ReallocatePresigsOptions { package?: string; arguments: | ReallocatePresigsArguments - | [hashi: RawTransactionArgument, withdrawalId: RawTransactionArgument]; + | [ + hashi: RawTransactionArgument | undefined, + withdrawalId: RawTransactionArgument, + ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } /** * Reassign fresh presignatures to the still-unsigned inputs of a withdrawal whose @@ -259,7 +403,7 @@ export interface ReallocatePresigsOptions { * by the `mpc_signing` stale-epoch guard. */ export function reallocatePresigs(options: ReallocatePresigsOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address'] satisfies (string | null)[]; const parameterNames = ['hashi', 'withdrawalId']; return (tx: Transaction) => @@ -267,18 +411,44 @@ export function reallocatePresigs(options: ReallocatePresigsOptions) { package: packageAddress, module: 'withdraw', function: 'reallocate_presigs', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'withdraw', + functionName: 'reallocate_presigs', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface CleanupSpentUtxosArguments { - hashi: RawTransactionArgument; - utxoIds: RawTransactionArgument; + hashi?: RawTransactionArgument; + utxoIds: TransactionArgument; } export interface CleanupSpentUtxosOptions { package?: string; arguments: | CleanupSpentUtxosArguments - | [hashi: RawTransactionArgument, utxoIds: RawTransactionArgument]; + | [hashi: RawTransactionArgument | undefined, utxoIds: TransactionArgument]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } /** * Finalize the on-chain bookkeeping for spent UTXOs. Moves each UTXO's record from @@ -290,7 +460,7 @@ export interface CleanupSpentUtxosOptions { * and must stay callable during an emergency pause. */ export function cleanupSpentUtxos(options: CleanupSpentUtxosOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'vector'] satisfies (string | null)[]; const parameterNames = ['hashi', 'utxoIds']; return (tx: Transaction) => @@ -298,23 +468,49 @@ export function cleanupSpentUtxos(options: CleanupSpentUtxosOptions) { package: packageAddress, module: 'withdraw', function: 'cleanup_spent_utxos', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'withdraw', + functionName: 'cleanup_spent_utxos', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface RequestWithdrawalArguments { - hashi: RawTransactionArgument; - btc: RawTransactionArgument; - bitcoinAddress: RawTransactionArgument; + hashi?: RawTransactionArgument; + btc: TransactionArgument; + bitcoinAddress: RawTransactionArgument>; } export interface RequestWithdrawalOptions { package?: string; arguments: | RequestWithdrawalArguments | [ - hashi: RawTransactionArgument, - btc: RawTransactionArgument, - bitcoinAddress: RawTransactionArgument, + hashi: RawTransactionArgument | undefined, + btc: TransactionArgument, + bitcoinAddress: RawTransactionArgument>, ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } /** * Request a withdrawal of BTC from the bridge. @@ -326,7 +522,7 @@ export interface RequestWithdrawalOptions { * guarantees the amount covers worst-case miner fees plus dust. */ export function requestWithdrawal(options: RequestWithdrawalOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, '0x2::clock::Clock', null, 'vector'] satisfies ( | string | null @@ -337,18 +533,47 @@ export function requestWithdrawal(options: RequestWithdrawalOptions) { package: packageAddress, module: 'withdraw', function: 'request_withdrawal', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'withdraw', + functionName: 'request_withdrawal', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } export interface CancelWithdrawalArguments { - hashi: RawTransactionArgument; + hashi?: RawTransactionArgument; requestId: RawTransactionArgument; } export interface CancelWithdrawalOptions { package?: string; arguments: | CancelWithdrawalArguments - | [hashi: RawTransactionArgument, requestId: RawTransactionArgument]; + | [ + hashi: RawTransactionArgument | undefined, + requestId: RawTransactionArgument, + ]; + config?: { + hashiObjectId: ConfigValue; + packageId?: string; + }; } /** * Cancel a pending withdrawal request and return the stored BTC to the requester. @@ -359,7 +584,7 @@ export interface CancelWithdrawalOptions { * bag and its BTC is burned — cancellation is no longer possible. */ export function cancelWithdrawal(options: CancelWithdrawalOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = [null, 'address', '0x2::clock::Clock'] satisfies (string | null)[]; const parameterNames = ['hashi', 'requestId']; return (tx: Transaction) => @@ -367,6 +592,28 @@ export function cancelWithdrawal(options: CancelWithdrawalOptions) { package: packageAddress, module: 'withdraw', function: 'cancel_withdrawal', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + arguments: normalizeMoveArguments( + applyConfigArguments(options.arguments, [ + { + index: 0, + name: 'hashi', + resolve: () => + resolveConfigArgument( + options.config?.hashiObjectId, + { + typeArguments: [], + packageAddress, + moduleName: 'withdraw', + functionName: 'cancel_withdrawal', + parameterIndex: 0, + parameterName: 'hashi', + }, + 'hashiObjectId', + ), + }, + ]), + argumentsTypes, + parameterNames, + ), }); } diff --git a/packages/hashi/src/contracts/hashi/withdrawal_queue.ts b/packages/hashi/src/contracts/hashi/withdrawal_queue.ts index 93259472c..51178d5e2 100644 --- a/packages/hashi/src/contracts/hashi/withdrawal_queue.ts +++ b/packages/hashi/src/contracts/hashi/withdrawal_queue.ts @@ -20,15 +20,10 @@ import { import { bcs } from '@mysten/sui/bcs'; import { type Transaction } from '@mysten/sui/transactions'; import * as object_bag from './deps/sui/object_bag.js'; -import * as object_bag_1 from './deps/sui/object_bag.js'; -import * as object_bag_2 from './deps/sui/object_bag.js'; -import * as object_bag_3 from './deps/sui/object_bag.js'; import * as committee from './committee.js'; import * as balance from './deps/sui/balance.js'; import * as utxo from './utxo.js'; import * as mpc_signing from './mpc_signing.js'; -import * as utxo_1 from './utxo.js'; -import * as utxo_2 from './utxo.js'; const $moduleName = '@local-pkg/hashi::withdrawal_queue'; export const WithdrawalRequestQueue = new MoveStruct({ name: `${$moduleName}::WithdrawalRequestQueue`, @@ -42,14 +37,14 @@ export const WithdrawalRequestQueue = new MoveStruct({ * Processed requests — BTC consumed, lifecycle continuing or complete (Processing, * Signed, Confirmed). */ - processed: object_bag_1.ObjectBag, + processed: object_bag.ObjectBag, /** * In-flight withdrawal transactions (unsigned, signed but unconfirmed). ObjectBag * so WithdrawalTransaction UIDs are directly accessible via getObject. */ - withdrawal_txns: object_bag_2.ObjectBag, + withdrawal_txns: object_bag.ObjectBag, /** Confirmed withdrawal transactions (historical record). */ - confirmed_txns: object_bag_3.ObjectBag, + confirmed_txns: object_bag.ObjectBag, }, }); export const OutputUtxo = new MoveStruct({ @@ -168,7 +163,7 @@ export const WithdrawalPickedForProcessing = new MoveStruct({ withdrawal_txn_id: bcs.Address, txid: bcs.Address, request_ids: bcs.vector(bcs.Address), - inputs: bcs.vector(utxo_1.Utxo), + inputs: bcs.vector(utxo.Utxo), withdrawal_outputs: bcs.vector(OutputUtxo), change_outputs: bcs.vector(OutputUtxo), timestamp_ms: bcs.u64(), @@ -211,7 +206,7 @@ export const WithdrawalConfirmed = new MoveStruct({ fields: { withdrawal_txn_id: bcs.Address, txid: bcs.Address, - change_utxo_ids: bcs.vector(utxo_2.UtxoId), + change_utxo_ids: bcs.vector(utxo.UtxoId), request_ids: bcs.vector(bcs.Address), change_utxo_amounts: bcs.vector(bcs.u64()), }, @@ -226,7 +221,7 @@ export const WithdrawalCancelled = new MoveStruct({ }); export interface OutputUtxoArguments { amount: RawTransactionArgument; - bitcoinAddress: RawTransactionArgument; + bitcoinAddress: RawTransactionArgument>; } export interface OutputUtxoOptions { package?: string; @@ -234,11 +229,14 @@ export interface OutputUtxoOptions { | OutputUtxoArguments | [ amount: RawTransactionArgument, - bitcoinAddress: RawTransactionArgument, + bitcoinAddress: RawTransactionArgument>, ]; + config?: { + packageId?: string; + }; } export function outputUtxo(options: OutputUtxoOptions) { - const packageAddress = options.package ?? '@local-pkg/hashi'; + const packageAddress = options.package ?? options.config?.packageId ?? '@local-pkg/hashi'; const argumentsTypes = ['u64', 'vector'] satisfies (string | null)[]; const parameterNames = ['amount', 'bitcoinAddress']; return (tx: Transaction) => diff --git a/packages/hashi/src/contracts/utils/index.ts b/packages/hashi/src/contracts/utils/index.ts index 9e2b22b6e..23a2fb7c1 100644 --- a/packages/hashi/src/contracts/utils/index.ts +++ b/packages/hashi/src/contracts/utils/index.ts @@ -1,32 +1,30 @@ import { bcs, - BcsType, - TypeTag, + type BcsType, + type TypeTag, TypeTagSerializer, BcsStruct, BcsEnum, BcsTuple, } from '@mysten/sui/bcs'; -import { normalizeSuiAddress } from '@mysten/sui/utils'; -import { TransactionArgument, isArgument } from '@mysten/sui/transactions'; -import { ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; +import { normalizeStructTag, normalizeSuiAddress } from '@mysten/sui/utils'; +import { + type TransactionArgument, + type TransactionObjectArgument, + isArgument, +} from '@mysten/sui/transactions'; +import { type ClientWithCoreApi, type SuiClientTypes } from '@mysten/sui/client'; const MOVE_STDLIB_ADDRESS = normalizeSuiAddress('0x1'); const SUI_FRAMEWORK_ADDRESS = normalizeSuiAddress('0x2'); export type RawTransactionArgument = T | TransactionArgument; -export interface GetOptions< - Include extends Omit = {}, -> extends SuiClientTypes.GetObjectOptions { - client: ClientWithCoreApi; -} +export type GetOptions = {}> = + SuiClientTypes.GetObjectOptions & { client: ClientWithCoreApi }; -export interface GetManyOptions< - Include extends Omit = {}, -> extends SuiClientTypes.GetObjectsOptions { - client: ClientWithCoreApi; -} +export type GetManyOptions = {}> = + SuiClientTypes.GetObjectsOptions & { client: ClientWithCoreApi }; export function getPureBcsSchema(typeTag: string | TypeTag): BcsType | null { const parsedTag = typeof typeTag === 'string' ? TypeTagSerializer.parseFromStr(typeTag) : typeTag; @@ -63,7 +61,8 @@ export function getPureBcsSchema(typeTag: string | TypeTag): BcsType | null } if (structTag.module === 'option' && structTag.name === 'Option') { - const type = getPureBcsSchema(structTag.typeParams[0]); + const inner = structTag.typeParams[0]; + const type = inner ? getPureBcsSchema(inner) : null; return type ? bcs.option(type) : null; } } @@ -95,7 +94,7 @@ export function normalizeMoveArguments( const normalizedArgs: TransactionArgument[] = []; let index = 0; - for (const [i, argType] of argTypes.entries()) { + for (const argType of argTypes) { if (argType === '0x2::clock::Clock') { normalizedArgs.push((tx) => tx.object.clock()); continue; @@ -111,6 +110,12 @@ export function normalizeMoveArguments( continue; } + if (argType === '0x2::accumulator::AccumulatorRoot') { + // Chain-wide shared singleton at a fixed address (SUI_ACCUMULATOR_ROOT_OBJECT_ID). + normalizedArgs.push((tx) => tx.object('0xacc')); + continue; + } + if (argType === '0x3::sui_system::SuiSystemState') { normalizedArgs.push((tx) => tx.object.system()); continue; @@ -129,7 +134,10 @@ export function normalizeMoveArguments( throw new Error(`Expected arguments to be passed as an array`); } const name = parameterNames[index]; - arg = args[name as keyof typeof args]; + arg = + name !== undefined && Object.prototype.hasOwnProperty.call(args, name) + ? args[name as keyof typeof args] + : undefined; if (arg === undefined) { throw new Error(`Parameter ${name} is required`); @@ -143,28 +151,285 @@ export function normalizeMoveArguments( continue; } - const type = argTypes[i]; - const bcsType = type === null ? null : getPureBcsSchema(type); + const bcsType = argType === null ? null : getPureBcsSchema(argType); if (bcsType) { const bytes = bcsType.serialize(arg as never); normalizedArgs.push((tx) => tx.pure(bytes)); continue; - } else if (typeof arg === 'string') { + } + + if (typeof arg === 'string') { normalizedArgs.push((tx) => tx.object(arg)); continue; } - throw new Error(`Invalid argument ${stringify(arg)} for type ${type}`); + throw new Error(`Invalid argument ${stringify(arg)} for type ${argType}`); } return normalizedArgs; } +/* -------------------------- Config-mapped arguments -------------------------- */ + +/** Context passed to config resolver functions. */ +export interface ConfigResolverContext { + /** + * The matched parameter's own instantiated type arguments (not the whole function's type + * argument tuple), as fully-qualified type tags. Hex-addressed struct tags are normalized to + * their long form before the resolver is invoked; MVR-named tags are passed through unchanged. + */ + typeArguments: string[]; + /** The package address the generated call will be sent to. */ + packageAddress: string; + /** The Move module of the generated call. */ + moduleName: string; + /** The Move function of the generated call. */ + functionName: string; + /** The Move name of the matched parameter, when the summary includes parameter names. */ + parameterName?: string; + /** The matched parameter's position in the generated function's arguments. */ + parameterIndex: number; +} + +/** + * A value in a generated config object: a plain object id/argument, or a resolver function. + * + * Any function is treated as a resolver and called with a `ConfigResolverContext`. To provide a + * transaction-callback object argument dynamically, return it from a resolver: + * `(ctx) => (tx) => ...`. + */ +export type ConfigValue = + | string + | Exclude unknown> + | ((ctx: ConfigResolverContext) => string | TransactionObjectArgument); + +/** Normalize a hex-addressed struct tag to its long form; pass anything else through. */ +function normalizeConfigTypeTag(tag: string): string { + if (/[@/]/.test(tag) || !tag.includes('::')) { + return tag; + } + try { + return normalizeStructTag(tag); + } catch { + return tag; + } +} + +export function resolveConfigArgument( + value: ConfigValue | undefined, + ctx: ConfigResolverContext, + name: string, +): string | TransactionObjectArgument { + if (value == null) { + throw new Error( + `Missing config value for "${name}": pass it explicitly in arguments, or include it in the config object`, + ); + } + + if (typeof value !== 'function') { + return value; + } + + const resolved = value({ + ...ctx, + typeArguments: ctx.typeArguments.map(normalizeConfigTypeTag), + }); + + if (resolved == null) { + throw new Error( + `Config resolver for "${name}" returned ${resolved} (${ctx.moduleName}::${ctx.functionName}, typeArguments: [${ctx.typeArguments.join(', ')}])`, + ); + } + + return resolved; +} + +/** + * Fill unset config-mapped positions in an arguments array/object with their resolved config + * values. Resolvers are only invoked for positions the caller did not pass explicitly. + */ +export function applyConfigArguments( + args: T, + defaults: readonly { index: number; name?: string; resolve: () => unknown }[], +): T { + if (Array.isArray(args)) { + const result = [...args]; + const matchedIndexes = new Set(defaults.map((entry) => entry.index)); + for (const entry of defaults) { + if (result[entry.index] === undefined) { + result[entry.index] = entry.resolve(); + } + } + // Filling a trailing config-mapped position can extend the array past positions the + // caller omitted; catch those holes here rather than failing deep inside serialization. + for (let i = 0; i < result.length; i++) { + if (result[i] === undefined && !matchedIndexes.has(i)) { + throw new Error(`Missing argument at position ${i}`); + } + } + return result as T; + } + + const result: Record = { ...args }; + for (const entry of defaults) { + if (entry.name === undefined) { + continue; + } + // Own-property check so inherited properties (e.g. a key named "constructor") are never + // mistaken for explicitly passed arguments. + if ( + !Object.prototype.hasOwnProperty.call(result, entry.name) || + result[entry.name] === undefined + ) { + Object.defineProperty(result, entry.name, { + value: entry.resolve(), + enumerable: true, + writable: true, + configurable: true, + }); + } + } + return result as T; +} + +/* -------------------------- Move type tags -------------------------- */ + +/** A type argument: a type tag string, or a BCS type whose name is a Move type. */ +export type TypeArgument = string | BcsType; + +export interface TypeTagOptions { + package?: string; + typeArguments?: readonly TypeArgument[]; +} + +/** + * `typeArguments` is required when the type's name contains unfilled + * `phantom X` parameters (at any depth). Everything else — argument arity, + * position contents, and tag validity — is validated at runtime. + */ +type TypeTagParams = Name extends `${string}phantom ${string}` + ? [options: TypeTagOptions & { typeArguments: readonly TypeArgument[] }] + : [options?: TypeTagOptions]; + +type ResolveTypeTagOptions = { + client: ClientWithCoreApi; +} & (Name extends `${string}phantom ${string}` + ? TypeTagOptions & { typeArguments: readonly TypeArgument[] } + : TypeTagOptions); + +const HAS_PHANTOM_REGEX = /phantom [A-Za-z_$][A-Za-z0-9_$]*/; + +function splitTopLevelTypeArgs(inner: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ''; + for (const char of inner) { + if (char === ',' && depth === 0) { + parts.push(current.trim()); + current = ''; + continue; + } + if (char === '<') depth++; + if (char === '>') depth--; + current += char; + } + if (current) parts.push(current.trim()); + return parts; +} + +function buildTypeTag(name: string, options: TypeTagOptions | undefined): string { + const lt = name.indexOf('<'); + const base = lt === -1 ? name : name.slice(0, lt); + + if (base.split('::').length !== 3) { + throw new Error(`${name} is not a top-level Move type`); + } + + let result = name; + + if (options?.typeArguments) { + const baked = lt === -1 ? [] : splitTopLevelTypeArgs(name.slice(lt + 1, -1)); + const supplied = options.typeArguments.map((arg) => { + if (typeof arg === 'string') { + return arg; + } + if (arg && typeof arg.serialize === 'function' && typeof arg.name === 'string') { + return arg.name; + } + throw new Error(`Invalid type argument ${stringify(arg)}`); + }); + + if (supplied.length !== baked.length) { + throw new Error( + `Expected ${baked.length} type arguments for ${base}, got ${supplied.length}`, + ); + } + + result = supplied.length === 0 ? base : `${base}<${supplied.join(', ')}>`; + } + + if (HAS_PHANTOM_REGEX.test(result)) { + throw new Error( + options?.typeArguments + ? `A type argument contains an unfilled phantom parameter in ${result}` + : `Missing type arguments for ${result}`, + ); + } + + if (options?.package) { + const [, ...rest] = result.split('::'); + result = [options.package, ...rest].join('::'); + } + + // fully validate address-only tags (MVR names can't be parsed as type tags) + if (!HAS_PHANTOM_REGEX.test(result) && !/[@/]/.test(result)) { + TypeTagSerializer.parseFromStr(result); + } + + return result; +} + +async function resolveBuiltTypeTag( + name: string, + options: { client: ClientWithCoreApi } & TypeTagOptions, +): Promise { + const { client, ...rest } = options; + const { type } = await client.core.mvr.resolveType({ + type: buildTypeTag(name, rest), + }); + return normalizeStructTag(type); +} + export class MoveStruct< T extends Record>, const Name extends string = string, > extends BcsStruct { + /** + * Build the type tag for this struct. + * + * `typeArguments` is the full positional list, in Move declaration order, and + * is required when the struct has unfilled phantom parameters. The result may + * contain MVR names: those are valid in transaction `typeArguments`, but for + * queries or comparisons against on-chain data use `resolveTypeTag` instead. + */ + typeTag(...args: TypeTagParams): string { + return buildTypeTag(this.name, args[0] as TypeTagOptions | undefined); + } + + /** + * Build the type tag for this struct, then resolve any MVR names through the + * client (using its configured overrides and the MVR API) and return the + * normalized, address-only form suitable for queries and comparisons against + * on-chain data. + */ + async resolveTypeTag(options: ResolveTypeTagOptions): Promise { + return resolveBuiltTypeTag( + this.name, + options as { client: ClientWithCoreApi } & TypeTagOptions, + ); + } + async get = {}>({ objectId, ...options @@ -178,6 +443,10 @@ export class MoveStruct< objectIds: [objectId], }); + if (!res) { + throw new Error(`No object found for id ${objectId}`); + } + return res; } @@ -215,16 +484,44 @@ export class MoveStruct< export class MoveEnum< T extends Record | null>, const Name extends string, -> extends BcsEnum {} +> extends BcsEnum { + /** Build the type tag for this enum. See `MoveStruct.typeTag` for semantics. */ + typeTag(...args: TypeTagParams): string { + return buildTypeTag(this.name, args[0] as TypeTagOptions | undefined); + } + + /** Build and resolve the type tag for this enum. See `MoveStruct.resolveTypeTag`. */ + async resolveTypeTag(options: ResolveTypeTagOptions): Promise { + return resolveBuiltTypeTag( + this.name, + options as { client: ClientWithCoreApi } & TypeTagOptions, + ); + } +} export class MoveTuple< const T extends readonly BcsType[], const Name extends string, -> extends BcsTuple {} +> extends BcsTuple { + /** Build the type tag for this struct. See `MoveStruct.typeTag` for semantics. */ + typeTag(...args: TypeTagParams): string { + return buildTypeTag(this.name, args[0] as TypeTagOptions | undefined); + } + + /** Build and resolve the type tag for this struct. See `MoveStruct.resolveTypeTag`. */ + async resolveTypeTag(options: ResolveTypeTagOptions): Promise { + return resolveBuiltTypeTag( + this.name, + options as { client: ClientWithCoreApi } & TypeTagOptions, + ); + } +} function stringify(val: unknown) { if (typeof val === 'object') { - return JSON.stringify(val, (val: unknown) => val); + return JSON.stringify(val, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ); } if (typeof val === 'bigint') { return val.toString(); diff --git a/packages/hashi/sui-codegen.config.ts b/packages/hashi/sui-codegen.config.ts index af8e537bd..7f7368e70 100644 --- a/packages/hashi/sui-codegen.config.ts +++ b/packages/hashi/sui-codegen.config.ts @@ -9,6 +9,10 @@ const config: SuiCodegenConfig = { { package: '@local-pkg/hashi', // TODO: update this when hashi is published on MVR. path: '../../../hashi/packages/hashi', + configArguments: { + hashiObjectId: { type: 'hashi::Hashi' }, + packageId: { package: '@local-pkg/hashi' }, + }, }, ], }; diff --git a/packages/hashi/test/unit/client.test.ts b/packages/hashi/test/unit/client.test.ts index 5e7a26cfd..e111e0568 100644 --- a/packages/hashi/test/unit/client.test.ts +++ b/packages/hashi/test/unit/client.test.ts @@ -2036,7 +2036,7 @@ describe('HashiClient', () => { describe('tx', () => { describe('deposit', () => { - it('composes utxo_id + utxo + deposit for a single UTXO', () => { + it('composes utxo_id + utxo + deposit for a single UTXO', async () => { const tx = client.hashi.tx.deposit({ txid: '0x' + 'ab'.repeat(32), utxos: [{ vout: 0, amountSats: 100_000n }], @@ -2055,6 +2055,17 @@ describe('HashiClient', () => { expect(commands[2].$kind).toBe('MoveCall'); expect(commands[2].MoveCall?.function).toBe('deposit'); + + const json = JSON.parse(await tx.toJSON()); + expect( + json.commands.map( + (command: { MoveCall?: { package: string } }) => command.MoveCall?.package, + ), + ).toEqual([PACKAGE_ID, PACKAGE_ID, PACKAGE_ID]); + expect(json.inputs[4].UnresolvedObject?.objectId).toBe(HASHI_OBJECT_ID); + expect(json.commands[2].MoveCall?.arguments[0]).toMatchObject({ + Input: 4, + }); }); it('batches multiple UTXOs into one PTB (one triple per UTXO)', () => {