Fix NIP-03 timestamp upgrade: tree-walk pending attestations, correct PREPEND op composition, and verify Bitcoin attestation completion

This commit is contained in:
Laan Tungir
2026-07-28 06:31:43 -04:00
parent be3a32b488
commit 932c770f6f
8 changed files with 472 additions and 70 deletions
+1 -1
View File
@@ -1 +1 @@
0.6.7
0.6.8
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7 -43
View File
@@ -319,9 +319,8 @@ static int do_stamp(const char* file_path, const char* calendar_url,
sleep(interval_sec);
/* Try to upgrade the proof by re-submitting to all calendars */
char* upgraded = nostr_nip03_stamp_with_multiple_calendars(
digest_hex, calendars_to_use, num_to_use, 30);
/* Try to upgrade the proof by walking the tree and fetching upgraded sub-proofs */
char* upgraded = nostr_nip03_upgrade_proof_tree(ots_b64, 30);
if (upgraded) {
free(ots_b64);
ots_b64 = upgraded;
@@ -501,49 +500,14 @@ static int do_upgrade(const char* ots_path, const char* file_path,
return 0;
}
/* Use multiple calendars for upgrade */
const char* default_calendars[] = {
"https://alice.btc.calendar.opentimestamps.org",
"https://bob.btc.calendar.opentimestamps.org",
"https://finney.calendar.eternitywall.com",
"https://btc.calendar.catallaxy.com",
};
(void)calendar_url; /* upgrade now uses pending-attestation URIs from proof */
(void)file_path;
/* Determine which calendars to use */
const char** calendars_to_use;
int num_to_use;
if (strcmp(calendar_url, DEFAULT_CALENDAR) != 0) {
calendars_to_use = &calendar_url;
num_to_use = 1;
} else {
calendars_to_use = default_calendars;
num_to_use = 4;
}
printf("📤 Re-submitting to %d calendar(s)...\n", num_to_use);
/* Get the digest: try extracting from proof, or compute from file */
char digest_hex[65] = {0};
int have_digest = 0;
/* Try extracting from the proof (works for full DetachedTimestampFile) */
/* The upgrade_proof function does this internally, but we need the digest
* for the multi-calendar stamp function */
if (file_path) {
if (compute_file_sha256_hex(file_path, digest_hex) == 0) {
have_digest = 1;
}
}
printf("📤 Checking pending attestations and fetching upgrades...\n");
char* upgraded = NULL;
if (have_digest) {
printf(" Digest: %s\n", digest_hex);
upgraded = nostr_nip03_stamp_with_multiple_calendars(
digest_hex, calendars_to_use, num_to_use, 30);
} else {
/* Fall back to single-calendar upgrade via embedded digest */
upgraded = nostr_nip03_upgrade_proof(ots_b64, calendar_url, 30);
}
/* Use the tree-walking upgrade which correctly queries commitment hashes */
upgraded = nostr_nip03_upgrade_proof_tree(ots_b64, 30);
if (!upgraded) {
printf("⏳ No upgrade available yet. The proof is still pending.\n");
+446 -24
View File
@@ -375,6 +375,361 @@ static int ots_parse_file(const unsigned char* data, size_t data_len,
return ots_parse_timestamp(data, data_len, &pos, list, 0);
}
/* ========================================================================== */
/* Tree-Walking Upgrade Logic */
/* ========================================================================== */
/*
* Walk the proof tree and find all pending attestations with their
* commitment hashes. For each, fetch the upgraded proof from the calendar
* via GET /timestamp/<commitment-hash>. If an upgraded proof is found,
* replace the pending attestation in the original proof bytes.
*
* This implements the same logic as the reference otsclient upgrade_timestamp():
* - Walk the tree, computing intermediate hashes by applying ops
* - For each PendingAttestation, get the commitment hash (sub_stamp.msg)
* - Call GET /timestamp/<commitment-hash> on the calendar
* - If the response contains a Bitcoin attestation, splice it in
*/
/* Track a pending attestation location in the proof */
typedef struct {
unsigned char commitment[128]; /* The commitment hash at this point (can be >32 bytes) */
size_t commitment_len; /* Length of the commitment */
char uri[256]; /* Calendar URI (null-terminated) */
size_t att_start; /* Byte offset of the 0x00 attestation tag */
size_t att_end; /* Byte offset after the attestation */
} ots_pending_location_t;
#define OTS_MAX_PENDING_LOCATIONS 32
typedef struct {
ots_pending_location_t locations[OTS_MAX_PENDING_LOCATIONS];
int count;
} ots_pending_location_list_t;
/* Compute SHA-256 of data */
static int compute_sha256(const unsigned char* data, size_t len, unsigned char* out) {
return nostr_sha256(data, len, out);
}
/* Apply an op to a message and return the result */
static int apply_op(unsigned char op, const unsigned char* arg, size_t arg_len,
const unsigned char* msg, size_t msg_len,
unsigned char* result, size_t* result_len) {
if (op == OTS_OP_APPEND) {
/* result = msg + arg */
if (msg_len + arg_len > 4096) return -1;
memcpy(result, msg, msg_len);
memcpy(result + msg_len, arg, arg_len);
*result_len = msg_len + arg_len;
} else if (op == OTS_OP_PREPEND) {
/* result = arg + msg */
if (msg_len + arg_len > 4096) return -1;
memcpy(result, arg, arg_len);
memcpy(result + arg_len, msg, msg_len);
*result_len = msg_len + arg_len;
} else if (op == OTS_OP_SHA256) {
/* result = SHA256(msg) */
compute_sha256(msg, msg_len, result);
*result_len = 32;
} else if (op == OTS_OP_RIPEMD160) {
/* Not commonly used in OTS proofs, skip */
return -1;
} else if (op == OTS_OP_REVERSE) {
/* result = reverse(msg) */
if (msg_len > 4096) return -1;
for (size_t i = 0; i < msg_len; i++) {
result[i] = msg[msg_len - 1 - i];
}
*result_len = msg_len;
} else if (op == OTS_OP_HEXLIFY) {
/* result = hex(msg) */
if (msg_len * 2 > 4096) return -1;
nostr_bytes_to_hex(msg, msg_len, (char*)result);
*result_len = msg_len * 2;
} else {
return -1;
}
return 0;
}
/*
* Walk the proof tree tracking commitment hashes and finding pending attestations.
* Records the byte positions of each pending attestation for later replacement.
*/
static int walk_tree_for_pending(const unsigned char* data, size_t data_len,
size_t* pos, const unsigned char* current_msg,
size_t msg_len,
ots_pending_location_list_t* locations, int depth) {
if (depth > 256 || *pos >= data_len) return -1;
unsigned char tag = data[*pos];
(*pos)++;
/* Handle 0xff separators */
while (tag == OTS_TAG_SEPARATOR) {
if (*pos >= data_len) return -1;
unsigned char sep_tag = data[*pos];
(*pos)++;
if (sep_tag == OTS_TAG_ATTESTATION) {
/* Attestation after separator - check if pending */
size_t att_start = *pos - 1; /* Position of the 0x00 tag */
ots_attestation_t att;
if (ots_parse_attestation(data, data_len, pos, &att) != 0) return -1;
size_t att_end = *pos;
if (att.type == OTS_ATTESTATION_PENDING && locations->count < OTS_MAX_PENDING_LOCATIONS) {
ots_pending_location_t* loc = &locations->locations[locations->count];
size_t copy_len = msg_len < sizeof(loc->commitment) ? msg_len : sizeof(loc->commitment);
memcpy(loc->commitment, current_msg, copy_len);
loc->commitment_len = copy_len;
size_t uri_copy_len = att.uri_len < sizeof(loc->uri) - 1 ? att.uri_len : sizeof(loc->uri) - 1;
memcpy(loc->uri, att.uri, uri_copy_len);
loc->uri[uri_copy_len] = '\0';
loc->att_start = att_start;
loc->att_end = att_end;
locations->count++;
}
} else {
/* Op after separator - process op and recurse */
unsigned char result[4096];
size_t result_len = 0;
if (sep_tag == OTS_OP_APPEND || sep_tag == OTS_OP_PREPEND) {
const unsigned char* arg;
size_t arg_len;
if (ots_read_varbytes(data, data_len, pos, &arg, &arg_len) != 0) return -1;
if (apply_op(sep_tag, arg, arg_len, current_msg, msg_len, result, &result_len) != 0) return -1;
} else {
if (apply_op(sep_tag, NULL, 0, current_msg, msg_len, result, &result_len) != 0) return -1;
}
if (walk_tree_for_pending(data, data_len, pos, result, result_len, locations, depth + 1) != 0)
return -1;
}
if (*pos >= data_len) return -1;
tag = data[*pos];
(*pos)++;
}
/* Process the final tag */
if (tag == OTS_TAG_ATTESTATION) {
size_t att_start = *pos - 1;
ots_attestation_t att;
if (ots_parse_attestation(data, data_len, pos, &att) != 0) return -1;
size_t att_end = *pos;
if (att.type == OTS_ATTESTATION_PENDING && locations->count < OTS_MAX_PENDING_LOCATIONS) {
ots_pending_location_t* loc = &locations->locations[locations->count];
size_t copy_len = msg_len < sizeof(loc->commitment) ? msg_len : sizeof(loc->commitment);
memcpy(loc->commitment, current_msg, copy_len);
loc->commitment_len = copy_len;
size_t uri_copy_len = att.uri_len < sizeof(loc->uri) - 1 ? att.uri_len : sizeof(loc->uri) - 1;
memcpy(loc->uri, att.uri, uri_copy_len);
loc->uri[uri_copy_len] = '\0';
loc->att_start = att_start;
loc->att_end = att_end;
locations->count++;
}
} else {
/* Op - process and recurse */
unsigned char result[4096];
size_t result_len = 0;
if (tag == OTS_OP_APPEND || tag == OTS_OP_PREPEND) {
const unsigned char* arg;
size_t arg_len;
if (ots_read_varbytes(data, data_len, pos, &arg, &arg_len) != 0) return -1;
if (apply_op(tag, arg, arg_len, current_msg, msg_len, result, &result_len) != 0) return -1;
} else {
if (apply_op(tag, NULL, 0, current_msg, msg_len, result, &result_len) != 0) return -1;
}
if (walk_tree_for_pending(data, data_len, pos, result, result_len, locations, depth + 1) != 0)
return -1;
}
return 0;
}
/*
* Fetch an upgraded proof from a calendar via GET /timestamp/<commitment-hash>
* Returns the raw proof bytes (caller must free), or NULL on error/404.
*/
static unsigned char* fetch_upgraded_proof(const char* calendar_uri,
const unsigned char* commitment, size_t commitment_len,
int timeout_seconds,
size_t* out_len) {
char commitment_hex[65];
nostr_bytes_to_hex(commitment, commitment_len, commitment_hex);
char full_url[512];
snprintf(full_url, sizeof(full_url), "%s/timestamp/%s", calendar_uri, commitment_hex);
nostr_http_request_t req = {0};
req.method = "GET";
req.url = full_url;
req.timeout_seconds = timeout_seconds > 0 ? timeout_seconds : 10;
req.follow_redirects = 1;
req.max_redirects = 3;
const char* headers[] = { "Accept: application/octet-stream", NULL };
req.headers = headers;
nostr_http_response_t resp = {0};
if (nostr_http_request(&req, &resp) != NOSTR_SUCCESS ||
resp.status_code != 200 || !resp.body || resp.body_len == 0) {
nostr_http_response_free(&resp);
return NULL;
}
/* Copy the response body */
unsigned char* proof = (unsigned char*)malloc(resp.body_len);
if (proof) {
memcpy(proof, resp.body, resp.body_len);
*out_len = resp.body_len;
}
nostr_http_response_free(&resp);
return proof;
}
/*
* Check if a raw timestamp proof contains a Bitcoin attestation.
*/
static int has_bitcoin_attestation(const unsigned char* data, size_t data_len) {
ots_attestation_list_t list;
if (ots_parse_file(data, data_len, &list) != 0) return 0;
for (int i = 0; i < list.count; i++) {
if (list.attestations[i].type == OTS_ATTESTATION_BITCOIN ||
list.attestations[i].type == OTS_ATTESTATION_LITECOIN) {
return 1;
}
}
return 0;
}
/*
* Upgrade a proof by walking the tree, finding pending attestations,
* fetching upgraded proofs from calendars, and splicing them in.
*
* Returns a new base64-encoded proof (caller must free), or NULL on error.
*/
char* nostr_nip03_upgrade_proof_tree(const char* ots_data_base64,
int timeout_seconds) {
if (!ots_data_base64) return NULL;
size_t input_len = strlen(ots_data_base64);
size_t max_decoded_len = (input_len / 4) * 3 + 3;
unsigned char* data = (unsigned char*)malloc(max_decoded_len);
if (!data) return NULL;
size_t data_len = base64_decode(ots_data_base64, data);
if (data_len == 0) {
free(data);
return NULL;
}
/* Find the start of the timestamp tree and the file digest */
size_t pos = 0;
unsigned char file_digest[32];
size_t digest_len = 32;
if (data_len >= OTS_HEADER_MAGIC_LEN &&
memcmp(data, OTS_HEADER_MAGIC, OTS_HEADER_MAGIC_LEN) == 0) {
pos = OTS_HEADER_MAGIC_LEN;
uint64_t version;
if (ots_read_varuint(data, data_len, &pos, &version) != 0) { free(data); return NULL; }
if (pos >= data_len) { free(data); return NULL; }
unsigned char hash_op = data[pos++];
digest_len = ots_digest_length(hash_op);
if (digest_len == 0 || pos + digest_len > data_len) { free(data); return NULL; }
memcpy(file_digest, data + pos, digest_len);
pos += digest_len;
} else {
/* Raw timestamp - can't upgrade without knowing the digest */
free(data);
return NULL;
}
/* Walk the tree to find all pending attestations */
ots_pending_location_list_t locations;
memset(&locations, 0, sizeof(locations));
size_t tree_pos = pos;
if (walk_tree_for_pending(data, data_len, &tree_pos, file_digest, digest_len,
&locations, 0) != 0) {
free(data);
return NULL;
}
if (locations.count == 0) {
/* No pending attestations - nothing to upgrade */
free(data);
return NULL;
}
/* For each pending attestation, try to fetch an upgraded proof */
int upgraded = 0;
for (int i = 0; i < locations.count; i++) {
ots_pending_location_t* loc = &locations.locations[i];
size_t upgraded_len = 0;
unsigned char* upgraded_proof = fetch_upgraded_proof(
loc->uri, loc->commitment, loc->commitment_len, timeout_seconds, &upgraded_len);
if (!upgraded_proof) {
continue;
}
if (!has_bitcoin_attestation(upgraded_proof, upgraded_len)) {
free(upgraded_proof);
continue;
}
/* We have an upgraded proof with a Bitcoin attestation!
* Replace the pending attestation bytes [att_start, att_end) with
* the upgraded proof bytes. */
size_t old_att_len = loc->att_end - loc->att_start;
size_t new_data_len = data_len - old_att_len + upgraded_len;
unsigned char* new_data = (unsigned char*)malloc(new_data_len);
if (!new_data) { free(upgraded_proof); continue; }
/* Copy: [0..att_start) + upgraded_proof + [att_end..data_len) */
memcpy(new_data, data, loc->att_start);
memcpy(new_data + loc->att_start, upgraded_proof, upgraded_len);
memcpy(new_data + loc->att_start + upgraded_len, data + loc->att_end,
data_len - loc->att_end);
free(upgraded_proof);
free(data);
data = new_data;
data_len = new_data_len;
upgraded = 1;
/* Only need one Bitcoin attestation */
break;
}
if (!upgraded) {
free(data);
return NULL;
}
/* Base64 encode the upgraded proof */
size_t b64_size = ((data_len + 2) / 3) * 4 + 1;
char* b64 = (char*)malloc(b64_size);
if (b64) {
base64_encode(data, data_len, b64, b64_size);
}
free(data);
return b64;
}
/* ========================================================================== */
/* NIP-03 Public API */
/* ========================================================================== */
@@ -700,24 +1055,25 @@ char* nostr_nip03_upgrade_proof(const char* ots_data_base64,
return NULL;
}
/* Re-submit the digest to the calendar's /digest endpoint.
* The calendar will return the current proof, which may include
* new Bitcoin attestations if the digest has been committed since
* the original submission. */
/* Fetch the upgraded proof from the calendar's /timestamp/<hash> endpoint.
* This is the correct API for retrieving upgraded proofs — it returns the
* current proof which may include Bitcoin attestations if the digest has
* been committed since the original submission.
* (POST /digest always returns a fresh pending proof, not the upgraded one.) */
char digest_hex_str[65];
nostr_bytes_to_hex(digest, 32, digest_hex_str);
char full_url[512];
snprintf(full_url, sizeof(full_url), "%s/digest", calendar_url);
snprintf(full_url, sizeof(full_url), "%s/timestamp/%s", calendar_url, digest_hex_str);
nostr_http_request_t req = {0};
req.method = "POST";
req.method = "GET";
req.url = full_url;
req.body = digest;
req.body_len = 32;
req.timeout_seconds = timeout_seconds > 0 ? timeout_seconds : 10;
req.follow_redirects = 1;
req.max_redirects = 3;
const char* headers[] = {
"Content-Type: application/octet-stream",
"Accept: application/octet-stream",
NULL
};
@@ -748,27 +1104,19 @@ char* nostr_nip03_upgrade_proof_with_digest(const char* digest_hex,
int timeout_seconds) {
if (!digest_hex || !calendar_url) return NULL;
/* Convert hex digest to binary */
unsigned char digest[32];
if (nostr_hex_to_bytes(digest_hex, digest, 32) != 0) {
return NULL;
}
/* Re-submit the digest to the calendar's /digest endpoint */
/* Fetch the upgraded proof from the calendar's /timestamp/<hash> endpoint.
* This returns the current proof which may include Bitcoin attestations. */
char full_url[512];
snprintf(full_url, sizeof(full_url), "%s/digest", calendar_url);
snprintf(full_url, sizeof(full_url), "%s/timestamp/%s", calendar_url, digest_hex);
nostr_http_request_t req = {0};
req.method = "POST";
req.method = "GET";
req.url = full_url;
req.body = digest;
req.body_len = 32;
req.timeout_seconds = timeout_seconds > 0 ? timeout_seconds : 10;
req.follow_redirects = 1;
req.max_redirects = 3;
const char* headers[] = {
"Content-Type: application/octet-stream",
"Accept: application/octet-stream",
NULL
};
@@ -1085,7 +1433,81 @@ char* nostr_nip03_upgrade_proof_multi(const char* digest_hex,
calendar_count = NOSTR_OTS_DEFAULT_CALENDAR_COUNT;
}
/* Re-submit to all calendars to get current proofs */
return nostr_nip03_stamp_with_multiple_calendars(digest_hex, calendar_urls,
calendar_count, timeout_seconds);
/* Convert hex digest to binary */
unsigned char digest[32];
if (nostr_hex_to_bytes(digest_hex, digest, 32) != 0) {
return NULL;
}
/* Fetch upgraded proof from each calendar via GET /timestamp/<hash> */
const unsigned char* raw_proofs[16];
size_t raw_proof_lens[16];
unsigned char* proof_buffers[16];
int success_count = 0;
for (int i = 0; i < calendar_count && i < 16; i++) {
if (!calendar_urls[i]) continue;
char full_url[512];
snprintf(full_url, sizeof(full_url), "%s/timestamp/%s", calendar_urls[i], digest_hex);
nostr_http_request_t req = {0};
req.method = "GET";
req.url = full_url;
req.timeout_seconds = timeout_seconds > 0 ? timeout_seconds : 10;
req.follow_redirects = 1;
req.max_redirects = 3;
const char* headers[] = {
"Accept: application/octet-stream",
NULL
};
req.headers = headers;
nostr_http_response_t resp = {0};
if (nostr_http_request(&req, &resp) != NOSTR_SUCCESS ||
resp.status_code != 200 || !resp.body || resp.body_len == 0) {
nostr_http_response_free(&resp);
continue; /* Calendar may not have this digest (404) */
}
/* Copy the raw proof data */
unsigned char* proof_copy = (unsigned char*)malloc(resp.body_len);
if (proof_copy) {
memcpy(proof_copy, resp.body, resp.body_len);
raw_proofs[success_count] = proof_copy;
raw_proof_lens[success_count] = resp.body_len;
proof_buffers[success_count] = proof_copy;
success_count++;
}
nostr_http_response_free(&resp);
}
if (success_count == 0) {
return NULL; /* No calendar had an upgraded proof */
}
/* Build a combined DetachedTimestampFile */
size_t out_len = 0;
unsigned char* ots_data = build_dts_file(digest, 32,
raw_proofs, raw_proof_lens,
success_count, &out_len);
/* Free the individual proof buffers */
for (int i = 0; i < success_count; i++) {
free(proof_buffers[i]);
}
if (!ots_data) return NULL;
/* Base64 encode */
size_t b64_size = ((out_len + 2) / 3) * 4 + 1;
char* b64 = (char*)malloc(b64_size);
if (b64) {
base64_encode(ots_data, out_len, b64, b64_size);
}
free(ots_data);
return b64;
}
+16
View File
@@ -135,6 +135,22 @@ char* nostr_nip03_upgrade_proof_with_digest(const char* digest_hex,
const char* calendar_url,
int timeout_seconds);
/**
* Upgrade an OTS proof by walking the proof tree and fetching upgraded sub-proofs
*
* This is the correct upgrade mechanism, matching the reference ots client:
* 1. Parses the proof tree
* 2. Walks the tree computing intermediate commitment hashes
* 3. For each PendingAttestation, calls GET /timestamp/<commitment-hash>
* 4. If the calendar returns a proof with a Bitcoin attestation, splices it in
*
* @param ots_data_base64 Base64 encoded DetachedTimestampFile
* @param timeout_seconds Per-request timeout
* @return char* New base64 encoded upgraded proof, or NULL if no upgrade available (caller must free)
*/
char* nostr_nip03_upgrade_proof_tree(const char* ots_data_base64,
int timeout_seconds);
/**
* Get all pending calendar URIs from an OTS proof
*
+2 -2
View File
@@ -2,10 +2,10 @@
#define NOSTR_CORE_H
// Version information (auto-updated by increment_and_push.sh)
#define VERSION "v0.6.7"
#define VERSION "v0.6.8"
#define VERSION_MAJOR 0
#define VERSION_MINOR 6
#define VERSION_PATCH 7
#define VERSION_PATCH 8
/*
* NOSTR Core Library - Complete API Reference