diff --git a/examples/precompiled/contract.algo.ts b/examples/precompiled/contract.algo.ts new file mode 100644 index 0000000..abce1ec --- /dev/null +++ b/examples/precompiled/contract.algo.ts @@ -0,0 +1,136 @@ +import type { bytes, uint64 } from '@algorandfoundation/algorand-typescript' +import { assert, compile, Contract, itxn } from '@algorandfoundation/algorand-typescript' +import { decodeArc4, encodeArc4, methodSelector, OnCompleteAction } from '@algorandfoundation/algorand-typescript/arc4' +import { Hello, HelloTemplate, HelloTemplateCustomPrefix, LargeProgram, TerribleCustodialAccount } from './precompiled-apps.algo' + +export class HelloFactory extends Contract { + test_compile_contract() { + const compiled = compile(Hello) + + const helloApp = itxn + .applicationCall({ + appArgs: [methodSelector('create(string)void'), encodeArc4('hello')], + approvalProgram: compiled.approvalProgram, + clearStateProgram: compiled.clearStateProgram, + globalNumBytes: 1, + }) + .submit().createdApp + + const txn = itxn + .applicationCall({ + appArgs: [methodSelector('greet(string)string'), encodeArc4('world')], + appId: helloApp, + }) + .submit() + const result = decodeArc4(txn.lastLog, 'log') + + assert(result === 'hello world') + + itxn + .applicationCall({ + appId: helloApp, + appArgs: [methodSelector('delete()void')], + onCompletion: OnCompleteAction.DeleteApplication, + }) + .submit() + } + + test_compile_contract_with_template() { + const compiled = compile(HelloTemplate, { templateVars: { GREETING: 'hey' } }) + + const helloApp = itxn + .applicationCall({ + appArgs: [methodSelector('create()void')], + approvalProgram: compiled.approvalProgram, + clearStateProgram: compiled.clearStateProgram, + globalNumBytes: 1, + }) + .submit().createdApp + + const txn = itxn + .applicationCall({ + appArgs: [methodSelector('greet(string)string'), encodeArc4('world')], + appId: helloApp, + }) + .submit() + const result = decodeArc4(txn.lastLog, 'log') + + assert(result === 'hey world') + + itxn + .applicationCall({ + appId: helloApp, + appArgs: [methodSelector('delete()void')], + onCompletion: OnCompleteAction.DeleteApplication, + }) + .submit() + } + + test_compile_contract_with_template_and_custom_prefix() { + const compiled = compile(HelloTemplateCustomPrefix, { templateVars: { GREETING: 'bonjour' }, templateVarsPrefix: 'PRFX_' }) + + const helloApp = itxn + .applicationCall({ + appArgs: [methodSelector('create()void')], + approvalProgram: compiled.approvalProgram, + clearStateProgram: compiled.clearStateProgram, + globalNumBytes: 1, + }) + .submit().createdApp + + const txn = itxn + .applicationCall({ + appArgs: [methodSelector('greet(string)string'), encodeArc4('world')], + appId: helloApp, + }) + .submit() + const result = decodeArc4(txn.lastLog, 'log') + + assert(result === 'bonjour world') + + itxn + .applicationCall({ + appId: helloApp, + appArgs: [methodSelector('delete()void')], + onCompletion: OnCompleteAction.DeleteApplication, + }) + .submit() + } + + test_compile_contract_large() { + const compiled = compile(LargeProgram) + + const largeApp = itxn + .applicationCall({ + approvalProgram: compiled.approvalProgram, + clearStateProgram: compiled.clearStateProgram, + extraProgramPages: compiled.extraProgramPages, + globalNumBytes: compiled.globalBytes, + }) + .submit().createdApp + + const txn = itxn + .applicationCall({ + appArgs: [methodSelector('getBigBytesLength()uint64')], + appId: largeApp, + }) + .submit() + const result = decodeArc4(txn.lastLog, 'log') + + assert(result === 4096) + + itxn + .applicationCall({ + appId: largeApp, + appArgs: [methodSelector('delete()void')], + onCompletion: OnCompleteAction.DeleteApplication, + }) + .submit() + } + + test_compile_logic_sig(account: bytes) { + const compiled = compile(TerribleCustodialAccount) + + assert(compiled.account.bytes === account) + } +} diff --git a/examples/precompiled/contract.spec.ts b/examples/precompiled/contract.spec.ts new file mode 100644 index 0000000..5f85484 --- /dev/null +++ b/examples/precompiled/contract.spec.ts @@ -0,0 +1,81 @@ +import { arc4 } from '@algorandfoundation/algorand-typescript' +import { TestExecutionContext } from '@algorandfoundation/algorand-typescript-testing' +import { afterEach, describe, it } from 'vitest' +import { ABI_RETURN_VALUE_LOG_PREFIX, MAX_BYTES_SIZE } from '../../src/constants' +import { asUint64Cls } from '../../src/util' +import { HelloFactory } from './contract.algo' +import { Hello, HelloTemplate, HelloTemplateCustomPrefix, LargeProgram, TerribleCustodialAccount } from './precompiled-apps.algo' + +describe('pre compiled app calls', () => { + const ctx = new TestExecutionContext() + afterEach(() => { + ctx.reset() + }) + + it('should be able to compile and call a precompiled app', () => { + // Arrange + const helloApp = ctx.any.application({ + approvalProgram: ctx.any.bytes(20), + appLogs: [ABI_RETURN_VALUE_LOG_PREFIX.concat(new arc4.Str('hello world').bytes)], + }) + ctx.setCompiledApp(Hello, helloApp.id) + + const contract = ctx.contract.create(HelloFactory) + + // Act + contract.test_compile_contract() + }) + + it('should be able to compile with template vars and call a precompiled app', () => { + // Arrange + const helloTemplateApp = ctx.any.application({ + approvalProgram: ctx.any.bytes(20), + appLogs: [ABI_RETURN_VALUE_LOG_PREFIX.concat(new arc4.Str('hey world').bytes)], + }) + ctx.setCompiledApp(HelloTemplate, helloTemplateApp.id) + + const contract = ctx.contract.create(HelloFactory) + + // Act + contract.test_compile_contract_with_template() + }) + + it('should be able to compile with template vars and custom prefix', () => { + // Arrange + const helloTemplateCustomPrefixApp = ctx.any.application({ + approvalProgram: ctx.any.bytes(20), + appLogs: [ABI_RETURN_VALUE_LOG_PREFIX.concat(new arc4.Str('bonjour world').bytes)], + }) + ctx.setCompiledApp(HelloTemplateCustomPrefix, helloTemplateCustomPrefixApp.id) + + const contract = ctx.contract.create(HelloFactory) + + // Act + contract.test_compile_contract_with_template_and_custom_prefix() + }) + + it('should be able to compile large program', () => { + // Arrange + const largeProgramApp = ctx.any.application({ + approvalProgram: ctx.any.bytes(20), + appLogs: [ABI_RETURN_VALUE_LOG_PREFIX.concat(asUint64Cls(MAX_BYTES_SIZE).toBytes().asAlgoTs())], + }) + ctx.setCompiledApp(LargeProgram, largeProgramApp.id) + + const contract = ctx.contract.create(HelloFactory) + + // Act + contract.test_compile_contract_large() + }) + + it('should be able to compile logic sig', () => { + // Arrange + const terribleCustodialAccount = ctx.any.account() + ctx.setCompiledLogicSig(TerribleCustodialAccount, terribleCustodialAccount) + + const contract = ctx.contract.create(HelloFactory) + + // Act + contract.test_compile_logic_sig(terribleCustodialAccount.bytes) + }) +}) diff --git a/examples/precompiled/precompiled-apps.algo.ts b/examples/precompiled/precompiled-apps.algo.ts new file mode 100644 index 0000000..bdec103 --- /dev/null +++ b/examples/precompiled/precompiled-apps.algo.ts @@ -0,0 +1,65 @@ +import { abimethod, Contract, GlobalState, LogicSig, op, TemplateVar } from '@algorandfoundation/algorand-typescript' + +abstract class HelloBase extends Contract { + greeting = GlobalState({ initialValue: '' }) + + @abimethod({ allowActions: 'DeleteApplication' }) + delete() {} + + @abimethod({ allowActions: 'UpdateApplication' }) + update() {} + + greet(name: string): string { + return `${this.greeting.value} ${name}` + } +} + +export class Hello extends HelloBase { + @abimethod({ onCreate: 'require' }) + create(greeting: string) { + this.greeting.value = greeting + } +} + +export class HelloTemplate extends HelloBase { + constructor() { + super() + this.greeting.value = TemplateVar('GREETING') + } + + @abimethod({ onCreate: 'require' }) + create() {} +} + +export class HelloTemplateCustomPrefix extends HelloBase { + constructor() { + super() + this.greeting.value = TemplateVar('GREETING', 'PRFX_') + } + + @abimethod({ onCreate: 'require' }) + create() {} +} + +function getBigBytes() { + return op.bzero(4096) +} + +export class LargeProgram extends Contract { + getBigBytesLength() { + return getBigBytes().length + } + + @abimethod({ allowActions: 'DeleteApplication' }) + delete() {} +} + +/** + * This logic sig can be used to create a custodial account that will allow any transaction to transfer its + * funds/assets. + */ +export class TerribleCustodialAccount extends LogicSig { + program() { + return true + } +} diff --git a/examples/rollup.config.ts b/examples/rollup.config.ts index 79ef1aa..d16a189 100644 --- a/examples/rollup.config.ts +++ b/examples/rollup.config.ts @@ -13,6 +13,7 @@ const config: RollupOptions = { 'examples/voting/contract.algo.ts', 'examples/simple-voting/contract.algo.ts', 'examples/zk-whitelist/contract.algo.ts', + 'examples/precompiled/contract.algo.ts', ], output: [ { diff --git a/package-lock.json b/package-lock.json index c224f49..c4f787f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "hasInstallScript": true, "dependencies": { - "@algorandfoundation/algorand-typescript": "^0.0.1-alpha.23", + "@algorandfoundation/algorand-typescript": "^0.0.1-alpha.24", "@algorandfoundation/puya-ts": "^1.0.0-alpha.36", "elliptic": "^6.5.7", "js-sha256": "^0.11.0", @@ -73,9 +73,9 @@ } }, "node_modules/@algorandfoundation/algorand-typescript": { - "version": "0.0.1-alpha.23", - "resolved": "https://registry.npmjs.org/@algorandfoundation/algorand-typescript/-/algorand-typescript-0.0.1-alpha.23.tgz", - "integrity": "sha512-hK1rom4MD2qn3tO03Bsui8lcuA7WF5CeFGdUCIrRJgc+wLnWKNx0s9TkAotrl8VwfD5Wg7Gn3u9txz4RgdQsPw==", + "version": "0.0.1-alpha.24", + "resolved": "https://registry.npmjs.org/@algorandfoundation/algorand-typescript/-/algorand-typescript-0.0.1-alpha.24.tgz", + "integrity": "sha512-aAuIRrTmnprfquxY7ZNrlBmnH6SyCGHUOKrC5L+K5AKduCgWS3S4I2e9CRBZlSpdMKBZ5Ek97Ufm5ZJHvs1I+g==", "peerDependencies": { "tslib": "^2.6.2" } @@ -13248,6 +13248,7 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 1996da5..c3001e9 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "tslib": "^2.6.2" }, "dependencies": { - "@algorandfoundation/algorand-typescript": "^0.0.1-alpha.23", + "@algorandfoundation/algorand-typescript": "^0.0.1-alpha.24", "@algorandfoundation/puya-ts": "^1.0.0-alpha.36", "elliptic": "^6.5.7", "js-sha256": "^0.11.0", diff --git a/src/impl/compiled.ts b/src/impl/compiled.ts new file mode 100644 index 0000000..71aa7a2 --- /dev/null +++ b/src/impl/compiled.ts @@ -0,0 +1,55 @@ +import { + Account, + BaseContract, + CompileContractOptions, + CompiledContract, + CompiledLogicSig, + CompileLogicSigOptions, + LogicSig, +} from '@algorandfoundation/algorand-typescript' +import { lazyContext } from '../context-helpers/internal-context' +import { ConstructorFor } from '../typescript-helpers' +import { ApplicationData } from './application' + +export function compileImpl( + artefact: ConstructorFor | ConstructorFor, + options?: CompileContractOptions | CompileLogicSigOptions, +): CompiledLogicSig | CompiledContract { + let app: ApplicationData | undefined + let account: Account | undefined + const compiledApp = lazyContext.value.getCompiledApp(artefact as ConstructorFor) + const compiledLogicSig = lazyContext.value.getCompiledLogicSig(artefact as ConstructorFor) + if (compiledApp !== undefined) { + app = lazyContext.ledger.applicationDataMap.get(compiledApp[1]) + } + if (compiledLogicSig !== undefined) { + account = compiledLogicSig[1] + } + if (options?.templateVars) { + Object.entries(options.templateVars).forEach(([key, value]) => { + lazyContext.value.setTemplateVar(key, value, options.templateVarsPrefix) + }) + } + return new Proxy({} as CompiledLogicSig | CompiledContract, { + get: (_target, prop) => { + switch (prop) { + case 'approvalProgram': + return app?.application.approvalProgram ?? [lazyContext.any.bytes(10), lazyContext.any.bytes(10)] + case 'clearStateProgram': + return app?.application.clearStateProgram ?? [lazyContext.any.bytes(10), lazyContext.any.bytes(10)] + case 'extraProgramPages': + return (options as CompileContractOptions)?.extraProgramPages ?? app?.application.extraProgramPages ?? lazyContext.any.uint64() + case 'globalUints': + return (options as CompileContractOptions)?.globalUints ?? app?.application.globalNumUint ?? lazyContext.any.uint64() + case 'globalBytes': + return (options as CompileContractOptions)?.globalBytes ?? app?.application.globalNumBytes ?? lazyContext.any.uint64() + case 'localUints': + return (options as CompileContractOptions)?.localUints ?? app?.application.localNumUint ?? lazyContext.any.uint64() + case 'localBytes': + return (options as CompileContractOptions)?.localBytes ?? app?.application.localNumBytes ?? lazyContext.any.uint64() + case 'account': + return account ?? lazyContext.any.account() + } + }, + }) +} diff --git a/src/impl/encoded-types.ts b/src/impl/encoded-types.ts index 162c5fb..1d50312 100644 --- a/src/impl/encoded-types.ts +++ b/src/impl/encoded-types.ts @@ -1185,11 +1185,14 @@ export const getArc4TypeName = (typeInfo: TypeInfo): string | undefined => { return undefined } -export function decodeArc4Impl(sourceTypeInfoString: string, bytes: internal.primitives.StubBytesCompat): T { +export function decodeArc4Impl( + sourceTypeInfoString: string, + bytes: internal.primitives.StubBytesCompat, + prefix: 'none' | 'log' = 'none', +): T { const sourceTypeInfo = JSON.parse(sourceTypeInfoString) const encoder = getArc4Encoder(sourceTypeInfo) - const source = encoder(bytes, sourceTypeInfo) - + const source = encoder(bytes, sourceTypeInfo, prefix) return getNativeValue(source) as T } diff --git a/src/impl/inner-transactions.ts b/src/impl/inner-transactions.ts index ea68185..b2ec556 100644 --- a/src/impl/inner-transactions.ts +++ b/src/impl/inner-transactions.ts @@ -137,8 +137,17 @@ export class ApplicationInnerTxn extends ApplicationTransaction implements itxn. /* @internal */ constructor(fields: Mutable) { const { appId, approvalProgram, clearStateProgram, onCompletion, appArgs, accounts, assets, apps, ...rest } = mapCommonFields(fields) + const compiledApp = + appId === undefined && approvalProgram !== undefined + ? lazyContext.ledger.getApplicationForApprovalProgram(approvalProgram) + : undefined super({ - appId: appId instanceof internal.primitives.Uint64Cls ? getApp(appId) : (appId as Application), + appId: + appId === undefined && compiledApp + ? compiledApp + : appId instanceof internal.primitives.Uint64Cls + ? getApp(appId) + : (appId as Application), onCompletion: typeof onCompletion === 'string' ? (onCompletion as arc4.OnCompleteActionStr) @@ -153,6 +162,7 @@ export class ApplicationInnerTxn extends ApplicationTransaction implements itxn. accounts: accounts?.map((x) => x), assets: assets?.map((x) => x), apps: apps?.map((x) => x), + createdApp: compiledApp, ...rest, }) } diff --git a/src/runtime-helpers.ts b/src/runtime-helpers.ts index a620884..b16e975 100644 --- a/src/runtime-helpers.ts +++ b/src/runtime-helpers.ts @@ -7,6 +7,7 @@ import { DeliberateAny } from './typescript-helpers' import { nameOfType } from './util' export { attachAbiMetadata } from './abi-metadata' +export { compileImpl } from './impl/compiled' export { emitImpl } from './impl/emit' export * from './impl/encoded-types' export { decodeArc4Impl, encodeArc4Impl } from './impl/encoded-types' diff --git a/src/subcontexts/ledger-context.ts b/src/subcontexts/ledger-context.ts index b943539..e98688c 100644 --- a/src/subcontexts/ledger-context.ts +++ b/src/subcontexts/ledger-context.ts @@ -1,4 +1,4 @@ -import { Account, Application, Asset, BaseContract, internal, LocalStateForAccount } from '@algorandfoundation/algorand-typescript' +import { Account, Application, Asset, BaseContract, bytes, internal, LocalStateForAccount } from '@algorandfoundation/algorand-typescript' import { AccountMap, Uint64Map } from '../collections/custom-key-map' import { MAX_UINT64 } from '../constants' import { AccountData, AssetHolding } from '../impl/account' @@ -52,6 +52,28 @@ export class LedgerContext { throw internal.errors.internalError('Unknown contract, check correct testing context is active') } + getApplicationForApprovalProgram(approvalProgram: bytes | readonly bytes[] | undefined): Application | undefined { + if (approvalProgram === undefined) { + return undefined + } + const entries = this.applicationDataMap.entries() + let next = entries.next().value + let found = false + while (next && !found) { + found = next[1].application.approvalProgram === approvalProgram + if (!found) { + next = entries.next().value + } + } + if (found && next) { + const appId = asUint64(next[0]) + if (this.applicationDataMap.has(appId)) { + return Application(appId) + } + } + return undefined + } + /** * Update asset holdings for account, only specified values will be updated. * Account will also be opted-in to asset diff --git a/src/test-execution-context.ts b/src/test-execution-context.ts index efbf68e..af618ef 100644 --- a/src/test-execution-context.ts +++ b/src/test-execution-context.ts @@ -1,4 +1,4 @@ -import { Account, Application, Asset, bytes, internal, LogicSig, uint64 } from '@algorandfoundation/algorand-typescript' +import { Account, Application, Asset, BaseContract, bytes, internal, LogicSig, uint64 } from '@algorandfoundation/algorand-typescript' import { captureMethodConfig } from './abi-metadata' import { DEFAULT_TEMPLATE_VAR_PREFIX } from './constants' import { DecodedLogs, LogDecoding } from './decode-logs' @@ -19,7 +19,7 @@ import { Box, BoxMap, BoxRef, GlobalState, LocalState } from './impl/state' import { ContractContext } from './subcontexts/contract-context' import { LedgerContext } from './subcontexts/ledger-context' import { TransactionContext } from './subcontexts/transaction-context' -import { DeliberateAny } from './typescript-helpers' +import { ConstructorFor, DeliberateAny } from './typescript-helpers' import { getRandomBytes } from './util' import { ValueGenerator } from './value-generators' @@ -31,6 +31,8 @@ export class TestExecutionContext implements internal.ExecutionContext { #defaultSender: Account #activeLogicSigArgs: bytes[] #template_vars: Record = {} + #compiledApps: Array<[ConstructorFor, uint64]> = [] + #compiledLogicSigs: Array<[ConstructorFor, Account]> = [] constructor(defaultSenderAddress?: bytes) { internal.ctxMgr.instance = this @@ -142,8 +144,34 @@ export class TestExecutionContext implements internal.ExecutionContext { } } - setTemplateVar(name: string, value: DeliberateAny) { - this.#template_vars[DEFAULT_TEMPLATE_VAR_PREFIX + name] = value + setTemplateVar(name: string, value: DeliberateAny, prefix?: string) { + this.#template_vars[(prefix ?? DEFAULT_TEMPLATE_VAR_PREFIX) + name] = value + } + + getCompiledApp(contract: ConstructorFor) { + return this.#compiledApps.find(([c, _]) => c === contract) + } + + setCompiledApp(c: ConstructorFor, appId: uint64) { + const existing = this.getCompiledApp(c) + if (existing) { + existing[1] = appId + } else { + this.#compiledApps.push([c, appId]) + } + } + + getCompiledLogicSig(logicsig: ConstructorFor) { + return this.#compiledLogicSigs.find(([c, _]) => c === logicsig) + } + + setCompiledLogicSig(c: ConstructorFor, account: Account) { + const existing = this.getCompiledLogicSig(c) + if (existing) { + existing[1] = account + } else { + this.#compiledLogicSigs.push([c, account]) + } } reset() { @@ -152,6 +180,7 @@ export class TestExecutionContext implements internal.ExecutionContext { this.#txnContext = new TransactionContext() this.#activeLogicSigArgs = [] this.#template_vars = {} + this.#compiledApps = [] internal.ctxMgr.reset() internal.ctxMgr.instance = this } diff --git a/src/test-transformer/visitors.ts b/src/test-transformer/visitors.ts index fd686ae..fe9bf68 100644 --- a/src/test-transformer/visitors.ts +++ b/src/test-transformer/visitors.ts @@ -367,7 +367,7 @@ const tryGetStubbedFunctionName = (node: ts.CallExpression, helper: VisitorHelpe if (sourceFileName && !algotsModulePaths.some((s) => sourceFileName.includes(s))) return undefined } const functionName = functionSymbol?.getName() ?? identityExpression.text - const stubbedFunctionNames = ['interpretAsArc4', 'decodeArc4', 'encodeArc4', 'TemplateVar', 'ensureBudget', 'emit'] + const stubbedFunctionNames = ['interpretAsArc4', 'decodeArc4', 'encodeArc4', 'TemplateVar', 'ensureBudget', 'emit', 'compile'] return stubbedFunctionNames.includes(functionName) ? functionName : undefined } diff --git a/src/typescript-helpers.ts b/src/typescript-helpers.ts index d933a6c..82a9fd1 100644 --- a/src/typescript-helpers.ts +++ b/src/typescript-helpers.ts @@ -2,6 +2,7 @@ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ export type DeliberateAny = any export type AnyFunction = (...args: DeliberateAny[]) => DeliberateAny +export type ConstructorFor = new (...args: TArgs) => T export type Mutable = { -readonly [P in keyof T]: T[P] }