diff --git a/servers/cu/package-lock.json b/servers/cu/package-lock.json index a3b473db9..53cd5ec60 100644 --- a/servers/cu/package-lock.json +++ b/servers/cu/package-lock.json @@ -8,7 +8,7 @@ "name": "@permaweb/ao-cu", "version": "1.0.0", "dependencies": { - "@permaweb/ao-loader": "0.0.7", + "@permaweb/ao-loader": "0.0.9", "cors": "^2.8.5", "debug": "^4.3.4", "express": "^4.18.2", @@ -29,9 +29,9 @@ } }, "node_modules/@permaweb/ao-loader": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@permaweb/ao-loader/-/ao-loader-0.0.7.tgz", - "integrity": "sha512-Ja9ofZz62J6p/p0fzQvuA0N4hLKoMIvIAsWk3p9BAAM4QsU2BhiJnfrUl9Z66ebYddamZydJ7CbRc4VUhDTQag==", + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@permaweb/ao-loader/-/ao-loader-0.0.9.tgz", + "integrity": "sha512-PdjfRiA38NTSzgkoi48XS8W1ebTD/TK3+2xsVTzHGOwxDGC9uPRx6YEpGXZi3SDeK4hx41WIWDyEwSYY1p+XWg==", "engines": { "node": ">=18" } @@ -1867,9 +1867,9 @@ }, "dependencies": { "@permaweb/ao-loader": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@permaweb/ao-loader/-/ao-loader-0.0.7.tgz", - "integrity": "sha512-Ja9ofZz62J6p/p0fzQvuA0N4hLKoMIvIAsWk3p9BAAM4QsU2BhiJnfrUl9Z66ebYddamZydJ7CbRc4VUhDTQag==" + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@permaweb/ao-loader/-/ao-loader-0.0.9.tgz", + "integrity": "sha512-PdjfRiA38NTSzgkoi48XS8W1ebTD/TK3+2xsVTzHGOwxDGC9uPRx6YEpGXZi3SDeK4hx41WIWDyEwSYY1p+XWg==" }, "abbrev": { "version": "1.1.1", diff --git a/servers/cu/package.json b/servers/cu/package.json index 5e9a90de1..89bbb3e90 100644 --- a/servers/cu/package.json +++ b/servers/cu/package.json @@ -12,7 +12,7 @@ "test": "node --test ./src" }, "dependencies": { - "@permaweb/ao-loader": "0.0.7", + "@permaweb/ao-loader": "0.0.9", "cors": "^2.8.5", "debug": "^4.3.4", "express": "^4.18.2", diff --git a/servers/cu/src/domain/lib/evaluate.js b/servers/cu/src/domain/lib/evaluate.js index 5489e8bcc..e5537ea49 100644 --- a/servers/cu/src/domain/lib/evaluate.js +++ b/servers/cu/src/domain/lib/evaluate.js @@ -1,5 +1,5 @@ import { - T, always, applySpec, assocPath, cond, identity, + T, always, applySpec, assocPath, cond, defaultTo, identity, ifElse, is, mergeRight, pathOr, pipe, propOr, reduced } from 'ramda' @@ -27,7 +27,7 @@ const ctxSchema = z.object({ function addHandler (ctx) { return of(ctx.src) - .map(AoLoader) + .chain(fromPromise(AoLoader)) .map((handle) => ({ handle, ...ctx })) } @@ -91,33 +91,34 @@ export function evaluateWith (env) { * return a function that will merge the next interaction output * with the previous. */ - const mergeOutput = (prev) => applySpec({ - /** - * We default to 'new' state, from applying an interaction, - * to the previous state, but it will be overwritten by the outputs state - * - * This ensures the new interaction in the chain has state to - * operate on, even if the previous interaction only produced - * messages and no state change. - * - * If the output contains an error, ignore its state, - * and use the previous message's state - */ - state: ifElse( - pathOr(undefined, ['result', 'error']), - always(prev.state), - propOr(prev.state, 'state') - ), - result: { - error: pathOr(undefined, ['result', 'error']), - messages: pathOr([], ['result', 'messages']), - spawns: pathOr([], ['result', 'spawns']), + const mergeOutput = (prev) => pipe( + defaultTo({}), + applySpec({ + /** + * We default to 'new' state, from applying an interaction, + * to the previous state, but it will be overwritten by the outputs state + * + * This ensures the new interaction in the chain has state to + * operate on, even if the previous interaction only produced + * messages and no state change. + * + * If the output contains an error, ignore its state, + * and use the previous message's state + */ + buffer: ifElse( + pathOr(undefined, ['error']), + always(prev.buffer), + propOr(prev.buffer, 'buffer') + ), + error: pathOr(undefined, ['error']), + messages: pathOr([], ['messages']), + spawns: pathOr([], ['spawns']), output: pipe( - pathOr('', ['result', 'output']), + pathOr('', ['output']), /** - * Always make sure the output - * is a string - */ + * Always make sure the output + * is a string + */ cond([ [is(String), identity], [is(Number), String], @@ -125,25 +126,23 @@ export function evaluateWith (env) { [T, identity] ]) ) - } - }) + }) + ) return (ctx) => of(ctx) .chain(addHandler) .chain(fromPromise(async (ctx) => { - let prev = { - state: ctx.state, + let prev = applySpec({ /** * Ensure all result fields are initialized * to their identity */ - result: applySpec({ - messages: pathOr([], ['messages']), - output: pathOr('', ['output']), - spawns: pathOr([], ['spawns']) - })(ctx.result) - } + buffer: pathOr(null, ['buffer']), + messages: always([]), + output: always(''), + spawns: always([]) + })(ctx) /** * Iterate over the async iterable of messages, @@ -153,24 +152,27 @@ export function evaluateWith (env) { prev = await Promise.resolve(prev) .then(maybeRejectError) .then(prev => - Promise.resolve(prev.state) + Promise.resolve(prev.buffer) .then(logger.tap( 'Evaluating message with sortKey "%s" to process "%s"', sortKey, ctx.id )) - .then((state) => ctx.handle(state, message, AoGlobal)) + /** + * Where the actual evaluation is performed + */ + .then((buffer) => ctx.handle(buffer, message, AoGlobal)) /** * Map thrown error to a result.error */ - .catch((err) => Promise.resolve(assocPath(['result', 'error'], err, {}))) + .catch((err) => Promise.resolve(assocPath(['error'], err, {}))) /** * The the previous interaction output, and merge it * with the output of the current interaction */ .then(mergeOutput(prev)) .then((output) => { - return output.result && output.result.error + return output.error ? Promise.reject(output) : Promise.resolve(output) }) diff --git a/servers/cu/src/domain/lib/evaluate.test.js b/servers/cu/src/domain/lib/evaluate.test.js index c7b384c55..0cb4348cf 100644 --- a/servers/cu/src/domain/lib/evaluate.test.js +++ b/servers/cu/src/domain/lib/evaluate.test.js @@ -24,15 +24,15 @@ describe('evaluate', () => { ctx = { id: 'ctr-1234', from: 'sort-key-start', - src: readFileSync('./test/contracts/happy/contract.wasm'), - state: { foo: 'bar' }, + src: readFileSync('./test/processes/happy/process.wasm'), + buffer: null, messages: toAsyncIterable([ { message: { owner: 'owner-123', - tags: { - function: 'hello' - } + tags: [ + { name: 'function', value: 'hello' } + ] }, sortKey: 'a', AoGlobal: {} @@ -40,9 +40,9 @@ describe('evaluate', () => { { message: { owner: 'owner-456', - tags: { - function: 'world' - } + tags: [ + { name: 'function', value: 'world' } + ] }, sortKey: 'b', AoGlobal: {} @@ -56,26 +56,36 @@ describe('evaluate', () => { assert.ok(output) }) + /** + * TODO: how to assert eval? + */ test('folds the state', async () => { const { output } = await evaluate(ctx).toPromise() + /** + * Assert the buffer of the internal process state is returned + */ + assert.ok(output.buffer) assert.deepStrictEqual( - output.state, + /** + * Our process used in the unit tests serializes the state being mutated + * by the process, so we can parse it here and run assertions + */ + JSON.parse(output.output), { - foo: 'bar', heardHello: true, heardWorld: true, happy: true, lastMessage: { owner: 'owner-456', - tags: { - function: 'world' - } + tags: [ + { name: 'function', value: 'world' } + ] } } ) }) - test('returns result.messages', async () => { + test('returns messages', async () => { const expectedMessage = { target: 'process-foo-123', tags: [ @@ -84,10 +94,10 @@ describe('evaluate', () => { ] } const { output } = await evaluate(ctx).toPromise() - assert.deepStrictEqual(output.result.messages, [expectedMessage]) + assert.deepStrictEqual(output.messages, [expectedMessage]) }) - test('returns result.spawns', async () => { + test('returns spawns', async () => { const expectedSpawn = { owner: 'owner-123', tags: [ @@ -96,12 +106,22 @@ describe('evaluate', () => { ] } const { output } = await evaluate(ctx).toPromise() - assert.deepStrictEqual(output.result.spawns, [expectedSpawn]) + assert.deepStrictEqual(output.spawns, [expectedSpawn]) }) - test('returns result.output', async () => { + test('returns output', async () => { const { output } = await evaluate(ctx).toPromise() - assert.deepStrictEqual(output.result.output, 'foobar') + assert.deepStrictEqual(JSON.parse(output.output), { + heardHello: true, + heardWorld: true, + happy: true, + lastMessage: { + owner: 'owner-456', + tags: [ + { name: 'function', value: 'world' } + ] + } + }) }) }) @@ -120,15 +140,15 @@ describe('evaluate', () => { const ctx = { id: 'ctr-1234', from: 'sort-key-start', - src: readFileSync('./test/contracts/happy/contract.wasm'), - state: { balances: { 1: 1 } }, + src: readFileSync('./test/processes/happy/process.wasm'), + buffer: null, messages: toAsyncIterable([ { message: { owner: 'owner-123', - tags: { - function: 'hello' - } + tags: [ + { name: 'function', value: 'hello' } + ] }, sortKey: 'a', AoGlobal: {} @@ -136,9 +156,9 @@ describe('evaluate', () => { { message: { owner: 'owner-456', - tags: { - function: 'world' - } + tags: [ + { name: 'function', value: 'world' } + ] }, sortKey: 'b', AoGlobal: {} @@ -162,23 +182,26 @@ describe('evaluate', () => { const ctx = { id: 'ctr-1234', from: 'sort-key-start', - src: readFileSync('./test/contracts/happy/contract.wasm'), - state: { balances: { 1: 1 } }, + src: readFileSync('./test/processes/happy/process.wasm'), + /** + * In reality this would be an illegible byte array, since it's format + * will be determined by whatever the underlying runtime is, in this case, + * Lua + */ + buffer: Buffer.from('Hello', 'utf-8'), messages: toAsyncIterable([]) } const { output } = await evaluate(ctx).toPromise() assert.deepStrictEqual(output, { - state: { balances: { 1: 1 } }, - result: { - messages: [], - spawns: [], - output: '' - } + buffer: Buffer.from('Hello', 'utf-8'), + messages: [], + spawns: [], + output: '' }) }) - test('error returned in contract result', async () => { + test('error returned in process result', async () => { const env = { saveEvaluation: async () => assert.fail(), logger @@ -189,16 +212,16 @@ describe('evaluate', () => { const ctx = { id: 'ctr-1234', from: 'sort-key-start', - src: readFileSync('./test/contracts/sad/contract.wasm'), - state: { foo: 'bar' }, + src: readFileSync('./test/processes/sad/process.wasm'), + buffer: Buffer.from('Hello', 'utf-8'), messages: toAsyncIterable([ { - // Will include an error in result.error + // Will include an error in error message: { owner: 'owner-456', - tags: { - function: 'errorResult' - } + tags: [ + { name: 'function', value: 'errorResult' } + ] }, sortKey: 'a', AoGlobal: {} @@ -207,20 +230,24 @@ describe('evaluate', () => { } const res = await evaluate(ctx).toPromise() - console.log(res) assert.ok(res.output) assert.deepStrictEqual(res.output, { - state: { foo: 'bar' }, - result: { - error: { code: 123, message: 'a handled error within the contract' }, - messages: [], - spawns: [], - output: '' - } + /** + * When an error occurs in eval, its output buffer is ignored + * and the output buffer from the previous eval is used. + * + * So we assert that the original buffer that was passed in is returned + * from eval + */ + buffer: Buffer.from('Hello', 'utf-8'), + error: { code: 123, message: 'a handled error within the process' }, + messages: [], + spawns: [], + output: '0' }) }) - test('error thrown by contract', async () => { + test('error thrown by process', async () => { const env = { saveEvaluation: async () => assert.fail(), logger @@ -231,16 +258,16 @@ describe('evaluate', () => { const ctx = { id: 'ctr-1234', from: 'sort-key-start', - src: readFileSync('./test/contracts/sad/contract.wasm'), - state: { foo: 'bar' }, + src: readFileSync('./test/processes/sad/process.wasm'), + buffer: Buffer.from('Hello', 'utf-8'), messages: toAsyncIterable([ { - // Will intentionally throw from the lua contract + // Will intentionally throw from the lua process message: { owner: 'owner-456', - tags: { - function: 'errorThrow' - } + tags: [ + { name: 'function', value: 'errorThrow' } + ] }, sortKey: 'a', AoGlobal: {} @@ -251,17 +278,15 @@ describe('evaluate', () => { const res = await evaluate(ctx).toPromise() assert.ok(res.output) assert.deepStrictEqual(res.output, { - state: { foo: 'bar' }, - result: { - error: { code: 123, message: 'a thrown error within the contract' }, - messages: [], - spawns: [], - output: '' - } + buffer: Buffer.from('Hello', 'utf-8'), + error: { code: 123, message: 'a thrown error within the process' }, + messages: [], + spawns: [], + output: '' }) }) - test('error unhandled by contract', async () => { + test('error unhandled by process', async () => { const env = { saveEvaluation: async () => assert.fail(), logger @@ -272,16 +297,16 @@ describe('evaluate', () => { const ctx = { id: 'ctr-1234', from: 'sort-key-start', - src: readFileSync('./test/contracts/sad/contract.wasm'), - state: { foo: 'bar' }, + src: readFileSync('./test/processes/sad/process.wasm'), + buffer: Buffer.from('Hello', 'utf-8'), messages: toAsyncIterable([ { // Will unintentionally throw from the lua contract message: { owner: 'owner-456', - tags: { - function: 'errorUnhandled' - } + tags: [ + { name: 'function', value: 'errorUnhandled' } + ] }, sortKey: 'a', AoGlobal: {} @@ -290,10 +315,9 @@ describe('evaluate', () => { } const res = await evaluate(ctx).toPromise() - console.log(res.output) assert.ok(res.output) - assert.ok(res.output.result.error) - assert.deepStrictEqual(res.state, { foo: 'bar' }) + assert.ok(res.output.error) + assert.deepStrictEqual(res.buffer, Buffer.from('Hello', 'utf-8')) }) test('continue evaluating, ignoring output of errored message', async () => { @@ -311,29 +335,40 @@ describe('evaluate', () => { const ctx = { id: 'ctr-1234', from: 'sort-key-start', - src: readFileSync('./test/contracts/sad/contract.wasm'), - state: { counter: 1 }, + src: readFileSync('./test/processes/sad/process.wasm'), + buffer: null, messages: toAsyncIterable([ { // Will include an error in result.error message: { owner: 'owner-456', - tags: { - function: 'errorResult' - } + tags: [ + { name: 'function', value: 'errorResult' } + ] }, sortKey: 'a', AoGlobal: {} }, { - // Will include an error in result.error + // Will increment a counter in global state message: { owner: 'owner-456', - tags: { - function: 'counter' - } + tags: [ + { name: 'function', value: 'counter' } + ] }, - sortKey: 'a', + sortKey: 'b', + AoGlobal: {} + }, + { + // Will increment a counter in global state + message: { + owner: 'owner-456', + tags: [ + { name: 'function', value: 'counter' } + ] + }, + sortKey: 'c', AoGlobal: {} } ]) @@ -341,15 +376,8 @@ describe('evaluate', () => { const res = await evaluate(ctx).toPromise() assert.ok(res.output) - assert.deepStrictEqual(res.output, { - state: { counter: 2 }, - result: { - error: undefined, - messages: [], - spawns: [], - output: '' - } - }) - assert.equal(cacheCount, 1) + assert.equal(JSON.parse(res.output.output), 2) + // Only cache the evals that did not produce errors + assert.equal(cacheCount, 2) }) }) diff --git a/servers/cu/test/contracts/happy/contract.lua b/servers/cu/test/contracts/happy/contract.lua deleted file mode 100644 index 25b34b2d0..000000000 --- a/servers/cu/test/contracts/happy/contract.lua +++ /dev/null @@ -1,72 +0,0 @@ --- This contract is published to Arweave for the purpose of testing --- the SDK end to end. If you alter this contract code, be sure --- to build and publish using the ao CLI, and use the new Contract Source to --- create a new contract and interactions - --- Corresponding local wasm at ./contract.wasm - -local contract = { _version = "0.0.1" } - -local function assoc(prop, val, obj) - local result = {} - for p, k in pairs(obj) do - result[p] = k - end - result[prop] = val - return result -end - -local function hello(state) - return assoc('heardHello', true, state) -end - -local function world(state) - return assoc('heardWorld', true, state) -end - -local actions = {} -actions['hello'] = hello -actions['world'] = world - -function contract.handle(state, message, AoGlobal) - local func = message.tags['function'] - if func == nil then return error({ code = 500, message = 'no function tag in the message'}) end - - local newState = actions[func](state, message, AoGlobal) - - newState = assoc('lastMessage', message, newState) - - if (newState.heardHello and newState.heardWorld) then - newState = assoc('happy', true, newState) - end - - return { - state = newState, - result = { - -- stub messages - messages = { - { - target = 'process-foo-123', - tags = { - { name = 'foo', value = 'bar' }, - { name = 'function', value = 'noop' } - } - } - }, - -- stub spawns - spawns = { - { - owner = 'owner-123', - tags = { - { name = 'foo', value = 'bar' }, - { name = 'balances', value = "{\"myOVEwyX7QKFaPkXo3Wlib-Q80MOf5xyjL9ZyvYSVYc\": 1000 }" } - } - } - }, - -- stub output - output = 'foobar' - } - } -end - -return contract diff --git a/servers/cu/test/contracts/sad/contract.lua b/servers/cu/test/contracts/sad/contract.lua deleted file mode 100644 index bf7d888b0..000000000 --- a/servers/cu/test/contracts/sad/contract.lua +++ /dev/null @@ -1,51 +0,0 @@ --- This contract is published to Arweave for the purpose of testing --- the SDK end to end, specifically when an error occurs. If you alter this contract code, be sure --- to build and publish using the ao CLI, and use the new Contract Source to --- create a new contract and interactions - --- Corresponding local wasm at ./contract.wasm - -local contract = { _version = "0.0.1" } - -local function assoc(prop, val, obj) - local result = {} - for p, k in pairs(obj) do - result[p] = k - end - result[prop] = val - return result -end - -local function counter(state) - return assoc('counter', state.counter + 1, state), nil -end - -local function result(res) - return nil, assoc('error', { code = 123, message = "a handled error within the contract" }, res) -end - -local function throw() - return error({ code = 123, message = "a thrown error within the contract" }) -end - -local function unhandled() - contract.field.does_not_exist = 'foo' -end - -local actions = {} -actions['counter'] = counter -actions['errorResult'] = result -actions['errorThrow'] = throw -actions['errorUnhandled'] = unhandled - -function contract.handle(state, message, AoGlobal) - local func = message.tags['function'] - - if func == nil then return error({ code = 500, message = 'no function tag in the message'}) end - - local newState, newResult = actions[func](state, message, AoGlobal) - - return { state = newState, result = newResult } -end - -return contract diff --git a/servers/cu/test/processes/happy/process.lua b/servers/cu/test/processes/happy/process.lua new file mode 100644 index 000000000..e68553664 --- /dev/null +++ b/servers/cu/test/processes/happy/process.lua @@ -0,0 +1,81 @@ +-- Corresponding local wasm at ./process.wasm +local JSON = require("json") + +local process = { _version = "0.0.6" } + +local function assoc(prop, val, obj) + local result = {} + for p, k in pairs(obj) do + result[p] = k + end + result[prop] = val + return result +end + +local function findObject(array, key, value) + for i, object in ipairs(array) do + if object[key] == value then + return object + end + end + return nil +end + +local function dump(o) + if type(o) == 'table' then + local s = '{ ' + for k,v in pairs(o) do + if type(k) ~= 'number' then k = '"'..k..'"' end + s = s .. '['..k..'] = ' .. dump(v) .. ',' + end + return s .. '} ' + else + return tostring(o) + end +end + +local actions = {} +actions['hello'] = function (state) + return assoc('heardHello', true, state) +end +actions['world'] = function (state) + return assoc('heardWorld', true, state) +end + +function process.handle(message, AoGlobal) + if state == nil then state = {} end + + local func = findObject(message.tags, "name", "function") + if func == nil then return error({ code = 500, message = 'no function tag in the message'}) end + + state = actions[func.value](state, message, AoGlobal) + state = assoc('lastMessage', message, state) + if (state.heardHello and state.heardWorld) then state = assoc('happy', true, state) end + + return { + -- stub messages + messages = { + { + target = 'process-foo-123', + tags = { + { name = 'foo', value = 'bar' }, + { name = 'function', value = 'noop' } + } + } + }, + -- stub spawns + spawns = { + { + owner = 'owner-123', + tags = { + { name = 'foo', value = 'bar' }, + { name = 'balances', value = "{\"myOVEwyX7QKFaPkXo3Wlib-Q80MOf5xyjL9ZyvYSVYc\": 1000 }" } + } + } + }, + -- So we can assert the state in tests + output = JSON.encode(state) + } +end + +return process diff --git a/servers/cu/test/contracts/happy/contract.wasm b/servers/cu/test/processes/happy/process.wasm similarity index 70% rename from servers/cu/test/contracts/happy/contract.wasm rename to servers/cu/test/processes/happy/process.wasm index 75d0159b6..17896dc48 100755 Binary files a/servers/cu/test/contracts/happy/contract.wasm and b/servers/cu/test/processes/happy/process.wasm differ diff --git a/servers/cu/test/processes/sad/process.lua b/servers/cu/test/processes/sad/process.lua new file mode 100644 index 000000000..9db1babec --- /dev/null +++ b/servers/cu/test/processes/sad/process.lua @@ -0,0 +1,62 @@ +-- Corresponding local wasm at ./process.wasm +local JSON = require("json") + +local process = { _version = "0.0.6" } + +local function assoc(prop, val, obj) + local result = {} + for p, k in pairs(obj) do + result[p] = k + end + result[prop] = val + return result +end + +local function findObject(array, key, value) + for i, object in ipairs(array) do + if object[key] == value then + return object + end + end + return nil +end + +local function dump(o) + if type(o) == 'table' then + local s = '{ ' + for k,v in pairs(o) do + if type(k) ~= 'number' then k = '"'..k..'"' end + s = s .. '['..k..'] = ' .. dump(v) .. ',' + end + return s .. '} ' + else + return tostring(o) + end +end + +local actions = {} +actions['counter'] = function (state) + return assoc('counter', state.counter + 1, state), nil +end +actions['errorResult'] = function (state) + return state, { code = 123, message = "a handled error within the process" } +end +actions['errorThrow'] = function () + return error({ code = 123, message = "a thrown error within the process" }) +end +actions['errorUnhandled'] = function () + process.field.does_not_exist = 'foo' +end + +function process.handle(message, AoGlobal) + if state == nil then state = { counter = 0 } end + + local func = findObject(message.tags, "name", "function") + if func == nil then return error({ code = 500, message = 'no function tag in the message'}) end + + state, err = actions[func.value](state, message, AoGlobal) + + return { error = err, output = JSON.encode(state.counter) } +end + +return process diff --git a/servers/cu/test/contracts/sad/contract.wasm b/servers/cu/test/processes/sad/process.wasm similarity index 69% rename from servers/cu/test/contracts/sad/contract.wasm rename to servers/cu/test/processes/sad/process.wasm index 48cd73e89..01ab2fcc9 100755 Binary files a/servers/cu/test/contracts/sad/contract.wasm and b/servers/cu/test/processes/sad/process.wasm differ