diff options
| author | Adam Soltys <[email protected]> | 2026-10-22 21:06:32 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-22 21:06:32 -0700 |
| commit | 648a5503089d33232855925a24ff60f78cab6df4 (patch) | |
| tree | 1280073d797653d3ed86f0bb7b6c26b5d2271804 /tools | |
| parent | 8257a98e8d1891d0795f6b8b0623678245cc8e33 (diff) | |
| download | coinosv3-648a5503089d33232855925a24ff60f78cab6df4.tar.gz coinosv3-648a5503089d33232855925a24ff60f78cab6df4.zip | |
Custody is a choice, and coinos holding the coins is the default: nothing renews on its own and no renewal fee is paid unless the user opts in
Why: renewal fees were "money passively draining" to people who thought they had a custodial wallet (Stacker News, 2026-10-22). Measured over Sep–Oct: 84% of what users paid came from the client's own consolidation round at 20 coins (0.2% + 300 on every fresh coin), the rest from 300-sat rounds that each cohort of Lightning-received coins (30-day pool lifetime) triggered on its own schedule. The ASP itself honors a coin past its timelock — rounds and offboards never checked expiry, and captaind's allow_expired_arkoor (now on at ark.coinos.io) lets arkoor spends through too — so expiry only ever meant "the exit is gone", which most people coming from v2 never had.
Settings → Spending gets a Custody card with three tiers:
- coinos holds my coins (default): no renewals, no fees; a coin past its timelock shows as "held by coinos", stays spendable and receivable, and is left out of a unilateral exit (there is nothing on-chain for it to take).
- Keep my own exit: renew in the last hours (free bracket + base), the server backstop registered 36 blocks out and snapped to a 48-block grid so every wallet's orders share one round.
- Renew a week early: 1008 blocks out on a 144-block grid (0.1% + base).
renewPolicy() in ark/manager.js is the one table; the app, the service worker (the mirror carries the tier) and the Manage page all read it. Only shown on servers that honor expired coins (presets carry honorsExpired); elsewhere renewal stays automatic.
Manager: honorsExpired makes a past-timelock coin ordinary money (balance, selection, receive validation via acceptExpired) while a server refusal (expiryRejected) still excludes it; such coins spend LAST so an older build on the receiving side almost never meets one. A once-per-wallet rescan re-reads the mailbox and adopts coins older builds refused as expired, un-forgets expired dust, and clears stale refusals. The refresh gate that retired a renewal of expired inputs is gone (the server accepts them; the Manage page's Renew of exactly those coins was failing silently). Automatic consolidation is gone; the send cap goes 24 → 48 coins; small change (<1,000 sats) folds into a renewal that happens anyway.
Server (cs:~/ark/captaind-main.toml, restarted): allow_expired_arkoor = true; pool vtxo_lifetime 4320 → 8640 so Lightning-received coins live as long as the rest.
Tests: tools/ark/custody-test.js (policies, honoring, ordering, grid snap, validator), tools/ark/custody-ui-test.js (the card, held coins, exit count, switching), nwc-sw-test updated for the tier the worker reads.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/ark/custody-test.js | 98 | ||||
| -rw-r--r-- | tools/ark/custody-ui-test.js | 122 | ||||
| -rw-r--r-- | tools/nwc-sw-test.js | 19 |
3 files changed, 236 insertions, 3 deletions
diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js new file mode 100644 index 0000000..da1079c --- /dev/null +++ b/tools/ark/custody-test.js @@ -0,0 +1,98 @@ +// The custody tiers, without a network: what each policy aims for, how the +// manager treats a coin past its timelock on a server that honors it, how +// backstop orders snap to a grid so wallets share rounds, and that the +// receive-side validator can be told to accept such a coin. +// Run: bun tools/ark/custody-test.js +import assert from 'node:assert/strict'; +import { hex } from '@scure/base'; +import { ArkManager, renewPolicy, CUSTODY_TIERS, MAX_SEND_INPUTS } from '../../src/ark/manager.js'; +import { validateVtxo, VtxoValidationError } from '../../src/ark/validate.js'; + +// policies +assert.deepEqual(CUSTODY_TIERS, ['custodial', 'self', 'early']); +assert.equal(renewPolicy('custodial', 'mainnet'), null, 'the default tier renews nothing'); +assert.equal(renewPolicy('nonsense', 'mainnet'), null); +assert.deepEqual(renewPolicy('self', 'mainnet'), { window: 96, margin: 36, grid: 48 }); +assert.deepEqual(renewPolicy('early', 'mainnet'), { window: 1008, margin: 1008, grid: 144 }); +assert.deepEqual(renewPolicy('self', 'regtest'), { window: 24, margin: 6, grid: 1 }); +// the snap never leaves the bracket: self stays under 96 blocks out, early under 2160 +for (let h = 100000; h < 100200; h++) { + const s = h - 36; assert.ok(h - (s - (s % 48)) < 96); + const e = h - 1008; assert.ok(h - (e - (e % 144)) < 2160 && h - (e - (e % 144)) >= 199); +} +console.log('✓ renewal policies: custodial renews nothing; self and early aim inside one fee bracket'); + +const mk = (honorsExpired) => { + const mgr = new ArkManager({ account: null, storage: { load: () => null, save() {} }, arkUrl: 'http://x', esploraUrl: 'http://y', network: 'mainnet', honorsExpired }); + mgr.state = { v: 1, nextKeyIndex: 1, vtxos: [], actions: [], movements: [], scheduled: [] }; + mgr.info = { maxVtxoAmountSat: 5_000_000, refreshFees: { baseFeeSat: 300, ppmExpiryTable: [] } }; + mgr._tipH = 1000; + return mgr; +}; +const coin = (id, amountSat, expiryHeight, extra = {}) => ({ id, amountSat, expiryHeight, state: 'spendable', policy: 'pubkey', keyIndex: 1, ...extra }); + +// a coin past its timelock: unusable on a server that refuses it, money on one that honors it +{ + const strict = mk(false), lenient = mk(true); + for (const m of [strict, lenient]) m.state.vtxos = [coin('a', 5000, 900), coin('b', 7000, 1200), coin('c', 100, 900)]; + assert.equal(strict._expired(strict.state.vtxos[0], 1000), true); + assert.equal(strict._expired(strict.state.vtxos[2], 1000), false, 'sub-dust was always honored'); + assert.deepEqual(strict.balance(), { spendableSat: 7100, pendingSat: 0, boardingSat: 0, expiredSat: 5000 }); + assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), false); + assert.deepEqual(lenient.balance(), { spendableSat: 12100, pendingSat: 0, boardingSat: 0, expiredSat: 0 }); + assert.deepEqual(lenient._selectInputs(5000).map((v) => v.id), ['b'], 'a coin still inside its timelock is preferred…'); + assert.deepEqual(lenient._selectInputs(7001).map((v) => v.id), ['b', 'a'], '…and the expired one joins only when needed'); + lenient.state.vtxos.push(coin('d', 9000, 900)); + assert.deepEqual(lenient._selectInputs(8000).map((v) => v.id), ['d'], 'a single expired coin beats a many-coin package'); + lenient.state.vtxos.pop(); + assert.deepEqual(strict._selectInputs(5000).map((v) => v.id), ['b']); + // the server's word still wins: a refusal naming the coin excludes it + lenient.state.vtxos[0].expiryRejected = true; + assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), true); + assert.equal(lenient.balance().spendableSat, 7100); + console.log('✓ honorsExpired: a coin past its timelock stays spendable; a server refusal still excludes it'); +} + +// the send cap is wider now that nothing consolidates on its own +{ + const m = mk(true); + m.state.vtxos = Array.from({ length: 40 }, (_, i) => coin('c' + i, 100, 2000)); + assert.equal(m._selectInputs(3500).length, 35, 'a 35-coin send is fine'); + m.state.vtxos = Array.from({ length: MAX_SEND_INPUTS + 5 }, (_, i) => coin('d' + i, 100, 2000)); + assert.throws(() => m._selectInputs(100 * (MAX_SEND_INPUTS + 1)), /too many coins/); + console.log(`✓ a send takes up to ${MAX_SEND_INPUTS} coins`); +} + +// backstop orders snap down to the grid, and skip what the snap would put in the past +{ + const m = mk(true); + const orders = []; + m._tipMemo = async () => 1000; + m.scheduleRefresh = async (ids, height) => { orders.push([ids[0], height]); }; + m.state.vtxos = [ + coin('x', 10000, 1100), // 1100-36 = 1064 → 1056 + coin('y', 20000, 1041), // 1005 → 960 ≤ tip: skipped (the app itself renews it) + coin('z', 30000, 1200), // 1164 → 1152 + coin('w', 40000, 1200, { state: 'pending' }), + ]; + assert.equal(await m.ensureScheduledRefreshes(36, 8, 48), 2); + assert.deepEqual(orders, [['z', 1152], ['x', 1056]], 'largest first, snapped to multiples of 48'); + assert.ok(orders.every(([, h]) => h % 48 === 0)); + orders.length = 0; + m.state.scheduled = []; + assert.equal(await m.ensureScheduledRefreshes(36, 8, 1), 3, 'grid 1 = the old exact heights'); + assert.deepEqual(orders, [['z', 1164], ['y', 1005], ['x', 1064]]); + console.log('✓ backstop orders snap to the grid (and never into the past)'); +} + +// the validator: an expired coin is refused by default, accepted when told the server honors it +{ + const serverPub = new Uint8Array(33).fill(2); + const v = { policy: { type: 'pubkey', userPubkey: 'aa' }, serverPubkey: hex.encode(serverPub), genesis: [{ transition: {} }], + expiryHeight: 500, amountSat: 1000, anchorPoint: { txid: 'ab'.repeat(32), vout: 0 }, point: { txid: 'cd'.repeat(32), vout: 0 }, id: 'x' }; + const chain = { tipHeight: async () => 600, getTxStatus: async () => ({ confirmed: false }), getTxHex: async () => null }; + await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain }), (e) => e instanceof VtxoValidationError && /expired at 500/.test(e.message)); + await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain, acceptExpired: true }), (e) => e instanceof VtxoValidationError && /not confirmed/.test(e.message), 'past the expiry check, on to the anchor'); + console.log('✓ validateVtxo: acceptExpired lets a coin past its timelock through the expiry check'); +} +console.log('\n✅ custody tiers behave'); diff --git a/tools/ark/custody-ui-test.js b/tools/ark/custody-ui-test.js new file mode 100644 index 0000000..d85cd36 --- /dev/null +++ b/tools/ark/custody-ui-test.js @@ -0,0 +1,122 @@ +// The custody card on the Spending settings page: three tiers, coinos holding +// the coins by default, a coin past its timelock shown as held (not expired), +// and switching tiers persists and reprices the page. Real app with a local +// Ark manager stub; nothing is sent anywhere. +// Run: bun tools/ark/custody-ui-test.js +import assert from 'node:assert/strict'; +import puppeteer from '../puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; + +const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true, + plugins: [{ name: 'custody-test', setup(build) { + build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + ` +window.coopApp = { ui, wallet, ctx, render, enterWallet, openWalletSettings, get ready() { return !_bootDeciding; }, + setSpending: (on = true) => { const a = activeAccount(); if (a) { if (on) a.kind = 'spending'; else delete a.kind; } } }; +` })); + build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents: + (await Bun.file(path).text()).replace(" return {\n id: 'ark',", ` + window.coopFeature = { setManager: (mgr) => { ark = mgr; }, getCustody, autoRefreshCalls: [] }; + return {\n id: 'ark',`) })); + build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' })); + } }] }); +assert(bundle.success, bundle.logs.join('\n')); +const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`; +const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/' + ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(), errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width: 390, height: 844 }); + const cdp = await page.createCDPSession(); + await cdp.send('Network.enable'); + await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort()); + await page.evaluateOnNewDocument(() => { + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-ark-provider:regtest', 'local'); // the local preset honors expired coins + }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.coopApp?.ready); + await page.evaluate(async (mn) => { + await coopApp.enterWallet(mn, '', { fresh: true, generated: true }); + coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step'); + coopApp.wallet.scan = async () => {}; + const table = [{ thresholdBlocks: 0, ppm: 0 }, { thresholdBlocks: 97, ppm: 250 }, { thresholdBlocks: 199, ppm: 1000 }, { thresholdBlocks: 2161, ppm: 2000 }]; + window.coopManager = { + honorsExpired: true, + state: { vtxos: [ + { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921 }, // 29 days left + { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 960000 }, // past its timelock + ], actions: [], movements: [], scheduled: [] }, + info: { network: 'regtest', vtxoExpiryDelta: 8640, roundIntervalSecs: 120, refreshFees: { baseFeeSat: 300, ppmExpiryTable: table } }, _tipH: 969751, + chain: { tipHeight: async () => 969751 }, + vtxos() { return this.state.vtxos; }, + _decoded() { throw new Error('no exit transaction fixture'); }, + balance() { return { spendableSat: 25000, pendingSat: 0, boardingSat: 0, expiredSat: 0 }; }, + refreshFee(inputs, tip) { + let u = 0; + for (const v of inputs) u += v.amountSat * (table.filter((e) => e.thresholdBlocks <= v.expiryHeight - tip).pop()?.ppm ?? 0); + return 300 + Math.ceil(u / 1e6); + }, + async ensureScheduledRefreshes(margin, max, grid) { coopFeature.autoRefreshCalls.push(['ensure', margin, max, grid]); return 0; }, + async refresh(ids) { coopFeature.autoRefreshCalls.push(['refresh', ids]); return 'refresh-1'; }, + unseenReceives() { return []; }, ackReceives() {}, + address() { return 'test-ark-address'; }, + }; + coopFeature.setManager(coopManager); + coopApp.setSpending(true); + coopApp.openWalletSettings('spending'); + }, generateMnemonic(wordlist)); + await page.waitForSelector('.ark-custody'); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const text = () => page.evaluate(() => document.body.innerText); + + let tx = await text(); + assert.match(tx, /Custody/); + assert.match(tx, /coinos holds my coins[\s\S]*no fees/); + assert.match(tx, /Keep my own exit[\s\S]*300 sats per renewal/); + assert.match(tx, /Renew a week early[\s\S]*0\.1% \+ 300 sats per renewal/); + const checked = await page.$$eval('.ark-custody input[type=radio]', (els) => els.filter((e) => e.checked).map((e) => e.value)); + assert.deepEqual(checked, ['custodial'], 'coinos holds the coins by default'); + assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial'); + assert.match(tx, /held by coinos/, 'the coin past its timelock is held, not "expired"'); + assert.doesNotMatch(tx, /\bexpired\b/); + assert.match(tx, /With “coinos holds my coins”, nothing renews on its own/); + if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT, fullPage: true }); + console.log('✓ the custody card: three tiers with their prices, coinos holding by default, a past-timelock coin shown as held'); + + // the unilateral exit only counts the coin still inside its timelock + assert.match(tx, /Exit now/); + const exitBtn = await page.$$eval('button', (bs) => bs.filter((b) => b.textContent === 'Exit now').map((b) => b.disabled)); + assert.deepEqual(exitBtn, [false]); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Exit now').click()); await sleep(200); + tx = await text(); + assert.match(tx, /Coins\s*1/); assert.match(tx, /Amount\s*20,000 sats/); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200); + console.log('✓ a unilateral exit counts only the coin still inside its timelock'); + + // opt into keeping the exit: persisted, the page reprices, renewal starts covering + await page.click('.ark-custody-opt[data-tier=self] input'); await sleep(400); + assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self'); + assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody')), 'self', 'saved with the wallet'); + tx = await text(); + assert.match(tx, /Custody choice saved/); + assert.doesNotMatch(tx, /nothing renews on its own/); + assert.match(tx, /renews coins automatically once they are close to expiring/); + const calls = await page.evaluate(() => coopFeature.autoRefreshCalls); + assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered at once (regtest margin 6)'); + assert.deepEqual(calls[1], ['refresh', ['coin-1']], 'the coin past its timelock is renewed now; the healthy one is left alone'); + console.log('✓ switching to "Keep my own exit" persists, reprices the page and starts renewing'); + + await page.click('.ark-custody-opt[data-tier=early] input'); await sleep(300); + assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'early'); + await page.click('.ark-custody-opt[data-tier=custodial] input'); await sleep(300); + assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial'); + assert.match(await text(), /nothing renews on its own/); + console.log('✓ the other tiers switch the same way, and back'); + assert.deepEqual(errors, [], 'no page errors'); + console.log('\n✅ custody is a choice, made on the Spending page'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index e3bdb7b..2d07963 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -332,16 +332,17 @@ console.log('\n[tending renewals while closed]'); if (ran) (this.state.actions ||= []).push({ id: 'r1', type: 'refresh', step: 'submitted', unlockHash: ran.unlockHash }); }; ArkManager.prototype._driveRefresh = async function (a) { calls.push(['claim', a.id, a.step]); a.step = 'done'; }; - ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max) { calls.push(['ensure', margin, max]); return 1; }; + ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max, grid) { calls.push(['ensure', margin, max, grid]); return 1; }; try { const rec = baseRec(); rec.key4 = 'ff'.repeat(32); + rec.custody = 'self'; // a renewing tier: the worker covers bare coins rec.mgr.nextKeyIndex = 5; rec.keys = { 1: 'aa', 5: 'bb', 6: 'cc' }; // two upcoming indices in the window rec.mgr.scheduled = [{ unlockHash: 'ran', inputIds: ['v1'], outKeyIndex: 2 }, { unlockHash: 'waiting', inputIds: ['v1'], outKeyIndex: 3 }]; const saved = []; const n = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async (k, r) => saved.push(k), log: () => {} }); - check('claims what ran, then covers what is bare: orders limited to the key window', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2]]), JSON.stringify(calls)); + check('claims what ran, then covers what is bare: orders limited to the key window, snapped to the 48-block grid', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2, 48]]), JSON.stringify(calls)); check('one claim and one order counted, the record saved', n === 2 && saved.includes('w1')); check('the claimed order is gone from the mirror, its claim done', rec.mgr.scheduled.length === 1 && rec.mgr.scheduled[0].unlockHash === 'waiting' && rec.mgr.actions[0].step === 'done'); // a claim left in flight by an earlier wake (out of time, unconfirmed) is driven on by the next @@ -351,8 +352,20 @@ console.log('\n[tending renewals while closed]'); const n5 = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w5', rec: rec5 }], saveFn: async () => {} }); check('a renewal claim in flight is driven on; finished ones and other actions are not', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['claim', 'r2', 'issued']]) && n5 === 1, JSON.stringify(calls)); + // the default tier — coinos holds the coins — registers no order at all, + // however many keys the window holds (the claim of an order that already + // ran still happens: it was priced and promised when registered) calls.length = 0; - const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key + const rec6 = baseRec(); rec6.key4 = 'ff'.repeat(32); rec6.mgr.nextKeyIndex = 5; rec6.keys = { 1: 'aa', 5: 'bb', 6: 'cc' }; + await tendRenewals({ recordsFn: async () => [{ walletKey: 'w6', rec: rec6 }], saveFn: async () => {} }); + check('custodial (default, or a mirror without the field) → claims only, no orders', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0]]), JSON.stringify(calls)); + calls.length = 0; + const rec7 = baseRec(); rec7.key4 = 'ff'.repeat(32); rec7.custody = 'early'; rec7.mgr.nextKeyIndex = 5; rec7.keys = { 1: 'aa', 5: 'bb' }; + await tendRenewals({ recordsFn: async () => [{ walletKey: 'w7', rec: rec7 }], saveFn: async () => {} }); + check('early tier → a week ahead on the daily grid', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['ensure', 1008, 1, 144]]), JSON.stringify(calls)); + + calls.length = 0; + const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.custody = 'self'; rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key await tendRenewals({ recordsFn: async () => [{ walletKey: 'w2', rec: rec2 }], saveFn: async () => {} }); check('no key for an order\'s output → claims still, registers nothing (regtest margin would be 6)', JSON.stringify(calls) === JSON.stringify([['init', 'regtest'], ['tip'], ['drive', 0]]), JSON.stringify(calls)); |
