4 Ring Gathering
First, the data on ring member ages must be collected from the Monero blockchain. This is done by repeated RPC queries to a running instance of monerod
, the Monero node software.
Several parallel processes are spawned to query monerod
. The number of processes is half of the CPU’s threads, minus one.
The user should specify two variables
current.height
, which is the most recent height that you want to collect data for. This should be the last block in the last day of an ISO week, by UTC time. Usehttps://xmrchain.net
or another block explorer to determine what block number it is.url.rpc
, which is the URL of themonerod
RPC. By default, it should behttp://127.0.0.1:18081
.
Open an R session and run the following code. Do not close the R session after running it. On a powerful machine, it will take about 24 hours to finish.
4.1 Code
library(data.table)
<- NA # 3263496
current.height # current.height should be the most recent height that you want to collect data for
stopifnot(!is.na(current.height))
<- 1220516:current.height
block.heights # 1220516 is hard fork height that allowed the first RingCT transactions
# https://github.com/monero-project/monero#scheduled-softwarenetwork-upgrades
<- "http://127.0.0.1:18081"
url.rpc # Set the IP address and port of your node. Should usually be "http://127.0.0.1:18081"
# Data can be pulled from multiple monerod instances. In that case, the blockchains
# of the multiple monerod instances should be on different storage devices because
# storage I/O seems to be the bottleneck. Specify multiple instances as:
# url.rpc <- c("http://127.0.0.1:18081", "http://127.0.0.1:58081")
# Modified from TownforgeR::tf_rpc_curl function
<- function(
xmr.rpc url.rpc = "http://127.0.0.1:18081/json_rpc",
method = "",
params = list(),
userpwd = "",
num.as.string = FALSE,
nonce.as.string = FALSE,
keep.trying.rpc = FALSE,
curl = RCurl::getCurlHandle(),
...
){
<- RJSONIO::toJSON(
json.ret list(
jsonrpc = "2.0",
id = "0",
method = method,
params = params
digits = 50
),
)
<- tryCatch(RCurl::postForm(url.rpc,
rcp.ret .opts = list(
userpwd = userpwd,
postfields = json.ret,
httpheader = c('Content-Type' = 'application/json', Accept = 'application/json')
# https://stackoverflow.com/questions/19267261/timeout-while-reading-csv-file-from-url-in-r
),curl = curl
error = function(e) {NULL})
),
if (keep.trying.rpc && length(rcp.ret) == 0) {
while (length(rcp.ret) == 0) {
<- tryCatch(RCurl::postForm(url.rpc,
rcp.ret .opts = list(
userpwd = userpwd,
postfields = json.ret,
httpheader = c('Content-Type' = 'application/json', Accept = 'application/json')
# https://stackoverflow.com/questions/19267261/timeout-while-reading-csv-file-from-url-in-r
),curl = curl
error = function(e) {NULL})
),
}
}
if (is.null(rcp.ret)) {
stop("Cannot connect to monerod. Is monerod running?")
}
if (num.as.string) {
<- gsub("(: )([-0123456789.]+)([,\n\r])", "\\1\"\\2\"\\3", rcp.ret )
rcp.ret
}
if (nonce.as.string & ! num.as.string) {
<- gsub("(\"nonce\": )([-0123456789.]+)([,\n\r])", "\\1\"\\2\"\\3", rcp.ret )
rcp.ret
}
::fromJSON(rcp.ret, asText = TRUE) # , simplify = FALSE
RJSONIO
}
system.time({
<- max(2, min(floor(parallelly::availableCores()/2), 32L) - length(url.rpc))
threads
::plan(future::multisession(workers = threads))
futureoptions(future.globals.maxSize= 8000*1024^2)
set.seed(314)
# Randomize block heights to make processing time more uniform between parallel processes
<- split(block.heights, sample(cut(block.heights, threads)))
block.heights # First randomly put heights into list elements (split() will sort them ascendingly in each list element)
<- lapply(block.heights, sample)
block.heights # Then order the heights randomly within each list element
<- unname(block.heights)
block.heights
<- future.apply::future_lapply(block.heights, function(block.heights) {
returned
<- sample(url.rpc, 1)
url.rpc
<- RCurl::getCurlHandle()
handle
<- vector("list", length(block.heights))
return.data
for (height.iter in seq_along(block.heights)) {
<- block.heights[height.iter]
height
<- xmr.rpc(url.rpc = paste0(url.rpc, "/json_rpc"),
block.data method = "get_block",
params = list(height = height ),
keep.trying.rpc = TRUE,
curl = handle)$result
<- c(block.data$miner_tx_hash, block.data$tx_hashes)
txs.to.collect
<- tryCatch(RCurl::postForm(paste0(url.rpc, "/get_transactions"),
rcp.ret .opts = list(
postfields = paste0('{"txs_hashes":["', paste0(txs.to.collect, collapse = '","'), '"],"decode_as_json":true}'),
httpheader = c('Content-Type' = 'application/json', Accept = 'application/json')
),curl = handle
error = function(e) {NULL})
),
if (length(rcp.ret) == 0) {
while (length(rcp.ret) == 0) {
<- tryCatch(RCurl::postForm(paste0(url.rpc, "/get_transactions"),
rcp.ret .opts = list(
postfields = paste0('{"txs_hashes":["', paste0(txs.to.collect, collapse = '","'), '"],"decode_as_json":true}'),
httpheader = c('Content-Type' = 'application/json', Accept = 'application/json')
),curl = handle
error = function(e) {NULL})
),
}
}
<- RJSONIO::fromJSON(rcp.ret, asText = TRUE)
rcp.ret
<- vector("list", length(txs.to.collect))
output.index.collected <- vector("list", length(txs.to.collect) - 1)
rings.collected
for (i in seq_along(txs.to.collect)) {
<- tryCatch(
tx.json ::fromJSON(rcp.ret$txs[[i]]$as_json, asText = TRUE),
RJSONIOerror = function(e) {NULL} )
if (is.null(tx.json)) {
# stop()
cat(paste0("tx: ", i, " block: ", height, "\n"), file = "~/RingCT-problems.txt", append = TRUE)
next
}
<- sapply(tx.json$vout, FUN = function(x) {x$amount})
output.amounts
<- ifelse(i == 1,
tx_size_bytes nchar(rcp.ret$txs[[i]]$pruned_as_hex) / 2,
nchar(rcp.ret$txs[[i]]$as_hex) / 2)
# Coinbase has special structure
# Reference:
# https://libera.monerologs.net/monero-dev/20221231
# https://github.com/monero-project/monero/pull/8691
# https://github.com/monero-project/monero/issues/8311
<- function(p, is.bpp) {
calc.tx.weight.clawback <- 2^(1:4)
pow.of.two <- findInterval(p, pow.of.two, left.open = TRUE) + 1
pow.of.two.index
<- pow.of.two[pow.of.two.index]
n_padded_outputs
if (is.bpp) {
<- 6
multiplier else {
} <- 9
multiplier
}
<- (32 * (multiplier + 7 * 2)) / 2
bp_base <- ceiling(log2(64 * p))
nlr <- 32 * (multiplier + 2 * nlr)
bp_size <- (bp_base * n_padded_outputs - bp_size) * 4 / 5
transaction_clawback floor(transaction_clawback) # With bpp, this is sometimes (always?) not an integer
}# Equation from page 63 of Zero to Monero 2.0
# Updated with Bulletproofs+
# https://github.com/monero-project/monero/blame/c8214782fb2a769c57382a999eaf099691c836e7/src/cryptonote_basic/cryptonote_format_utils.cpp#L106
if (length(tx.json$vout) == 2 || i == 1) {
# i == 1 means the first tx, which is the coinbase tx
<- tx_size_bytes
tx_weight_bytes else {
} <- tx_size_bytes +
tx_weight_bytes calc.tx.weight.clawback(length(tx.json$vout), length(tx.json$rctsig_prunable$bpp) > 0)
}
<- ifelse(i == 1 || is.null(tx.json$rct_signatures), NA, tx.json$rct_signatures$txnFee)
tx_fee # missing non-RingCT tx fee
<-
is.mordinal >= 2838965 &&
height length(tx.json$vout) == 2 &&
> 1 && # not the first tx, which is the coinbase tx
i length(tx.json$extra) > 44 &&
$extra[45] == 16
tx.json# With "&&", evaluates each expression sequentially until it is false (if ever). Then stops.
# If all are TRUE, then returns true.
<-
is.mordinal.transfer >= 2838965 &&
height length(tx.json$vout) == 2 &&
> 1 && # not the first tx, which is the coinbase tx
i length(tx.json$extra) > 44 &&
$extra[45] == 17
tx.json
<- data.table(
output.index.collected[[i]] block_height = height,
block_timestamp = block.data$block_header$timestamp,
tx_num = i,
tx_hash = txs.to.collect[i],
tx_version = tx.json$version,
tx_fee = tx_fee,
tx_size_bytes = tx_size_bytes,
tx_weight_bytes = tx_weight_bytes,
number_of_inputs = length(tx.json$vin),
number_of_outputs = length(tx.json$vout),
output_num = seq_along(rcp.ret$txs[[i]]$output_indices),
output_index = rcp.ret$txs[[i]]$output_indices,
output_amount = output.amounts,
output_unlock_time = tx.json$unlock_time,
is_mordinal = is.mordinal,
is_mordinal_transfer = is.mordinal.transfer)
if (i == 1L) { next }
# Skip first tx since it is the coinbase and has no inputs
<- txs.to.collect[i]
tx_hash
<- vector("list", length(tx.json$vin))
rings
for (j in seq_along(tx.json$vin)) {
<- data.table(
rings[[j]] tx_hash = tx_hash,
input_num = j,
input_amount = tx.json$vin[[j]]$key$amount,
key_offset_num = seq_along(tx.json$vin[[j]]$key$key_offsets),
key_offsets = tx.json$vin[[j]]$key$key_offsets
)
}
-1]] <- rbindlist(rings)
rings.collected[[i
}
<- data.table::rbindlist(output.index.collected)
output.index.collected <- rbindlist(rings.collected)
rings.collected
<- list(
return.data[[height.iter]] output.index.collected = output.index.collected,
rings.collected = rings.collected)
}
return.data
future.seed = TRUE)
},
})
::plan(future::sequential)
future# Shuts down R threads to free RAM
<- vector("list", length(returned))
returned.temp
for (i in seq_along(returned)) {
<- list(
returned.temp[[i]] output.index.collected = rbindlist(lapply(returned[[i]],
FUN = function(y) { y$output.index.collected })),
rings.collected = rbindlist(lapply(returned[[i]],
FUN = function(y) { y$rings.collected }))
)
}
<- list(
returned.temp output.index.collected = rbindlist(lapply(returned.temp,
FUN = function(y) { y$output.index.collected })),
rings.collected = rbindlist(lapply(returned.temp,
FUN = function(y) { y$rings.collected }))
)
<- returned.temp$output.index.collected
output.index $output.index.collected <- NULL
returned.temp<- returned.temp$rings.collected
xmr.rings rm(returned.temp)
setorder(xmr.rings, tx_hash, input_num, key_offset_num)
:= cumsum(key_offsets), by = c("tx_hash", "input_num")]
xmr.rings[, output_index
<- merge(xmr.rings, unique(output.index[, .(tx_hash, block_height,
xmr.rings by = "tx_hash")
block_timestamp, tx_fee, tx_size_bytes, tx_weight_bytes, is_mordinal, is_mordinal_transfer)]),
<- c("block_height", "block_timestamp", "tx_fee", "tx_size_bytes",
ring.col.names "tx_weight_bytes", "is_mordinal", "is_mordinal_transfer")
setnames(xmr.rings, ring.col.names, paste0(ring.col.names, "_ring"))
:= ifelse(tx_num == 1, 0, output_amount)]
output.index[, output_amount_for_index
<- output.index[ !(tx_num == 1 & tx_version == 1), ]
output.index # Remove coinbase outputs that are ineligible for use in a RingCT ring
# See https://libera.monerologs.net/monero-dev/20230323#c224570
<- 2689608 # 2022-08-14
v16.fork.height <- xmr.rings[block_height_ring >= v16.fork.height, ]
xmr.rings # Remove data from before August 2022 hard fork
<- merge(xmr.rings, output.index[, .(block_height, block_timestamp, tx_num, output_num,
xmr.rings
output_index, output_amount, output_amount_for_index, output_unlock_time,
is_mordinal, is_mordinal_transfer, tx_fee, tx_size_bytes)],# only dont need tx_hash column from output.index
by.x = c("input_amount", "output_index"),
by.y = c("output_amount_for_index", "output_index")) #, all = TRUE)
<- xmr.rings[input_amount == 0, ]
xmr.rings # Remove non-RingCT rings
setorder(output.index, block_height, tx_num, output_num)
<- unique(xmr.rings[, .(block_timestamp_ring = block_timestamp_ring)])
xmr.rings.isoweek
:= paste0(lubridate::isoyear(as.POSIXct(block_timestamp_ring, origin = "1970-01-01", tz = "UTC")), "-",
xmr.rings.isoweek[, block_timestamp_ring_isoweek formatC(lubridate::isoweek(as.POSIXct(block_timestamp_ring, origin = "1970-01-01", tz = "UTC")), width = 2, flag = "0"))]
<- merge(xmr.rings, xmr.rings.isoweek, by = "block_timestamp_ring")
xmr.rings # speed improvement by splitting and then merging
<- xmr.rings[, unique(block_timestamp_ring_isoweek)]
iso.weeks
<- iso.weeks[as.numeric(gsub("-", "", iso.weeks, fixed = TRUE)) >= 202233]
iso.weeks # week after hard fork