diff --git a/CHANGELOG.md b/CHANGELOG.md index 468cadf..12f489c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix app state not being properly reset when stepping back ([#19](https://github.com/algorand/avm-debugger/pull/19)) +- Properly handle failing clear state programs ([#20](https://github.com/algorand/avm-debugger/pull/20)) ## [0.1.2] - 2023-12-14 diff --git a/FEATURES.md b/FEATURES.md index 03ff731..331fbca 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -47,6 +47,10 @@ Execution errors will be reported by the debugger. Since any error stops the exe transaction group, the debugger will not allow you to advance after an error. You can however step backwards to inspect what happened prior to the error. +> Note: There are a special class of errors which do not stop the execution of a transaction group. +> These are errors or rejections that occur in a clear state program. The debugger will still show +> these errors, and it will allow you to continue execution over them. + ![An error in the debugger](images/error.png) ## Inspect program state diff --git a/algosdk/client/v2/algod/models/types.d.ts b/algosdk/client/v2/algod/models/types.d.ts index 95b0bd2..16d8796 100644 --- a/algosdk/client/v2/algod/models/types.d.ts +++ b/algosdk/client/v2/algod/models/types.d.ts @@ -2316,6 +2316,17 @@ export declare class SimulationTransactionExecTrace extends BaseModel { * Program trace that contains a trace of opcode effects in a clear state program. */ clearStateProgramTrace?: SimulationOpcodeTraceUnit[]; + /** + * If true, indicates that the clear state program failed and any persistent state + * changes it produced should be reverted once the program exits. + */ + clearStateRollback?: boolean; + /** + * The error message explaining why the clear state program failed. This field will + * only be populated if clear-state-rollback is true and the failure was due to an + * execution error. + */ + clearStateRollbackError?: string; /** * An array of SimulationTransactionExecTrace representing the execution trace of * any inner transactions executed. @@ -2335,16 +2346,23 @@ export declare class SimulationTransactionExecTrace extends BaseModel { * @param approvalProgramTrace - Program trace that contains a trace of opcode effects in an approval program. * @param clearStateProgramHash - SHA512_256 hash digest of the clear state program executed in transaction. * @param clearStateProgramTrace - Program trace that contains a trace of opcode effects in a clear state program. + * @param clearStateRollback - If true, indicates that the clear state program failed and any persistent state + * changes it produced should be reverted once the program exits. + * @param clearStateRollbackError - The error message explaining why the clear state program failed. This field will + * only be populated if clear-state-rollback is true and the failure was due to an + * execution error. * @param innerTrace - An array of SimulationTransactionExecTrace representing the execution trace of * any inner transactions executed. * @param logicSigHash - SHA512_256 hash digest of the logic sig executed in transaction. * @param logicSigTrace - Program trace that contains a trace of opcode effects in a logic sig. */ - constructor({ approvalProgramHash, approvalProgramTrace, clearStateProgramHash, clearStateProgramTrace, innerTrace, logicSigHash, logicSigTrace, }: { + constructor({ approvalProgramHash, approvalProgramTrace, clearStateProgramHash, clearStateProgramTrace, clearStateRollback, clearStateRollbackError, innerTrace, logicSigHash, logicSigTrace, }: { approvalProgramHash?: string | Uint8Array; approvalProgramTrace?: SimulationOpcodeTraceUnit[]; clearStateProgramHash?: string | Uint8Array; clearStateProgramTrace?: SimulationOpcodeTraceUnit[]; + clearStateRollback?: boolean; + clearStateRollbackError?: string; innerTrace?: SimulationTransactionExecTrace[]; logicSigHash?: string | Uint8Array; logicSigTrace?: SimulationOpcodeTraceUnit[]; diff --git a/algosdk/client/v2/algod/models/types.js b/algosdk/client/v2/algod/models/types.js index 180954f..e2e4ddd 100644 --- a/algosdk/client/v2/algod/models/types.js +++ b/algosdk/client/v2/algod/models/types.js @@ -2706,12 +2706,17 @@ class SimulationTransactionExecTrace extends basemodel_1.default { * @param approvalProgramTrace - Program trace that contains a trace of opcode effects in an approval program. * @param clearStateProgramHash - SHA512_256 hash digest of the clear state program executed in transaction. * @param clearStateProgramTrace - Program trace that contains a trace of opcode effects in a clear state program. + * @param clearStateRollback - If true, indicates that the clear state program failed and any persistent state + * changes it produced should be reverted once the program exits. + * @param clearStateRollbackError - The error message explaining why the clear state program failed. This field will + * only be populated if clear-state-rollback is true and the failure was due to an + * execution error. * @param innerTrace - An array of SimulationTransactionExecTrace representing the execution trace of * any inner transactions executed. * @param logicSigHash - SHA512_256 hash digest of the logic sig executed in transaction. * @param logicSigTrace - Program trace that contains a trace of opcode effects in a logic sig. */ - constructor({ approvalProgramHash, approvalProgramTrace, clearStateProgramHash, clearStateProgramTrace, innerTrace, logicSigHash, logicSigTrace, }) { + constructor({ approvalProgramHash, approvalProgramTrace, clearStateProgramHash, clearStateProgramTrace, clearStateRollback, clearStateRollbackError, innerTrace, logicSigHash, logicSigTrace, }) { super(); this.approvalProgramHash = typeof approvalProgramHash === 'string' @@ -2723,6 +2728,8 @@ class SimulationTransactionExecTrace extends basemodel_1.default { ? (0, binarydata_1.base64ToBytes)(clearStateProgramHash) : clearStateProgramHash; this.clearStateProgramTrace = clearStateProgramTrace; + this.clearStateRollback = clearStateRollback; + this.clearStateRollbackError = clearStateRollbackError; this.innerTrace = innerTrace; this.logicSigHash = typeof logicSigHash === 'string' @@ -2734,6 +2741,8 @@ class SimulationTransactionExecTrace extends basemodel_1.default { approvalProgramTrace: 'approval-program-trace', clearStateProgramHash: 'clear-state-program-hash', clearStateProgramTrace: 'clear-state-program-trace', + clearStateRollback: 'clear-state-rollback', + clearStateRollbackError: 'clear-state-rollback-error', innerTrace: 'inner-trace', logicSigHash: 'logic-sig-hash', logicSigTrace: 'logic-sig-trace', @@ -2751,6 +2760,8 @@ class SimulationTransactionExecTrace extends basemodel_1.default { clearStateProgramTrace: typeof data['clear-state-program-trace'] !== 'undefined' ? data['clear-state-program-trace'].map(SimulationOpcodeTraceUnit.from_obj_for_encoding) : undefined, + clearStateRollback: data['clear-state-rollback'], + clearStateRollbackError: data['clear-state-rollback-error'], innerTrace: typeof data['inner-trace'] !== 'undefined' ? data['inner-trace'].map(SimulationTransactionExecTrace.from_obj_for_encoding) : undefined, diff --git a/sampleWorkspace/.vscode/launch.json b/sampleWorkspace/.vscode/launch.json index 9f373eb..9667da6 100644 --- a/sampleWorkspace/.vscode/launch.json +++ b/sampleWorkspace/.vscode/launch.json @@ -142,6 +142,26 @@ "simulateTraceFile": "${workspaceFolder}/errors/logicsig-after-error/simulate-response.json", "programSourcesDescriptionFile": "${workspaceFolder}/errors/logicsig-after-error/sources.json", + "stopOnEntry": true + }, + { + "type": "avm", + "request": "launch", + "name": "Clear State Rejection", + + "simulateTraceFile": "${workspaceFolder}/errors/clear-state/rejection-response.json", + "programSourcesDescriptionFile": "${workspaceFolder}/errors/clear-state/sources.json", + + "stopOnEntry": true + }, + { + "type": "avm", + "request": "launch", + "name": "Clear State Error", + + "simulateTraceFile": "${workspaceFolder}/errors/clear-state/error-response.json", + "programSourcesDescriptionFile": "${workspaceFolder}/errors/clear-state/sources.json", + "stopOnEntry": true } ] diff --git a/sampleWorkspace/errors/clear-state/error-response.json b/sampleWorkspace/errors/clear-state/error-response.json new file mode 100644 index 0000000..46e9ca2 --- /dev/null +++ b/sampleWorkspace/errors/clear-state/error-response.json @@ -0,0 +1,180 @@ +{ + "exec-trace-config": { + "enable": true, + "scratch-change": true, + "stack-change": true, + "state-change": true + }, + "initial-states": { + "app-initial-states": [ + { + "app-globals": { + "kvs": [ + { + "key": "Y291bnRlcg==", + "value": { + "type": 2, + "uint": 4 + } + } + ] + }, + "id": 1001 + } + ] + }, + "last-round": 4, + "txn-groups": [ + { + "app-budget-added": 700, + "app-budget-consumed": 14, + "txn-results": [ + { + "app-budget-consumed": 14, + "exec-trace": { + "clear-state-program-hash": "ZY8dOBgLGyQoqiQqBH1PSo+dCcJIDpwqLzqLBvNJaNk=", + "clear-state-program-trace": [ + { + "pc": 1 + }, + { + "pc": 4, + "stack-additions": [ + { + "bytes": "Y291bnRlcg==", + "type": 1 + } + ] + }, + { + "pc": 13, + "stack-additions": [ + { + "bytes": "Y291bnRlcg==", + "type": 1 + }, + { + "bytes": "Y291bnRlcg==", + "type": 1 + } + ], + "stack-pop-count": 1 + }, + { + "pc": 14, + "stack-additions": [ + { + "type": 2, + "uint": 4 + } + ], + "stack-pop-count": 1 + }, + { + "pc": 15, + "stack-additions": [ + { + "type": 2, + "uint": 1 + } + ] + }, + { + "pc": 16, + "stack-additions": [ + { + "type": 2, + "uint": 5 + } + ], + "stack-pop-count": 2 + }, + { + "pc": 17, + "stack-pop-count": 2, + "state-changes": [ + { + "app-state-type": "g", + "key": "Y291bnRlcg==", + "new-value": { + "type": 2, + "uint": 5 + }, + "operation": "w" + } + ] + }, + { + "pc": 18, + "stack-additions": [ + { + "type": 2, + "uint": 1001 + } + ] + }, + { + "pc": 20, + "stack-pop-count": 1 + }, + { + "pc": 23, + "stack-additions": [ + { + "type": 2, + "uint": 3 + } + ] + }, + { + "pc": 25, + "stack-additions": [ + { + "type": 2, + "uint": 1 + } + ] + }, + { + "pc": 26, + "stack-additions": [ + { + "type": 2 + } + ], + "stack-pop-count": 2 + }, + { + "pc": 27, + "stack-pop-count": 1 + }, + { + "pc": 30 + } + ], + "clear-state-rollback": true, + "clear-state-rollback-error": "invalid ApplicationArgs index 0" + }, + "txn-result": { + "pool-error": "", + "txn": { + "sig": "Q0UCQSgS7KBWT+N/xXFELqajmZatiAp4NZgoOJRLoEKRGwUkr0/E7BMOybspT7BIVa0vCpjN0l8O/ePdcs9EAg==", + "txn": { + "apan": 3, + "apid": 1001, + "fee": 1000, + "fv": 4, + "gh": "dsgtQlDfh3EgBWQs+f6G+ZorH4WiY2Vmzu7sGgfcbng=", + "lv": 1004, + "note": "sV/32BiFDlA=", + "snd": "EI4F6GLWBIUTRCC4EJK7TXKY4AEW6DOZNUXYZXYEHBPXNFJU53REVFPI34", + "type": "appl" + } + } + } + } + ] + } + ], + "version": 2 +} diff --git a/sampleWorkspace/errors/clear-state/rejection-response.json b/sampleWorkspace/errors/clear-state/rejection-response.json new file mode 100644 index 0000000..bf97236 --- /dev/null +++ b/sampleWorkspace/errors/clear-state/rejection-response.json @@ -0,0 +1,204 @@ +{ + "exec-trace-config": { + "enable": true, + "scratch-change": true, + "stack-change": true, + "state-change": true + }, + "initial-states": { + "app-initial-states": [ + { + "app-globals": { + "kvs": [ + { + "key": "Y291bnRlcg==", + "value": { + "type": 2, + "uint": 4 + } + } + ] + }, + "id": 1001 + } + ] + }, + "last-round": 4, + "txn-groups": [ + { + "app-budget-added": 700, + "app-budget-consumed": 16, + "txn-results": [ + { + "app-budget-consumed": 16, + "exec-trace": { + "clear-state-program-hash": "ZY8dOBgLGyQoqiQqBH1PSo+dCcJIDpwqLzqLBvNJaNk=", + "clear-state-program-trace": [ + { + "pc": 1 + }, + { + "pc": 4, + "stack-additions": [ + { + "bytes": "Y291bnRlcg==", + "type": 1 + } + ] + }, + { + "pc": 13, + "stack-additions": [ + { + "bytes": "Y291bnRlcg==", + "type": 1 + }, + { + "bytes": "Y291bnRlcg==", + "type": 1 + } + ], + "stack-pop-count": 1 + }, + { + "pc": 14, + "stack-additions": [ + { + "type": 2, + "uint": 4 + } + ], + "stack-pop-count": 1 + }, + { + "pc": 15, + "stack-additions": [ + { + "type": 2, + "uint": 1 + } + ] + }, + { + "pc": 16, + "stack-additions": [ + { + "type": 2, + "uint": 5 + } + ], + "stack-pop-count": 2 + }, + { + "pc": 17, + "stack-pop-count": 2, + "state-changes": [ + { + "app-state-type": "g", + "key": "Y291bnRlcg==", + "new-value": { + "type": 2, + "uint": 5 + }, + "operation": "w" + } + ] + }, + { + "pc": 18, + "stack-additions": [ + { + "type": 2, + "uint": 1001 + } + ] + }, + { + "pc": 20, + "stack-pop-count": 1 + }, + { + "pc": 23, + "stack-additions": [ + { + "type": 2, + "uint": 3 + } + ] + }, + { + "pc": 25, + "stack-additions": [ + { + "type": 2, + "uint": 1 + } + ] + }, + { + "pc": 26, + "stack-additions": [ + { + "type": 2 + } + ], + "stack-pop-count": 2 + }, + { + "pc": 27, + "stack-pop-count": 1 + }, + { + "pc": 30, + "stack-additions": [ + { + "bytes": "AAAAAAAAAAA=", + "type": 1 + } + ] + }, + { + "pc": 33, + "stack-additions": [ + { + "type": 2 + } + ], + "stack-pop-count": 1 + }, + { + "pc": 34, + "stack-additions": [ + { + "type": 2 + } + ], + "stack-pop-count": 1 + } + ], + "clear-state-rollback": true + }, + "txn-result": { + "pool-error": "", + "txn": { + "sig": "z0+Cs2OxDfDUTVEJkQjiYMOaG8EBEQe6iY1QtusTZ7/4pX1dcYBmC9aWKDTdA4bjvjCLFzEc7bp4J9V3E/rvCA==", + "txn": { + "apaa": ["AAAAAAAAAAA="], + "apan": 3, + "apid": 1001, + "fee": 1000, + "fv": 4, + "gh": "dsgtQlDfh3EgBWQs+f6G+ZorH4WiY2Vmzu7sGgfcbng=", + "lv": 1004, + "note": "1DI44/pGiCI=", + "snd": "EI4F6GLWBIUTRCC4EJK7TXKY4AEW6DOZNUXYZXYEHBPXNFJU53REVFPI34", + "type": "appl" + } + } + } + } + ] + } + ], + "version": 2 +} diff --git a/sampleWorkspace/errors/clear-state/returnFirstAppArg.teal b/sampleWorkspace/errors/clear-state/returnFirstAppArg.teal new file mode 100644 index 0000000..e499bf4 --- /dev/null +++ b/sampleWorkspace/errors/clear-state/returnFirstAppArg.teal @@ -0,0 +1,19 @@ +#pragma version 6 +byte "counter" +dup +app_global_get +int 1 ++ +app_global_put +txn ApplicationID +bz end +txn OnCompletion +int OptIn +== +bnz end +txn ApplicationArgs 0 +btoi +return +end: +int 1 +return diff --git a/sampleWorkspace/errors/clear-state/returnFirstAppArg.teal.tok.map b/sampleWorkspace/errors/clear-state/returnFirstAppArg.teal.tok.map new file mode 100644 index 0000000..3de4f1d --- /dev/null +++ b/sampleWorkspace/errors/clear-state/returnFirstAppArg.teal.tok.map @@ -0,0 +1 @@ +{"version":3,"sources":["returnFirstAppArg.teal"],"names":[],"mappings":";;;;AACA;;;;;;;;;AACA;AACA;AACA;AACA;AACA;AACA;;AACA;;;AACA;;AACA;AACA;AACA;;;AACA;;;AACA;AACA;AAEA;AACA"} \ No newline at end of file diff --git a/sampleWorkspace/errors/clear-state/sources.json b/sampleWorkspace/errors/clear-state/sources.json new file mode 100644 index 0000000..b5ee351 --- /dev/null +++ b/sampleWorkspace/errors/clear-state/sources.json @@ -0,0 +1,8 @@ +{ + "txn-group-sources": [ + { + "sourcemap-location": "./returnFirstAppArg.teal.tok.map", + "hash": "ZY8dOBgLGyQoqiQqBH1PSo+dCcJIDpwqLzqLBvNJaNk=" + } + ] +} diff --git a/src/common/traceReplayEngine.ts b/src/common/traceReplayEngine.ts index 88c08cf..a80ecd2 100644 --- a/src/common/traceReplayEngine.ts +++ b/src/common/traceReplayEngine.ts @@ -863,13 +863,33 @@ export class ProgramStackFrame extends TraceReplayStackFrame { if (this.index < this.programTrace.length) { this.state.pc = Number(this.programTrace[this.index].pc); this.handledInnerTxns = false; - } else if ( - this.failureInfo && - pathsEqual(this.txnPath.slice(1), this.failureInfo.path) - ) { - // If there's an error, show it at the end of execution - this.blockingException = new ExceptionInfo(this.failureInfo.message); - return this.blockingException; + } else { + if (this.programType === 'clear state' && this.trace.clearStateRollback) { + // If there's a rollback, reset the app state to the initial state + this.engine.currentAppState.set( + this.currentAppID()!, + this.initialAppState!.clone(), + ); + // Don't return the clear state error here, failureInfo takes precedence + } + + if ( + this.failureInfo && + pathsEqual(this.txnPath.slice(1), this.failureInfo.path) + ) { + // If there's an error, show it at the end of execution + this.blockingException = new ExceptionInfo(this.failureInfo.message); + return this.blockingException; + } + + if (this.programType === 'clear state' && this.trace.clearStateRollback) { + // Show error message for clear state rollback. This is NOT a blocking error. + if (typeof this.trace.clearStateRollbackError !== 'undefined') { + return new ExceptionInfo(this.trace.clearStateRollbackError); + } + // If no specific error message, show a generic one (this is what happens during rejection) + return new ExceptionInfo('Clear state program did not succeed'); + } } } diff --git a/tests/adapter.test.ts b/tests/adapter.test.ts index 4ed5d7f..8323632 100644 --- a/tests/adapter.test.ts +++ b/tests/adapter.test.ts @@ -2347,5 +2347,219 @@ describe('Debug Adapter Tests', () => { stoppedEvent.body.text, ); }); + + it('should correctly report a clear state error', async () => { + const simulateTraceFile = path.join( + DATA_ROOT, + 'errors/clear-state/error-response.json', + ); + const programSourcesDescriptionFile = path.join( + DATA_ROOT, + 'errors/clear-state/sources.json', + ); + const { client } = fixture; + + const program = normalizePathAndCasing( + nodeFileAccessor, + path.join(DATA_ROOT, 'errors/clear-state/returnFirstAppArg.teal'), + ); + + await Promise.all([ + client.configurationSequence(), + client.launch({ + simulateTraceFile, + programSourcesDescriptionFile, + stopOnEntry: true, + }), + client.assertStoppedLocation('entry', {}), + ]); + + await client.continueRequest({ threadId: 1 }); + await client.assertStoppedLocation('exception', { + path: program, + line: 14, + column: 1, + }); + const stoppedEvent = await client.waitForStop(); + assert.ok( + stoppedEvent.body.text?.includes('invalid ApplicationArgs index 0'), + stoppedEvent.body.text, + ); + await assertVariables(client, { + pc: 30, + stack: [], + apps: [ + { + appID: 1001, + globalState: new ByteArrayMap([[Buffer.from('counter'), 4]]), + }, + ], + }); + + // Can walk backwards + await client.stepBackRequest({ threadId: 1 }); + await client.assertStoppedLocation('step', { + path: program, + line: 14, + column: 1, + }); + await assertVariables(client, { + pc: 30, // We're at the same pc, but before the opcode ran, hence the global state value + stack: [], + apps: [ + { + appID: 1001, + globalState: new ByteArrayMap([[Buffer.from('counter'), 5]]), + }, + ], + }); + + // And backwards again + await client.stepBackRequest({ threadId: 1 }); + await client.assertStoppedLocation('step', { + path: program, + line: 13, + column: 1, + }); + await assertVariables(client, { + pc: 27, + stack: [0], + apps: [ + { + appID: 1001, + globalState: new ByteArrayMap([[Buffer.from('counter'), 5]]), + }, + ], + }); + + // Walking forward hits the error again + await client.continueRequest({ threadId: 1 }); + await client.assertStoppedLocation('exception', { + path: program, + line: 14, + column: 1, + }); + await assertVariables(client, { + pc: 30, + stack: [], + apps: [ + { + appID: 1001, + globalState: new ByteArrayMap([[Buffer.from('counter'), 4]]), + }, + ], + }); + + // Can walk forward, but it ends the program in this example + await client.nextRequest({ threadId: 1 }); + await fixture.client.waitForEvent('terminated'); + }); + }); + + it('should correctly report a clear state rejection', async () => { + const simulateTraceFile = path.join( + DATA_ROOT, + 'errors/clear-state/rejection-response.json', + ); + const programSourcesDescriptionFile = path.join( + DATA_ROOT, + 'errors/clear-state/sources.json', + ); + const { client } = fixture; + + const program = normalizePathAndCasing( + nodeFileAccessor, + path.join(DATA_ROOT, 'errors/clear-state/returnFirstAppArg.teal'), + ); + + await Promise.all([ + client.configurationSequence(), + client.launch({ + simulateTraceFile, + programSourcesDescriptionFile, + stopOnEntry: true, + }), + client.assertStoppedLocation('entry', {}), + ]); + + await client.continueRequest({ threadId: 1 }); + await client.assertStoppedLocation('exception', { + path: program, + line: 16, + column: 1, + }); + const stoppedEvent = await client.waitForStop(); + assert.ok( + stoppedEvent.body.text?.includes('Clear state program did not succeed'), // Our error message + stoppedEvent.body.text, + ); + await assertVariables(client, { + pc: 34, + stack: [0], + apps: [ + { + appID: 1001, + globalState: new ByteArrayMap([[Buffer.from('counter'), 4]]), + }, + ], + }); + + // Can walk backwards + await client.stepBackRequest({ threadId: 1 }); + await client.assertStoppedLocation('step', { + path: program, + line: 16, + column: 1, + }); + await assertVariables(client, { + pc: 34, // We're at the same pc, but before the opcode ran, hence the global state value + stack: [0], + apps: [ + { + appID: 1001, + globalState: new ByteArrayMap([[Buffer.from('counter'), 5]]), + }, + ], + }); + + // And backwards again + await client.stepBackRequest({ threadId: 1 }); + await client.assertStoppedLocation('step', { + path: program, + line: 15, + column: 1, + }); + await assertVariables(client, { + pc: 33, + stack: [new Uint8Array(8)], + apps: [ + { + appID: 1001, + globalState: new ByteArrayMap([[Buffer.from('counter'), 5]]), + }, + ], + }); + + // Walking forward hits the error again + await client.continueRequest({ threadId: 1 }); + await client.assertStoppedLocation('exception', { + path: program, + line: 16, + column: 1, + }); + await assertVariables(client, { + pc: 34, + stack: [0], + apps: [ + { + appID: 1001, + globalState: new ByteArrayMap([[Buffer.from('counter'), 4]]), + }, + ], + }); + + // Can walk forward, but it ends the program in this example + await client.nextRequest({ threadId: 1 }); + await fixture.client.waitForEvent('terminated'); }); });