wallet: dual-write chaintopology UTXO changes into our_outputs
What changed, and why it matters
This is a database refactoring commit in Core Lightning. It makes the wallet write UTXO (unspent transaction output) state to a new table called our_outputs in addition to the legacy outputs table, while reads still use the old table. The goal is to prepare for a future migration, not to fix an active security bug. There is no direct evidence in the commit that this change itself creates or fixes a vulnerability.
Treat as a normal refactoring commit. Monitor the follow-up commit that switches reads to our_outputs for consistency and correctness. If auditing, verify that dual-write pairs are atomic and that no code path updates only one table, which could cause data divergence on downgrade or rollback.
Security signals we found
Database schema migration with dual-write mirror pattern
No security claim in commit title or message
No CVE, advisory, or researcher attribution in commit metadata
Test-only additions to satisfy new table constraints
Evidence from the diff
The commit dual-writes UTXO lifecycle changes from chaintopology into both the new our_outputs table and the legacy outputs table. Affected helpers: wallet_add_utxo, wallet_add_onchaind_utxo, db_set_utxo, wallet_confirm_tx, and wallet_outpoint_spend. Reads remain on outputs until a follow-up commit. The change is framed as a downgrade mirror to support rollback. The diff also updates test fixtures to populate new required fields (scriptPubkey, csv) for the new table schema. No security relevance is claimed by the commit message or diff.
Changed components
wallet/wallet.cwallet/test/run-wallet.cour_outputs database tableoutputs database table (legacy mirror)Inspect captured patch +118 / −16
diff --git a/wallet/test/run-wallet.c b/wallet/test/run-wallet.c
index 051fa3c..9433732 100644
--- a/wallet/test/run-wallet.c
+++ b/wallet/test/run-wallet.c
@@ -1002,6 +1002,11 @@ static bool test_wallet_outputs(struct lightningd *ld, const tal_t *ctx, bool bi
memset(&u, 0, sizeof(u));
u.amount = AMOUNT_SAT(1);
+ u.scriptPubkey = tal_arr(w, u8, BITCOIN_SCRIPTPUBKEY_P2SH_LEN);
+ u.scriptPubkey[0] = OP_HASH160;
+ u.scriptPubkey[1] = 20;
+ memset(u.scriptPubkey + 2, 0, 20);
+ u.scriptPubkey[22] = OP_EQUAL;
pubkey_from_der(tal_hexdata(w, "02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc", 66), 33, &pk);
node_id_from_pubkey(&id, &pk);
@@ -1024,6 +1029,7 @@ static bool test_wallet_outputs(struct lightningd *ld, const tal_t *ctx, bool bi
u.close_info->peer_id = id;
u.close_info->commitment_point = &pk;
u.close_info->option_anchors = false;
+ u.close_info->csv = 1;
/* P2WSH */
u.scriptPubkey = tal_arr(w, u8, BITCOIN_SCRIPTPUBKEY_P2WSH_LEN);
u.scriptPubkey[0] = OP_0;
@@ -1084,6 +1090,7 @@ static bool test_wallet_outputs(struct lightningd *ld, const tal_t *ctx, bool bi
u.close_info->peer_id = id;
u.close_info->commitment_point = NULL;
u.close_info->option_anchors = true;
+ u.close_info->csv = 1;
/* The blockheight has to be set for an option_anchor_output
* closed UTXO to be spendable */
u32 *blockheight = tal(w, u32);
diff --git a/wallet/wallet.c b/wallet/wallet.c
index c3b2737..c24747a 100644
--- a/wallet/wallet.c
+++ b/wallet/wallet.c
@@ -344,6 +344,56 @@ static bool wallet_add_utxo(struct wallet *w,
}
tal_free(stmt);
+ /* our_outputs is the live table. */
+ stmt = db_prepare_v2(
+ w->db, SQL("INSERT INTO our_outputs ("
+ " txid"
+ ", outnum"
+ ", blockheight"
+ ", txindex"
+ ", scriptpubkey"
+ ", satoshis"
+ ", spendheight"
+ ", keyindex"
+ ", reserved_til"
+ ", channel_dbid"
+ ", peer_id"
+ ", commitment_point"
+ ", option_anchors"
+ ", csv"
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"));
+ db_bind_txid(stmt, &utxo->outpoint.txid);
+ db_bind_int(stmt, utxo->outpoint.n);
+ db_bind_int(stmt, utxo->blockheight ? *utxo->blockheight : 0);
+ db_bind_int(stmt, utxo->is_in_coinbase ? 0 : 1);
+ db_bind_blob(stmt, utxo->scriptPubkey,
+ tal_bytelen(utxo->scriptPubkey));
+ db_bind_amount_sat(stmt, utxo->amount);
+ if (utxo->spendheight)
+ db_bind_int(stmt, *utxo->spendheight);
+ else
+ db_bind_null(stmt);
+ db_bind_int(stmt, utxo->keyindex);
+ db_bind_int(stmt, utxo->reserved_til);
+ if (utxo->close_info) {
+ db_bind_u64(stmt, utxo->close_info->channel_id);
+ db_bind_node_id(stmt, &utxo->close_info->peer_id);
+ if (utxo->close_info->commitment_point)
+ db_bind_pubkey(stmt, utxo->close_info->commitment_point);
+ else
+ db_bind_null(stmt);
+ db_bind_int(stmt, utxo->close_info->option_anchors);
+ db_bind_int(stmt, utxo->close_info->csv);
+ } else {
+ db_bind_null(stmt);
+ db_bind_null(stmt);
+ db_bind_null(stmt);
+ db_bind_null(stmt);
+ db_bind_null(stmt);
+ }
+ db_exec_prepared_v2(take(stmt));
+
+ /* Mirror the same output into legacy `outputs` for downgrade. */
stmt = db_prepare_v2(
w->db, SQL("INSERT INTO outputs ("
" prev_out_tx"
@@ -766,6 +816,16 @@ static void db_set_utxo(struct db *db, const struct utxo *utxo)
else
assert(!utxo->reserved_til);
+ /* our_outputs derives status from spendheight and reserved_til. */
+ stmt = db_prepare_v2(
+ db, SQL("UPDATE our_outputs SET reserved_til = ? "
+ "WHERE txid = ? AND outnum = ?"));
+ db_bind_int(stmt, utxo->reserved_til);
+ db_bind_txid(stmt, &utxo->outpoint.txid);
+ db_bind_int(stmt, utxo->outpoint.n);
+ db_exec_prepared_v2(take(stmt));
+
+ /* Mirror the reservation into legacy `outputs` for downgrade. */
stmt = db_prepare_v2(
db, SQL("UPDATE outputs SET status=?, reserved_til=? "
"WHERE prev_out_tx=? AND prev_out_index=?"));
@@ -1026,6 +1086,39 @@ bool wallet_add_onchaind_utxo(struct wallet *w,
}
tal_free(stmt);
+ /* Store the channel-close output in the live table. We do not know
+ * its real tx position here; 1 means "confirmed, not coinbase". */
+ stmt = db_prepare_v2(w->db,
+ SQL("INSERT INTO our_outputs ("
+ " txid"
+ ", outnum"
+ ", blockheight"
+ ", txindex"
+ ", scriptpubkey"
+ ", satoshis"
+ ", channel_dbid"
+ ", peer_id"
+ ", commitment_point"
+ ", option_anchors"
+ ", csv"
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"));
+ db_bind_txid(stmt, &outpoint->txid);
+ db_bind_int(stmt, outpoint->n);
+ db_bind_int(stmt, blockheight);
+ db_bind_int(stmt, blockheight ? 1 : 0);
+ db_bind_blob(stmt, scriptpubkey, tal_bytelen(scriptpubkey));
+ db_bind_amount_sat(stmt, amount);
+ db_bind_u64(stmt, channel->dbid);
+ db_bind_node_id(stmt, &channel->peer->id);
+ if (commitment_point)
+ db_bind_pubkey(stmt, commitment_point);
+ else
+ db_bind_null(stmt);
+ db_bind_int(stmt, channel_type_has_anchors(channel->type));
+ db_bind_int(stmt, csv_lock);
+ db_exec_prepared_v2(take(stmt));
+
+ /* Mirror the same output into legacy `outputs` for downgrade. */
stmt = db_prepare_v2(
w->db, SQL("INSERT INTO outputs ("
" prev_out_tx"
@@ -1055,17 +1148,14 @@ bool wallet_add_onchaind_utxo(struct wallet *w,
db_bind_pubkey(stmt, commitment_point);
else
db_bind_null(stmt);
-
- db_bind_int(stmt,
- channel_type_has_anchors(channel->type));
- db_bind_int(stmt, blockheight);
-
- /* spendheight */
+ db_bind_int(stmt, channel_type_has_anchors(channel->type));
+ if (blockheight)
+ db_bind_int(stmt, blockheight);
+ else
+ db_bind_null(stmt);
db_bind_null(stmt);
db_bind_blob(stmt, scriptpubkey, tal_bytelen(scriptpubkey));
-
db_bind_int(stmt, csv_lock);
-
db_exec_prepared_v2(take(stmt));
return true;
}
@@ -3319,6 +3409,15 @@ void wallet_confirm_tx(struct wallet *w,
{
struct db_stmt *stmt;
assert(confirmation_height > 0);
+
+ stmt = db_prepare_v2(w->db, SQL("UPDATE our_outputs "
+ "SET blockheight = ? "
+ "WHERE txid = ?"));
+ db_bind_int(stmt, confirmation_height);
+ db_bind_txid(stmt, txid);
+ db_exec_prepared_v2(take(stmt));
+
+ /* Mirror the confirmation into legacy `outputs` for downgrade. */
stmt = db_prepare_v2(w->db, SQL("UPDATE outputs "
"SET confirmation_height = ? "
"WHERE prev_out_tx = ?"));
@@ -5002,19 +5101,15 @@ bool wallet_outpoint_spend(const tal_t *ctx, struct wallet *w, const u32 blockhe
struct db_stmt *stmt;
bool our_spend;
if (outpointfilter_matches(w->owned_outpoints, outpoint)) {
- stmt = db_prepare_v2(w->db, SQL("UPDATE outputs "
- "SET spend_height = ?, "
- " status = ? "
- "WHERE prev_out_tx = ?"
- " AND prev_out_index = ?"));
-
+ stmt = db_prepare_v2(w->db,
+ SQL("UPDATE our_outputs SET spendheight = ? "
+ "WHERE txid = ? AND outnum = ?"));
db_bind_int(stmt, blockheight);
- db_bind_int(stmt, output_status_in_db(OUTPUT_STATE_SPENT));
db_bind_txid(stmt, &outpoint->txid);
db_bind_int(stmt, outpoint->n);
-
db_exec_prepared_v2(take(stmt));
+ legacy_outputs_mark_spent(w, outpoint, blockheight);
our_spend = true;
} else
our_spend = false;
Why this scored 29/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.