Skip to content

Commit 285632a

Browse files
authored
feat: stx addr encoding LRU cache
1 parent aaafb5a commit 285632a

File tree

8 files changed

+217
-4
lines changed

8 files changed

+217
-4
lines changed

.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,6 @@ MAINNET_SEND_MANY_CONTRACT_ID=SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.send-man
8484
# IMGIX_DOMAIN=https://<your domain>.imgix.net
8585
# IMGIX_TOKEN=<your token>
8686

87+
# Specify max number of STX address to store in an in-memory LRU cache (CPU optimization).
88+
# Defaults to 50,000, which should result in around 25 megabytes of additional memory usage.
89+
# STACKS_ADDRESS_CACHE_SIZE=10000

package-lock.json

Lines changed: 6 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
"@stacks/transactions": "^v2.0.1",
109109
"@types/dockerode": "^2.5.34",
110110
"@types/express-list-endpoints": "^4.0.1",
111+
"@types/lru-cache": "^5.1.1",
111112
"@types/ws": "^7.2.5",
112113
"big-integer": "^1.6.48",
113114
"bignumber.js": "^9.0.1",
@@ -131,6 +132,7 @@
131132
"http-proxy-middleware": "^2.0.1",
132133
"jsonc-parser": "^3.0.0",
133134
"jsonrpc-lite": "^2.1.0",
135+
"lru-cache": "^6.0.0",
134136
"micro-base58": "^0.5.0",
135137
"nock": "^13.1.1",
136138
"node-fetch": "^2.6.0",

src/c32-addr-cache.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
This module is hacky and does things that should generally be avoided in this codebase. We are using a procedure to
3+
"re-export" a function on an existing module in order to add some functionality specific to the API. In this case,
4+
the `c32check.c32address` function is difficult to override within this codebase. Many entry points into the function
5+
are from calls into stacks.js libs. A cleaner solution would involve implementing optional params to many stacks.js
6+
functions to provide a stx address encoding function. However, that would be a significant change to the stacks.js libs,
7+
and for now this approach is much easier and faster.
8+
*/
9+
10+
import * as c32check from 'c32check';
11+
import * as LruCache from 'lru-cache';
12+
13+
type c32AddressFn = typeof c32check.c32address;
14+
15+
const MAX_ADDR_CACHE_SIZE = 50_000;
16+
export const ADDR_CACHE_ENV_VAR = 'STACKS_ADDRESS_CACHE_SIZE';
17+
18+
let addressLruCache: LruCache<string, string> | undefined;
19+
export function getAddressLruCache() {
20+
if (addressLruCache === undefined) {
21+
let cacheSize = MAX_ADDR_CACHE_SIZE;
22+
const envAddrCacheVar = process.env[ADDR_CACHE_ENV_VAR];
23+
if (envAddrCacheVar) {
24+
cacheSize = Number.parseInt(envAddrCacheVar);
25+
}
26+
addressLruCache = new LruCache<string, string>({ max: cacheSize });
27+
}
28+
return addressLruCache;
29+
}
30+
31+
const c32EncodeInjectedSymbol = Symbol();
32+
let origC32AddressProp: PropertyDescriptor | undefined;
33+
let origC32AddressFn: c32AddressFn | undefined;
34+
35+
function createC32AddressCache(origFn: c32AddressFn): c32AddressFn {
36+
const c32addressCached: c32AddressFn = (version, hash160hex) => {
37+
const cacheKey = `${version}${hash160hex}`;
38+
const addrCache = getAddressLruCache();
39+
let addrVal = addrCache.get(cacheKey);
40+
if (addrVal === undefined) {
41+
addrVal = origFn(version, hash160hex);
42+
addrCache.set(cacheKey, addrVal);
43+
}
44+
return addrVal;
45+
};
46+
Object.defineProperty(c32addressCached, c32EncodeInjectedSymbol, { value: true });
47+
return c32addressCached;
48+
}
49+
50+
/**
51+
* Override the `c32address` function on the `c32check` module to use an LRU cache
52+
* where commonly used encoded address strings can be cached.
53+
*/
54+
export function injectC32addressEncodeCache() {
55+
// Skip if already injected
56+
if (c32EncodeInjectedSymbol in c32check.c32address) {
57+
return;
58+
}
59+
// eslint-disable-next-line @typescript-eslint/no-var-requires
60+
const c32checkModule = require('c32check');
61+
const origProp = Object.getOwnPropertyDescriptor(c32checkModule, 'c32address');
62+
if (!origProp) {
63+
throw new Error(`Could not get property descriptor for 'c32address' on module 'c32check'`);
64+
}
65+
origC32AddressProp = origProp;
66+
const origFn = origProp.get?.();
67+
if (!origFn) {
68+
throw new Error(`Falsy result for 'c32address' property getter on 'c32check' module`);
69+
}
70+
origC32AddressFn = origFn;
71+
const newFn = createC32AddressCache(origFn);
72+
73+
// The exported module object specifies a property with a getter and setter (rather than simple indexer value),
74+
// so use `defineProperty` to work around errors from trying to set/re-define the property with `c32checkModule.c32address = newFn`.
75+
Object.defineProperty(c32checkModule, 'c32address', { get: () => newFn });
76+
}
77+
78+
export function restoreC32AddressModule() {
79+
if (addressLruCache !== undefined) {
80+
addressLruCache.reset();
81+
addressLruCache = undefined;
82+
}
83+
84+
if (origC32AddressProp !== undefined) {
85+
// eslint-disable-next-line @typescript-eslint/no-var-requires
86+
const c32checkModule = require('c32check');
87+
Object.defineProperty(c32checkModule, 'c32address', origC32AddressProp);
88+
origC32AddressProp = undefined;
89+
}
90+
}

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,14 @@ import * as getopts from 'getopts';
3131
import * as fs from 'fs';
3232
import * as path from 'path';
3333
import * as net from 'net';
34+
import { injectC32addressEncodeCache } from './c32-addr-cache';
3435

3536
loadDotEnv();
3637

3738
sourceMapSupport.install({ handleUncaughtExceptions: false });
3839

40+
injectC32addressEncodeCache();
41+
3942
registerShutdownConfig();
4043

4144
async function monitorCoreRpcConnection(): Promise<void> {

src/tests/helpers-tests.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import * as c32check from 'c32check';
2+
import * as c32AddrCache from '../c32-addr-cache';
3+
import { ADDR_CACHE_ENV_VAR } from '../c32-addr-cache';
24
import { getCurrentGitTag, has0xPrefix, isValidBitcoinAddress } from '../helpers';
35

46
test('get git tag', () => {
@@ -16,6 +18,52 @@ describe('has0xPrefix()', () => {
1618
});
1719
});
1820

21+
test('c32address lru caching', () => {
22+
c32AddrCache.restoreC32AddressModule();
23+
const origAddrCacheEnvVar = process.env[ADDR_CACHE_ENV_VAR];
24+
process.env[ADDR_CACHE_ENV_VAR] = '5';
25+
try {
26+
// No LRU cache used for c32address fn
27+
expect(c32AddrCache.getAddressLruCache().itemCount).toBe(0);
28+
const stxAddr1 = 'SP2JKEZC09WVMR33NBSCWQAJC5GS590RP1FR9CK55';
29+
const decodedAddr1 = c32check.c32addressDecode(stxAddr1);
30+
const encodeResult1 = c32check.c32address(decodedAddr1[0], decodedAddr1[1]);
31+
expect(encodeResult1).toBe(stxAddr1);
32+
expect(c32AddrCache.getAddressLruCache().itemCount).toBe(0);
33+
34+
// Inject LRU cache into c32address fn, ensure it gets used
35+
c32AddrCache.injectC32addressEncodeCache();
36+
expect(c32AddrCache.getAddressLruCache().max).toBe(5);
37+
38+
const encodeResult2 = c32check.c32address(decodedAddr1[0], decodedAddr1[1]);
39+
expect(encodeResult2).toBe(stxAddr1);
40+
expect(c32AddrCache.getAddressLruCache().itemCount).toBe(1);
41+
42+
const encodeResult3 = c32check.c32address(decodedAddr1[0], decodedAddr1[1]);
43+
expect(encodeResult3).toBe(stxAddr1);
44+
expect(c32AddrCache.getAddressLruCache().itemCount).toBe(1);
45+
46+
// Test max cache size
47+
c32AddrCache.getAddressLruCache().reset();
48+
for (let i = 1; i < 10; i++) {
49+
// hash160 hex string
50+
const buff = Buffer.alloc(20);
51+
buff[i] = i;
52+
c32check.c32address(1, buff.toString('hex'));
53+
expect(c32AddrCache.getAddressLruCache().itemCount).toBe(Math.min(i, 5));
54+
}
55+
56+
// Sanity check: reset c32 lib to original state, ensure no LRU cache used
57+
c32AddrCache.restoreC32AddressModule();
58+
const encodeResult4 = c32check.c32address(decodedAddr1[0], decodedAddr1[1]);
59+
expect(encodeResult4).toBe(stxAddr1);
60+
expect(c32AddrCache.getAddressLruCache().itemCount).toBe(0);
61+
} finally {
62+
process.env[ADDR_CACHE_ENV_VAR] = origAddrCacheEnvVar;
63+
c32AddrCache.restoreC32AddressModule();
64+
}
65+
});
66+
1967
test('bitcoin<->stacks address', () => {
2068
const mainnetStxAddr = 'SP2JKEZC09WVMR33NBSCWQAJC5GS590RP1FR9CK55';
2169
const mainnetBtcAddr = '1G4ayBXJvxZMoZpaNdZG6VyWwWq2mHpMjQ';

utils/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"scripts": {
77
"build": "rimraf ./lib && npm run build:node",
88
"build:node": "tsc",
9-
"start": "node ./lib/utils/src/index.js"
9+
"start": "node ./lib/utils/src/index.js",
10+
"address-cache-test": "npm run build && NODE_ENV=production node --expose-gc ./lib/utils/src/addr-lru-cache-test.js"
1011
},
1112
"prettier": "@stacks/prettier-config",
1213
"dependencies": {

utils/src/addr-lru-cache-test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as util from 'util';
2+
import * as assert from 'assert';
3+
import * as c32check from 'c32check';
4+
import * as c32AddrCache from '../../src/c32-addr-cache';
5+
6+
if (!global.gc) {
7+
throw new Error('Enable --expose-gc');
8+
}
9+
10+
const iters = 500_000;
11+
process.env[c32AddrCache.ADDR_CACHE_ENV_VAR] = iters.toString();
12+
13+
c32AddrCache.injectC32addressEncodeCache();
14+
15+
const buff = Buffer.alloc(20);
16+
c32check.c32address(1, buff.toString('hex'));
17+
const startMemory = process.memoryUsage();
18+
const startRss = startMemory.rss;
19+
const startMemoryStr = util.inspect(startMemory);
20+
21+
for (let i = 0; i < iters; i++) {
22+
// hash160 hex string
23+
buff.writeInt32LE(i);
24+
c32check.c32address(1, buff.toString('hex'));
25+
}
26+
27+
global.gc();
28+
29+
const endMemory = process.memoryUsage();
30+
const endRss = endMemory.rss;
31+
const endMemoryStr = util.inspect(endMemory);
32+
console.log('Start memory', startMemoryStr);
33+
console.log('End memory', endMemoryStr);
34+
35+
assert.equal(c32AddrCache.getAddressLruCache().itemCount, iters);
36+
37+
const rn = (num: number) => Math.round(num * 100) / 100;
38+
const megabytes = (bytes: number) => rn(bytes / 1024 / 1024);
39+
40+
const byteDiff = (endRss - startRss) / (iters / 10_000);
41+
42+
console.log(`Start RSS: ${megabytes(startRss)}, end RSS: ${megabytes(endRss)}`);
43+
console.log(`Around ${megabytes(byteDiff)} megabytes per 10k cache entries`);
44+
45+
/*
46+
Several rounds of running this benchmark show "Around 4.44 megabytes per 10k cache entries":
47+
48+
Start memory {
49+
rss: 26202112,
50+
heapTotal: 5578752,
51+
heapUsed: 3642392,
52+
external: 1147316,
53+
arrayBuffers: 59931
54+
}
55+
End memory {
56+
rss: 259125248,
57+
heapTotal: 216875008,
58+
heapUsed: 181636328,
59+
external: 1261038,
60+
arrayBuffers: 18090
61+
}
62+
Start RSS: 24.99, end RSS: 247.12
63+
*/

0 commit comments

Comments
 (0)