Skip to content

refactor: wallet unique constrant#672

Merged
michael1011 merged 3 commits into
masterfrom
refactor/wallets-unique-constraint
Apr 27, 2026
Merged

refactor: wallet unique constrant#672
michael1011 merged 3 commits into
masterfrom
refactor/wallets-unique-constraint

Conversation

@jackstar12

@jackstar12 jackstar12 commented Apr 22, 2026

Copy link
Copy Markdown
Member

only enforce same wallets within tenants

Summary by CodeRabbit

  • Improvements
    • Wallet credentials are now scoped per tenant, allowing the same credentials to be imported for different tenants without conflicts.
  • Bug Fixes
    • Re-import of identical credentials is rejected only when they belong to the same tenant.
  • Chores
    • Database migration added to apply tenant-scoped wallet uniqueness.
  • Tests
    • Import tests updated to validate tenant-scoped duplicate-credential behavior.

@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Wallet credential uniqueness is changed from global to tenant-scoped by adding tenantId to the wallets unique constraint. A schema migration (v19) recreates the wallets table to apply the change. RPC import logic was adjusted to reject duplicate credentials only when they belong to the same tenantId.

Changes

Cohort / File(s) Summary
Database Schema
internal/database/database.go
Replaced global unique index on (xpub, coreDescriptor, mnemonic, nodePubkey) with a tenant-scoped unique index including tenantId.
Migrations
internal/database/migration.go
Bumped schema to v19 and added migration that recreates wallets (create new_wallets, copy, drop, rename) to enforce tenantId NOT NULL FK and tenant-scoped uniqueness plus legacy/lastIndex columns.
RPC Router
internal/rpcserver/router.go
Changed duplicate-credentials check to consider tenantId; duplicates are errors only when existing wallet has same tenantId as the import.
Tests
internal/rpcserver/wallet_test.go
Restructured TestImportDuplicateCredentials to run in standalone mode and assert tenant-scoped behavior: same credentials fail within a tenant, succeed across different tenants (with follow-up checks).

Sequence Diagram

sequenceDiagram
    participant Client
    participant Router as RPC Router
    participant DB as Database
    participant Migrator as Migration

    Client->>Router: Import wallet (tenantId, credentials)
    Router->>Router: Validate request, extract tenantId + credentials
    Router->>DB: Query wallets matching (tenantId, xpub, coreDescriptor, mnemonic, nodePubkey)
    DB-->>Router: Return matching rows
    alt Match found for same tenant
        Router-->>Client: Error (InvalidArgument) "same credentials"
    else No match for tenant
        Router->>DB: Insert new wallet record
        DB-->>Router: Insert OK
        Router-->>Client: Success (wallet created)
    end

    Note over Migrator,DB: Migration v18 → v19 recreates wallets table to add tenantId into unique constraint
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

A rabbit hops where schemas change,
Tenants safe in each small range.
"Same creds?" I sniff, "but not the same den!"
Each wallet settles with its rightful pen. 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'refactor: wallet unique constrant' refers to the main change (wallet uniqueness constraint), but contains a typo ('constrant' instead of 'constraint') and is somewhat vague about the specific modification.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/wallets-unique-constraint

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

only enforce same wallets within tenants
@jackstar12 jackstar12 force-pushed the refactor/wallets-unique-constraint branch from 496004f to 6c496d7 Compare April 22, 2026 13:10
@jackstar12 jackstar12 marked this pull request as ready for review April 22, 2026 13:10
Comment thread internal/database/migration.go Outdated
@jackstar12 jackstar12 requested a review from michael1011 April 27, 2026 07:15
mnemonic VARCHAR,
subaccount INT,
salt VARCHAR,
tenantId INT NOT NULL REFERENCES tenants (id),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here tenantId is NOT NULL

Comment thread internal/database/migration.go Outdated
mnemonic VARCHAR,
subaccount INT,
salt VARCHAR,
tenantId INT REFERENCES tenants (id),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the migration it can be NULL

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/database/migration.go`:
- Around line 653-656: The migration must not DROP the live wallets table
because that triggers foreign-key ON DELETE actions and clears swaps.walletId,
reverseSwaps.walletId, and chainSwapsData.walletId; instead, rename the existing
wallets table to a temporary name (e.g., wallets_old), then RENAME new_wallets
TO wallets, and only then DROP TABLE wallets_old — update the SQL block that
currently does "DROP TABLE wallets; ALTER TABLE new_wallets RENAME TO wallets;"
to perform the safe rename sequence so swaps.walletId (and related FKs) continue
pointing to the new wallets table without being nulled.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8faab21e-959f-420d-8137-d4bc132ea88c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e5e799 and 121b659.

📒 Files selected for processing (1)
  • internal/database/migration.go

Comment on lines +650 to +651
UNIQUE (name, tenantId, nodePubkey),
UNIQUE (tenantId, xpub, coreDescriptor, mnemonic, nodePubkey)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
import sqlite3

con = sqlite3.connect(":memory:")
con.executescript("""
CREATE TABLE wallets(
  tenantId INT NOT NULL,
  xpub TEXT,
  coreDescriptor TEXT,
  mnemonic TEXT,
  nodePubkey TEXT,
  UNIQUE (tenantId, xpub, coreDescriptor, mnemonic, nodePubkey)
);

INSERT INTO wallets VALUES (1, 'xpub-1', NULL, NULL, 'node-1');
""")

try:
    con.execute("INSERT INTO wallets VALUES (1, 'xpub-1', NULL, NULL, 'node-1')")
    print("duplicate insert succeeded")
except Exception as exc:
    print("duplicate insert failed:", exc)
PY

Repository: BoltzExchange/boltz-client

Length of output: 97


🏁 Script executed:

cat -n internal/database/migration.go | sed -n '640,660p'

Repository: BoltzExchange/boltz-client

Length of output: 1014


🏁 Script executed:

cat -n internal/database/database.go | sed -n '200,240p'

Repository: BoltzExchange/boltz-client

Length of output: 1316


🏁 Script executed:

rg -i "wallet.*unique\|unique.*wallet" --type-list 2>/dev/null || rg -i "wallet.*unique\|unique.*wallet" -g "*.go" | head -20

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg -A 5 -B 5 "INSERT INTO wallets" --type-list 2>/dev/null || rg -A 5 -B 5 "INSERT INTO wallets" -g "*.go"

Repository: BoltzExchange/boltz-client

Length of output: 2418


🏁 Script executed:

rg "coreDescriptor\|mnemonic.*NULL\|xpub.*NULL" -g "*.go" | head -30

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg -i "credential.*type\|wallet.*type" -g "*.go" | grep -i "enum\|const\|type" | head -20

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg "type Wallet struct" -A 20 -g "*.go"

Repository: BoltzExchange/boltz-client

Length of output: 8526


🏁 Script executed:

rg "CreateWallet\|NewWallet" -B 2 -A 10 -g "*.go" | head -50

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg "credential\|Credential" -g "*.go" | grep -v "test" | head -20

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg "type WalletCredentials struct" -A 15 -g "*.go"

Repository: BoltzExchange/boltz-client

Length of output: 3333


🏁 Script executed:

rg -B 5 -A 15 "struct.*WalletCredentials\|WalletCredentials.*struct" -g "*.go" | head -60

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg -B 5 -A 10 "func.*CreateWallet\|func.*NewWallet" -g "*.go" internal/

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg "xpub.*empty\|mnemonic.*empty\|Xpub\s*=\|Mnemonic\s*=" -g "*.go" | head -30

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg "xpub.*required\|mnemonic.*required" -i -g "*.go"

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg "Mnemonic\|Xpub\|CoreDescriptor" -B 3 -A 3 internal/onchain/wallet.go | head -50

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg -i "credential.*type\|wallet.*type" -B 2 -A 10 internal/ -g "*.go" | grep -A 10 "type"

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg "Validate\|validate" -B 2 -A 5 internal/database/wallet.go

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

cat -n internal/database/wallet.go | head -80

Repository: BoltzExchange/boltz-client

Length of output: 2712


🏁 Script executed:

rg "CreateWallet" -B 10 -A 15 -g "*.go" | head -100

Repository: BoltzExchange/boltz-client

Length of output: 9214


🏁 Script executed:

rg "INSERT INTO wallets" -B 5 -A 15 -g "*.go"

Repository: BoltzExchange/boltz-client

Length of output: 3395


🏁 Script executed:

rg "Mnemonic\|Xpub\|CoreDescriptor" -B 2 -A 2 internal/onchain/wallet.go | head -40

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

grep -n "Mnemonic\|Xpub\|CoreDescriptor" internal/onchain/wallet.go

Repository: BoltzExchange/boltz-client

Length of output: 1230


🏁 Script executed:

cat internal/onchain/wallet.go | sed -n '1,30p'

Repository: BoltzExchange/boltz-client

Length of output: 576


🏁 Script executed:

cat -n internal/onchain/wallet.go | sed -n '160,180p'

Repository: BoltzExchange/boltz-client

Length of output: 856


🏁 Script executed:

rg -B 10 -A 10 "credential.*type\|Type.*credential" internal/ -g "*.go" | head -60

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg "xpub\|Xpub" -i -B 2 -A 2 internal/onchain/wallet.go

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg "ValidateWalletCredentials" -B 3 -A 3 -g "*.go"

Repository: BoltzExchange/boltz-client

Length of output: 2037


🏁 Script executed:

rg "CreateWallet" -B 15 -A 5 -g "*.go" | grep -A 15 "func.*CreateWallet\|CreateWallet.*func" | head -60

Repository: BoltzExchange/boltz-client

Length of output: 4213


🏁 Script executed:

find . -name "*.go" -type f -exec grep -l "CreateWallet" {} \; | head -10

Repository: BoltzExchange/boltz-client

Length of output: 534


🏁 Script executed:

rg "CreateWallet\|func.*Wallet" -B 5 -A 20 internal/rpcserver/router.go | grep -A 20 "func.*CreateWallet"

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

cat -n internal/rpcserver/router.go | sed -n '1,50p'

Repository: BoltzExchange/boltz-client

Length of output: 1914


🏁 Script executed:

rg "NewWallet\|CreateWallet" -B 10 -A 15 internal/rpcserver/router.go | head -100

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg -n "func.*CreateWallet" internal/rpcserver/router.go

Repository: BoltzExchange/boltz-client

Length of output: 223


🏁 Script executed:

rg "func.*createWallet\|func.*newWallet" -i internal/rpcserver/router.go

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

wc -l internal/rpcserver/router.go

Repository: BoltzExchange/boltz-client

Length of output: 104


🏁 Script executed:

cat -n internal/rpcserver/router.go | sed -n '1631,1700p'

Repository: BoltzExchange/boltz-client

Length of output: 2900


🏁 Script executed:

rg -n "func.*ImportWallet" internal/rpcserver/router.go

Repository: BoltzExchange/boltz-client

Length of output: 209


🏁 Script executed:

cat -n internal/rpcserver/router.go | sed -n '1500,1630p'

Repository: BoltzExchange/boltz-client

Length of output: 4999


🏁 Script executed:

cat -n internal/rpcserver/router.go | sed -n '1480,1510p'

Repository: BoltzExchange/boltz-client

Length of output: 1351


🏁 Script executed:

rg "GetWallets\|QueryWallets" -B 3 -A 5 internal/database/ -g "*.go" | head -40

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

rg "nodePubkey" -B 2 -A 2 internal/onchain/wallet.go

Repository: BoltzExchange/boltz-client

Length of output: 52


🏁 Script executed:

cat -n internal/database/database.go | sed -n '210,230p'

Repository: BoltzExchange/boltz-client

Length of output: 815


🏁 Script executed:

cat -n internal/database/wallet.go | sed -n '43,65p'

Repository: BoltzExchange/boltz-client

Length of output: 814


The UNIQUE constraint has a SQLite NULL-semantics weakness that allows potential duplicate-wallet insertion.

In SQLite, NULL values are treated as distinct in UNIQUE constraints. The constraint UNIQUE (tenantId, xpub, coreDescriptor, mnemonic, nodePubkey) cannot prevent duplicates when nodePubkey or other columns are NULL, even if non-NULL columns match. While ValidateWalletCredentials() ensures CoreDescriptor is populated before database insertion, and application-level duplicate detection exists (lines 1501–1506 in router.go), the database constraint alone provides no guarantee. Partial unique indexes per credential type would be safer and move the enforcement to the schema layer.

Comment on lines +653 to +656
INSERT INTO new_wallets (id, name, currency, nodePubkey, xpub, coreDescriptor, mnemonic, subaccount, salt, tenantId, legacy, lastIndex)
SELECT id, name, currency, nodePubkey, xpub, coreDescriptor, mnemonic, subaccount, salt, tenantId, legacy, lastIndex FROM wallets;
DROP TABLE wallets;
ALTER TABLE new_wallets RENAME TO wallets;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don't drop the live wallets parent table in this migration.

swaps.walletId, reverseSwaps.walletId, and chainSwapsData.walletId all point at wallets(id) with ON DELETE SET NULL (see Line 425-Line 428 and Line 470). In SQLite, DROP TABLE wallets applies foreign-key actions, so this can clear existing walletId links before new_wallets is renamed back. That would silently detach existing swaps from their wallets.

#!/bin/bash
python - <<'PY'
import sqlite3

con = sqlite3.connect(":memory:")
con.execute("PRAGMA foreign_keys = ON")
con.executescript("""
CREATE TABLE wallets(id INTEGER PRIMARY KEY);
CREATE TABLE swaps(
  id INTEGER PRIMARY KEY,
  walletId INT REFERENCES wallets(id) ON DELETE SET NULL
);

INSERT INTO wallets(id) VALUES (1);
INSERT INTO swaps(id, walletId) VALUES (1, 1);

CREATE TABLE new_wallets(id INTEGER PRIMARY KEY);
INSERT INTO new_wallets(id) VALUES (1);

DROP TABLE wallets;
""")

print(con.execute("SELECT walletId FROM swaps").fetchall())
PY

Expected result: [(None,)], which shows the wallet reference is lost before the rename happens.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/database/migration.go` around lines 653 - 656, The migration must
not DROP the live wallets table because that triggers foreign-key ON DELETE
actions and clears swaps.walletId, reverseSwaps.walletId, and
chainSwapsData.walletId; instead, rename the existing wallets table to a
temporary name (e.g., wallets_old), then RENAME new_wallets TO wallets, and only
then DROP TABLE wallets_old — update the SQL block that currently does "DROP
TABLE wallets; ALTER TABLE new_wallets RENAME TO wallets;" to perform the safe
rename sequence so swaps.walletId (and related FKs) continue pointing to the new
wallets table without being nulled.

@michael1011 michael1011 merged commit 1d041ae into master Apr 27, 2026
2 of 3 checks passed
@michael1011 michael1011 deleted the refactor/wallets-unique-constraint branch April 27, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants