Skip to content

Commit 1545833

Browse files
committed
feat: add arc65 support: loggedErr() and loggedAssert() functions, to log a formatted error string before failing
1 parent f583ca8 commit 1545833

File tree

4 files changed

+238
-1
lines changed

4 files changed

+238
-1
lines changed

src/impl/log.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,51 @@
11
import type { BytesBacked, StringCompat } from '@algorandfoundation/algorand-typescript'
22
import { lazyContext } from '../context-helpers/internal-context'
33

4+
import { AssertError, AvmError, CodeError } from '../errors'
45
import { toBytes } from './encoded-types'
56
import type { StubBigUintCompat, StubBytesCompat, StubUint64Compat } from './primitives'
67

78
/** @internal */
89
export function log(...args: Array<StubUint64Compat | StubBytesCompat | StubBigUintCompat | StringCompat | BytesBacked>): void {
910
lazyContext.txn.appendLog(args.map((a) => toBytes(a)).reduce((left, right) => left.concat(right)))
1011
}
12+
13+
/** @internal */
14+
export function loggedAssert(
15+
condition: unknown,
16+
code: string,
17+
messageOrOptions?: string | { message?: string | undefined; prefix?: 'ERR' | 'AER' },
18+
): asserts condition {
19+
if (!condition) {
20+
const errorMessage = resolveErrorMessage(code, messageOrOptions)
21+
log(errorMessage)
22+
throw new AssertError(errorMessage)
23+
}
24+
}
25+
26+
/** @internal */
27+
export function loggedErr(code: string, messageOrOptions?: string | { message?: string; prefix?: 'ERR' | 'AER' }): never {
28+
const errorMessage = resolveErrorMessage(code, messageOrOptions)
29+
log(errorMessage)
30+
throw new AvmError(errorMessage)
31+
}
32+
33+
const VALID_PREFIXES = new Set(['ERR', 'AER'])
34+
function resolveErrorMessage(code: string, messageOrOptions?: string | { message?: string | undefined; prefix?: 'ERR' | 'AER' }): string {
35+
const message = typeof messageOrOptions === 'string' ? messageOrOptions : messageOrOptions?.message
36+
const prefix = typeof messageOrOptions === 'string' ? undefined : (messageOrOptions?.prefix ?? 'ERR')
37+
38+
if (code.includes(':')) {
39+
throw new CodeError("error code must not contain domain separator ':'")
40+
}
41+
42+
if (message && message.includes(':')) {
43+
throw new CodeError("error message must not contain domain separator ':'")
44+
}
45+
46+
const prefixStr = prefix || 'ERR'
47+
if (!VALID_PREFIXES.has(prefixStr)) {
48+
throw new CodeError('error prefix must be one of AER, ERR')
49+
}
50+
return message ? `${prefixStr}:${code}:${message}` : `${prefixStr}:${code}`
51+
}

src/internal/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export { ensureBudget } from '../impl/ensure-budget'
1717
/** @internal */
1818
export { Global } from '../impl/global'
1919
/** @internal */
20-
export { log } from '../impl/log'
20+
export { log, loggedAssert, loggedErr } from '../impl/log'
2121
/** @internal */
2222
export { assertMatch, match } from '../impl/match'
2323
/** @internal */
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { uint64 } from '@algorandfoundation/algorand-typescript'
2+
import { Contract, loggedAssert, loggedErr } from '@algorandfoundation/algorand-typescript'
3+
4+
export class LoggedErrorsContract extends Contract {
5+
public testValid(arg: uint64): void {
6+
loggedAssert(arg !== 1, '01')
7+
loggedAssert(arg !== 2, '02', {})
8+
loggedAssert(arg !== 3, '03', { message: 'arg is 3' })
9+
loggedAssert(arg !== 4, '04', { prefix: 'AER' })
10+
loggedAssert(arg !== 5, '05', { message: 'arg is 5', prefix: 'AER' })
11+
loggedAssert(arg !== 6, '06', 'arg is 6')
12+
if (arg === 7) {
13+
loggedErr('07')
14+
}
15+
if (arg === 8) {
16+
loggedErr('08', {})
17+
}
18+
if (arg === 9) {
19+
loggedErr('09', { message: 'arg is 9' })
20+
}
21+
if (arg === 10) {
22+
loggedErr('10', { prefix: 'AER' })
23+
}
24+
if (arg === 11) {
25+
loggedErr('11', { message: 'arg is 11', prefix: 'AER' })
26+
}
27+
if (arg === 12) {
28+
loggedErr('12', 'arg is 12')
29+
}
30+
}
31+
32+
public testInvalidCode(arg: uint64): void {
33+
loggedAssert(arg !== 1, 'not-alnum!')
34+
loggedErr('not-alnum!')
35+
}
36+
37+
public testCamelCaseCode(arg: uint64): void {
38+
loggedAssert(arg !== 1, 'MyCode')
39+
loggedErr('MyCode')
40+
}
41+
42+
public testAERPrefix(arg: uint64): void {
43+
loggedAssert(arg !== 1, '01', { prefix: 'AER' })
44+
loggedErr('01', { prefix: 'AER' })
45+
}
46+
47+
public testLongMessage(arg: uint64): void {
48+
loggedAssert(arg !== 1, '01', {
49+
message: 'I will now provide a succint description of the error. I guess it all started when I was 5...',
50+
})
51+
loggedErr('01', { message: 'I will now provide a succint description of the error. I guess it all started when I was 5...' })
52+
}
53+
54+
public test8ByteMessage(arg: uint64): void {
55+
loggedAssert(arg !== 1, 'abcd')
56+
loggedErr('abcd')
57+
}
58+
59+
public test32ByteMessage(arg: uint64): void {
60+
loggedAssert(arg !== 1, '01', { message: 'aaaaaaaaaaaaaaaaaaaaaaaaa' })
61+
loggedErr('01', { message: 'aaaaaaaaaaaaaaaaaaaaaaaaa' })
62+
}
63+
64+
public testColonInCode(arg: uint64): void {
65+
loggedAssert(arg !== 1, 'bad:code')
66+
}
67+
68+
public testColonInMessage(arg: uint64): void {
69+
loggedAssert(arg !== 1, '01', { message: 'bad:msg' })
70+
}
71+
72+
public testInvalidPrefix(arg: uint64): void {
73+
loggedAssert(arg !== 1, '01', { prefix: 'BAD' as 'ERR' })
74+
}
75+
}

tests/logged-errors.algo.spec.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { afterEach, describe, expect, test } from 'vitest'
2+
import { decodeLogs } from '../src/decode-logs'
3+
import { TestExecutionContext } from '../src/test-execution-context'
4+
import { LoggedErrorsContract } from './artifacts/logged-errors/contract.algo'
5+
6+
describe('logged errors', async () => {
7+
const ctx = new TestExecutionContext()
8+
9+
afterEach(() => {
10+
ctx.reset()
11+
})
12+
13+
test.for([
14+
{ arg: 1, expectedError: 'ERR:01' },
15+
{ arg: 2, expectedError: 'ERR:02' },
16+
{ arg: 3, expectedError: 'ERR:03:arg is 3' },
17+
{ arg: 4, expectedError: 'AER:04' },
18+
{ arg: 5, expectedError: 'AER:05:arg is 5' },
19+
{ arg: 6, expectedError: 'ERR:06:arg is 6' },
20+
{ arg: 7, expectedError: 'ERR:07' },
21+
{ arg: 8, expectedError: 'ERR:08' },
22+
{ arg: 9, expectedError: 'ERR:09:arg is 9' },
23+
{ arg: 10, expectedError: 'AER:10' },
24+
{ arg: 11, expectedError: 'AER:11:arg is 11' },
25+
{ arg: 12, expectedError: 'ERR:12:arg is 12' },
26+
])('should log correct error for arg $arg', ({ arg, expectedError }) => {
27+
const contract = ctx.contract.create(LoggedErrorsContract)
28+
expect(() => contract.testValid(arg)).toThrow(expectedError)
29+
assertLog(expectedError)
30+
})
31+
32+
test.for([
33+
{ arg: 1, expectedError: 'ERR:not-alnum!' },
34+
{ arg: 2, expectedError: 'ERR:not-alnum!' },
35+
])('should log error with non alphanumeric code', ({ arg, expectedError }) => {
36+
const contract = ctx.contract.create(LoggedErrorsContract)
37+
expect(() => contract.testInvalidCode(arg)).toThrow(expectedError)
38+
assertLog(expectedError)
39+
})
40+
41+
test.for([
42+
{ arg: 1, expectedError: 'ERR:MyCode' },
43+
{ arg: 2, expectedError: 'ERR:MyCode' },
44+
])('should log error with camel case code', ({ arg, expectedError }) => {
45+
const contract = ctx.contract.create(LoggedErrorsContract)
46+
expect(() => contract.testCamelCaseCode(arg)).toThrow(expectedError)
47+
assertLog(expectedError)
48+
})
49+
50+
test.for([
51+
{ arg: 1, expectedError: 'AER:01' },
52+
{ arg: 2, expectedError: 'AER:01' },
53+
])('should log error with AER prefix', ({ arg, expectedError }) => {
54+
const contract = ctx.contract.create(LoggedErrorsContract)
55+
expect(() => contract.testAERPrefix(arg)).toThrow(expectedError)
56+
assertLog(expectedError)
57+
})
58+
59+
test.for([
60+
{
61+
arg: 1,
62+
expectedError: 'ERR:01:I will now provide a succint description of the error. I guess it all started when I was 5...',
63+
},
64+
{
65+
arg: 2,
66+
expectedError: 'ERR:01:I will now provide a succint description of the error. I guess it all started when I was 5...',
67+
},
68+
])('should log error with long message', ({ arg, expectedError }) => {
69+
const contract = ctx.contract.create(LoggedErrorsContract)
70+
expect(() => contract.testLongMessage(arg)).toThrow(expectedError)
71+
assertLog(expectedError)
72+
})
73+
74+
test.for([
75+
{ arg: 1, expectedError: 'ERR:abcd' },
76+
{ arg: 2, expectedError: 'ERR:abcd' },
77+
])('should log error with 8 byte message', ({ arg, expectedError }) => {
78+
const contract = ctx.contract.create(LoggedErrorsContract)
79+
expect(() => contract.test8ByteMessage(arg)).toThrow(expectedError)
80+
assertLog(expectedError)
81+
})
82+
83+
test.for([
84+
{
85+
arg: 1,
86+
expectedError: 'ERR:01:aaaaaaaaaaaaaaaaaaaaaaaaa',
87+
},
88+
{
89+
arg: 2,
90+
expectedError: 'ERR:01:aaaaaaaaaaaaaaaaaaaaaaaaa',
91+
},
92+
])('should log error with 32 byte message', ({ arg, expectedError }) => {
93+
const contract = ctx.contract.create(LoggedErrorsContract)
94+
expect(() => contract.test32ByteMessage(arg)).toThrow(expectedError)
95+
assertLog(expectedError)
96+
})
97+
98+
test('should throw error when code contains colon', () => {
99+
const expectedError = "error code must not contain domain separator ':'"
100+
const contract = ctx.contract.create(LoggedErrorsContract)
101+
expect(() => contract.testColonInCode(1)).toThrow(expectedError)
102+
})
103+
104+
test('should throw error when message contains colon', () => {
105+
const expectedError = "error message must not contain domain separator ':'"
106+
const contract = ctx.contract.create(LoggedErrorsContract)
107+
expect(() => contract.testColonInMessage(1)).toThrow(expectedError)
108+
})
109+
110+
test('should throw error when prefix is invalid', () => {
111+
const expectedError = 'error prefix must be one of AER, ERR'
112+
const contract = ctx.contract.create(LoggedErrorsContract)
113+
expect(() => contract.testInvalidPrefix(1)).toThrow(expectedError)
114+
})
115+
116+
function assertLog(expectedError: string) {
117+
const appLogs = ctx.txn.activeGroup.getApplicationCallTransaction().appLogs
118+
const [log] = decodeLogs(appLogs, ['s'])
119+
expect(log).toEqual(expectedError)
120+
}
121+
})

0 commit comments

Comments
 (0)