[{"id":"8ee174a8a298654a","type":"tab","label":"Flow 1","disabled":false,"info":""},{"id":"9318cc15f9afd75f","type":"group","z":"8ee174a8a298654a","name":"unitsChanged v1","style":{"label":true},"nodes":["e1f6f9ae2e6910d1","27566d8948e738f9","c3c0627c1de5e349","caada626130afaf6","ac42d7e5c6032791","65d289142a86384f","6ca11a0d57bdc6cc","b8d668ba7a1a03ed","ca32fbaad3ee3347","e9eb48b870d28564","d4403c151c10fd53","98a4076a7346f77b"],"x":2074,"y":119,"w":1032,"h":262},{"id":"35ba395e82c0b1fd","type":"group","z":"8ee174a8a298654a","name":"No protocol (old message format)","style":{"label":true},"nodes":["126dbca226762933","58dda468b6e90876","be6ed27e6faa08cc","56fd2cd0cc0812f8"],"x":74,"y":399,"w":892,"h":82},{"id":"04c09f0c2cd6afcc","type":"group","z":"8ee174a8a298654a","name":"Protocol version 1","style":{"label":true},"nodes":["fb43395da3eb18ac","746c2dd32cd8fa7f","af129b9f346b5e93","0bdc014d7d7e836a","a986536a19a878b8","4d6a02a638fceb91"],"x":74,"y":559,"w":832,"h":122},{"id":"a407a49de3d419a0","type":"sqlite-config","name":"","dbLocation":""},{"id":"cbe7827e8b1ce3bd","type":"function","z":"8ee174a8a298654a","name":"Restart source","func":"node.warn('Force resetting source')\nreturn {\n    ...msg,\n    topic: 'force-reset'\n}","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":280,"y":220,"wires":[["8308f574539280d2"]]},{"id":"39c8b0b71a53b058","type":"link in","z":"8ee174a8a298654a","name":"Handle error IN","links":["41758cdba3bbaddc","5c9c86e7123f5da9"],"x":125,"y":220,"wires":[["cbe7827e8b1ce3bd"]]},{"id":"e6aebb3547ebbd29","type":"link in","z":"8ee174a8a298654a","name":"Finished processing IN","links":["27566d8948e738f9","e1f6f9ae2e6910d1","e16ab80308b97075"],"x":125,"y":280,"wires":[["a8f1dc8b2b511da8"]]},{"id":"a8f1dc8b2b511da8","type":"function","z":"8ee174a8a298654a","name":"Finished processing","func":"node.warn('Finished processing')\nreturn {\n    ...msg,\n    topic: 'finished-processing'\n}","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":280,"y":280,"wires":[["8308f574539280d2"]]},{"id":"e1f6f9ae2e6910d1","type":"link out","z":"8ee174a8a298654a","g":"9318cc15f9afd75f","name":"Finished processing correct OUT","mode":"link","links":["e6aebb3547ebbd29"],"x":3065,"y":260,"wires":[]},{"id":"27566d8948e738f9","type":"link out","z":"8ee174a8a298654a","g":"9318cc15f9afd75f","name":"Finished processing wrong tx OUT","mode":"link","links":["e6aebb3547ebbd29"],"x":3065,"y":300,"wires":[]},{"id":"c3c0627c1de5e349","type":"comment","z":"8ee174a8a298654a","g":"9318cc15f9afd75f","name":"Validation failed","info":"","x":2480,"y":340,"wires":[]},{"id":"caada626130afaf6","type":"comment","z":"8ee174a8a298654a","g":"9318cc15f9afd75f","name":"Validation succeeded","info":"","x":2500,"y":220,"wires":[]},{"id":"ac42d7e5c6032791","type":"voting-marketplace","z":"8ee174a8a298654a","g":"9318cc15f9afd75f","name":"","workerAddress":"","indexerUrl":"","solutionNamespace":"","hashVote":true,"x":2500,"y":260,"wires":[["ca32fbaad3ee3347"]]},{"id":"65d289142a86384f","type":"voting-marketplace","z":"8ee174a8a298654a","g":"9318cc15f9afd75f","name":"","workerAddress":"","indexerUrl":"","solutionNamespace":"","hashVote":false,"x":2500,"y":300,"wires":[["27566d8948e738f9"]]},{"id":"8308f574539280d2","type":"source-http-api","z":"8ee174a8a298654a","name":"","host":"","appId":"ggp","sqliteConfig":"a407a49de3d419a0","x":520,"y":220,"wires":[["737cc9c7a540ab89"]]},{"id":"737cc9c7a540ab89","type":"switch","z":"8ee174a8a298654a","name":"Protocol check","property":"payload.protocolVersion","propertyType":"msg","rules":[{"t":"eq","v":"1","vt":"str"},{"t":"istype","v":"undefined","vt":"undefined"},{"t":"else"}],"checkall":"true","repair":false,"outputs":3,"x":760,"y":220,"wires":[["a487a21a1443f6b7"],["42fe81814bb0c41b"],["90f75c63b182bae9"]]},{"id":"126dbca226762933","type":"json-schema-validator","z":"8ee174a8a298654a","g":"35ba395e82c0b1fd","name":"Validate old message type","jsonSchema":"{\n    \"type\": \"object\",\n    \"required\": [\"type\", \"txLog\"],\n    \"properties\": {\n        \"type\": { \"const\": \"unitsChanged\" },\n        \"txLog\": {\n            \"type\": \"object\",\n            \"required\": [\"rootUnitId\", \"changes\"],\n            \"properties\": {\n                \"rootUnitId\": { \"type\": \"string\" },\n                \"changes\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"object\",\n                        \"required\": [\"unitId\", \"volume\", \"owner\", \"prevOwner\"],\n                        \"properties\": {\n                            \"unitId\": { \"type\": \"string\" },\n                            \"volume\": { \"type\": \"number\" },\n                            \"owner\": { \"type\": \"string\" },\n                            \"prevOwner\": { \"type\": [\"string\", \"null\"] }\n                        }\n                    }\n                }\n            }\n        }\n    }\n}","x":510,"y":440,"wires":[["56fd2cd0cc0812f8"]]},{"id":"6ca11a0d57bdc6cc","type":"sqlite-inject","z":"8ee174a8a298654a","g":"9318cc15f9afd75f","name":"","sqliteConfig":"a407a49de3d419a0","x":2170,"y":160,"wires":[["e9eb48b870d28564"]]},{"id":"b8d668ba7a1a03ed","type":"function","z":"8ee174a8a298654a","g":"9318cc15f9afd75f","name":"Query ledger for accounts","func":"const txLog = msg.payload.payload.txLog;\n\nconst accountIds = txLog.changes.flatMap((change) => {\n    return [change.owner].concat(change.prevOwner ?? []);\n});\n\nconst accountIdsWithRootIds = accountIds.map(accountId => ({\n    accountId,\n    rootUnitId: txLog.rootUnitId,\n}));\n\nmsg.payload.sqlite.then(async db => {\n    if (accountIdsWithRootIds.length === 0) {\n        node.send({\n            ...msg,\n            payload: {\n                ...msg.payload,\n                ledger: [],\n            }\n        });\n\n        return;\n    }\n    \n    const result = await db.selectFrom('ledger')\n        .selectAll()\n        .where(({ eb, refTuple, tuple }) => eb(\n            refTuple('account_id', 'root_unit_id'),\n            'in',\n            accountIdsWithRootIds.flatMap((accountWithUnit) => {\n                return tuple(accountWithUnit.accountId, accountWithUnit.rootUnitId)\n            })\n        ))\n        .execute()\n    \n    node.send({\n        ...msg,\n        payload: {\n            ...msg.payload,\n            ledger: result.map(r => ({\n                accountId: r.account_id,\n                rootUnitId: r.root_unit_id,\n                volume: r.volume\n            }))\n        }\n    })\n}).catch(node.error)\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":2630,"y":160,"wires":[["98a4076a7346f77b"]]},{"id":"6b974a17d0bf6e5a","type":"catch","z":"8ee174a8a298654a","name":"","scope":null,"uncaught":false,"x":100,"y":160,"wires":[["75e8cba180af61bb"]]},{"id":"6d927d07bd0f86c8","type":"comment","z":"8ee174a8a298654a","name":"No protocol (old message format)","info":"","x":1090,"y":220,"wires":[]},{"id":"42fe81814bb0c41b","type":"link out","z":"8ee174a8a298654a","name":"No protocol flow (OUT)","mode":"link","links":["58dda468b6e90876"],"x":935,"y":220,"wires":[]},{"id":"58dda468b6e90876","type":"link in","z":"8ee174a8a298654a","g":"35ba395e82c0b1fd","name":"No protocol flow (IN)","links":["42fe81814bb0c41b"],"x":115,"y":440,"wires":[["be6ed27e6faa08cc"]]},{"id":"ca32fbaad3ee3347","type":"function","z":"8ee174a8a298654a","g":"9318cc15f9afd75f","name":"Update ledger","func":"const txLog = msg.payload.payload.txLog;\nconst ledger = msg.payload.ledger;\n\nconst rootUnitId = txLog.rootUnitId;\nconst volumeMap = ledger.reduce((volumeMap, entry) => {\n    const key = `${entry.accountId}.${entry.rootUnitId}`;\n    const value = entry.volume;\n\n    volumeMap[key] ||= value;\n\n    return volumeMap;\n}, {});\n\nfor (const change of txLog.changes) {\n    if (change.prevOwner) {\n        const prevOwnerVolume = volumeMap[`${change.prevOwner}.${rootUnitId}`];\n\n        if (prevOwnerVolume === undefined) {\n            throw new Error(`Prev owner not found: prevOwnerId=${change.prevOwner}, rootUnitId=${rootUnitId}`);\n        }\n\n        volumeMap[`${change.prevOwner}.${rootUnitId}`] -= change.volume;\n    }\n\n    volumeMap[`${change.owner}.${rootUnitId}`] ||= 0;\n    volumeMap[`${change.owner}.${rootUnitId}`] += change.volume;\n}\n\nconst ledgerUpdate = Object.entries(volumeMap).map(([key, volume]) => {\n    const [accountId, rootUnitId] = key.split('.');\n\n    return {\n        accountId,\n        rootUnitId,\n        volume,\n    };\n});\n\nif (ledgerUpdate.length === 0) {\n    return;\n}\n\nmsg.payload.sqlite.then(async db => {\n    await db\n        .insertInto('ledger')\n        .values(ledgerUpdate.map(e => ({\n            account_id: e.accountId,\n            root_unit_id: e.rootUnitId,\n            volume: e.volume\n        })))\n        .onConflict(c => c.columns(['account_id', 'root_unit_id']).doUpdateSet((eb) => ({\n            volume: eb.ref('excluded.volume'),\n        })))\n        .execute()\n\n    node.send(msg);\n}).catch(node.error);","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":2720,"y":260,"wires":[["d4403c151c10fd53"]]},{"id":"75e8cba180af61bb","type":"function","z":"8ee174a8a298654a","name":"Log error","func":"node.error(`[${msg.error.source.type}:${msg.error.source.name}] ${msg.error.message}`);\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":260,"y":160,"wires":[["cbe7827e8b1ce3bd"]]},{"id":"be6ed27e6faa08cc","type":"function","z":"8ee174a8a298654a","g":"35ba395e82c0b1fd","name":"No \"type\" compatibility","func":"return {\n    ...msg,\n    payload: {\n        ...msg.payload,\n        type: 'unitsChanged'\n    }\n}","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":260,"y":440,"wires":[["126dbca226762933"]]},{"id":"e9eb48b870d28564","type":"function","z":"8ee174a8a298654a","g":"9318cc15f9afd75f","name":"Filter processed","func":"const txLog = msg.payload.payload.txLog;\n\nmsg.payload.sqlite.then(async db => {\n    const unitIds = txLog.changes.map(c => c.unitId);\n\n    if (unitIds.length === 0) {\n        node.send(msg);\n        return;\n    }\n\n    const existingUnits = await db\n        .selectFrom('processed')\n        .select('unit_id')\n        .where('unit_id', 'in', unitIds)\n        .execute()\n        .then(result => result.map(row => row.unit_id));\n\n    const existingUnitsSet = new Set(existingUnits);\n    const notExistingUnits = unitIds.filter(unitId => !existingUnitsSet.has(unitId));\n\n    if (existingUnits.length !== 0) {\n        if (existingUnits.length === unitIds.length) {\n            node.log(`Filtered ALL changes (unit ids: ${existingUnits.join(', ')})`);\n        } else {\n            node.log(`Filtered some changes (unit ids ${existingUnits.join(', ')})`);\n        }\n    }\n\n    node.send({\n        ...msg,\n        payload: {\n            ...msg.payload,\n            payload: {\n                ...msg.payload.payload,\n                txLog: {\n                    ...txLog,\n                    changes: txLog.changes.filter(change => notExistingUnits.includes(change.unitId))\n                }\n            }\n        }\n    })\n}).catch(node.error);\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":2360,"y":160,"wires":[["b8d668ba7a1a03ed"]]},{"id":"d4403c151c10fd53","type":"function","z":"8ee174a8a298654a","g":"9318cc15f9afd75f","name":"Update processed","func":"const txLog = msg.payload.payload.txLog;\n\nmsg.payload.sqlite.then(async db => {\n    const changes = txLog.changes;\n\n    if (changes.length === 0) {\n        node.send(msg);\n        return;\n    }\n\n    await db\n        .insertInto('processed')\n        .values(changes.map(c => ({\n            owner: c.owner,\n            prev_owner: c.prevOwner,\n            root_unit_id: txLog.rootUnitId,\n            unit_id: c.unitId,\n            volume: c.volume,\n            created_at: Date.now(),\n        })))\n        .execute();\n\n    node.send(msg);\n}).catch(node.error);\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":2930,"y":260,"wires":[["e1f6f9ae2e6910d1"]]},{"id":"98a4076a7346f77b","type":"function","z":"8ee174a8a298654a","g":"9318cc15f9afd75f","name":"Transaction validator","func":"const VOTE_APPROVE = 'approve';\nconst VOTE_REJECT = 'reject';\n\nconst txLog = msg.payload.payload.txLog;\nconst ledger = msg.payload.ledger;\n\nconst rootUnitId = txLog.rootUnitId;\nconst changes = txLog.changes;\nconst volumeMap = ledger.reduce((volumeMap, entry) => {\n    const key = `${entry.accountId}.${entry.rootUnitId}`;\n    const value = entry.volume;\n\n    volumeMap[key] ||= value;\n\n    return volumeMap;\n}, {});\n\nfor (const change of changes) {\n    // Ensure entries to be modified exist\n    if (change.prevOwner) {\n        volumeMap[`${change.prevOwner}.${rootUnitId}`] ||= 0;\n    }\n    volumeMap[`${change.owner}.${rootUnitId}`] ||= 0;\n\n    if (change.prevOwner) {\n        const ownedVolume = volumeMap[`${change.prevOwner}.${rootUnitId}`] ?? 0;\n\n        if (ownedVolume - change.volume < 0) {\n            return [\n                undefined,\n                {\n                    ...msg,\n                    payload: {\n                        ...msg.payload,\n                        txFailedReason: `${change.prevOwner} has ${ownedVolume} volume in unit ${rootUnitId}, but ${change.volume} is required`,\n                        voting: txLog.changes.map(v => ({\n                            vote: VOTE_REJECT,\n                            votingId: v.votingId,\n                        }))\n                    }\n                }\n            ];\n\n        }\n    }\n\n    if (change.prevOwner) {\n        volumeMap[`${change.prevOwner}.${rootUnitId}`] -= change.volume;\n    }\n\n    volumeMap[`${change.owner}.${rootUnitId}`] += change.volume;\n}\n\n\nreturn [\n    {\n        ...msg,\n        payload: {\n            ...msg.payload,\n            voting: txLog.changes.map(v => ({\n                vote: VOTE_APPROVE,\n                votingId: v.votingId,\n            }))\n        }\n    },\n    undefined\n]\n","outputs":2,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":2220,"y":280,"wires":[["ac42d7e5c6032791"],["65d289142a86384f"]]},{"id":"90f75c63b182bae9","type":"function","z":"8ee174a8a298654a","name":"Unknown protocol version","func":"throw new Error(`Protocol version ${msg.payload.protocolVersion} not supported`);\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1070,"y":260,"wires":[[]]},{"id":"a487a21a1443f6b7","type":"link out","z":"8ee174a8a298654a","name":"Protocol vresion 1 (OUT)","mode":"link","links":["fb43395da3eb18ac"],"x":935,"y":180,"wires":[]},{"id":"fb43395da3eb18ac","type":"link in","z":"8ee174a8a298654a","g":"04c09f0c2cd6afcc","name":"Protocol version 1 (IN)","links":["a487a21a1443f6b7"],"x":115,"y":620,"wires":[["746c2dd32cd8fa7f"]]},{"id":"8cf263f8817fe694","type":"comment","z":"8ee174a8a298654a","name":"Protocol version 1","info":"","x":1050,"y":180,"wires":[]},{"id":"746c2dd32cd8fa7f","type":"json-schema-validator","z":"8ee174a8a298654a","g":"04c09f0c2cd6afcc","name":"Protocol validator","jsonSchema":"{\n    \"type\": \"object\",\n    \"required\": [\"protocolVersion\", \"type\", \"version\", \"payload\"],\n    \"properties\": {\n        \"protocolVersion\": { \"const\": 1 },\n        \"type\": { \"type\": \"string\" },\n        \"version\": { \"type\": \"number\" },\n        \"payload\": {}\n    }\n}","x":290,"y":620,"wires":[["af129b9f346b5e93"]]},{"id":"af129b9f346b5e93","type":"switch","z":"8ee174a8a298654a","g":"04c09f0c2cd6afcc","name":"Message type check","property":"payload.type","propertyType":"msg","rules":[{"t":"eq","v":"unitsChanged","vt":"str"},{"t":"else"}],"checkall":"true","repair":false,"outputs":2,"x":520,"y":620,"wires":[["a986536a19a878b8"],["0bdc014d7d7e836a"]]},{"id":"0bdc014d7d7e836a","type":"function","z":"8ee174a8a298654a","g":"04c09f0c2cd6afcc","name":"Unknown message type","func":"throw new Error(`Message type ${msg.payload.type} not supported`);\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":770,"y":640,"wires":[[]]},{"id":"04570ad1292f1568","type":"link in","z":"8ee174a8a298654a","name":"unitsChanged (IN)","links":["a986536a19a878b8"],"x":1335,"y":200,"wires":[["7f0c15b61ca09b2f"]]},{"id":"a986536a19a878b8","type":"link out","z":"8ee174a8a298654a","g":"04c09f0c2cd6afcc","name":"unitsChanged (OUT)","mode":"link","links":["04570ad1292f1568"],"x":675,"y":600,"wires":[]},{"id":"4d6a02a638fceb91","type":"comment","z":"8ee174a8a298654a","g":"04c09f0c2cd6afcc","name":"unitsChanged","info":"","x":770,"y":600,"wires":[]},{"id":"a7187e080554af45","type":"comment","z":"8ee174a8a298654a","name":"unitsChanged","info":"","x":1390,"y":160,"wires":[]},{"id":"56fd2cd0cc0812f8","type":"function","z":"8ee174a8a298654a","g":"35ba395e82c0b1fd","name":"Convert old message to new one","func":"const { txLog, type, ...payload } = msg.payload;\n\nreturn {\n    ...msg,\n    payload: {\n        ...payload,\n        protocolVersion: 1,\n        type: 'unitsChanged',\n        version: 1,\n        payload: {\n            txLog: {\n                ...txLog,\n                changes: txLog.changes.map(change => ({\n                    ...change,\n                    votingId: change.unitId\n                }))\n            }\n        }\n    }\n}","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":800,"y":440,"wires":[["746c2dd32cd8fa7f"]]},{"id":"7f0c15b61ca09b2f","type":"switch","z":"8ee174a8a298654a","name":"unitsChanged version check","property":"payload.version","propertyType":"msg","rules":[{"t":"eq","v":"1","vt":"num"},{"t":"else"}],"checkall":"true","repair":false,"outputs":2,"x":1500,"y":200,"wires":[["7a9d1c1446786bd7"],["6e4d452be82e6fee"]]},{"id":"6e4d452be82e6fee","type":"function","z":"8ee174a8a298654a","name":"Unknown message version","func":"throw new Error(`Message type \"unitsChanged\" in version ${msg.payload.version} is not supported`);\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1780,"y":240,"wires":[[]]},{"id":"7a9d1c1446786bd7","type":"json-schema-validator","z":"8ee174a8a298654a","name":"Validate unitsChanged v1 payload","jsonSchema":"{\n    \"type\": \"object\",\n    \"required\": [\"payload\"],\n    \"properties\": {\n        \"payload\": {\n            \"type\": \"object\",\n            \"required\": [\"txLog\"],\n            \"properties\": {\n                \"txLog\": {\n                    \"type\": \"object\",\n                    \"required\": [\"rootUnitId\", \"changes\"],\n                    \"properties\": {\n                        \"rootUnitId\": { \"type\": \"string\" },\n                        \"changes\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"type\": \"object\",\n                                \"required\": [\"unitId\", \"volume\", \"owner\", \"prevOwner\", \"votingId\"],\n                                \"properties\": {\n                                    \"unitId\": { \"type\": \"string\" },\n                                    \"votingId\": { \"type\": \"string\" },\n                                    \"volume\": { \"type\": \"number\" },\n                                    \"owner\": { \"type\": \"string\" },\n                                    \"prevOwner\": { \"type\": [\"string\", \"null\"] }\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n}","x":1800,"y":160,"wires":[["6ca11a0d57bdc6cc"]]}]