diff --git a/src/common/main.lua b/src/common/main.lua index e3c1812..90a07f9 100644 --- a/src/common/main.lua +++ b/src/common/main.lua @@ -88,38 +88,50 @@ function ant.init() createActionHandler(TokenSpecActionMap.Transfer, function(msg) local recipient = msg.Tags.Recipient - utils.validateOwner(msg.From) + if msg.From ~= Owner then + utils.Send(msg, { + Action = "Transfer-Error", + ["Message-Id"] = msg.Id, + Error = "Insufficient Balance!", + }) + end balances.transfer(recipient, msg.Tags["Allow-Unsafe-Addresses"]) + if not msg.Cast then - ao.send(notices.debit(msg)) - ao.send(notices.credit(msg)) + utils.Send(msg, notices.debit(msg)) + utils.Send(msg, notices.credit(msg)) end end) createActionHandler(TokenSpecActionMap.Balance, function(msg) - local balRes = balances.balance(msg.Tags.Recipient or msg.From, msg.Tags["Allow-Unsafe-Addresses"]) + local addressToCheck = msg.Tags.Recipient or msg.Tags.Target or msg.From + local balRes = balances.balance(addressToCheck, msg.Tags["Allow-Unsafe-Addresses"]) - ao.send({ - Target = msg.From, + utils.Send(msg, { Action = "Balance-Notice", Balance = tostring(balRes), Ticker = Ticker, - Address = msg.Tags.Recipient or msg.From, - Data = balRes, + Account = addressToCheck, + Address = addressToCheck, + Data = tostring(balRes), }) end) - createActionHandler(TokenSpecActionMap.Balances, function() - return balances.balances() + createActionHandler(TokenSpecActionMap.Balances, function(msg) + local bals = {} + for k, v in pairs(balances.balances()) do + bals[k] = tostring(v) + end + + utils.Send(msg, { Data = json.encode(bals) }) end) createActionHandler(TokenSpecActionMap.TotalSupply, function(msg) assert(msg.From ~= ao.id, "Cannot call Total-Supply from the same process!") - ao.send({ - Target = msg.From, - Action = "Total-Supply-Notice", - Data = TotalSupply, + utils.Send(msg, { + Action = "Total-Supply", + Data = tostring(TotalSupply), Ticker = Ticker, }) end) @@ -136,8 +148,7 @@ function ant.init() Owner = Owner, Handlers = utils.getHandlerNames(Handlers), } - ao.send({ - Target = msg.From, + utils.Send(msg, { Action = "Info-Notice", Tags = info, Data = json.encode(info), diff --git a/src/common/utils.lua b/src/common/utils.lua index 495b130..070b6c4 100644 --- a/src/common/utils.lua +++ b/src/common/utils.lua @@ -296,20 +296,25 @@ function utils.createHandler(tagName, tagValue, handler, position) return handler(msg) end, utils.errorHandler) + local resultNotice = nil if not handlerStatus then - ao.send(notices.addForwardedTags(msg, { + resultNotice = notices.addForwardedTags(msg, { Target = msg.From, Action = "Invalid-" .. tagValue .. "-Notice", Error = tagValue .. "-Error", ["Message-Id"] = msg.Id, Data = handlerRes, - })) + }) elseif handlerRes then - ao.send(notices.addForwardedTags(msg, { + resultNotice = notices.addForwardedTags(msg, { Target = msg.From, Action = tagValue .. "-Notice", Data = type(handlerRes) == "string" and handlerRes or json.encode(handlerRes), - })) + }) + end + + if resultNotice then + utils.Send(msg, resultNotice) end local hasNewOwner = Owner ~= prevOwner @@ -358,4 +363,15 @@ function utils.validateKeywords(keywords) end end +--- @param msg AoMessage +--- @param response table +function utils.Send(msg, response) + if msg.reply then + --- Reference: https://github.com/permaweb/aos/blob/main/blueprints/patch-legacy-reply.lua + msg.reply(response) + else + ao.send(response) + end +end + return utils diff --git a/test/balances.test.mjs b/test/balances.test.mjs index 9137b70..e3a7fab 100644 --- a/test/balances.test.mjs +++ b/test/balances.test.mjs @@ -71,7 +71,7 @@ describe('aos Balances', async () => { if (shouldPass === true) { const targetBalance = result.Messages[0].Data; - assert.equal(typeof targetBalance === 'number', shouldPass); + assert.equal(typeof targetBalance === 'string', shouldPass); } else { assert.strictEqual( result.Messages[0].Tags.find((t) => t.name === 'Error')?.value, @@ -97,7 +97,7 @@ describe('aos Balances', async () => { transferResult.Memory, ); const balances = JSON.parse(balancesResult.Messages[0].Data); - assert.equal(balances[target_address] === 1, shouldPass); + assert.equal(balances[target_address] === '1', shouldPass); } else { assert.strictEqual( transferResult.Messages[0].Tags.find((t) => t.name === 'Error') @@ -157,7 +157,7 @@ describe('aos Balances', async () => { const [ownerAddress, ownerBalance] = ownerEntry; assert(Object.entries(balances).length === 1); assert(ownerAddress === STUB_ADDRESS); - assert(ownerBalance === 1); + assert(ownerBalance === '1'); }); it('should set the logo of the ant', async () => { @@ -174,6 +174,6 @@ describe('aos Balances', async () => { }); it('should get total supply', async () => { const res = await getTotalSupply(); - assert.strictEqual(res, 1, 'total supply should be equal to 1'); + assert.strictEqual(res, '1', 'total supply should be equal to 1'); }); }); diff --git a/test/specs/token.test.mjs b/test/specs/token.test.mjs new file mode 100644 index 0000000..92850a2 --- /dev/null +++ b/test/specs/token.test.mjs @@ -0,0 +1,230 @@ +import { createAntAosLoader } from '../utils.mjs'; +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { + AO_LOADER_HANDLER_ENV, + DEFAULT_HANDLE_OPTIONS, + STUB_ADDRESS, +} from '../../tools/constants.mjs'; + +/** + * Background + * + * This test is for testing the compliance with https://github.com/permaweb/aos/blob/main/blueprints/token.lua "spec" + * + * The reason we attempt to comply to this is for integration with other platforms like bazar.arweave.net and botega.arweave.net + */ + +describe('Token spec compliance', async () => { + const { handle: originalHandle, memory: startMemory } = + await createAntAosLoader(); + + async function handle(options = {}, mem = startMemory) { + return originalHandle( + mem, + { + ...DEFAULT_HANDLE_OPTIONS, + ...options, + }, + AO_LOADER_HANDLER_ENV, + ); + } + + /** + * Handlers: + * - info + * - total supply + * - balance + * - balances + * - transfer + + We do not implement: + * - mint + * - burn + * + */ + + it('should get the process info', async () => { + const result = await handle({ + Tags: [{ name: 'Action', value: 'Info' }], + }); + /** + * Check for: + * - Name (string) + * - Ticker (string) + * - Logo (arweave ID) + * - Denomination (number as string) + */ + + const tags = result.Messages[0].Tags; + const ref = tags.find((t) => t.name === 'X-Reference')?.value; + const name = tags.find((t) => t.name === 'Name')?.value; + const ticker = tags.find((t) => t.name === 'Ticker')?.value; + const logo = tags.find((t) => t.name === 'Logo')?.value; + const denomination = tags.find((t) => t.name === 'Denomination')?.value; + + assert.strictEqual(typeof name, 'string'); + assert.strictEqual(typeof ticker, 'string'); + assert.strictEqual(typeof logo, 'string'); + assert.strictEqual(typeof denomination, 'string'); + assert.strictEqual(Number.isInteger(parseInt(denomination)), true); + assert(ref, 'Not a reply message'); + }); + + it('should get the balance of an account', async () => { + /** + * Test with caller as: + * - Recipient + * - Target + * - From + * Test as: + * - Owner + * - Anonymous + * + * Check for: + * - Balance (string int) + * - Ticker (string) + * - Account (string equal to target address we are checking balance of) + * - Data (same as balance, both in type and qty) + */ + const targetAccount = ''.padEnd(43, 'target-0-'); + const targetAccountTags = [ + // Should return 0 + { name: 'Recipient', value: targetAccount }, + { name: 'Target', value: targetAccount }, + // happy path (owner calls for balance) should return 1 + { name: 'Recipient', value: STUB_ADDRESS }, + { name: 'Target', value: STUB_ADDRESS }, + null, // "equivalent to "from" + ]; + + for (const targetAccountTag of targetAccountTags) { + const result = await handle({ + Tags: [ + { name: 'Action', value: 'Balance' }, + ...(targetAccountTag ? [targetAccountTag] : []), + ], + }); + const tags = result.Messages[0].Tags; + const ref = tags.find((t) => t.name === 'X-Reference')?.value; + + const balanceData = result.Messages[0].Data; + const balanceTag = tags.find((t) => t.name === 'Balance')?.value; + const ticker = tags.find((t) => t.name === 'Ticker')?.value; + const account = tags.find((t) => t.name === 'Account')?.value; + + assert.strictEqual(typeof balanceData, 'string'); + assert.strictEqual(typeof balanceTag, 'string'); + assert.strictEqual(balanceData, balanceTag); + assert.strictEqual(Number.isInteger(parseInt(balanceData)), true); + assert.strictEqual(typeof ticker, 'string'); + assert.strictEqual(targetAccountTag?.value ?? STUB_ADDRESS, account); + assert(ref, 'Not a reply message'); + } + }); + + it('should get the balances', async () => { + /** + * Assert each balance is a string and int, + */ + const result = await handle({ + Tags: [{ name: 'Action', value: 'Balances' }], + }); + + const tags = result.Messages[0].Tags; + const ref = tags.find((t) => t.name === 'X-Reference')?.value; + assert(ref, 'Not a reply message'); + + for (const bal of Object.values(JSON.parse(result.Messages[0].Data))) { + assert.strictEqual(typeof bal, 'string'); + assert.strictEqual(Number.isInteger(parseInt(bal)), true); + } + }); + + it('should transfer', async () => { + /** + * Check as: + * - Owner (sufficient balance) + * - Not owner (will send error message) + */ + const recipient = ''.padEnd(43, 'recipient-1'); + const result = await handle({ + Tags: [ + { name: 'Action', value: 'Transfer' }, + { name: 'Recipient', value: recipient }, + ], + }); + + const creditNotice = result.Messages.find(({ Tags }) => + Tags.find((t) => t.name === 'Action' && t.value === 'Credit-Notice'), + ); + assert(creditNotice, 'missing credit notice'); + const debitNotice = result.Messages.find(({ Tags }) => + Tags.find((t) => t.name === 'Action' && t.value === 'Debit-Notice'), + ); + const ref = debitNotice.Tags.find((t) => t.name === 'X-Reference')?.value; + assert(ref, 'Credit notice is not a reply message'); + assert(debitNotice, 'Missing debit notice'); + + const infoRes = await handle( + { + Tags: [{ name: 'Action', value: 'Info' }], + }, + result.Memory, + ); + const ownerAfterTransfer = infoRes.Messages[0].Tags.find( + (t) => t.name === 'Owner', + )?.value; + assert.strictEqual( + ownerAfterTransfer, + recipient, + 'Owner after transfer not equal to recipient', + ); + + // non owner transfer + const badResult = await handle({ + From: recipient, + Owner: recipient, + Tags: [ + { name: 'Action', value: 'Transfer' }, + { name: 'Recipient', value: STUB_ADDRESS }, + ], + }); + + // should return an insufficient funds error + const errorMessage = badResult.Messages[0]; + const errorRef = debitNotice.Tags.find( + (t) => t.name === 'X-Reference', + )?.value; + assert(errorRef, 'Error notice is not a reply message'); + const errorTag = errorMessage.Tags.find((t) => t.name === 'Error'); + assert.strictEqual(errorTag.value, 'Insufficient Balance!'); + const errorActionTag = errorMessage.Tags.find((t) => t.name === 'Action'); + assert.strictEqual(errorActionTag.value, 'Transfer-Error'); + }); + + it('should get the total supply', async () => { + /** + * Check: + * - data for supply (string int) + * - action is Total-Supply + * - Ticker tag + * - response is reply + */ + const result = await handle({ + From: ''.padEnd(43, 'rando'), + Owner: ''.padEnd(43, 'rando'), + Tags: [{ name: 'Action', value: 'Total-Supply' }], + }); + + const totalSupply = result.Messages[0]; + const ref = totalSupply.Tags.find((t) => t.name === 'X-Reference')?.value; + assert(ref, 'Total supply is not a reply message'); + const action = totalSupply.Tags.find((t) => t.name === 'Action'); + const ticker = totalSupply.Tags.find((t) => t.name === 'Ticker'); + assert.strictEqual(action.value, 'Total-Supply'); + assert.strictEqual(typeof ticker.value, 'string'); + assert.strictEqual(typeof totalSupply.Data, 'string'); + assert.strictEqual(Number.isInteger(parseInt(totalSupply.Data)), true); + }); +}); diff --git a/tools/constants.mjs b/tools/constants.mjs index 09d4561..5bc29a0 100644 --- a/tools/constants.mjs +++ b/tools/constants.mjs @@ -68,4 +68,6 @@ export const DEFAULT_HANDLE_OPTIONS = { Target: STUB_ADDRESS, From: STUB_ADDRESS, Timestamp: Date.now(), + // for msg.reply + Reference: '1', };