跳至主要内容

POST /users/me/wallet — 已登入 Twitch 使用者綁定 MetaMask

狀態:待實作

背景​

目前 POST /auth/web3/nonce + POST /auth/web3/verify 是「用錢包登入」的流程。已用 Twitch 登入的使用者沒有辦法把 MetaMask 錢包綁到自己的帳號,導致 ClaimService.Claim() 呼叫 resolveWalletAddress() 時找不到 provider='web3' 的 AuthProvider,回傳 ErrClaimWalletNotLinked。

Demo 階段的 wallet_linker helper 已在 #243 移除,正式綁定流程需補上。


設計決策​

問題決策
每個 user 幾個錢包?只允許一個;綁新的自動取代舊的
nonce 端點複用現有 POST /auth/web3/nonce(public),binding 本身需 JWT
舊 web3 row 處理方式Soft delete(保留歷史),restore 而非 insert 若同 user 重綁同一地址
SIWE helper 位置抽到 services/siwe.go,package-level unexported,auth_service 與 user_service 共用

API 規格​

前置:取 nonce(現有端點,不動)​

POST /api/v1/auth/web3/nonce
Content-Type: application/json

{"address": "0xAbCd..."}

→ 200 {"success":true,"data":{"nonce":"<hex64>"}}

新端點:綁定錢包​

POST /api/v1/users/me/wallet
Authorization: Bearer <access_token>
Content-Type: application/json

{
"address": "0xAbCd...", // EIP-55 or lowercase, 後端統一 checksum
"nonce": "<hex64>",
"signature": "0x<hex130>"
}

→ 200 {"success":true,"data":{"address":"0xAbCd..."}} // checksummed

Error mapping​

情況HTTPerror 欄位
address 格式不合法400invalid wallet address
nonce 不存在或過期401invalid or expired nonce
簽名驗證失敗401invalid wallet signature
address 已被其他 user 綁定409wallet already linked to another account
DB 錯誤500internal server error

資料庫變更​

migration 014:將全域 unique index 改為 partial unique index​

現有 001_init.sql 有:

UNIQUE (provider, provider_id)

Soft delete 後舊 row 的 deleted_at IS NOT NULL,insert 新 row 時會踩到這個 constraint。需新增 migration:

-- backend/migrations/014_auth_provider_partial_unique.sql

-- 移除全域 unique constraint
ALTER TABLE auth_providers DROP CONSTRAINT IF EXISTS auth_providers_provider_provider_id_key;

-- 改為 partial unique index(只約束 active row)
CREATE UNIQUE INDEX IF NOT EXISTS
idx_auth_providers_provider_provider_id_active
ON auth_providers(provider, provider_id)
WHERE deleted_at IS NULL;

PostgreSQL 支援 partial index;專案其他 migration 已有先例。

實作前確認 DB 中沒有重複的 active (provider, provider_id),否則 CREATE UNIQUE INDEX 會失敗。 不要使用 CONCURRENTLY(migration runner 若包 transaction 會報錯;目前 spec 未用,OK)。

SQLite(測試環境)​

SQLite 支援 partial unique index(WHERE clause)。backend/internal/services/testutil_test.go 已有先例(idx_watch_sessions_active_user_channel)。測試 helper 應同步新增:

CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_providers_provider_provider_id_active
ON auth_providers (provider, provider_id)
WHERE deleted_at IS NULL;

Implementation Checklist​

  • 新增 backend/migrations/014_auth_provider_partial_unique.sql
  • 更新 SQLite test helper schema(補同等 partial unique index)
  • 新增 services/siwe.go,搬移 siweMessage / verifyEthSignature
  • 修改 services/auth_service.go,移除原 helper 定義,保留呼叫
  • 實作 UserService.LinkWallet
  • 更新 backend/internal/handlers/swagger_types.go,新增 WalletResponse
  • 實作 UserHandler.LinkWallet
  • 更新 router/router.go
  • 補 services/user_service_test.go test cases
  • 補 handlers/user_handler_test.go test cases

新增 / 修改物件清單​

0. backend/migrations/014_auth_provider_partial_unique.sql(新增)​

ALTER TABLE auth_providers
DROP CONSTRAINT IF EXISTS auth_providers_provider_provider_id_key;

CREATE UNIQUE INDEX IF NOT EXISTS
idx_auth_providers_provider_provider_id_active
ON auth_providers(provider, provider_id)
WHERE deleted_at IS NULL;
  • 執行前確認 DB 中無重複 active (provider, provider_id),否則 index 建立失敗
  • 不使用 CONCURRENTLY

test helper(services/testutil_test.go)同步新增:

CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_providers_provider_provider_id_active
ON auth_providers (provider, provider_id)
WHERE deleted_at IS NULL;

1a. backend/internal/services/siwe.go(新增)​

Package-level unexported helpers,auth_service.go 與 user_service.go 同在 services package,可直接呼叫。

func siweMessage(address, nonce string) string
func verifyEthSignature(message, sigHex, expectedAddress string) bool

1b. backend/internal/services/auth_service.go(修改)​

  • 移除 siweMessage 與 verifyEthSignature 的原始定義(Go build 不允許同 package 重複定義,移除前先確認 siwe.go 已建立)
  • Web3Verify() 內的呼叫不變(同 package,直接可用)
  • 移除因此產生的 unused imports;預期至少檢查 github.com/ethereum/go-ethereum/crypto(fmt、encoding/hex 在其他函式仍有使用,不會被移除)

2. backend/internal/services/user_service.go(新增方法)​

type LinkWalletInput struct {
Address string `json:"address" binding:"required"`
Nonce string `json:"nonce" binding:"required"`
Signature string `json:"signature" binding:"required"`
}

func (s *UserService) LinkWallet(userID uuid.UUID, input LinkWalletInput) (string, error)

LinkWallet 內部流程(transaction 外 → transaction 內):

[transaction 外]
1. common.IsHexAddress(input.Address) → false → ErrInvalidWalletAddress
2. checksumAddr = common.HexToAddress(input.Address).Hex()
3. lookupAddr = strings.ToLower(checksumAddr) // canonical: checksum 後再 lower
4. db.Where("nonce=? AND address=?", input.Nonce, lookupAddr).First(&nonceRecord)
→ not found or expired → ErrInvalidNonce
5. msg = siweMessage(lookupAddr, input.Nonce)
verifyEthSignature(msg, input.Signature, lookupAddr) → false → ErrInvalidSignature

[BEGIN TRANSACTION]
6. result = db.Where("nonce=? AND address=?", ...).Delete(&Web3Nonce{})
result.RowsAffected != 1 → ErrInvalidNonce // 防止並發重放
7. db.Where("provider='web3' AND provider_id=? AND deleted_at IS NULL AND user_id != ?",
checksumAddr, userID).
Count(&count)
→ count > 0 → ErrProviderLinked

8. db.Where("user_id=? AND provider='web3' AND deleted_at IS NULL", userID).
Update("deleted_at", now) // soft delete 目前 active web3 row(若有,含同 address)

9. db.Unscoped().
Where("user_id=? AND provider='web3' AND provider_id=?", userID, checksumAddr).
First(&ap)
// step 8 之後,若找到此 row,它一定是 soft-deleted 狀態(剛被 soft delete 或更早前的)
→ 找到 → db.Unscoped().Model(&ap).Update("deleted_at", nil) // restore
→ 找不到 → db.Create(&AuthProvider{provider:'web3', provider_id: checksumAddr})
// Create / restore 若遇到 partial unique index 衝突(race),轉 ErrProviderLinked → 409

[COMMIT]
10. return checksumAddr, nil

新增 sentinel errors(在 user_service.go 宣告):

ErrInvalidWalletAddress = errors.New("invalid wallet address")

沿用現有:ErrInvalidNonce、ErrInvalidSignature、ErrProviderLinked(已在 auth_service.go,同 package 可直接用)

3. backend/internal/handlers/swagger_types.go(修改)​

新增:

type WalletResponse struct {
Address string `json:"address"`
}

與 UserResponse、ProvidersResponse 等現有 swagger type 放在同一檔案。

4. backend/internal/handlers/user_handler.go(新增方法)​

// LinkWallet godoc
// @Summary Bind a MetaMask wallet address to the current user
// @Tags users
// @Accept json
// @Produce json
// @Param body body services.LinkWalletInput true "address + nonce + signature"
// @Success 200 {object} Response{data=WalletResponse}
// @Failure 400 {object} Response
// @Failure 401 {object} Response
// @Failure 409 {object} Response
// @Security BearerAuth
// @Router /users/me/wallet [post]
func (h *UserHandler) LinkWallet(c *gin.Context)
  • MustClaims(c) 取 userID
  • 呼叫 userSvc.LinkWallet(userID, input)
  • error switch:
    • ErrInvalidWalletAddress → badRequest
    • ErrInvalidNonce → unauthorized
    • ErrInvalidSignature → unauthorized
    • ErrProviderLinked → conflict("wallet already linked to another account")
    • default → internal

5. backend/internal/router/router.go(修改)​

在 protected group 新增:

protected.POST("users/me/wallet", userH.LinkWallet)

測試規格​

services/user_service_test.go(新增 test cases)​

Case預期結果
合法 address + nonce + 簽名,首次綁定成功,AuthProvider insert,checksumAddr 回傳
合法,已有 active web3 row → 取代舊 row soft deleted,新 row insert
同 user 重綁同一地址(soft-deleted row 存在)restore deleted_at = NULL,不 insert 新 row
nonce 不存在ErrInvalidNonce
nonce 已過期ErrInvalidNonce
成功綁定後重用同一 nonceErrInvalidNonce(nonce 已被 consume)
簽名錯誤ErrInvalidSignature
address 已被其他 user 綁定ErrProviderLinked
Create / restore 遇 active unique index 衝突(race)ErrProviderLinked(不掉到 500)
address 格式不合法ErrInvalidWalletAddress

handlers/user_handler_test.go(新增 test cases)​

HTTP 層測試,驗證 status code 與 response body,不重複 service 邏輯。


本票明確不做​

  • GET /users/me/wallet(查詢已綁錢包)
  • 解綁走現有 DELETE /auth/providers/web3,不改動
  • 前端(tachimint / dashboard)串接
  • 不修改 ClaimService.resolveWalletAddress()
  • 不改 POST /auth/web3/nonce 或 POST /auth/web3/verify