diff --git a/lib/masternodeSnapshot.js b/lib/masternodeSnapshot.js new file mode 100644 index 0000000..0cbadd9 --- /dev/null +++ b/lib/masternodeSnapshot.js @@ -0,0 +1,95 @@ +'use strict'; + +const geoip = require('geoip-country'); +const countries = require('i18n-iso-countries'); + +const HEX64 = /^[0-9a-fA-F]{64}$/; + +// Parse the outer dict key that Syscoin's `masternode_list` returns +// for each MN. Core serialises it as `COutPoint::ToStringShort()` = +// `"-"`. Split on the last dash for future-proofing even +// though txids are currently fixed 64-hex strings. +function parseOutpointKey(key) { + if (typeof key !== 'string') return null; + const i = key.lastIndexOf('-'); + if (i <= 0 || i === key.length - 1) return null; + const hash = key.slice(0, i); + const n = Number(key.slice(i + 1)); + if (!HEX64.test(hash)) return null; + if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) return null; + return { collateralHash: hash.toLowerCase(), collateralIndex: n }; +} + +function endpointHost(value) { + const text = String(value || ''); + if (!text) return ''; + + if (text.startsWith('[')) { + const end = text.indexOf(']'); + if (end > 0) return text.slice(1, end); + } + + const firstColon = text.indexOf(':'); + const lastColon = text.lastIndexOf(':'); + if (firstColon === -1) return text; + + const tail = text.slice(lastColon + 1); + if (firstColon === lastColon) { + return /^\d+$/.test(tail) ? text.slice(0, lastColon) : text; + } + + return /^\d+$/.test(tail) ? text.slice(0, lastColon) : text; +} + +function countryAlpha3ForHost(host, lookupCountry = geoip.lookup) { + if (!host || typeof lookupCountry !== 'function') return null; + const geo = lookupCountry(host); + const iso = geo && geo.country; + if (!iso) return null; + try { + return countries.alpha2ToAlpha3(iso); + } catch { + return null; + } +} + +function buildMasternodeSnapshot(masternodes, { lookupCountry = geoip.lookup } = {}) { + const masternodesArr = []; + const mapData = {}; + + for (const key of Object.keys(masternodes || {})) { + const node = { ...masternodes[key] }; + const outpoint = parseOutpointKey(key); + if (outpoint) { + node.collateralHash = outpoint.collateralHash; + node.collateralIndex = outpoint.collateralIndex; + } + masternodesArr.push(node); + + if (node.status !== 'ENABLED') continue; + + const alpha3 = countryAlpha3ForHost(endpointHost(node.address), lookupCountry); + if (!alpha3) continue; + + if (mapData[alpha3] === undefined) { + mapData[alpha3] = { masternodes: 1 }; + } else { + mapData[alpha3].masternodes += 1; + } + } + + masternodesArr.sort((a, b) => b.lastpaidtime - a.lastpaidtime); + const highestMN = Object.values(mapData).reduce( + (max, entry) => Math.max(max, entry.masternodes || 0), + 0 + ); + + return { masternodesArr, mapData, highestMN }; +} + +module.exports = { + buildMasternodeSnapshot, + countryAlpha3ForHost, + endpointHost, + parseOutpointKey, +}; diff --git a/lib/masternodeSnapshot.test.js b/lib/masternodeSnapshot.test.js new file mode 100644 index 0000000..aecd80a --- /dev/null +++ b/lib/masternodeSnapshot.test.js @@ -0,0 +1,93 @@ +'use strict'; + +const { + buildMasternodeSnapshot, + endpointHost, + parseOutpointKey, +} = require('./masternodeSnapshot'); + +const H1 = 'a'.repeat(64); +const H2 = 'b'.repeat(64); +const H3 = 'c'.repeat(64); + +function lookupCountry(host) { + const countries = { + '203.0.113.1': { country: 'DE' }, + '203.0.113.2': { country: 'US' }, + '2001:db8::1': { country: 'GB' }, + }; + return countries[host] || null; +} + +describe('parseOutpointKey', () => { + test('extracts collateral hash/index from Core masternode_list keys', () => { + expect(parseOutpointKey(`${H1}-7`)).toEqual({ + collateralHash: H1, + collateralIndex: 7, + }); + }); + + test('rejects malformed outpoint keys', () => { + expect(parseOutpointKey('not-an-outpoint')).toBe(null); + expect(parseOutpointKey(`${H1}-nope`)).toBe(null); + }); +}); + +describe('endpointHost', () => { + test('normalises IPv4, bracketed IPv6, and unbracketed IPv6 endpoints', () => { + expect(endpointHost('203.0.113.1:8369')).toBe('203.0.113.1'); + expect(endpointHost('[2001:db8::1]:8369')).toBe('2001:db8::1'); + expect(endpointHost('2001:db8::1:8369')).toBe('2001:db8::1'); + }); +}); + +describe('buildMasternodeSnapshot', () => { + test('enriches all masternodes but counts only ENABLED nodes in mapData', () => { + const out = buildMasternodeSnapshot( + { + [`${H1}-0`]: { + status: 'ENABLED', + address: '203.0.113.1:8369', + lastpaidtime: 10, + }, + [`${H2}-1`]: { + status: 'POSE_BANNED', + address: '203.0.113.2:8369', + lastpaidtime: 20, + }, + [`${H3}-2`]: { + status: 'ENABLED', + address: '[2001:db8::1]:8369', + lastpaidtime: 30, + }, + }, + { lookupCountry } + ); + + expect(out.masternodesArr).toHaveLength(3); + expect(out.masternodesArr[0]).toMatchObject({ + collateralHash: H3, + collateralIndex: 2, + }); + expect(out.mapData).toEqual({ + DEU: { masternodes: 1 }, + GBR: { masternodes: 1 }, + }); + expect(out.highestMN).toBe(1); + }); + + test('returns an empty map when no enabled nodes have a known country', () => { + const out = buildMasternodeSnapshot( + { + [`${H1}-0`]: { + status: 'POSE_BANNED', + address: '203.0.113.2:8369', + }, + }, + { lookupCountry } + ); + + expect(out.mapData).toEqual({}); + expect(out.highestMN).toBe(0); + }); +}); diff --git a/package-lock.json b/package-lock.json index 7ba5bc5..392af05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,9 @@ "cors": "2.8.5", "csv-parser": "3.0.0", "express": "^4.22.1", - "express-rate-limit": "^8.3.2", + "express-rate-limit": "^8.5.1", "fs": "0.0.1-security", - "geoip-country": "4.1.49", + "geoip-country": "^5.0.202605052357", "helmet": "^8.1.0", "i18n-iso-countries": "7.5.0", "moment": "^2.30.1", @@ -2723,7 +2723,8 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/base-x": { "version": "5.0.1", @@ -3039,6 +3040,7 @@ "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -3427,14 +3429,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3459,7 +3453,8 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true }, "node_modules/content-disposition": { "version": "0.5.4", @@ -3539,6 +3534,12 @@ "node": ">= 0.10" } }, + "node_modules/countries-list": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/countries-list/-/countries-list-3.3.0.tgz", + "integrity": "sha512-XRUjS+dcZuNh/fg3+mka3bXgcg4TbQZ1gaK5IJqO6qulerBANl1bmrd20P2dgmPkBpP+5FnejiSF1gd7bgAg+g==", + "license": "MIT" + }, "node_modules/create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -3946,6 +3947,8 @@ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -3972,6 +3975,8 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -4417,12 +4422,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -4434,15 +4439,6 @@ "express": ">= 4.11" } }, - "node_modules/express-rate-limit/node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/express/node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -4724,7 +4720,8 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -4765,31 +4762,18 @@ } }, "node_modules/geoip-country": { - "version": "4.1.49", - "resolved": "https://registry.npmjs.org/geoip-country/-/geoip-country-4.1.49.tgz", - "integrity": "sha512-8l+pAJZCVWueJWqD+YHDOFlYhY/wt3ZKiYFIHC9lxnPYStmD3Yr9NVjxNGprng4Wcx+fqv5W4ZghJaWRb8dN8g==", + "version": "5.0.202605052357", + "resolved": "https://registry.npmjs.org/geoip-country/-/geoip-country-5.0.202605052357.tgz", + "integrity": "sha512-MdcKy94brvyTlKnFhYtShIZs9Qnhbxi+jwqmkvxQwtaLAPqcf4L9+toOJfxO5/G0u01r0TAkgIUZIoYLOMq6IQ==", + "license": "MaxMind GeoLite2 License", "dependencies": { "async": "^2.6.4", - "colors": "^1.4.0", - "iconv-lite": "^0.5.2", - "ip-address": "^6.3.0", - "lazy": "^1.0.11", - "rimraf": "^2.7.1", + "countries-list": "^3.1.1", + "ip-address": "^6.4.0", "yauzl": "^2.10.0" }, "engines": { - "node": ">=0.6.3" - } - }, - "node_modules/geoip-country/node_modules/iconv-lite": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", - "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" + "node": ">=10.20.0" } }, "node_modules/get-caller-file": { @@ -4871,6 +4855,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -5186,6 +5171,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -5202,20 +5188,12 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, "node_modules/ip-address": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-6.4.0.tgz", - "integrity": "sha512-c5uxc2WUTuRBVHT/6r4m7HIr/DfV0bF6DvLH3iZGSK8wp8iMwwZSgIq2do0asFf8q9ECug0SE+6+1ACMe4sorA==", - "dependencies": { - "jsbn": "1.1.0", - "lodash.find": "4.6.0", - "lodash.max": "4.0.1", - "lodash.merge": "4.6.2", - "lodash.padstart": "4.6.1", - "lodash.repeat": "4.1.0", - "sprintf-js": "1.1.2" - }, + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 12" } }, "node_modules/ipaddr.js": { @@ -7288,11 +7266,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7351,14 +7324,6 @@ "node": ">=6" } }, - "node_modules/lazy": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz", - "integrity": "sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==", - "engines": { - "node": ">=0.2.0" - } - }, "node_modules/level-codec": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/level-codec/-/level-codec-9.0.2.tgz", @@ -7565,31 +7530,6 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, - "node_modules/lodash.find": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.find/-/lodash.find-4.6.0.tgz", - "integrity": "sha512-yaRZoAV3Xq28F1iafWN1+a0rflOej93l1DQUejs3SZ41h2O9UJBoS9aueGjPDgAl4B6tPC0NuuchLKaDQQ3Isg==" - }, - "node_modules/lodash.max": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.max/-/lodash.max-4.0.1.tgz", - "integrity": "sha512-iykTDTb7PK33HSQmKy34zv+hh4WEu7WonJPXQcgODzUbbtradtNs8RsD/GI7XV++60KaKR1xhW56N4ISqHesfQ==" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" - }, - "node_modules/lodash.padstart": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.6.1.tgz", - "integrity": "sha512-sW73O6S8+Tg66eY56DBk85aQzzUJDtpoXFBgELMd5P/SotAguo+1kYO6RuYgXxA4HJH3LFTFPASX6ET6bjfriw==" - }, - "node_modules/lodash.repeat": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/lodash.repeat/-/lodash.repeat-4.1.0.tgz", - "integrity": "sha512-eWsgQW89IewS95ZOcr15HHCX6FVDxq3f2PNUIng3fyzsPev9imFQxIYdFZ6crl8L56UR6ZlGDLcEb3RZsCSSqw==" - }, "node_modules/ltgt": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", @@ -7795,6 +7735,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -7876,22 +7817,23 @@ "license": "MIT" }, "node_modules/node-fetch": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", - "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { - "encoding": "^0.1.11", - "is-stream": "^1.0.1" - } - }, - "node_modules/node-fetch/node_modules/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", - "license": "MIT", + "whatwg-url": "^5.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, "node_modules/node-gyp-build": { @@ -8194,6 +8136,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -8644,18 +8587,6 @@ "node": ">=10" } }, - "node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/ripemd160": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", @@ -9024,11 +8955,6 @@ "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", - "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==" - }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -9421,6 +9347,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -9663,6 +9595,12 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/websocket": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz", @@ -9685,6 +9623,16 @@ "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index f04039f..2c976e9 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "audit:prod": "npm audit --omit=dev --audit-level=critical" + "audit:prod": "npm audit --omit=dev --audit-level=high" }, "jest": { "testEnvironment": "node", @@ -33,9 +33,9 @@ "cors": "2.8.5", "csv-parser": "3.0.0", "express": "^4.22.1", - "express-rate-limit": "^8.3.2", + "express-rate-limit": "^8.5.1", "fs": "0.0.1-security", - "geoip-country": "4.1.49", + "geoip-country": "^5.0.202605052357", "helmet": "^8.1.0", "i18n-iso-countries": "7.5.0", "moment": "^2.30.1", @@ -62,5 +62,10 @@ "backend" ], "author": "Your Name", - "license": "MIT" + "license": "MIT", + "overrides": { + "node-fetch": "2.7.0", + "elliptic": "6.6.1", + "ip-address": "10.2.0" + } } diff --git a/routes/gov.js b/routes/gov.js index 67da966..c39b71e 100644 --- a/routes/gov.js +++ b/routes/gov.js @@ -202,9 +202,15 @@ function createGovRouter({ nowMs(), masternodeCacheMaxAgeMs ); - const knownOutpoints = snapshot.fresh - ? knownOutpointSet(snapshot.masternodes) - : new Set(); + if (!snapshot.fresh) { + return res.status(503).json({ error: 'masternode_cache_stale' }); + } + + const knownOutpoints = knownOutpointSet(snapshot.masternodes); + if (knownOutpoints.size === 0) { + return res.status(503).json({ error: 'masternode_cache_empty' }); + } + const relayEntries = []; const relayIndexes = []; const results = new Array(parsed.entries.length); diff --git a/services/masternodeTracker.js b/services/masternodeTracker.js index ae028f9..6611190 100644 --- a/services/masternodeTracker.js +++ b/services/masternodeTracker.js @@ -1,7 +1,6 @@ -const geoip = require("geoip-country"); -const countries = require("i18n-iso-countries"); const { client, rpcServices } = require("../services/rpcClient"); const data = require("../data/dataStore"); +const { buildMasternodeSnapshot } = require("../lib/masternodeSnapshot"); function componentToHex(c) { const hex = c.toString(16); @@ -17,69 +16,18 @@ for (let i = 255; i >= 0; i--) { data.mapFills["heat" + i] = rgbToHex(0, 255 - i, 255); } -// Parse the outer dict key that Syscoin's `masternode_list` returns -// for each MN. Core serialises it as `COutPoint::ToStringShort()` = -// `"-"` (see src/primitives/transaction.cpp). We split on -// the LAST dash so a hypothetical future change that embeds a dash -// inside the hash half still round-trips; txid is always 64 hex -// chars today, so this is belt-and-braces. -function parseOutpointKey(key) { - if (typeof key !== "string") return null; - const i = key.lastIndexOf("-"); - if (i <= 0 || i === key.length - 1) return null; - const hash = key.slice(0, i); - const n = Number(key.slice(i + 1)); - if (!/^[0-9a-fA-F]{64}$/.test(hash)) return null; - if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) return null; - return { collateralHash: hash.toLowerCase(), collateralIndex: n }; -} - setInterval(() => { rpcServices(client.callRpc).masternode_list().call().then(masternodes => { - - data.masternodesArr = []; - data.mapData = {}; - - for (let key in masternodes) { - const node = masternodes[key]; - // Additive enrichment: Syscoin Core's `masternode_list` values - // don't carry `collateralHash`/`collateralIndex` directly, but - // the object's outer key IS the outpoint (COutPoint::ToStringShort - // => "-"). Preserving that here lets the /gov endpoints - // relay votes without a second round-trip per MN. No existing - // consumer (mnSearch, mnList, mnStats) reads these fields, so - // adding them is strictly additive. - const outpoint = parseOutpointKey(key); - if (outpoint) { - node.collateralHash = outpoint.collateralHash; - node.collateralIndex = outpoint.collateralIndex; - } - data.masternodesArr.push(node); - - if (geoip.lookup(node.address.split(':')[0]) != null) { - let iso = geoip.lookup(node.address.split(':')[0]).country; - let alpha3; - try { - alpha3 = countries.alpha2ToAlpha3(iso); - } catch { - continue; - } - - if (data.mapData[alpha3] === undefined) { - data.mapData[alpha3] = { masternodes: 1 }; - } else { - data.mapData[alpha3].masternodes++; - } - } - } - - data.masternodesArr.sort((a, b) => b.lastpaidtime - a.lastpaidtime); + const snapshot = buildMasternodeSnapshot(masternodes); + data.masternodesArr = snapshot.masternodesArr; + data.mapData = snapshot.mapData; data.masternodesUpdatedAt = Date.now(); - - data.highestMN = Math.max(...Object.values(data.mapData).map(e => e.masternodes || 0)); + data.highestMN = snapshot.highestMN; for (let country in data.mapData) { - const intensity = Math.round((255 * data.mapData[country].masternodes) / data.highestMN); + const intensity = data.highestMN > 0 + ? Math.round((255 * data.mapData[country].masternodes) / data.highestMN) + : 0; data.mapData[country].fillKey = "heat" + intensity; } diff --git a/tests/gov.routes.test.js b/tests/gov.routes.test.js index e2d421b..7e46968 100644 --- a/tests/gov.routes.test.js +++ b/tests/gov.routes.test.js @@ -353,7 +353,7 @@ describe('POST /gov/vote', () => { } }); - test('fails open to voteraw while the masternode cache is empty or warming', async () => { + test('fails closed while the masternode cache is empty or warming', async () => { const { ctx, calls } = buildApp({ masternodes: [] }); try { const { agent, csrf } = await loggedInAgent(ctx); @@ -361,16 +361,15 @@ describe('POST /gov/vote', () => { .post('/gov/vote') .set('X-CSRF-Token', csrf) .send(validVoteBody()); - expect(res.status).toBe(200); - expect(res.body.accepted).toBe(2); - expect(res.body.rejected).toBe(0); - expect(calls).toHaveLength(2); + expect(res.status).toBe(503); + expect(res.body.error).toBe('masternode_cache_empty'); + expect(calls).toHaveLength(0); } finally { ctx.db.close(); } }); - test('fails open to voteraw when the masternode cache snapshot is stale', async () => { + test('fails closed when the masternode cache snapshot is stale', async () => { const { ctx, calls } = buildApp({ masternodes: { masternodes: [{ collateralHash: H2, collateralIndex: 0 }], @@ -383,10 +382,9 @@ describe('POST /gov/vote', () => { .post('/gov/vote') .set('X-CSRF-Token', csrf) .send(validVoteBody()); - expect(res.status).toBe(200); - expect(res.body.accepted).toBe(2); - expect(res.body.rejected).toBe(0); - expect(calls).toHaveLength(2); + expect(res.status).toBe(503); + expect(res.body.error).toBe('masternode_cache_stale'); + expect(calls).toHaveLength(0); } finally { ctx.db.close(); }