Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .nsprc
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,13 @@
"GHSA-848j-6mx2-7j84": {
"active": true,
"notes": "Required for Algorand cryptographic operations. Mitigated by using latest version (6.6.1) and monitoring for updates."
},
"GHSA-c2c7-rcm5-vvqj": {
"active": true,
"notes": "ReDoS vulnerability in picomatch extglob quantifiers. Transitive dependency via npm internals (npm > picomatch)."
},
"GHSA-3v7f-55p6-f55p": {
"active": true,
"notes": "Method injection in picomatch POSIX character classes causing incorrect glob matching. Transitive dependency via npm internals (npm > picomatch)."
}
}
26 changes: 24 additions & 2 deletions examples/calculator/contract.algo.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Uint64 } from '@algorandfoundation/algorand-typescript'
import { AvmError, TestExecutionContext } from '@algorandfoundation/algorand-typescript-testing'
import { AssertError, AvmError, TestExecutionContext } from '@algorandfoundation/algorand-typescript-testing'
import { afterEach, describe, expect, it } from 'vitest'
import MyContract from './contract.algo'

Expand All @@ -19,7 +19,29 @@ describe('Calculator', () => {
}),
])
.execute(() => {
expect(() => contract.approvalProgram()).toThrowError(new AvmError('Unknown operation'))
expect(() => contract.approvalProgram()).toThrowError(new AvmError('ERR:Unknown operation'))
const logs = ctx.txn.activeGroup.getApplicationCallTransaction().appLogs
expect(logs.length).toBe(3)
expect(logs[2].toString()).toEqual('ERR:Unknown operation')
})
})
})

describe('when calling with with two args', () => {
it('errors', async () => {
const contract = ctx.contract.create(MyContract)
ctx.txn
.createScope([
ctx.any.txn.applicationCall({
appId: contract,
appArgs: [Uint64(1), Uint64(2)],
}),
])
.execute(() => {
expect(() => contract.approvalProgram()).toThrowError(new AssertError('ERR:Expected 3 args'))
const logs = ctx.txn.activeGroup.getApplicationCallTransaction().appLogs
expect(logs.length).toBe(1)
expect(logs[0].toString()).toEqual('ERR:Expected 3 args')
})
})
})
Expand Down
8 changes: 4 additions & 4 deletions examples/calculator/contract.algo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { uint64 } from '@algorandfoundation/algorand-typescript'
import { assert, BaseContract, Bytes, err, log, op, Txn, Uint64 } from '@algorandfoundation/algorand-typescript'
import { BaseContract, Bytes, log, loggedAssert, loggedErr, op, Txn, Uint64 } from '@algorandfoundation/algorand-typescript'

const ADD = Uint64(1)
const SUB = Uint64(2)
Expand All @@ -24,7 +24,7 @@ export default class MyContract extends BaseContract {
log(a)
log(b)
} else {
assert(numArgs === 3, 'Expected 3 args')
loggedAssert(numArgs === 3, 'Expected 3 args')
action = op.btoi(Txn.applicationArgs(0))
const a_bytes = Txn.applicationArgs(1)
const b_bytes = Txn.applicationArgs(2)
Expand All @@ -49,7 +49,7 @@ export default class MyContract extends BaseContract {
case DIV:
return ` / `
default:
err('Unknown operation')
loggedErr('Unknown operation')
}
}

Expand All @@ -64,7 +64,7 @@ export default class MyContract extends BaseContract {
case DIV:
return this.div(a, b)
default:
err('Unknown operation')
loggedErr('Unknown operation')
}
}

Expand Down
21 changes: 12 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions src/impl/log.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,51 @@
import type { BytesBacked, StringCompat } from '@algorandfoundation/algorand-typescript'
import { lazyContext } from '../context-helpers/internal-context'

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

/** @internal */
export function log(...args: Array<StubUint64Compat | StubBytesCompat | StubBigUintCompat | StringCompat | BytesBacked>): void {
lazyContext.txn.appendLog(args.map((a) => toBytes(a)).reduce((left, right) => left.concat(right)))
}

/** @internal */
export function loggedAssert(
condition: unknown,
code: string,
messageOrOptions?: string | { message?: string | undefined; prefix?: 'ERR' | 'AER' },
): asserts condition {
if (!condition) {
const errorMessage = resolveErrorMessage(code, messageOrOptions)
log(errorMessage)
throw new AssertError(errorMessage)
}
}

/** @internal */
export function loggedErr(code: string, messageOrOptions?: string | { message?: string; prefix?: 'ERR' | 'AER' }): never {
const errorMessage = resolveErrorMessage(code, messageOrOptions)
log(errorMessage)
throw new AvmError(errorMessage)
}

const VALID_PREFIXES = new Set(['ERR', 'AER'])
function resolveErrorMessage(code: string, messageOrOptions?: string | { message?: string | undefined; prefix?: 'ERR' | 'AER' }): string {
const message = typeof messageOrOptions === 'string' ? messageOrOptions : messageOrOptions?.message
const prefix = typeof messageOrOptions === 'string' ? undefined : (messageOrOptions?.prefix ?? 'ERR')

if (code.includes(':')) {
throw new CodeError("error code must not contain domain separator ':'")
}

if (message && message.includes(':')) {
throw new CodeError("error message must not contain domain separator ':'")
}

const prefixStr = prefix || 'ERR'
if (!VALID_PREFIXES.has(prefixStr)) {
throw new CodeError('error prefix must be one of AER, ERR')
}
return message ? `${prefixStr}:${code}:${message}` : `${prefixStr}:${code}`
}
2 changes: 1 addition & 1 deletion src/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export { ensureBudget } from '../impl/ensure-budget'
/** @internal */
export { Global } from '../impl/global'
/** @internal */
export { log } from '../impl/log'
export { log, loggedAssert, loggedErr } from '../impl/log'
/** @internal */
export { assertMatch, match } from '../impl/match'
/** @internal */
Expand Down
75 changes: 75 additions & 0 deletions tests/artifacts/logged-errors/contract.algo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { uint64 } from '@algorandfoundation/algorand-typescript'
import { Contract, loggedAssert, loggedErr } from '@algorandfoundation/algorand-typescript'

export class LoggedErrorsContract extends Contract {
public testValid(arg: uint64): void {
loggedAssert(arg !== 1, '01')
loggedAssert(arg !== 2, '02', {})
loggedAssert(arg !== 3, '03', { message: 'arg is 3' })
loggedAssert(arg !== 4, '04', { prefix: 'AER' })
loggedAssert(arg !== 5, '05', { message: 'arg is 5', prefix: 'AER' })
loggedAssert(arg !== 6, '06', 'arg is 6')
if (arg === 7) {
loggedErr('07')
}
if (arg === 8) {
loggedErr('08', {})
}
if (arg === 9) {
loggedErr('09', { message: 'arg is 9' })
}
if (arg === 10) {
loggedErr('10', { prefix: 'AER' })
}
if (arg === 11) {
loggedErr('11', { message: 'arg is 11', prefix: 'AER' })
}
if (arg === 12) {
loggedErr('12', 'arg is 12')
}
}

public testInvalidCode(arg: uint64): void {
loggedAssert(arg !== 1, 'not-alnum!')
loggedErr('not-alnum!')
}

public testCamelCaseCode(arg: uint64): void {
loggedAssert(arg !== 1, 'MyCode')
loggedErr('MyCode')
}

public testAERPrefix(arg: uint64): void {
loggedAssert(arg !== 1, '01', { prefix: 'AER' })
loggedErr('01', { prefix: 'AER' })
}

public testLongMessage(arg: uint64): void {
loggedAssert(arg !== 1, '01', {
message: 'I will now provide a succint description of the error. I guess it all started when I was 5...',
})
loggedErr('01', { message: 'I will now provide a succint description of the error. I guess it all started when I was 5...' })
}

public test8ByteMessage(arg: uint64): void {
loggedAssert(arg !== 1, 'abcd')
loggedErr('abcd')
}

public test32ByteMessage(arg: uint64): void {
loggedAssert(arg !== 1, '01', { message: 'aaaaaaaaaaaaaaaaaaaaaaaaa' })
loggedErr('01', { message: 'aaaaaaaaaaaaaaaaaaaaaaaaa' })
}

public testColonInCode(arg: uint64): void {
loggedAssert(arg !== 1, 'bad:code')
}

public testColonInMessage(arg: uint64): void {
loggedAssert(arg !== 1, '01', { message: 'bad:msg' })
}

public testInvalidPrefix(arg: uint64): void {
loggedAssert(arg !== 1, '01', { prefix: 'BAD' as 'ERR' })
}
}
121 changes: 121 additions & 0 deletions tests/logged-errors.algo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, test } from 'vitest'
import { decodeLogs } from '../src/decode-logs'
import { TestExecutionContext } from '../src/test-execution-context'
import { LoggedErrorsContract } from './artifacts/logged-errors/contract.algo'

describe('logged errors', async () => {
const ctx = new TestExecutionContext()

afterEach(() => {
ctx.reset()
})

test.for([
{ arg: 1, expectedError: 'ERR:01' },
{ arg: 2, expectedError: 'ERR:02' },
{ arg: 3, expectedError: 'ERR:03:arg is 3' },
{ arg: 4, expectedError: 'AER:04' },
{ arg: 5, expectedError: 'AER:05:arg is 5' },
{ arg: 6, expectedError: 'ERR:06:arg is 6' },
{ arg: 7, expectedError: 'ERR:07' },
{ arg: 8, expectedError: 'ERR:08' },
{ arg: 9, expectedError: 'ERR:09:arg is 9' },
{ arg: 10, expectedError: 'AER:10' },
{ arg: 11, expectedError: 'AER:11:arg is 11' },
{ arg: 12, expectedError: 'ERR:12:arg is 12' },
])('should log correct error for arg $arg', ({ arg, expectedError }) => {
const contract = ctx.contract.create(LoggedErrorsContract)
expect(() => contract.testValid(arg)).toThrow(expectedError)
assertLog(expectedError)
})

test.for([
{ arg: 1, expectedError: 'ERR:not-alnum!' },
{ arg: 2, expectedError: 'ERR:not-alnum!' },
])('should log error with non alphanumeric code', ({ arg, expectedError }) => {
const contract = ctx.contract.create(LoggedErrorsContract)
expect(() => contract.testInvalidCode(arg)).toThrow(expectedError)
assertLog(expectedError)
})

test.for([
{ arg: 1, expectedError: 'ERR:MyCode' },
{ arg: 2, expectedError: 'ERR:MyCode' },
])('should log error with camel case code', ({ arg, expectedError }) => {
const contract = ctx.contract.create(LoggedErrorsContract)
expect(() => contract.testCamelCaseCode(arg)).toThrow(expectedError)
assertLog(expectedError)
})

test.for([
{ arg: 1, expectedError: 'AER:01' },
{ arg: 2, expectedError: 'AER:01' },
])('should log error with AER prefix', ({ arg, expectedError }) => {
const contract = ctx.contract.create(LoggedErrorsContract)
expect(() => contract.testAERPrefix(arg)).toThrow(expectedError)
assertLog(expectedError)
})

test.for([
{
arg: 1,
expectedError: 'ERR:01:I will now provide a succint description of the error. I guess it all started when I was 5...',
},
{
arg: 2,
expectedError: 'ERR:01:I will now provide a succint description of the error. I guess it all started when I was 5...',
},
])('should log error with long message', ({ arg, expectedError }) => {
const contract = ctx.contract.create(LoggedErrorsContract)
expect(() => contract.testLongMessage(arg)).toThrow(expectedError)
assertLog(expectedError)
})

test.for([
{ arg: 1, expectedError: 'ERR:abcd' },
{ arg: 2, expectedError: 'ERR:abcd' },
])('should log error with 8 byte message', ({ arg, expectedError }) => {
const contract = ctx.contract.create(LoggedErrorsContract)
expect(() => contract.test8ByteMessage(arg)).toThrow(expectedError)
assertLog(expectedError)
})

test.for([
{
arg: 1,
expectedError: 'ERR:01:aaaaaaaaaaaaaaaaaaaaaaaaa',
},
{
arg: 2,
expectedError: 'ERR:01:aaaaaaaaaaaaaaaaaaaaaaaaa',
},
])('should log error with 32 byte message', ({ arg, expectedError }) => {
const contract = ctx.contract.create(LoggedErrorsContract)
expect(() => contract.test32ByteMessage(arg)).toThrow(expectedError)
assertLog(expectedError)
})

test('should throw error when code contains colon', () => {
const expectedError = "error code must not contain domain separator ':'"
const contract = ctx.contract.create(LoggedErrorsContract)
expect(() => contract.testColonInCode(1)).toThrow(expectedError)
})

test('should throw error when message contains colon', () => {
const expectedError = "error message must not contain domain separator ':'"
const contract = ctx.contract.create(LoggedErrorsContract)
expect(() => contract.testColonInMessage(1)).toThrow(expectedError)
})

test('should throw error when prefix is invalid', () => {
const expectedError = 'error prefix must be one of AER, ERR'
const contract = ctx.contract.create(LoggedErrorsContract)
expect(() => contract.testInvalidPrefix(1)).toThrow(expectedError)
})

function assertLog(expectedError: string) {
const appLogs = ctx.txn.activeGroup.getApplicationCallTransaction().appLogs
const [log] = decodeLogs(appLogs, ['s'])
expect(log).toEqual(expectedError)
}
})
Loading