Skip to content

Commit 6d8d0bf

Browse files
authored
Merge pull request #111 from algorandfoundation/feat/generic-itxn-compose
Feat/generic itxn compose
2 parents d28702d + d91efb2 commit 6d8d0bf

File tree

11 files changed

+192
-67
lines changed

11 files changed

+192
-67
lines changed

package-lock.json

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@
6868
"vitest": "3.2.4"
6969
},
7070
"dependencies": {
71-
"@algorandfoundation/algorand-typescript": "1.0.1",
72-
"@algorandfoundation/puya-ts": "1.0.1",
71+
"@algorandfoundation/algorand-typescript": "1.1.0-beta.1",
72+
"@algorandfoundation/puya-ts": "1.1.0-beta.1",
7373
"elliptic": "^6.6.1",
7474
"js-sha256": "^0.11.0",
7575
"js-sha3": "^0.9.3",

src/abi-metadata.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { arc4, OnCompleteActionStr } from '@algorandfoundation/algorand-typescript'
22
import js_sha512 from 'js-sha512'
33
import { ConventionalRouting } from './constants'
4+
import { InternalError } from './errors'
45
import { Arc4MethodConfigSymbol, Contract } from './impl/contract'
56
import type { TypeInfo } from './impl/encoded-types'
67
import { getArc4TypeName } from './impl/encoded-types'
7-
import type { DeliberateAny } from './typescript-helpers'
8+
import type { DeliberateAny, InstanceMethod } from './typescript-helpers'
89

910
/** @internal */
1011
export interface AbiMetadata {
@@ -109,6 +110,24 @@ export const getArc4Selector = (metadata: AbiMetadata): Uint8Array => {
109110
return new Uint8Array(hash.slice(0, 4))
110111
}
111112

113+
/** @internal */
114+
export const getContractMethod = (contractFullName: string, methodName: string) => {
115+
const contract = getContractByName(contractFullName)
116+
117+
if (contract === undefined || typeof contract !== 'function') {
118+
throw new InternalError(`Unknown contract: ${contractFullName}`)
119+
}
120+
121+
if (!Object.hasOwn(contract.prototype, methodName)) {
122+
throw new InternalError(`Unknown method: ${methodName} in contract: ${contractFullName}`)
123+
}
124+
125+
return {
126+
method: contract.prototype[methodName] as InstanceMethod<Contract, DeliberateAny[]>,
127+
contract,
128+
}
129+
}
130+
112131
/**
113132
* Get routing properties inferred by conventional naming
114133
* @param methodName The name of the method

src/application-spy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export class ApplicationSpy<TContract extends Contract = Contract> {
112112
ocas = metadata.allowActions?.map((action) => OnCompleteAction[action]) ?? [OnCompleteAction.NoOp]
113113
}
114114

115-
const selector = methodSelector(fn, spy.contract)
115+
const selector = methodSelector({ method: fn, contract: spy.contract })
116116
spy.onAbiCall(selector, ocas, callback)
117117
}
118118
},

src/impl/c2c.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ import type {
44
ContractProxy,
55
TypedApplicationCallFields,
66
} from '@algorandfoundation/algorand-typescript/arc4'
7-
import { getContractByName, getContractMethodAbiMetadata } from '../abi-metadata'
7+
import { getContractMethod, getContractMethodAbiMetadata } from '../abi-metadata'
88
import { lazyContext } from '../context-helpers/internal-context'
9-
import { InternalError } from '../errors'
109
import type { ConstructorFor, DeliberateAny, InstanceMethod } from '../typescript-helpers'
1110
import type { ApplicationCallInnerTxn } from './inner-transactions'
1211
import { ApplicationCallInnerTxnContext } from './inner-transactions'
@@ -34,7 +33,7 @@ export function compileArc4<TContract extends Contract>(
3433
call: new Proxy({} as unknown as TContract, {
3534
get: (_target, prop) => {
3635
return (methodArgs: TypedApplicationCallFields<DeliberateAny[]>) => {
37-
const selector = methodSelector(prop as string, contract)
36+
const selector = methodSelector({ method: prop as string, contract })
3837
const abiMetadata = getContractMethodAbiMetadata(contract, prop as string)
3938
const onCompleteActions = abiMetadata?.allowActions?.map((action) => OnCompleteAction[action])
4039
const itxnContext = ApplicationCallInnerTxnContext.createFromTypedApplicationCallFields(
@@ -95,7 +94,7 @@ export function getApplicationCallInnerTxnContext<TArgs extends DeliberateAny[],
9594
contract?: Contract | { new (): Contract },
9695
) {
9796
const abiMetadata = contract ? getContractMethodAbiMetadata(contract, method.name) : undefined
98-
const selector = methodSelector(method, contract)
97+
const selector = methodSelector({ method, contract })
9998
return ApplicationCallInnerTxnContext.createFromTypedApplicationCallFields<TReturn>(
10099
{
101100
...methodArgs,
@@ -111,11 +110,9 @@ export function abiCall<TArgs extends DeliberateAny[], TReturn>(
111110
method: string,
112111
methodArgs: TypedApplicationCallFields<TArgs>,
113112
): { itxn: ApplicationCallInnerTxn; returnValue: TReturn | undefined } {
114-
const contract = getContractByName(contractFullName)
115-
if (contract === undefined || typeof contract !== 'function') throw new InternalError(`Unknown contract: ${contractFullName}`)
116-
if (!Object.hasOwn(contract.prototype, method)) throw new InternalError(`Unknown method: ${method} in contract: ${contractFullName}`)
113+
const { method: methodInstance, contract: contractInstance } = getContractMethod(contractFullName, method)
117114

118-
const itxnContext = getApplicationCallInnerTxnContext<TArgs, TReturn>(contract.prototype[method], methodArgs, contract)
115+
const itxnContext = getApplicationCallInnerTxnContext<TArgs, TReturn>(methodInstance, methodArgs, contractInstance)
119116

120117
invokeAbiCall(itxnContext)
121118

src/impl/itxn-compose.ts

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import type {
1010
KeyRegistrationComposeFields,
1111
PaymentComposeFields,
1212
} from '@algorandfoundation/algorand-typescript'
13-
import type { TypedApplicationCallFields } from '@algorandfoundation/algorand-typescript/arc4'
13+
import type { AbiCallOptions, TypedApplicationCallFields } from '@algorandfoundation/algorand-typescript/arc4'
14+
import { getContractMethod } from '../abi-metadata'
1415
import { lazyContext } from '../context-helpers/internal-context'
1516
import type { DeliberateAny, InstanceMethod } from '../typescript-helpers'
1617
import { getApplicationCallInnerTxnContext } from './c2c'
@@ -29,16 +30,9 @@ class ItxnCompose {
2930
fields: TypedApplicationCallFields<TArgs>,
3031
contract?: Contract | { new (): Contract },
3132
): void
32-
begin<TArgs extends DeliberateAny[]>(...args: unknown[]): void {
33-
lazyContext.txn.activeGroup.constructingItxnGroup.push(
34-
args.length === 1
35-
? (args[0] as AnyTransactionComposeFields)
36-
: getApplicationCallInnerTxnContext(
37-
args[0] as InstanceMethod<Contract, TArgs>,
38-
args[1] as TypedApplicationCallFields<TArgs>,
39-
args[2] as Contract | { new (): Contract },
40-
),
41-
)
33+
begin<TMethod>(options: AbiCallOptions<TMethod>, contract: string, method: string): void
34+
begin(...args: unknown[]): void {
35+
this.addInnerTransaction(...args)
4236
}
4337

4438
next(fields: PaymentComposeFields): void
@@ -49,22 +43,49 @@ class ItxnCompose {
4943
next(fields: ApplicationCallComposeFields): void
5044
next(fields: AnyTransactionComposeFields): void
5145
next(fields: ComposeItxnParams): void
52-
next<TArgs extends DeliberateAny[]>(_method: InstanceMethod<Contract, TArgs>, _fields: TypedApplicationCallFields<TArgs>): void
53-
next<TArgs extends DeliberateAny[]>(...args: unknown[]): void {
54-
lazyContext.txn.activeGroup.constructingItxnGroup.push(
55-
args.length === 1
56-
? (args[0] as AnyTransactionComposeFields)
57-
: getApplicationCallInnerTxnContext(
58-
args[0] as InstanceMethod<Contract, TArgs>,
59-
args[1] as TypedApplicationCallFields<TArgs>,
60-
args[2] as Contract | { new (): Contract },
61-
),
62-
)
46+
next<TArgs extends DeliberateAny[]>(
47+
_method: InstanceMethod<Contract, TArgs>,
48+
_fields: TypedApplicationCallFields<TArgs>,
49+
contract?: Contract | { new (): Contract },
50+
): void
51+
next<TMethod>(options: AbiCallOptions<TMethod>, contract: string, method: string): void
52+
next(...args: unknown[]): void {
53+
this.addInnerTransaction(...args)
6354
}
6455

6556
submit(): void {
6657
lazyContext.txn.activeGroup.submitInnerTransactionGroup()
6758
}
59+
60+
private addInnerTransaction<TArgs extends DeliberateAny[]>(...args: unknown[]): void {
61+
let innerTxnFields
62+
63+
// Single argument: direct transaction fields
64+
if (args.length === 1) {
65+
innerTxnFields = args[0] as AnyTransactionComposeFields
66+
}
67+
// Three arguments with object fields (deprecated signature):
68+
// e.g. `itxnCompose.begin(Hello.prototype.greet, { appId, args: ['ho'] })`
69+
else if (args.length === 3 && typeof args[1] === 'object') {
70+
innerTxnFields = getApplicationCallInnerTxnContext(
71+
args[0] as InstanceMethod<Contract, TArgs>,
72+
args[1] as TypedApplicationCallFields<TArgs>,
73+
args[2] as Contract | { new (): Contract },
74+
)
75+
}
76+
// Three arguments with string contract name:
77+
// e.g. `itxnCompose.next({ method: Hello.prototype.greet, appId, args: ['ho'] })`
78+
// or `itxnCompose.next<typeof Hello.prototype.greet>({ appId, args: ['ho'] })`
79+
else {
80+
const contractFullName = args[1] as string
81+
const methodName = args[2] as string
82+
const { method, contract } = getContractMethod(contractFullName, methodName)
83+
84+
innerTxnFields = getApplicationCallInnerTxnContext(method, args[0] as TypedApplicationCallFields<TArgs>, contract)
85+
}
86+
87+
lazyContext.txn.activeGroup.constructingItxnGroup.push(innerTxnFields)
88+
}
6889
}
6990

7091
/** @internal */

src/impl/method-selector.ts

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,57 @@
1-
import type { arc4, bytes } from '@algorandfoundation/algorand-typescript'
1+
import type { bytes } from '@algorandfoundation/algorand-typescript'
22
import { encodingUtil } from '@algorandfoundation/puya-ts'
3-
import { getArc4Selector, getContractMethodAbiMetadata } from '../abi-metadata'
4-
import type { Overloads } from '../typescript-helpers'
3+
import { getArc4Selector, getContractMethod, getContractMethodAbiMetadata } from '../abi-metadata'
4+
import { CodeError } from '../errors'
5+
import type { InstanceMethod } from '../typescript-helpers'
56
import type { Contract } from './contract'
67
import { sha512_256 } from './crypto'
78
import { Bytes } from './primitives'
89

9-
/** @internal */
10-
export const methodSelector = <TContract extends Contract>(
11-
methodSignature: Parameters<Overloads<typeof arc4.methodSelector>>[0],
12-
contract?: TContract | { new (): TContract },
13-
): bytes => {
14-
if (typeof methodSignature === 'string' && contract === undefined) {
15-
return sha512_256(Bytes(encodingUtil.utf8ToUint8Array(methodSignature))).slice(0, 4)
16-
} else {
17-
const abiMetadata = getContractMethodAbiMetadata(
18-
contract!,
19-
typeof methodSignature === 'string' ? methodSignature : methodSignature.name,
20-
)
10+
/**
11+
* Computes the method selector for an ARC-4 contract method.
12+
*
13+
* Supports three invocation patterns:
14+
* 1. `methodSelector('sink(string,uint8[])void')`:
15+
* Direct method signature string (no contract): Returns SHA-512/256 hash of signature
16+
* 2. `methodSelector<typeof SignaturesContract.prototype.sink>()`:
17+
* Contract name as string + method name as string: Looks up registered contract and returns ARC-4 selector
18+
* 3. `methodSelector(SignaturesContract.prototype.sink)`:
19+
* Contract class/instance + method instance/name: Returns ARC-4 selector from ABI metadata
20+
*
21+
* @internal
22+
*/
23+
export const methodSelector = <TContract extends Contract>({
24+
method,
25+
contract,
26+
}: {
27+
method?: string | InstanceMethod<Contract>
28+
contract?: string | TContract | { new (): TContract }
29+
}): bytes => {
30+
const isDirectSignature = typeof method === 'string' && contract === undefined
31+
const isContractNameLookup = typeof contract === 'string' && typeof method === 'string' && contract && method
32+
const isContractMethodLookup = typeof contract !== 'string' && contract && method
33+
34+
// Pattern 1: Direct method signature string (e.g., "add(uint64,uint64)uint64")
35+
if (isDirectSignature) {
36+
const signatureBytes = Bytes(encodingUtil.utf8ToUint8Array(method as string))
37+
return sha512_256(signatureBytes).slice(0, 4)
38+
}
39+
40+
// Pattern 2: Contract name as string with method name
41+
if (isContractNameLookup) {
42+
const { contract: registeredContract } = getContractMethod(contract, method)
43+
44+
const abiMetadata = getContractMethodAbiMetadata(registeredContract, method)
2145
return Bytes(getArc4Selector(abiMetadata))
2246
}
47+
48+
// Pattern 3: Contract class/instance with method signature or name
49+
if (isContractMethodLookup) {
50+
const methodName = typeof method === 'string' ? method : method.name
51+
52+
const abiMetadata = getContractMethodAbiMetadata(contract, methodName)
53+
return Bytes(getArc4Selector(abiMetadata))
54+
}
55+
56+
throw new CodeError('Invalid arguments to methodSelector')
2357
}

src/test-transformer/node-factory.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,16 +121,31 @@ export const nodeFactory = {
121121
return factory.updateCallExpression(node, node.expression, node.typeArguments, [...typeInfoArgs, ...(node.arguments ?? [])])
122122
},
123123

124-
callMethodSelectorFunction(node: ts.CallExpression) {
125-
if (
124+
callMethodSelectorFunction(node: ts.CallExpression, typeParams: ptypes.PType[]) {
125+
if (typeParams.length === 1 && typeParams[0] instanceof ptypes.FunctionPType && typeParams[0].declaredIn) {
126+
return factory.updateCallExpression(node, node.expression, node.typeArguments, [
127+
factory.createObjectLiteralExpression([
128+
factory.createPropertyAssignment('method', factory.createStringLiteral(typeParams[0].name)),
129+
factory.createPropertyAssignment('contract', factory.createStringLiteral(typeParams[0].declaredIn.fullName)),
130+
]),
131+
])
132+
} else if (
126133
node.arguments.length === 1 &&
127134
ts.isPropertyAccessExpression(node.arguments[0]) &&
128135
ts.isPropertyAccessExpression(node.arguments[0].expression)
129136
) {
130137
const contractIdenifier = node.arguments[0].expression.expression
131-
return factory.updateCallExpression(node, node.expression, node.typeArguments, [...node.arguments, contractIdenifier])
138+
return factory.updateCallExpression(node, node.expression, node.typeArguments, [
139+
factory.createObjectLiteralExpression([
140+
factory.createPropertyAssignment('method', node.arguments[0]),
141+
factory.createPropertyAssignment('contract', contractIdenifier),
142+
]),
143+
])
144+
} else {
145+
return factory.updateCallExpression(node, node.expression, node.typeArguments, [
146+
factory.createObjectLiteralExpression([factory.createPropertyAssignment('method', node.arguments[0])]),
147+
])
132148
}
133-
return node
134149
},
135150

136151
callAbiCallFunction(node: ts.CallExpression, typeParams: ptypes.PType[]) {
@@ -144,14 +159,23 @@ export const nodeFactory = {
144159
return node
145160
},
146161

147-
callItxnComposeFunction(node: ts.CallExpression) {
162+
callItxnComposeFunction(node: ts.CallExpression, typeParams: ptypes.PType[]) {
148163
if (
149164
node.arguments.length === 2 &&
150165
ts.isPropertyAccessExpression(node.arguments[0]) &&
151166
ts.isPropertyAccessExpression(node.arguments[0].expression)
152167
) {
153168
const contractIdenifier = node.arguments[0].expression.expression
154169
return factory.updateCallExpression(node, node.expression, node.typeArguments, [...node.arguments, contractIdenifier])
170+
} else if (
171+
node.arguments.length === 1 &&
172+
typeParams.length === 1 &&
173+
typeParams[0] instanceof ptypes.FunctionPType &&
174+
typeParams[0].declaredIn
175+
) {
176+
const contractIdentifier = factory.createStringLiteral(typeParams[0].declaredIn.fullName)
177+
const methodName = factory.createStringLiteral(typeParams[0].name)
178+
return factory.updateCallExpression(node, node.expression, node.typeArguments, [...node.arguments, contractIdentifier, methodName])
155179
}
156180
return node
157181
},

src/test-transformer/visitors.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,14 @@ class ExpressionVisitor {
213213

214214
if (stubbedFunctionName) {
215215
if (isCallingMethodSelector(stubbedFunctionName)) {
216-
updatedNode = nodeFactory.callMethodSelectorFunction(updatedNode)
216+
const typeParams = this.helper.resolveTypeParameters(updatedNode)
217+
updatedNode = nodeFactory.callMethodSelectorFunction(updatedNode, typeParams)
217218
} else if (isCallingAbiCall(stubbedFunctionName)) {
218219
const typeParams = this.helper.resolveTypeParameters(updatedNode)
219220
updatedNode = nodeFactory.callAbiCallFunction(updatedNode, typeParams)
220221
} else if (isCallingItxnCompose(stubbedFunctionName)) {
221-
updatedNode = nodeFactory.callItxnComposeFunction(updatedNode)
222+
const typeParams = this.helper.resolveTypeParameters(updatedNode)
223+
updatedNode = nodeFactory.callItxnComposeFunction(updatedNode, typeParams)
222224
} else {
223225
updatedNode = nodeFactory.callStubbedFunction(updatedNode, infoArg)
224226
}

0 commit comments

Comments
 (0)