Skip to content

Commit 4c24dc9

Browse files
committed
fix: app arg packing issue when there are exactly 15 args
1 parent 121b72a commit 4c24dc9

File tree

2 files changed

+58
-11
lines changed

2 files changed

+58
-11
lines changed

src/transactions/method-call.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { calculateExtraProgramPages } from '../util'
1717
import { AppCreateParams, AppDeleteParams, AppMethodCallParams, AppUpdateParams } from './app-call'
1818
import { buildTransactionCommonData } from './common'
1919

20-
const ARGS_TUPLE_PACKING_THRESHOLD = 14 // 14+ args trigger tuple packing, excluding the method selector
20+
const ARGS_TUPLE_PACKING_THRESHOLD = 15 // ARC-4 allows 15 ABI args (slots 1-15) before tuple packing is needed
2121

2222
/** Parameters to define an ABI method call create transaction. */
2323
export type AppCreateMethodCall = Expand<AppMethodCall<AppCreateParams>>
@@ -339,8 +339,8 @@ function encodeMethodArguments(method: ABIMethod, args: (ABIValue | undefined)[]
339339
throw new Error('Mismatch in length of non-transaction arguments')
340340
}
341341

342-
// Apply ARC-4 tuple packing for methods with more than 14 arguments
343-
// 14 instead of 15 in the ARC-4 because the first argument (method selector) is added separately
342+
// Apply ARC-4 tuple packing for methods with more than 15 ABI arguments
343+
// Algorand allows 16 app args total; slot 0 is the method selector, leaving 15 for ABI args
344344
if (abiTypes.length > ARGS_TUPLE_PACKING_THRESHOLD) {
345345
encodedArgs.push(...encodeArgsWithTuplePacking(abiTypes, abiValues))
346346
} else {
@@ -372,14 +372,16 @@ function encodeArgsIndividually(abiTypes: ABIType[], abiValues: ABIValue[]): Uin
372372
function encodeArgsWithTuplePacking(abiTypes: ABIType[], abiValues: ABIValue[]): Uint8Array[] {
373373
const encodedArgs: Uint8Array[] = []
374374

375-
// Encode first 14 arguments individually
376-
const first14AbiTypes = abiTypes.slice(0, ARGS_TUPLE_PACKING_THRESHOLD)
377-
const first14AbiValues = abiValues.slice(0, ARGS_TUPLE_PACKING_THRESHOLD)
378-
encodedArgs.push(...encodeArgsIndividually(first14AbiTypes, first14AbiValues))
375+
// When packing is needed (> 15 args), we split at 14 to leave one slot for the packed tuple
376+
// This gives us: 1 (selector) + 14 (individual) + 1 (packed tuple) = 16 total app args
377+
const splitAt = ARGS_TUPLE_PACKING_THRESHOLD - 1
378+
const firstAbiTypes = abiTypes.slice(0, splitAt)
379+
const firstAbiValues = abiValues.slice(0, splitAt)
380+
encodedArgs.push(...encodeArgsIndividually(firstAbiTypes, firstAbiValues))
379381

380-
// Pack remaining arguments into tuple at position 15
381-
const remainingAbiTypes = abiTypes.slice(ARGS_TUPLE_PACKING_THRESHOLD)
382-
const remainingAbiValues = abiValues.slice(ARGS_TUPLE_PACKING_THRESHOLD)
382+
// Pack remaining arguments into a tuple
383+
const remainingAbiTypes = abiTypes.slice(splitAt)
384+
const remainingAbiValues = abiValues.slice(splitAt)
383385

384386
if (remainingAbiTypes.length > 0) {
385387
const tupleType = new ABITupleType(remainingAbiTypes)

src/types/composer.spec.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ABIMethod } from '@algorandfoundation/algokit-abi'
1+
import { ABIMethod, ABITupleType, ABIType } from '@algorandfoundation/algokit-abi'
22
import { Address } from '@algorandfoundation/algokit-common'
33
import { Transaction, TransactionType } from '@algorandfoundation/algokit-transact'
44
import { beforeEach, describe, expect, test, vi } from 'vitest'
@@ -424,4 +424,49 @@ describe('TransactionComposer', () => {
424424
expect(resetSpy).toHaveBeenCalled()
425425
})
426426
})
427+
428+
describe('ARC-4 tuple packing', () => {
429+
const uint8ArrayType = ABIType.from('uint8[]')
430+
const singleArray = uint8ArrayType.encode([1])
431+
const twoArrays = new ABITupleType([uint8ArrayType, uint8ArrayType]).encode([[1], [1]])
432+
const threeArrays = new ABITupleType([uint8ArrayType, uint8ArrayType, uint8ArrayType]).encode([[1], [1], [1]])
433+
434+
const testCases: { numAbiArgs: number; expectedTxnArgs: number; expectedLastArg: Uint8Array }[] = [
435+
{ numAbiArgs: 1, expectedTxnArgs: 2, expectedLastArg: singleArray },
436+
{ numAbiArgs: 13, expectedTxnArgs: 14, expectedLastArg: singleArray },
437+
{ numAbiArgs: 14, expectedTxnArgs: 15, expectedLastArg: singleArray },
438+
{ numAbiArgs: 15, expectedTxnArgs: 16, expectedLastArg: singleArray },
439+
{ numAbiArgs: 16, expectedTxnArgs: 16, expectedLastArg: twoArrays },
440+
{ numAbiArgs: 17, expectedTxnArgs: 16, expectedLastArg: threeArrays },
441+
]
442+
443+
test.each(testCases)(
444+
'should handle $numAbiArgs ABI args correctly (expecting $expectedTxnArgs txn args)',
445+
async ({ numAbiArgs, expectedTxnArgs, expectedLastArg }) => {
446+
const { algorand, context } = fixture
447+
const sender = context.testAccount
448+
449+
// Build method signature with the specified number of uint8[] args
450+
const argsSignature = Array(numAbiArgs).fill('uint8[]').join(',')
451+
const method = ABIMethod.fromSignature(`args${numAbiArgs}(${argsSignature})void`)
452+
453+
const composer = algorand.newGroup({ populateAppCallResources: false, coverAppCallInnerTransactionFees: false })
454+
455+
composer.addAppCallMethodCall({
456+
appId: 1234n,
457+
method,
458+
sender,
459+
args: Array(numAbiArgs).fill([1]), // Each arg is [1] (a uint8 array with value 1)
460+
})
461+
462+
const built = await composer.build()
463+
const txn = built.transactions[0].txn
464+
465+
const args = txn.appCall?.args ?? []
466+
expect(args.length).toBe(expectedTxnArgs)
467+
expect(args[0]).toEqual(method.getSelector())
468+
expect(args[args.length - 1]).toEqual(expectedLastArg)
469+
},
470+
)
471+
})
427472
})

0 commit comments

Comments
 (0)