Skip to content

Commit d8b5fe6

Browse files
feat: Support ARC65 errors (#347)
Two new utility functions are introduced `loggedErr()` and `loggedAssert()`. These new functions work like the already present err and assert facilities with the addition of logging the error message (as specified in ARC65). This work is the `puya-ts` continuation of algorandfoundation/puya#657 Co-authored-by: Ignacio Losiggio <[email protected]>
1 parent 941f384 commit d8b5fe6

File tree

130 files changed

+10370
-784
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

130 files changed

+10370
-784
lines changed

packages/algo-ts/src/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
export * from './primitives'
2-
export { log, err, assert, match, assertMatch, ensureBudget, urange, OpUpFeeSource, clone, validateEncoding } from './util'
2+
export {
3+
log,
4+
err,
5+
assert,
6+
match,
7+
assertMatch,
8+
ensureBudget,
9+
urange,
10+
OpUpFeeSource,
11+
clone,
12+
validateEncoding,
13+
loggedAssert,
14+
loggedErr,
15+
} from './util'
316
export * from './reference'
417
export * as op from './op'
518
export { Txn, Global } from './op'

packages/algo-ts/src/util.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,88 @@ export function assert(condition: unknown, message?: string): asserts condition
2020
throw new NoImplementation()
2121
}
2222

23+
/**
24+
* Asserts that `condition` is truthy, logging a formatted error message before failing
25+
* if the condition is false.
26+
*
27+
* The logged output follows the format `{prefix}:{code}` or `{prefix}:{code}:{message}`
28+
* and is compatible with ARC-56 and ARC-32 clients.
29+
*
30+
* Note that this will generate extra bytecode, so it is strongly advised to keep your
31+
* messages and error codes short.
32+
*
33+
* @param condition The condition to assert; if false, logs an error and fails.
34+
* @param code An error code. Must not contain `:`. Should be alphanumeric.
35+
* @param options An optional object containing the message and prefix for the error.
36+
* @param options.message Message appended after the code. Must not contain `:`.
37+
* Defaults to no message.
38+
* @param options.prefix Error prefix, either `"ERR"` or `"AER"`. Defaults to `"ERR"`.
39+
*/
40+
export function loggedAssert(
41+
condition: unknown,
42+
code: string,
43+
options?: { message?: string | undefined; prefix?: 'ERR' | 'AER' },
44+
): asserts condition
45+
/**
46+
* Asserts that `condition` is truthy, logging a formatted error message before failing
47+
* if the condition is false.
48+
*
49+
* The logged output follows the format `ERR:{code}:{message}` and is compatible with
50+
* ARC-56 and ARC-32 clients.
51+
*
52+
* Note that this will generate extra bytecode, so it is strongly advised to keep your
53+
* messages and error codes short.
54+
*
55+
* @param condition The condition to assert; if false, logs an error and fails.
56+
* @param code An error code. Must not contain `:`. Should be alphanumeric.
57+
* @param message Message appended after the code. Must not contain `:`.
58+
*/
59+
export function loggedAssert(condition: unknown, code: string, message: string): asserts condition
60+
export function loggedAssert(
61+
condition: unknown,
62+
code: string,
63+
messageOrOptions?: string | { message?: string | undefined; prefix?: 'ERR' | 'AER' },
64+
): asserts condition {
65+
throw new NoImplementation()
66+
}
67+
68+
/**
69+
* Logs a formatted ARC-65 error message and immediately fails the transaction.
70+
*
71+
* Equivalent to `loggedAssert(false, code, {message, prefix})`.
72+
*
73+
* The logged output follows the format `{prefix}:{code}` or `{prefix}:{code}:{message}`
74+
* and is compatible with ARC-56 and ARC-32 clients.
75+
*
76+
* Note that this will generate extra bytecode, so it is strongly advised to keep your
77+
* messages and error codes short.
78+
*
79+
* @param code An error code. Must not contain `:`. Should be alphanumeric.
80+
* @param options An optional object containing the message and prefix for the error.
81+
* @param options.message Message appended after the code. Must not contain `:`.
82+
* Defaults to no message.
83+
* @param options.prefix Error prefix, either `"ERR"` or `"AER"`. Defaults to `"ERR"`.
84+
*/
85+
export function loggedErr(code: string, options?: { message?: string; prefix?: 'ERR' | 'AER' }): never
86+
/**
87+
* Logs a formatted ARC-65 error message and immediately fails the transaction.
88+
*
89+
* Equivalent to `loggedAssert(false, code, {message, prefix})`.
90+
*
91+
* The logged output follows the format `ERR:{code}:{message}` and is compatible with
92+
* ARC-56 and ARC-32 clients.
93+
*
94+
* Note that this will generate extra bytecode, so it is strongly advised to keep your
95+
* messages and error codes short.
96+
*
97+
* @param code An error code. Must not contain `:`. Should be alphanumeric.
98+
* @param message Message appended after the code. Must not contain `:`.
99+
*/
100+
export function loggedErr(code: string, message: string): never
101+
export function loggedErr(code: string, messageOrOptions?: string | { message?: string; prefix?: 'ERR' | 'AER' }): never {
102+
throw new NoImplementation()
103+
}
104+
23105
/**
24106
* Raise an error and halt execution
25107
* @param message The message to accompany the error

src/awst/intrinsic-factory.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,32 @@ export const intrinsicFactory = {
4040
opCode: 'concat',
4141
})
4242
},
43-
err({ sourceLocation, comment }: { sourceLocation: SourceLocation; comment: string | null }) {
43+
err({ sourceLocation, comment, logError }: { sourceLocation: SourceLocation; comment: string | null; logError?: boolean }) {
4444
return nodeFactory.assertExpression({
4545
condition: null,
4646
sourceLocation,
4747
wtype: wtypes.voidWType,
4848
errorMessage: comment,
49+
logError,
4950
})
5051
},
51-
assert({ sourceLocation, comment, condition }: { sourceLocation: SourceLocation; comment: string | null; condition: Expression }) {
52+
assert({
53+
sourceLocation,
54+
comment,
55+
condition,
56+
logError,
57+
}: {
58+
sourceLocation: SourceLocation
59+
comment: string | null
60+
condition: Expression
61+
logError?: boolean
62+
}) {
5263
return nodeFactory.assertExpression({
5364
sourceLocation,
5465
condition,
5566
wtype: wtypes.voidWType,
5667
errorMessage: comment,
68+
logError,
5769
})
5870
},
5971
bytesLen({ value, sourceLocation }: { value: awst.Expression; sourceLocation: SourceLocation }) {

src/awst/node-factory.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,13 @@ const explicitNodeFactory = {
5151
sourceLocation: SourceLocation
5252
wtype: wtypes.WType
5353
errorMessage: string | null
54+
logError?: boolean
5455
}) {
5556
return new AssertExpression({
5657
...props,
5758
wtype: wtypes.voidWType,
5859
explicit: true,
60+
logError: props.logError ?? false,
5961
})
6062
},
6163
voidConstant(props: { sourceLocation: SourceLocation }): VoidConstant {

src/awst/nodes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,13 @@ export class AssertExpression extends Expression {
134134
this.errorMessage = props.errorMessage
135135
this.wtype = props.wtype
136136
this.explicit = props.explicit
137+
this.logError = props.logError
137138
}
138139
readonly condition: Expression | null
139140
readonly errorMessage: string | null
140141
readonly wtype: wtypes.WType
141142
readonly explicit: boolean
143+
readonly logError: boolean
142144
accept<T>(visitor: ExpressionVisitor<T>): T {
143145
return visitor.visitAssertExpression(this)
144146
}

src/awst/to-code-visitor.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -404,12 +404,12 @@ export class ToCodeVisitor
404404
return ['', `logicsig ${moduleStatement.id} {`, ...indent(moduleStatement.program.body.accept(this)), '}']
405405
}
406406
visitAssertExpression(expression: nodes.AssertExpression): string {
407-
return [
408-
expression.condition ? 'assert(' : 'err(',
409-
expression.condition?.accept(this) ?? '',
410-
expression.errorMessage ? `, comment=${expression.errorMessage}` : '',
411-
')',
412-
].join('')
407+
if (!expression.condition) {
408+
const func = expression.logError ? 'logged_err(' : 'err('
409+
return `${func}${expression.errorMessage ?? ''})`
410+
}
411+
const func = expression.logError ? 'logged_assert(' : 'assert('
412+
return `${func}${expression.condition.accept(this)}${expression.errorMessage ? `, comment=${expression.errorMessage}` : ''})`
413413
}
414414

415415
visitConvertArray(expression: nodes.ConvertArray): string {
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import type ts from 'typescript'
2+
import { intrinsicFactory } from '../../awst/intrinsic-factory'
3+
import type { SourceLocation } from '../../awst/source-location'
4+
import { logger } from '../../logger'
5+
import type { PType } from '../ptypes'
6+
import { stringPType } from '../ptypes'
7+
import type { InstanceBuilder, NodeBuilder } from './index'
8+
import { FunctionBuilder } from './index'
9+
import { requestBuilderOfType, requireInstanceBuilder, requireStringConstant } from './util'
10+
import { parseFunctionArgs } from './util/arg-parsing'
11+
import { VoidExpressionBuilder } from './void-expression-builder'
12+
13+
const VALID_PREFIXES = new Set(['ERR', 'AER'])
14+
15+
function resolveErrorMessage(code: InstanceBuilder, message: InstanceBuilder | undefined, prefix: InstanceBuilder | undefined): string {
16+
const codeStr = requireStringConstant(code).value
17+
18+
if (codeStr.includes(':')) {
19+
logger.error(code.sourceLocation, "error code must not contain domain separator ':'")
20+
}
21+
22+
let messageStr: string | undefined
23+
if (message) {
24+
messageStr = requireStringConstant(message).value
25+
if (messageStr.includes(':')) {
26+
logger.error(message.sourceLocation, "error message must not contain domain separator ':'")
27+
}
28+
}
29+
30+
let prefixStr = 'ERR'
31+
if (prefix) {
32+
prefixStr = requireStringConstant(prefix).value
33+
if (!VALID_PREFIXES.has(prefixStr)) {
34+
logger.error(prefix.sourceLocation, 'error prefix must be one of AER, ERR')
35+
}
36+
}
37+
38+
return messageStr ? `${prefixStr}:${codeStr}:${messageStr}` : `${prefixStr}:${codeStr}`
39+
}
40+
41+
function resolveMessageAndPrefix(maybeMessageOrOptions: InstanceBuilder | undefined): {
42+
message: InstanceBuilder | undefined
43+
prefix: InstanceBuilder | undefined
44+
} {
45+
/**
46+
* Tries to get a field from the optional options object.
47+
*
48+
* Returns `undefined` if the field does not exist or no options object was passed.
49+
*/
50+
function get(field: string) {
51+
return maybeMessageOrOptions?.hasProperty(field)
52+
? requireInstanceBuilder(maybeMessageOrOptions.memberAccess(field, maybeMessageOrOptions.sourceLocation))
53+
: undefined
54+
}
55+
56+
// Desugar string-only overload (passing `"the message"` instead of `{ message: "the message" }`)
57+
if (maybeMessageOrOptions) {
58+
const message = requestBuilderOfType(maybeMessageOrOptions, stringPType)
59+
if (message) {
60+
return { message: message, prefix: undefined }
61+
}
62+
}
63+
64+
const message = get('message')
65+
const prefix = get('prefix')
66+
return { message, prefix }
67+
}
68+
69+
export class LoggedAssertFunctionBuilder extends FunctionBuilder {
70+
call(args: ReadonlyArray<NodeBuilder>, typeArgs: ReadonlyArray<PType>, sourceLocation: SourceLocation<ts.CallExpression>): NodeBuilder {
71+
const {
72+
args: [condition, code, maybeMessageOrOptions],
73+
} = parseFunctionArgs({
74+
args,
75+
typeArgs,
76+
genericTypeArgs: 0,
77+
callLocation: sourceLocation,
78+
funcName: 'loggedAssert',
79+
argSpec: (a) => [a.required(), a.required(stringPType), a.optional()],
80+
})
81+
82+
const { message, prefix } = resolveMessageAndPrefix(maybeMessageOrOptions)
83+
const errorMessage = resolveErrorMessage(code, message, prefix)
84+
85+
return new VoidExpressionBuilder(
86+
intrinsicFactory.assert({
87+
sourceLocation,
88+
condition: condition!.boolEval(sourceLocation),
89+
comment: errorMessage,
90+
logError: true,
91+
}),
92+
)
93+
}
94+
}
95+
96+
export class LoggedErrFunctionBuilder extends FunctionBuilder {
97+
call(args: ReadonlyArray<NodeBuilder>, typeArgs: ReadonlyArray<PType>, sourceLocation: SourceLocation<ts.CallExpression>): NodeBuilder {
98+
const {
99+
args: [code, maybeMessageOrOptions],
100+
} = parseFunctionArgs({
101+
args,
102+
typeArgs,
103+
genericTypeArgs: 0,
104+
callLocation: sourceLocation,
105+
funcName: 'loggedErr',
106+
argSpec: (a) => [a.required(stringPType), a.optional()],
107+
})
108+
109+
const { message, prefix } = resolveMessageAndPrefix(maybeMessageOrOptions)
110+
const errorMessage = resolveErrorMessage(code, message, prefix)
111+
112+
return new VoidExpressionBuilder(
113+
intrinsicFactory.err({
114+
sourceLocation,
115+
comment: errorMessage,
116+
logError: true,
117+
}),
118+
)
119+
}
120+
}

src/awst_build/ptypes/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,6 +1304,16 @@ export const errFunction = new LibFunctionType({
13041304
module: Constants.moduleNames.algoTs.util,
13051305
})
13061306

1307+
export const loggedAssertFunction = new LibFunctionType({
1308+
name: 'loggedAssert',
1309+
module: Constants.moduleNames.algoTs.util,
1310+
})
1311+
1312+
export const loggedErrFunction = new LibFunctionType({
1313+
name: 'loggedErr',
1314+
module: Constants.moduleNames.algoTs.util,
1315+
})
1316+
13071317
export const assetPType = new ABICompatibleInstanceType({
13081318
name: 'Asset',
13091319
wtype: wtypes.assetWType,

src/awst_build/ptypes/register.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { CloneFunctionBuilder } from '../eb/clone-function-builder'
5050
import { CompileFunctionBuilder } from '../eb/compiled/compile-function'
5151
import { ContractClassBuilder, ContractOptionsDecoratorBuilder } from '../eb/contract-builder'
5252
import { EnsureBudgetFunctionBuilder } from '../eb/ensure-budget'
53+
import { LoggedAssertFunctionBuilder, LoggedErrFunctionBuilder } from '../eb/logged-error-builder'
5354

5455
import { FreeSubroutineExpressionBuilder } from '../eb/free-subroutine-expression-builder'
5556
import { IntrinsicEnumBuilder } from '../eb/intrinsic-enum-builder'
@@ -210,6 +211,8 @@ import {
210211
LocalStateGeneric,
211212
LocalStateType,
212213
logFunction,
214+
loggedAssertFunction,
215+
loggedErrFunction,
213216
logicSigOptionsDecorator,
214217
LogicSigPType,
215218
matchFunction,
@@ -316,6 +319,8 @@ export function registerPTypes(typeRegistry: TypeRegistry) {
316319
typeRegistry.register({ ptype: logFunction, singletonEb: LogFunctionBuilder })
317320
typeRegistry.register({ ptype: assertFunction, singletonEb: AssertFunctionBuilder })
318321
typeRegistry.register({ ptype: errFunction, singletonEb: ErrFunctionBuilder })
322+
typeRegistry.register({ ptype: loggedAssertFunction, singletonEb: LoggedAssertFunctionBuilder })
323+
typeRegistry.register({ ptype: loggedErrFunction, singletonEb: LoggedErrFunctionBuilder })
319324
typeRegistry.register({ ptype: matchFunction, singletonEb: MatchFunctionBuilder })
320325
typeRegistry.register({ ptype: assertMatchFunction, singletonEb: AssertMatchFunctionBuilder })
321326
typeRegistry.register({ ptype: ensureBudgetFunction, singletonEb: EnsureBudgetFunctionBuilder })
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { uint64 } from '@algorandfoundation/algorand-typescript'
2+
import { Contract, loggedAssert, loggedErr } from '@algorandfoundation/algorand-typescript'
3+
4+
export class LoggedErrorsWarningsContract extends Contract {
5+
public testInvalidCode(arg: uint64): void {
6+
loggedAssert(arg !== 1, 'not-alnum!')
7+
loggedErr('not-alnum!')
8+
}
9+
10+
public testCamelCaseCode(arg: uint64): void {
11+
loggedAssert(arg !== 1, 'MyCode')
12+
loggedErr('MyCode')
13+
}
14+
15+
public testAERPrefix(arg: uint64): void {
16+
loggedAssert(arg !== 1, '01', { prefix: 'AER' })
17+
loggedErr('01', { prefix: 'AER' })
18+
}
19+
20+
public testLongMessage(arg: uint64): void {
21+
loggedAssert(arg !== 1, '01', {
22+
message: 'I will now provide a succint description of the error. I guess it all started when I was 5...',
23+
})
24+
loggedErr('01', { message: 'I will now provide a succint description of the error. I guess it all started when I was 5...' })
25+
}
26+
27+
public test8ByteMessage(arg: uint64): void {
28+
loggedAssert(arg !== 1, 'abcd')
29+
loggedErr('abcd')
30+
}
31+
32+
public test32ByteMessage(arg: uint64): void {
33+
loggedAssert(arg !== 1, '01', { message: 'aaaaaaaaaaaaaaaaaaaaaaaaa' })
34+
loggedErr('01', { message: 'aaaaaaaaaaaaaaaaaaaaaaaaa' })
35+
}
36+
}

0 commit comments

Comments
 (0)