mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(tips): stage 5 — GC workers + fail-closed flag + capabilities + openapi
- GC: cancel_old_drafts (draft >7d -> cancelled), cancel_stuck_pending_confirmation (>7d), log_stuck_claiming (read-only, >15min). Draft GC hourly + lifecycle GC 5min, spawned behind FEATURE_TIPS. Funded-but-unattached safety net is the 7-day window + clawback incl 'cancelled'. - config validate(): tips enabled fails closed unless a supported chain (btc/ltc/xmr/wow) is on AND tip_share_base is set. - capabilities: advertise tips=true only when actually serveable. - regenerated openapi.json (8 /tips/social/* endpoints). Public-tips port COMPLETE (stages 1-5). Full suite green.
This commit is contained in:
+696
@@ -709,6 +709,361 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tips/social": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"tips"
|
||||
],
|
||||
"summary": "Create a public tip (optionally as a two-phase draft).",
|
||||
"operationId": "create_social_tip",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateSocialTipRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Tip created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateSocialTipResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Tips off, targeted tip, or invalid input"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/tips/social/sent": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"tips"
|
||||
],
|
||||
"summary": "All tips the caller has sent, newest first.",
|
||||
"operationId": "get_sent_social_tips",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Sent tips",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocialTipsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Tips off"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/tips/social/{tip_id}/attach-funding": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"tips"
|
||||
],
|
||||
"summary": "Attach a broadcast funding tx to a draft tip, advancing it into the funding\nlifecycle (`pending_confirmation`; the funding worker later flips it to\n`pending` once the on-chain receipt is confirmed AND covers `amount`).",
|
||||
"description": "Idempotent: re-attaching the SAME txid is a no-op that returns the row; a\nDIFFERENT txid on a tip that already has one (or a non-draft tip) is a 400;\nan unknown / not-owned tip is a 404. Response mirrors create: the same\n`CreateSocialTipResponse { tip_id, status, share_url }`.",
|
||||
"operationId": "attach_funding",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tip_id",
|
||||
"in": "path",
|
||||
"description": "Tip id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AttachFundingRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Funding attached",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateSocialTipResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Tips off, empty/oversized txid, or a conflicting funding tx"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
},
|
||||
"404": {
|
||||
"description": "No such tip owned by the caller"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/tips/social/{tip_id}/cancel": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"tips"
|
||||
],
|
||||
"summary": "Cancel a still-unfunded draft (owner-only, draft-only). A funded/claimed tip\nis recovered via clawback, not cancel.",
|
||||
"operationId": "cancel_social_tip",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tip_id",
|
||||
"in": "path",
|
||||
"description": "Tip id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Draft cancelled",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CancelTipResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Tips off"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
},
|
||||
"404": {
|
||||
"description": "No cancellable draft"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/tips/social/{tip_id}/claim": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"tips"
|
||||
],
|
||||
"summary": "Claim a public tip: lock it into `claiming` and return the encrypted claim\nkey + tip address so the caller can sweep the funds to their own wallet.",
|
||||
"description": "AUTHED, no body. The `claiming` state is a UX signal, not a cryptographic\nlock — any URL holder may claim while the sweep hasn't confirmed (whoever\nwins the on-chain sweep race wins the tip; the reconciler resolves the\nwinner). A non-claimable tip (missing, underfunded, clawed back, already\nswept) is a 400.",
|
||||
"operationId": "claim_social_tip",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tip_id",
|
||||
"in": "path",
|
||||
"description": "Tip id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Tip locked into claiming",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ClaimTipResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Tips off or tip not claimable"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/tips/social/{tip_id}/clawback": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"tips"
|
||||
],
|
||||
"summary": "Sender reclaims a tip's funds. AUTHED, no body. Succeeds for the sender on a\npending / pending_confirmation / claiming / funding_mismatch / cancelled tip\nwhile its sweep hasn't confirmed; a settled or non-clawable tip is a 400.",
|
||||
"operationId": "clawback_social_tip",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tip_id",
|
||||
"in": "path",
|
||||
"description": "Tip id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Tip clawed back",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ClawbackTipResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Tips off or tip not clawable"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/tips/social/{tip_id}/confirm-sweep": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"tips"
|
||||
],
|
||||
"summary": "Record the claimer's broadcast sweep txid (first-recorder-wins). RECORD not\nsettle: the tip stays `claiming` and the reconciler owns the settle to\n`claimed` once the sweep confirms on-chain. Idempotent; a second, different\ntxid does NOT overwrite the recorded winner. A tip that was never claimed is\na 400.",
|
||||
"operationId": "confirm_sweep",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tip_id",
|
||||
"in": "path",
|
||||
"description": "Tip id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConfirmSweepRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Sweep txid recorded",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConfirmSweepResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Tips off, empty/oversized txid, or tip not claiming"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/tips/social/{tip_id}/public": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"tips"
|
||||
],
|
||||
"summary": "Public tip metadata for a share-URL holder. UNAUTHENTICATED: the tip id (a\nUUID) is the bearer token. 404s for an unknown or non-public tip.",
|
||||
"operationId": "get_public_social_tip",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tip_id",
|
||||
"in": "path",
|
||||
"description": "Tip id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Public tip info",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PublicTipInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Tips off"
|
||||
},
|
||||
"404": {
|
||||
"description": "No such public tip"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/by-username/{username}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -2089,6 +2444,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AttachFundingRequest": {
|
||||
"type": "object",
|
||||
"description": "Attach a broadcast funding transaction to a draft tip.",
|
||||
"required": [
|
||||
"funding_txid"
|
||||
],
|
||||
"properties": {
|
||||
"funding_txid": {
|
||||
"type": "string",
|
||||
"description": "The on-chain funding transaction id (XMR/WOW txid, BTC/LTC txid)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"AuthResponse": {
|
||||
"type": "object",
|
||||
"description": "Successful session response: a JWT pair plus minimal user info.",
|
||||
@@ -2177,6 +2545,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CancelTipResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ok"
|
||||
],
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CapabilitiesResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -2349,6 +2728,74 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ClaimTipResponse": {
|
||||
"type": "object",
|
||||
"description": "Claim a tip: lock it into `claiming` and receive the encrypted claim key +\ntip address to sweep. Load-bearing INCONSISTENT key: `success` (not `ok`).",
|
||||
"required": [
|
||||
"success"
|
||||
],
|
||||
"properties": {
|
||||
"encrypted_key": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Hex-encoded AES-GCM ciphertext of the claim secret; `null` if the tip\nstored none. Useless without the URL-fragment key."
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tip_address": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ClawbackTipResponse": {
|
||||
"type": "object",
|
||||
"description": "Sender clawback of a tip. Load-bearing INCONSISTENT key: `success` (not `ok`).",
|
||||
"required": [
|
||||
"success"
|
||||
],
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConfirmSweepRequest": {
|
||||
"type": "object",
|
||||
"description": "Record the claimer's broadcast sweep transaction (first-recorder-wins).",
|
||||
"required": [
|
||||
"sweep_txid"
|
||||
],
|
||||
"properties": {
|
||||
"sweep_txid": {
|
||||
"type": "string",
|
||||
"description": "The broadcast sweep transaction id."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConfirmSweepResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"sweep_txid": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The WINNING (first-recorded) sweep txid, or `null` if none is recorded."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConfirmationsRequest": {
|
||||
"type": "object",
|
||||
"description": "Confirmation-count query for a tx.",
|
||||
@@ -2416,6 +2863,103 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateSocialTipRequest": {
|
||||
"type": "object",
|
||||
"description": "Create a public tip. Targeted-only fields (`platform`, `username`,\n`grin_commitment`, `sender_anonymous`) are accepted for wire-compat but\nignored in this port.",
|
||||
"required": [
|
||||
"asset",
|
||||
"amount",
|
||||
"is_public"
|
||||
],
|
||||
"properties": {
|
||||
"amount": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"asset": {
|
||||
"type": "string"
|
||||
},
|
||||
"claim_key_hash": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "SHA256 of the claim key (hex); the key itself never touches the server."
|
||||
},
|
||||
"encrypted_key": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Hex-encoded AES-GCM ciphertext of the claim secret; the backend cannot\ndecrypt it."
|
||||
},
|
||||
"funding_txid": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"grin_commitment": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"is_public": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"platform": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sender_anonymous": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tip_address": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"tip_view_key": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Private view key for the tip address (XMR/WOW), so the backend can scan\nit for funding + sweep."
|
||||
},
|
||||
"username": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateSocialTipResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"tip_id",
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"share_url": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "`{TIP_SHARE_BASE_URL}/{tip_id}` for a public tip, else null."
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"tip_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExtensionRegisterRequest": {
|
||||
"type": "object",
|
||||
"description": "Register a new extension wallet or re-authenticate an existing one.",
|
||||
@@ -3519,6 +4063,65 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PublicTipInfo": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"asset",
|
||||
"amount",
|
||||
"status",
|
||||
"created_at",
|
||||
"is_public",
|
||||
"funding_confirmations",
|
||||
"confirmations_required",
|
||||
"is_claimable"
|
||||
],
|
||||
"properties": {
|
||||
"amount": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"asset": {
|
||||
"type": "string"
|
||||
},
|
||||
"confirmations_required": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"encrypted_key": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Hex-encoded AES-GCM ciphertext; useless without the URL-fragment key."
|
||||
},
|
||||
"funding_confirmations": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_claimable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_public": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"tip_address": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"RandomOutputDto": {
|
||||
"type": "object",
|
||||
"description": "A decoy output.",
|
||||
@@ -3822,6 +4425,81 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SentTip": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"sender_user_id",
|
||||
"asset",
|
||||
"amount",
|
||||
"is_public",
|
||||
"status",
|
||||
"created_at",
|
||||
"funding_confirmations",
|
||||
"confirmations_required",
|
||||
"is_claimable"
|
||||
],
|
||||
"properties": {
|
||||
"amount": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"asset": {
|
||||
"type": "string"
|
||||
},
|
||||
"claimed_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"clawed_back_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"confirmations_required": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"funding_confirmations": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_claimable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_public": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"recipient_platform": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Always null in the public-only port (no targeted recipients)."
|
||||
},
|
||||
"recipient_username": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sender_user_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetUsernameRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -3858,6 +4536,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocialTipsResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"tips"
|
||||
],
|
||||
"properties": {
|
||||
"tips": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SentTip"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SpentOutputDto": {
|
||||
"type": "object",
|
||||
"description": "A candidate spent output (verify with the spend key before trusting).",
|
||||
@@ -4367,6 +5059,10 @@
|
||||
{
|
||||
"name": "premium",
|
||||
"description": "Premium subscription: mint a tier invoice, activate on settlement, read status (feature-flagged)."
|
||||
},
|
||||
{
|
||||
"name": "tips",
|
||||
"description": "Public social tips: create/fund/claim/clawback share-URL tips (feature-flagged)."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+14
-1
@@ -250,7 +250,7 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
|
||||
nostr_relay: relay_advertised(config),
|
||||
premium_relay: premium_advertised(config),
|
||||
feed: feed_advertised(config),
|
||||
tips: config.features.tips,
|
||||
tips: tips_advertised(config),
|
||||
},
|
||||
restore: RestoreCapability {
|
||||
policy: config.restore.policy.as_str().to_string(),
|
||||
@@ -333,6 +333,19 @@ fn feed_advertised(config: &Config) -> bool {
|
||||
config.feed.enabled && relay_advertised(config)
|
||||
}
|
||||
|
||||
/// Whether public tips are actually serveable: the feature is on, a share-URL
|
||||
/// base is configured, AND at least one supported tip chain (btc/ltc/xmr/wow)
|
||||
/// is serviceable. Mirrors the chain/relay presence-downgrade — never advertise
|
||||
/// tips an instance can't escrow on or build share links for. (Grin is out of
|
||||
/// the tips port, so it is not a supported tip chain here.)
|
||||
fn tips_advertised(config: &Config) -> bool {
|
||||
config.features.tips
|
||||
&& config.tip_share_base.is_some()
|
||||
&& ["btc", "ltc", "xmr", "wow"]
|
||||
.iter()
|
||||
.any(|asset| chain_serviceable(config, asset))
|
||||
}
|
||||
|
||||
/// Describe this instance's enabled chains and features.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
|
||||
@@ -1102,6 +1102,27 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Public tips escrow on-chain and deliver by share URL, so an instance
|
||||
// with FEATURE_TIPS on but no chain to escrow on, or no TIP_SHARE_BASE_URL
|
||||
// to build share links from, would advertise a tips subsystem it cannot
|
||||
// actually serve. Fail closed rather than boot broken. (Grin is out of the
|
||||
// tips port, so it is NOT a supported tip chain here.)
|
||||
if self.features.tips {
|
||||
let c = &self.features.chains;
|
||||
if !(c.btc || c.ltc || c.xmr || c.wow) {
|
||||
return Err(cfg_err(
|
||||
"FEATURE_TIPS is on but no supported tip chain is enabled — enable at least \
|
||||
one of FEATURE_BTC / FEATURE_LTC / FEATURE_XMR / FEATURE_WOW",
|
||||
));
|
||||
}
|
||||
if self.tip_share_base.is_none() {
|
||||
return Err(cfg_err(
|
||||
"FEATURE_TIPS is on but TIP_SHARE_BASE_URL is unset — it is required to build \
|
||||
public tip share links",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Pay-to-register gate: when on, the processor wiring must be complete
|
||||
// and sane, or /capabilities would advertise payment_required while every
|
||||
// registration then fails. Fail closed at startup instead.
|
||||
@@ -1561,6 +1582,38 @@ mod tests {
|
||||
assert!(c.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tips_enabled_requires_share_base() {
|
||||
// FEATURE_TIPS on + a tip chain but NO share base → fail closed.
|
||||
let mut c = valid();
|
||||
c.features.tips = true;
|
||||
c.features.chains.btc = true;
|
||||
c.tip_share_base = None;
|
||||
assert!(c.validate().is_err(), "tips on without a share base must fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tips_enabled_requires_a_tip_chain() {
|
||||
// FEATURE_TIPS on + a share base but NO tip chain enabled → fail closed.
|
||||
// (valid() has all chains off.)
|
||||
let mut c = valid();
|
||||
c.features.tips = true;
|
||||
c.tip_share_base = Some("https://tips.example".into());
|
||||
assert!(c.validate().is_err(), "tips on with no tip chain must fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tips_enabled_with_chain_and_share_base_ok() {
|
||||
let mut c = valid();
|
||||
c.features.tips = true;
|
||||
c.features.chains.btc = true;
|
||||
c.tip_share_base = Some("https://tips.example".into());
|
||||
assert!(
|
||||
c.validate().is_ok(),
|
||||
"tips on with a tip chain + share base is valid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_price_provider_rejected() {
|
||||
let mut c = valid();
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ pub use login_events::LoginStats;
|
||||
pub use payment_invoices::PaymentInvoiceRow;
|
||||
pub use premium_invoices::PremiumInvoiceRow;
|
||||
pub use server_config::SetupState;
|
||||
pub use social_tips::{NewSocialTip, SocialTipRow};
|
||||
pub use social_tips::{GcCancelledTip, NewSocialTip, SocialTipRow, StuckClaimingTip};
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
|
||||
@@ -80,6 +80,29 @@ pub struct NewSocialTip<'a> {
|
||||
pub confirmations_required: i32,
|
||||
}
|
||||
|
||||
/// A `social_tips` row cancelled by a GC pass, distilled to the fields the
|
||||
/// worker logs. NOT the full [`SocialTipRow`] — the GC only needs enough to
|
||||
/// `warn!` per row (the audit trail for the funded-but-not-attached case).
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct GcCancelledTip {
|
||||
pub id: Uuid,
|
||||
pub asset: String,
|
||||
pub tip_address: Option<String>,
|
||||
pub funding_txid: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A stuck `claiming` row surfaced (never mutated) by the lifecycle GC's
|
||||
/// read-only scan. Fields are exactly what the operator `warn!` needs.
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct StuckClaimingTip {
|
||||
pub id: Uuid,
|
||||
pub asset: String,
|
||||
pub tip_address: Option<String>,
|
||||
pub claimed_by_user_id: Option<Uuid>,
|
||||
pub claimed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Fetch a tip by id, or `None`. The UUID is the public bearer token behind
|
||||
/// a share URL, so this read is intentionally not owner-scoped.
|
||||
@@ -778,4 +801,80 @@ impl Database {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Stage 5: lifecycle garbage collection ───────────────────────────────────
|
||||
//
|
||||
// Two janitor passes for rows that would otherwise leak forever:
|
||||
// - cancel_old_drafts / cancel_stuck_pending_confirmation flip long-stuck
|
||||
// rows to 'cancelled', RETURNING each cancelled row so the worker warns.
|
||||
// - log_stuck_claiming is a READ-ONLY scan (mutates NOTHING) that surfaces
|
||||
// confirm-sweep-never-landed rows for operator follow-up.
|
||||
//
|
||||
// MONEY-SAFETY: cancel_old_drafts / cancel_stuck_pending_confirmation flip a
|
||||
// funded-but-not-attached row to 'cancelled' with NO on-chain funds check —
|
||||
// the funding tx may have landed while the row sat stuck. The only mitigations
|
||||
// are (1) the generous 7-day window, (2) the per-row `warn!` the worker emits
|
||||
// so an operator can grep journald for a funded tip_address, and (3) clawback
|
||||
// INCLUDING 'cancelled' in its status set (see `clawback_social_tip`) so the
|
||||
// sender can still recover. 'cancelled' MUST stay in that clawback filter.
|
||||
|
||||
/// UPDATE `draft` rows older than 7 days to `cancelled`, RETURNING the
|
||||
/// cancelled rows (id / asset / tip_address / funding_txid / created_at) so
|
||||
/// the GC worker can `warn!` per row. Targets `status='draft'` ONLY — anything
|
||||
/// past the pre-funding stage (pending / claiming / …) has already been
|
||||
/// broadcast and must NOT be GC'd here (orphaning it would be fund loss).
|
||||
#[instrument(skip(self))]
|
||||
pub async fn cancel_old_drafts(&self) -> Result<Vec<GcCancelledTip>, AppError> {
|
||||
let rows = sqlx::query_as::<_, GcCancelledTip>(
|
||||
"UPDATE social_tips \
|
||||
SET status = 'cancelled', updated_at = NOW() \
|
||||
WHERE status = 'draft' \
|
||||
AND created_at < NOW() - INTERVAL '7 days' \
|
||||
RETURNING id, asset, tip_address, funding_txid, created_at",
|
||||
)
|
||||
.fetch_all(self.pool())
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// UPDATE `pending_confirmation` rows older than 7 days to `cancelled`,
|
||||
/// RETURNING each cancelled row so the worker can `warn!`. Targets
|
||||
/// `status='pending_confirmation'` ONLY — a row that reached `pending` already
|
||||
/// cleared the funding verifier and is a real tip the sender expects claimed;
|
||||
/// it must NOT be auto-cancelled here.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn cancel_stuck_pending_confirmation(
|
||||
&self,
|
||||
) -> Result<Vec<GcCancelledTip>, AppError> {
|
||||
let rows = sqlx::query_as::<_, GcCancelledTip>(
|
||||
"UPDATE social_tips \
|
||||
SET status = 'cancelled', updated_at = NOW() \
|
||||
WHERE status = 'pending_confirmation' \
|
||||
AND created_at < NOW() - INTERVAL '7 days' \
|
||||
RETURNING id, asset, tip_address, funding_txid, created_at",
|
||||
)
|
||||
.fetch_all(self.pool())
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// READ-ONLY scan of stuck `claiming` rows for operator observability. Mutates
|
||||
/// NOTHING — returns rows in `claiming` with no confirmed sweep whose claim is
|
||||
/// older than 15 minutes (well past the client's ~13s confirm-sweep retry
|
||||
/// window). Auto-reconciliation is the reconciler's job; this pass only
|
||||
/// surfaces rows whose `confirm_tip_sweep` never landed so an operator can
|
||||
/// follow up.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn log_stuck_claiming(&self) -> Result<Vec<StuckClaimingTip>, AppError> {
|
||||
let rows = sqlx::query_as::<_, StuckClaimingTip>(
|
||||
"SELECT id, asset, tip_address, claimed_by_user_id, claimed_at \
|
||||
FROM social_tips \
|
||||
WHERE status = 'claiming' \
|
||||
AND sweep_confirmed_at IS NULL \
|
||||
AND claimed_at < NOW() - INTERVAL '15 minutes'",
|
||||
)
|
||||
.fetch_all(self.pool())
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
+42
@@ -330,6 +330,48 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Public-tips draft GC: hourly, cancel `draft` rows abandoned mid-flow (> 7
|
||||
// days, never funded-attached) and warn per cancelled row. Same catch_unwind
|
||||
// guard as the money-in/out workers.
|
||||
if state.config.features.tips {
|
||||
use futures::FutureExt;
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let cycle = std::panic::AssertUnwindSafe(
|
||||
smirk_backend_core::tips::run_tip_draft_gc_cycle(state.clone()),
|
||||
);
|
||||
if cycle.catch_unwind().await.is_err() {
|
||||
tracing::error!("tip draft GC cycle panicked; continuing after backoff");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Public-tips lifecycle GC: every 5 min, cancel stuck `pending_confirmation`
|
||||
// rows (> 7 days) and warn-only-scan stuck `claiming` rows (> 15 min). Same
|
||||
// catch_unwind guard.
|
||||
if state.config.features.tips {
|
||||
use futures::FutureExt;
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let cycle = std::panic::AssertUnwindSafe(
|
||||
smirk_backend_core::tips::run_tip_lifecycle_gc_cycle(state.clone()),
|
||||
);
|
||||
if cycle.catch_unwind().await.is_err() {
|
||||
tracing::error!("tip lifecycle GC cycle panicked; continuing after backoff");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let app = build_router(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
//! Lifecycle + draft garbage-collection passes for public social tips.
|
||||
//!
|
||||
//! Two periodic janitor cycles, each spawned from `main` behind `FEATURE_TIPS`:
|
||||
//!
|
||||
//! - [`run_tip_draft_gc_cycle`] (hourly): cancel `draft` rows abandoned
|
||||
//! mid-flow (created > 7 days ago, never funded-attached). Cancels the row
|
||||
//! and `warn!`s per cancelled draft so an operator can audit the
|
||||
//! funded-but-not-attached case (the sender may have broadcast the funding
|
||||
//! tx before the attach call landed).
|
||||
//!
|
||||
//! - [`run_tip_lifecycle_gc_cycle`] (5 min): cancel `pending_confirmation`
|
||||
//! rows stuck > 7 days (RBF eviction / mempool drop / LWS blind spot left
|
||||
//! the funding tx unconfirmed forever), and READ-ONLY-scan `claiming` rows
|
||||
//! stuck > 15 minutes (`confirm_tip_sweep` never landed) for operator
|
||||
//! visibility — the scan mutates NOTHING.
|
||||
//!
|
||||
//! Ported (public subset) from the legacy `core/tip_draft_gc.rs` +
|
||||
//! `core/tip_lifecycle_gc.rs`. The intervals + WHERE clauses live in the DB
|
||||
//! methods (`cancel_old_drafts` / `cancel_stuck_pending_confirmation` /
|
||||
//! `log_stuck_claiming`); these workers are the warn-per-row surface over them.
|
||||
//!
|
||||
//! MONEY-SAFETY: the two cancel passes flip funded-but-not-attached rows to
|
||||
//! `cancelled` with no on-chain funds check — the 7-day window, the per-row
|
||||
//! `warn!`, and clawback INCLUDING `cancelled` (see `clawback_social_tip`) are
|
||||
//! the only mitigations. Neither pass ever touches a `pending` / `claiming`
|
||||
//! (already-broadcast) row: cancels are `draft`/`pending_confirmation`-only and
|
||||
//! the claiming scan is read-only.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
/// One draft-GC cycle: cancel `draft` rows older than 7 days and `warn!` per
|
||||
/// cancelled row. Never returns an error — a DB failure is logged and swallowed
|
||||
/// so the spawn loop survives.
|
||||
pub async fn run_tip_draft_gc_cycle(state: Arc<AppState>) {
|
||||
match state.db.cancel_old_drafts().await {
|
||||
Ok(cancelled) => {
|
||||
for row in &cancelled {
|
||||
warn!(
|
||||
tip_id = %row.id,
|
||||
asset = %row.asset,
|
||||
tip_address = %row.tip_address.as_deref().unwrap_or("<none>"),
|
||||
funding_txid = %row.funding_txid.as_deref().unwrap_or("<none>"),
|
||||
created_at = %row.created_at,
|
||||
"tip-draft GC: cancelled abandoned draft (operator: check tip_address for \
|
||||
on-chain funds — a funded-but-not-attached draft is recoverable via clawback)"
|
||||
);
|
||||
}
|
||||
if !cancelled.is_empty() {
|
||||
info!(count = cancelled.len(), "tip-draft GC: cancelled abandoned drafts");
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "tip-draft GC cycle failed"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One lifecycle-GC cycle: cancel stuck `pending_confirmation` rows (> 7 days,
|
||||
/// warn per row) then READ-ONLY-scan stuck `claiming` rows (> 15 min, warn-only,
|
||||
/// mutates nothing). Two independent passes — a failure in one is logged and
|
||||
/// does not abort the other. Never returns an error.
|
||||
pub async fn run_tip_lifecycle_gc_cycle(state: Arc<AppState>) {
|
||||
match state.db.cancel_stuck_pending_confirmation().await {
|
||||
Ok(cancelled) => {
|
||||
for row in &cancelled {
|
||||
warn!(
|
||||
tip_id = %row.id,
|
||||
asset = %row.asset,
|
||||
tip_address = %row.tip_address.as_deref().unwrap_or("<none>"),
|
||||
funding_txid = %row.funding_txid.as_deref().unwrap_or("<none>"),
|
||||
created_at = %row.created_at,
|
||||
"tip-lifecycle GC: cancelled stuck pending_confirmation (funding tx never \
|
||||
confirmed — RBF / mempool drop / LWS blind spot suspected; sender should \
|
||||
re-broadcast or clawback)"
|
||||
);
|
||||
}
|
||||
if !cancelled.is_empty() {
|
||||
info!(
|
||||
count = cancelled.len(),
|
||||
"tip-lifecycle GC: cancelled stuck pending_confirmation rows"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "tip-lifecycle GC: cancel_stuck_pending_confirmation failed"),
|
||||
}
|
||||
|
||||
match state.db.log_stuck_claiming().await {
|
||||
Ok(stuck) => {
|
||||
for row in &stuck {
|
||||
warn!(
|
||||
tip_id = %row.id,
|
||||
asset = %row.asset,
|
||||
tip_address = %row.tip_address.as_deref().unwrap_or("<none>"),
|
||||
claimed_by_user_id = %row
|
||||
.claimed_by_user_id
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_else(|| "<none>".to_string()),
|
||||
claimed_at = %row
|
||||
.claimed_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "<none>".to_string()),
|
||||
"tip-lifecycle GC: stuck claiming row (confirm-sweep never landed) — \
|
||||
operator-visible only; the sweep reconciler settles genuine on-chain sweeps"
|
||||
);
|
||||
}
|
||||
if !stuck.is_empty() {
|
||||
warn!(
|
||||
count = stuck.len(),
|
||||
"tip-lifecycle GC: observed stuck claiming rows — manual ops follow-up may be needed"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "tip-lifecycle GC: log_stuck_claiming failed"),
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@
|
||||
//! outage can therefore never mass-flip pending tips to `funding_mismatch`.
|
||||
|
||||
pub mod confirmation;
|
||||
pub mod gc;
|
||||
pub mod sweep_reconciler;
|
||||
|
||||
pub use confirmation::run_tip_confirmation_cycle;
|
||||
pub use gc::{run_tip_draft_gc_cycle, run_tip_lifecycle_gc_cycle};
|
||||
pub use sweep_reconciler::{run_sweep_reconcile_cycle, run_sweep_reorg_cycle};
|
||||
|
||||
+100
@@ -700,3 +700,103 @@ async fn reconciler_poll_queries_select_the_right_rows() {
|
||||
assert!(reorg.iter().any(|t| t.id == claimed), "a settled tip with a height witness is reorg-checked");
|
||||
assert!(!reorg.iter().any(|t| t.id == claiming), "a claiming tip is not reorg-checked");
|
||||
}
|
||||
|
||||
// ── Stage 5: lifecycle + draft garbage-collection DB tests ────────────────────
|
||||
//
|
||||
// The GC cancel passes filter on `created_at < NOW() - INTERVAL '7 days'` (and
|
||||
// the claiming scan on `claimed_at < NOW() - INTERVAL '15 minutes'`). The normal
|
||||
// insert stamps NOW(), so a test back-dates the timestamp with a raw UPDATE to
|
||||
// exercise the age filter.
|
||||
|
||||
/// Back-date a tip's `created_at` by `interval` (e.g. "8 days") via a raw UPDATE.
|
||||
async fn backdate_created_at(app: &common::TestApp, id: uuid::Uuid, interval: &str) {
|
||||
sqlx::query(&format!(
|
||||
"UPDATE social_tips SET created_at = NOW() - INTERVAL '{interval}' WHERE id = $1"
|
||||
))
|
||||
.bind(id)
|
||||
.execute(app.state.db.pool())
|
||||
.await
|
||||
.expect("backdate created_at");
|
||||
}
|
||||
|
||||
/// cancel_old_drafts flips a > 7d `draft` to `cancelled` and leaves a fresh draft
|
||||
/// (and non-draft rows) untouched.
|
||||
#[tokio::test]
|
||||
async fn cancel_old_drafts_flips_stale_draft_only() {
|
||||
let Some(app) = tips_app().await else { return };
|
||||
let uid = app.create_user().await;
|
||||
|
||||
let stale = make_draft(&app, uid, "btc", 100_000, 0, None).await;
|
||||
backdate_created_at(&app, stale, "8 days").await;
|
||||
let fresh = make_draft(&app, uid, "btc", 100_000, 0, None).await;
|
||||
|
||||
let cancelled = app.state.db.cancel_old_drafts().await.expect("cancel_old_drafts");
|
||||
assert!(cancelled.iter().any(|r| r.id == stale), "a > 7d draft must be cancelled");
|
||||
assert!(!cancelled.iter().any(|r| r.id == fresh), "a fresh draft must NOT be cancelled");
|
||||
|
||||
let stale_row = app.state.db.get_social_tip(stale).await.expect("get").expect("row");
|
||||
assert_eq!(stale_row.status, "cancelled");
|
||||
let fresh_row = app.state.db.get_social_tip(fresh).await.expect("get").expect("row");
|
||||
assert_eq!(fresh_row.status, "draft", "a fresh draft stays draft");
|
||||
}
|
||||
|
||||
/// cancel_stuck_pending_confirmation flips a > 7d `pending_confirmation` row to
|
||||
/// `cancelled` and leaves a fresh one untouched.
|
||||
#[tokio::test]
|
||||
async fn cancel_stuck_pending_confirmation_flips_stale_only() {
|
||||
let Some(app) = tips_app().await else { return };
|
||||
let uid = app.create_user().await;
|
||||
|
||||
let stale = make_draft(&app, uid, "btc", 100_000, 0, None).await;
|
||||
app.state.db.attach_funding_to_tip(stale, uid, "gc-stale-pc").await.expect("attach");
|
||||
backdate_created_at(&app, stale, "8 days").await;
|
||||
|
||||
let fresh = make_draft(&app, uid, "btc", 100_000, 0, None).await;
|
||||
app.state.db.attach_funding_to_tip(fresh, uid, "gc-fresh-pc").await.expect("attach");
|
||||
|
||||
let cancelled = app
|
||||
.state
|
||||
.db
|
||||
.cancel_stuck_pending_confirmation()
|
||||
.await
|
||||
.expect("cancel_stuck_pending_confirmation");
|
||||
assert!(
|
||||
cancelled.iter().any(|r| r.id == stale),
|
||||
"a > 7d pending_confirmation must be cancelled"
|
||||
);
|
||||
assert!(
|
||||
!cancelled.iter().any(|r| r.id == fresh),
|
||||
"a fresh pending_confirmation must NOT be cancelled"
|
||||
);
|
||||
|
||||
let stale_row = app.state.db.get_social_tip(stale).await.expect("get").expect("row");
|
||||
assert_eq!(stale_row.status, "cancelled");
|
||||
let fresh_row = app.state.db.get_social_tip(fresh).await.expect("get").expect("row");
|
||||
assert_eq!(fresh_row.status, "pending_confirmation");
|
||||
}
|
||||
|
||||
/// log_stuck_claiming returns a > 15m back-dated `claiming` row, excludes a fresh
|
||||
/// one, and mutates NOTHING (read-only scan).
|
||||
#[tokio::test]
|
||||
async fn log_stuck_claiming_returns_backdated_and_mutates_nothing() {
|
||||
let Some(app) = tips_app().await else { return };
|
||||
let sender = app.create_user().await;
|
||||
let claimant = app.create_user().await;
|
||||
|
||||
let stale = make_claiming(&app, sender, claimant, "gc-stale-claiming").await;
|
||||
sqlx::query("UPDATE social_tips SET claimed_at = NOW() - INTERVAL '16 minutes' WHERE id = $1")
|
||||
.bind(stale)
|
||||
.execute(app.state.db.pool())
|
||||
.await
|
||||
.expect("backdate claimed_at");
|
||||
let fresh = make_claiming(&app, sender, claimant, "gc-fresh-claiming").await;
|
||||
|
||||
let stuck = app.state.db.log_stuck_claiming().await.expect("log_stuck_claiming");
|
||||
assert!(stuck.iter().any(|r| r.id == stale), "a > 15m claiming row must be surfaced");
|
||||
assert!(!stuck.iter().any(|r| r.id == fresh), "a fresh claiming row must NOT be surfaced");
|
||||
|
||||
// READ-ONLY: the surfaced row is untouched — still 'claiming', no sweep confirm.
|
||||
let row = app.state.db.get_social_tip(stale).await.expect("get").expect("row");
|
||||
assert_eq!(row.status, "claiming", "log_stuck_claiming must not mutate status");
|
||||
assert!(row.sweep_confirmed_at.is_none(), "log_stuck_claiming must not settle the sweep");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user