mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 08:37:30 +00:00
1516 lines
68 KiB
Haskell
1516 lines
68 KiB
Haskell
{-# LANGUAGE CPP #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE QuasiQuotes #-}
|
|
{-# LANGUAGE TypeOperators #-}
|
|
|
|
module StoreService.Names.Store
|
|
( NamePhase (..),
|
|
RegistrationPayment (..),
|
|
NameReveal (..),
|
|
NameRegistration (..),
|
|
NewRegistration (..),
|
|
PaidOrder (..),
|
|
RegistrationRefused (..),
|
|
VersionWrite (..),
|
|
LiveOrder (..),
|
|
OrderTerms (..),
|
|
Balance (..),
|
|
BalanceHold (..),
|
|
BalanceLink (..),
|
|
LateCredit (..),
|
|
LateCreditState (..),
|
|
LatePending (..),
|
|
LateAlert (..),
|
|
LateSupport (..),
|
|
LateWait (..),
|
|
CleanupKept (..),
|
|
PrepaidCode (..),
|
|
NewPrepaidCode (..),
|
|
CodeHashTaken (..),
|
|
TxFor (..),
|
|
TxStatus (..),
|
|
NameTransaction (..),
|
|
NewTransaction (..),
|
|
RecordChange (..),
|
|
EditState (..),
|
|
RecordEdit (..),
|
|
NewRecordEdit (..),
|
|
findLimit,
|
|
orderHash,
|
|
orderLive,
|
|
newRegistrationId,
|
|
methodText,
|
|
textMethod,
|
|
createPaidRegistration,
|
|
createCodeRegistration,
|
|
createBalanceRegistration,
|
|
getRegistration,
|
|
getRegistrationByOrderHash,
|
|
getLiveOrders,
|
|
closeOverdue,
|
|
updateRegistration,
|
|
retargetRegistration,
|
|
findRegistrations,
|
|
isLabelRegistering,
|
|
activeRegistrations,
|
|
unpaidCleanupCandidates,
|
|
deleteUnpaidRegistration,
|
|
failedCodeOrdersBefore,
|
|
heldBy,
|
|
getBalance,
|
|
overdueHolder,
|
|
balanceByReference,
|
|
balanceChain,
|
|
creditBalance,
|
|
spendHeldBalance,
|
|
releaseHeldBalance,
|
|
refundBalance,
|
|
lateCreditState,
|
|
creditLatePayment,
|
|
latePending,
|
|
uncreditedLateInvoices,
|
|
lateAlertDue,
|
|
claimLateAlert,
|
|
setLateSupport,
|
|
lateWait,
|
|
insertPrepaidCode,
|
|
getPrepaidCode,
|
|
getPrepaidCodeById,
|
|
reservePrepaidCode,
|
|
releasePrepaidCode,
|
|
spendPrepaidCode,
|
|
spendLandedPrepaidCode,
|
|
revokePrepaidCode,
|
|
insertTransaction,
|
|
recordTransactionOutcome,
|
|
pendingTransactions,
|
|
registrationTransactions,
|
|
editTransactions,
|
|
insertRecordEdit,
|
|
getRecordEdit,
|
|
getRecordEditByBody,
|
|
getRecordEditByNonce,
|
|
settleRecordEdit,
|
|
pendingEditIds,
|
|
deleteUnsentEdit,
|
|
relayedEditTimes,
|
|
)
|
|
where
|
|
|
|
import qualified Control.Exception as E
|
|
import Control.Monad (when)
|
|
import Crypto.Number.Serialize (i2ospOf_)
|
|
import Data.ByteArray.Encoding (Base (Base16), convertToBase)
|
|
import Data.ByteString (ByteString)
|
|
import qualified Data.ByteString as B
|
|
import Data.Int (Int64)
|
|
import Data.Maybe (isJust, listToMaybe)
|
|
import Data.String (fromString)
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Data.Text.Encoding (encodeUtf8)
|
|
import Data.Time.Clock (UTCTime)
|
|
import Data.Word (Word64)
|
|
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..))
|
|
import Simplex.Chat.PaymentService.Types (CardProvider (..), CryptoCurrency (..), InvoiceId (..), InvoiceStatus (..), ServicePaymentMethod (..))
|
|
import Simplex.Chat.Store.Shared (insertedRowId)
|
|
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..), FromField (..), ToField (..), fromTextField_)
|
|
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
|
import Simplex.Messaging.Crypto (sha256Hash)
|
|
import Simplex.Messaging.Crypto.Secp256k1 (RecoverableSignature)
|
|
import Simplex.Messaging.Encoding.String (TextEncoding (..), strDecode)
|
|
import Simplex.Messaging.Eth.Address (Address, unAddress)
|
|
import Simplex.Messaging.Eth.EIP712 (serializeSignature)
|
|
import Simplex.Messaging.SimplexName (LabelHash (..))
|
|
import Simplex.Messaging.Util (maybeFirstRow, tshow)
|
|
import StoreService.Names.Abi (hex)
|
|
import StoreService.Names.Chain (TxHash (..))
|
|
import StoreService.Names.Phases (RegistrationId (..))
|
|
import StoreService.Names.Signatures (OrderFields (..), parseSignature)
|
|
import StoreService.Store (RevokeResult (..))
|
|
import StoreService.Store.Invoices (StoreDecodeError (..), executeChanging, invoiceStatusText, lockInvoice, newInvoiceId, textToCryptoCurrency, truncateToSecond, unfundedQuery)
|
|
|
|
#if defined(dbPostgres)
|
|
import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..))
|
|
import Database.PostgreSQL.Simple.SqlQQ (sql)
|
|
#else
|
|
import Database.SQLite.Simple (Only (..), Query, (:.) (..))
|
|
import Database.SQLite.Simple.QQ (sql)
|
|
#endif
|
|
|
|
data NamePhase
|
|
= PUnpaid
|
|
| PQueued
|
|
| PCommitting
|
|
| PCommitted
|
|
| PRegistering
|
|
| PRegistered
|
|
| PTaken
|
|
| PFailed
|
|
deriving (Eq, Show, Enum, Bounded)
|
|
|
|
instance TextEncoding NamePhase where
|
|
textEncode = \case
|
|
PUnpaid -> "unpaid"
|
|
PQueued -> "queued"
|
|
PCommitting -> "committing"
|
|
PCommitted -> "committed"
|
|
PRegistering -> "registering"
|
|
PRegistered -> "registered"
|
|
PTaken -> "taken"
|
|
PFailed -> "failed"
|
|
textDecode t = lookup t [(textEncode p, p) | p <- [minBound .. maxBound]]
|
|
|
|
instance FromField NamePhase where fromField = fromTextField_ textDecode
|
|
|
|
instance ToField NamePhase where toField = toField . textEncode
|
|
|
|
-- | RPBalance: an order paid wholly from another order's balance, with neither invoice nor code.
|
|
data RegistrationPayment
|
|
= RPInvoice ServicePaymentMethod InvoiceId
|
|
| RPCode Int64
|
|
| RPBalance
|
|
deriving (Eq, Show)
|
|
|
|
-- | The name, links and owner's intent, all set by an accepted reveal and cleared together.
|
|
data NameReveal = NameReveal
|
|
{ rvLabel :: Text,
|
|
rvSecret :: ByteString,
|
|
rvContactLink :: Maybe Text,
|
|
rvChannelLink :: Maybe Text,
|
|
rvNonce :: Word64,
|
|
rvDeadline :: Word64,
|
|
rvSignature :: RecoverableSignature
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
data NameRegistration = NameRegistration
|
|
{ nrId :: RegistrationId,
|
|
nrVersion :: Int64,
|
|
nrLabelHash :: LabelHash,
|
|
nrLength :: Int,
|
|
nrDuration :: Word64,
|
|
nrOwner :: Address,
|
|
nrCommitment :: ByteString,
|
|
nrSealed :: Text,
|
|
-- | '' for an order stored before orders signed the terms
|
|
nrAgreedTerms :: Text,
|
|
nrPhase :: NamePhase,
|
|
nrYears :: Int,
|
|
nrPrice :: Int,
|
|
nrPayment :: RegistrationPayment,
|
|
nrBalanceSource :: Maybe RegistrationId,
|
|
nrBalanceUsed :: Int,
|
|
nrReveal :: Maybe NameReveal,
|
|
nrCommitTx :: Maybe TxHash,
|
|
nrCommitBlock :: Maybe Word64,
|
|
nrRegisterTx :: Maybe TxHash,
|
|
nrRegisterBlock :: Maybe Word64,
|
|
nrRevealAfter :: Maybe Word64,
|
|
nrRevealBefore :: Maybe Word64,
|
|
nrExpiresAt :: Maybe Word64,
|
|
nrRegisteredAt :: Maybe UTCTime,
|
|
nrFailedAt :: Maybe UTCTime,
|
|
nrError :: Maybe Text,
|
|
nrLive :: Bool,
|
|
nrOrderHash :: ByteString,
|
|
nrMoves :: Int,
|
|
nrLinksRefusedAt :: Maybe UTCTime,
|
|
nrCreatedAt :: UTCTime,
|
|
nrUpdatedAt :: UTCTime
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
data NewRegistration = NewRegistration
|
|
{ nrgId :: RegistrationId,
|
|
nrgOwner :: Address,
|
|
nrgOrder :: OrderFields,
|
|
nrgYears :: Int
|
|
}
|
|
|
|
-- | The invoice row is inserted by the caller, in the same transaction and before the registration.
|
|
data PaidOrder = PaidOrder
|
|
{ poMethod :: ServicePaymentMethod,
|
|
poPrice :: Int,
|
|
poInvoiceId :: InvoiceId,
|
|
poProviderRef :: Text
|
|
}
|
|
|
|
-- | RRBalanceChanged: the source's balance no longer has the amount seen, or another order holds it.
|
|
data RegistrationRefused = RRLiveOrder | RRCodeNotReservable | RRBalanceChanged
|
|
deriving (Eq, Show)
|
|
|
|
-- | NotLive: the write would make a closed order live. LiveOrderExists: another live order holds its commitment, or its owner and label hash.
|
|
data VersionWrite = Written Int64 | VersionChanged | NotLive | LiveOrderExists
|
|
deriving (Eq, Show)
|
|
|
|
-- | A live order an order request meets; overdue when its open invoice is past its expiry time.
|
|
data LiveOrder = LiveOrder
|
|
{ loRegistration :: NameRegistration,
|
|
loOverdue :: Bool
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
-- | What an order pays with, as its hash covers it. OTBalance: the paying order, the price, the balance seen, and the method of a difference.
|
|
data OrderTerms = OTInvoice ServicePaymentMethod Int | OTCode ByteString | OTBalance RegistrationId Int Int (Maybe ServicePaymentMethod)
|
|
|
|
-- | Cents left to spend, and over the balance's life the cents credited and refunded.
|
|
data Balance = Balance
|
|
{ blRegistration :: RegistrationId,
|
|
blAmount :: Int,
|
|
blCredited :: Int,
|
|
blRefunded :: Int,
|
|
blHold :: Maybe BalanceHold,
|
|
blRefundedAt :: Maybe UTCTime
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
-- | The order whose open invoice holds a balance, until that invoice expires; overdue past it with no money received.
|
|
-- Funded once money reached it: it then holds until its payment settles or the provider closes it, whatever the time.
|
|
data BalanceHold = BalanceHold
|
|
{ bhHolder :: RegistrationId,
|
|
bhUntil :: UTCTime,
|
|
bhOverdue :: Bool,
|
|
bhFunded :: Bool
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
-- | An order the money of a balance came through. lkPaid and lkFromBalance count only an order that went past unpaid,
|
|
-- since closing an unpaid one released its source's balance.
|
|
data BalanceLink = BalanceLink
|
|
{ lkRegistration :: RegistrationId,
|
|
lkPayment :: RegistrationPayment,
|
|
lkPaid :: Int,
|
|
lkFromBalance :: Int,
|
|
lkLateCredits :: [LateCredit]
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
-- | Money paid on a name invoice after its order closed, credited to that order's balance. Rates are decimal text; card money has no units or rates.
|
|
data LateCredit = LateCredit
|
|
{ lcInvoice :: InvoiceId,
|
|
lcCreditNo :: Int,
|
|
lcRegistration :: RegistrationId,
|
|
lcUnits :: Maybe Int64,
|
|
lcInvoiceRate :: Maybe Text,
|
|
lcCurrentRate :: Maybe Text,
|
|
lcRateUsed :: Maybe Text,
|
|
lcCents :: Int
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
-- | A name invoice's payment figures, what was credited from it, and its order's phase and live flag. Card money (no coin) is counted in cents,
|
|
-- crypto in units. lsSupport: why its money will not be credited by itself, once known.
|
|
data LateCreditState = LateCreditState
|
|
{ lsInvoice :: InvoiceId,
|
|
lsRegistration :: RegistrationId,
|
|
lsPhase :: NamePhase,
|
|
lsLive :: Bool,
|
|
lsCoin :: Maybe CryptoCurrency,
|
|
lsSupport :: Maybe LateSupport,
|
|
lsReceivedCents :: Int,
|
|
lsConfirmedCents :: Int,
|
|
lsReceivedUnits :: Maybe Int64,
|
|
lsConfirmedUnits :: Maybe Int64,
|
|
lsInvoiceRate :: Maybe Text,
|
|
lsCreditedUnits :: Int64,
|
|
lsCreditedCents :: Int,
|
|
lsCredits :: Int,
|
|
lsPaymentCreatedAt :: UTCTime
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
-- | Why a closed order's money is not all credited: Monero confirming with a zero figure, confirmed money not credited yet,
|
|
-- money received and not confirmed, or crypto received that could not be counted in units.
|
|
data LatePending = LPConfirming | LPUncredited | LPUnconfirmed | LPUncounted
|
|
deriving (Eq, Show, Enum, Bounded)
|
|
|
|
instance TextEncoding LatePending where
|
|
textEncode = \case
|
|
LPConfirming -> "confirming"
|
|
LPUncredited -> "uncredited"
|
|
LPUnconfirmed -> "unconfirmed"
|
|
LPUncounted -> "uncounted"
|
|
textDecode t = lookup t [(textEncode p, p) | p <- [minBound .. maxBound]]
|
|
|
|
instance FromField LatePending where fromField = fromTextField_ textDecode
|
|
|
|
-- | What a closed order's late money waits on, for the view: confirmation, the credit, or support.
|
|
data LateWait = LWConfirming | LWCrediting | LWSupport
|
|
deriving (Eq, Show)
|
|
|
|
-- | Why a closed order's money will not be credited by itself: an invoice made under HighSpeed, an amount that could not be counted in units,
|
|
-- a current rate below the floor, unconfirmed money alerted past the read window, or money the provider shows no confirmation of.
|
|
data LateSupport = LSHighSpeed | LSUnits | LSRateFloor | LSUnconfirmed | LSNoConfirmation
|
|
deriving (Eq, Show, Enum, Bounded)
|
|
|
|
instance TextEncoding LateSupport where
|
|
textEncode = \case
|
|
LSHighSpeed -> "high_speed"
|
|
LSUnits -> "units"
|
|
LSRateFloor -> "rate_floor"
|
|
LSUnconfirmed -> "unconfirmed"
|
|
LSNoConfirmation -> "no_confirmation"
|
|
textDecode t = lookup t [(textEncode s, s) | s <- [minBound .. maxBound]]
|
|
|
|
instance FromField LateSupport where fromField = fromTextField_ textDecode
|
|
|
|
instance ToField LateSupport where toField = toField . textEncode
|
|
|
|
-- | A closed order's pending money to alert on, with the received figure the alert names and the one the last alert named.
|
|
data LateAlert = LateAlert
|
|
{ laState :: LateCreditState,
|
|
laPending :: LatePending,
|
|
laReceived :: Maybe Int64,
|
|
laAlertedAt :: Maybe UTCTime,
|
|
laAlertedReceived :: Maybe Int64
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
-- | Why the cleanup keeps an unpaid order: a payment was recorded for it, or it holds the balance of the given order.
|
|
data CleanupKept = CKPaid | CKHolder RegistrationId
|
|
deriving (Eq, Show)
|
|
|
|
data PrepaidCode = PrepaidCode
|
|
{ pcId :: Int64,
|
|
pcCodeHash :: ByteString,
|
|
pcMinLength :: Int,
|
|
pcYears :: Int,
|
|
pcPaymentStatus :: BadgeCodePaymentStatus,
|
|
pcReservedBy :: Maybe RegistrationId,
|
|
pcReservedAt :: Maybe UTCTime,
|
|
pcSpentAt :: Maybe UTCTime,
|
|
pcRevokedAt :: Maybe UTCTime,
|
|
pcExpiresAt :: Maybe UTCTime,
|
|
pcCreatedAt :: UTCTime
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
data NewPrepaidCode = NewPrepaidCode
|
|
{ npcCodeHash :: ByteString,
|
|
npcMinLength :: Int,
|
|
npcYears :: Int,
|
|
npcPaymentStatus :: BadgeCodePaymentStatus,
|
|
npcExpiresAt :: Maybe UTCTime
|
|
}
|
|
|
|
data CodeHashTaken = CodeHashTaken
|
|
deriving (Eq, Show)
|
|
|
|
data TxFor = TxCommit RegistrationId | TxRegister RegistrationId | TxEdit Int64
|
|
deriving (Eq, Show)
|
|
|
|
-- | A dropped transaction has no receipt and its registrar nonce is used.
|
|
data TxStatus = TSPending | TSMined | TSReverted | TSDropped
|
|
deriving (Eq, Show, Enum, Bounded)
|
|
|
|
instance TextEncoding TxStatus where
|
|
textEncode = \case
|
|
TSPending -> "pending"
|
|
TSMined -> "mined"
|
|
TSReverted -> "reverted"
|
|
TSDropped -> "dropped"
|
|
textDecode t = lookup t [(textEncode s, s) | s <- [minBound .. maxBound]]
|
|
|
|
instance FromField TxStatus where fromField = fromTextField_ textDecode
|
|
|
|
instance ToField TxStatus where toField = toField . textEncode
|
|
|
|
data NameTransaction = NameTransaction
|
|
{ ntId :: Int64,
|
|
ntFor :: TxFor,
|
|
ntRaw :: ByteString,
|
|
ntHash :: TxHash,
|
|
ntNonce :: Word64,
|
|
ntSentAt :: UTCTime,
|
|
ntMinedBlock :: Maybe Word64,
|
|
ntStatus :: TxStatus
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
data NewTransaction = NewTransaction
|
|
{ ntxRaw :: ByteString,
|
|
ntxHash :: TxHash,
|
|
ntxNonce :: Word64
|
|
}
|
|
|
|
-- | A text record set to a value, "" removing it, or every record cleared.
|
|
data RecordChange = RCSetText Text Text | RCClear
|
|
deriving (Eq, Show)
|
|
|
|
data EditState = ESPending | ESMined (Maybe TxHash) | ESReverted (Maybe Text)
|
|
deriving (Eq, Show)
|
|
|
|
data RecordEdit = RecordEdit
|
|
{ reId :: Int64,
|
|
reLabelHash :: LabelHash,
|
|
reOwner :: Address,
|
|
reNonce :: Word64,
|
|
reChange :: RecordChange,
|
|
reDeadline :: Word64,
|
|
reSignature :: RecoverableSignature,
|
|
reBodyHash :: ByteString,
|
|
reState :: EditState,
|
|
reCreatedAt :: UTCTime
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
data NewRecordEdit = NewRecordEdit
|
|
{ nreLabelHash :: LabelHash,
|
|
nreOwner :: Address,
|
|
nreNonce :: Word64,
|
|
nreChange :: RecordChange,
|
|
nreDeadline :: Word64,
|
|
nreSignature :: RecoverableSignature,
|
|
nreBodyHash :: ByteString
|
|
}
|
|
|
|
findLimit :: Int
|
|
findLimit = 50
|
|
|
|
initialVersion :: Int64
|
|
initialVersion = 1
|
|
|
|
orderHashFieldBytes :: Int
|
|
orderHashFieldBytes = 4
|
|
|
|
-- | Each field is length-prefixed, so no two different orders encode the same.
|
|
orderHash :: Address -> OrderFields -> OrderTerms -> ByteString
|
|
orderHash owner OrderFields {ofLabelHash = LabelHash lh, ofLength, ofDuration, ofCommitment, ofSealed, ofAgreedTerms} terms =
|
|
sha256Hash . B.concat . map field $ [tag, lh, num ofLength, num ofDuration, unAddress owner, ofCommitment, encodeUtf8 ofSealed, encodeUtf8 ofAgreedTerms] <> payment
|
|
where
|
|
(tag, payment) = case terms of
|
|
OTInvoice method price -> ("name-invoice", [encodeUtf8 $ methodText method, num price])
|
|
OTCode codeHash -> ("name-code", [codeHash])
|
|
OTBalance (RegistrationId source) price seen method -> ("name-balance", [encodeUtf8 source, num price, num seen, maybe "" (encodeUtf8 . methodText) method])
|
|
field bs = i2ospOf_ orderHashFieldBytes (toInteger $ B.length bs) <> bs
|
|
num :: Show a => a -> ByteString
|
|
num = encodeUtf8 . tshow
|
|
|
|
newRegistrationId :: IO RegistrationId
|
|
newRegistrationId = (\(InvoiceId t) -> RegistrationId t) <$> newInvoiceId
|
|
|
|
methodText :: ServicePaymentMethod -> Text
|
|
methodText = \case
|
|
SPMCard CPStripe -> "card"
|
|
SPMCrypto CCBtc -> "btc"
|
|
SPMCrypto CCXmr -> "xmr"
|
|
|
|
textMethod :: Text -> Maybe ServicePaymentMethod
|
|
textMethod t = lookup t [(methodText m, m) | m <- [SPMCard CPStripe, SPMCrypto CCBtc, SPMCrypto CCXmr]]
|
|
|
|
-- | A difference order (OTBalance) holds all of the balance seen while its invoice is open. The hold is written after the registration,
|
|
-- so the registration's row is locked before the balance's.
|
|
createPaidRegistration :: DB.Connection -> NewRegistration -> OrderTerms -> PaidOrder -> UTCTime -> IO (Either RegistrationRefused ())
|
|
createPaidRegistration db new@NewRegistration {nrgId = regId@(RegistrationId rid), nrgOwner, nrgOrder} terms PaidOrder {poMethod, poPrice, poInvoiceId = InvoiceId invId, poProviderRef} now' = do
|
|
let now = truncateToSecond now'
|
|
held = case terms of
|
|
OTBalance source _ seen _ -> Just (source, seen)
|
|
OTInvoice {} -> Nothing
|
|
OTCode _ -> Nothing
|
|
inserted <- insertRegistration db new PUnpaid poPrice (Just poMethod) Nothing held (orderHash nrgOwner nrgOrder terms) now
|
|
if not inserted
|
|
then pure $ Left RRLiveOrder
|
|
else do
|
|
DB.execute
|
|
db
|
|
"INSERT INTO sx_badge_service_name_invoices (invoice_id, registration_id, provider_ref, created_at) VALUES (?,?,?,?)"
|
|
(invId, rid, poProviderRef, now)
|
|
held' <- maybe (pure True) (\(source, seen) -> holdBalance db source seen regId now) held
|
|
if held' then pure $ Right () else Left RRBalanceChanged <$ deleteRegistration db regId
|
|
|
|
-- | Reserving the code is the conditional update of the transaction that creates the registration.
|
|
createCodeRegistration :: DB.Connection -> NewRegistration -> PrepaidCode -> UTCTime -> IO (Either RegistrationRefused ())
|
|
createCodeRegistration db new@NewRegistration {nrgId = regId, nrgOwner, nrgOrder} PrepaidCode {pcId, pcCodeHash} now' = do
|
|
let now = truncateToSecond now'
|
|
inserted <- insertRegistration db new PQueued 0 Nothing (Just pcId) Nothing (orderHash nrgOwner nrgOrder (OTCode pcCodeHash)) now
|
|
if not inserted
|
|
then pure $ Left RRLiveOrder
|
|
else
|
|
reservePrepaidCode db pcId regId now >>= \case
|
|
True -> pure $ Right ()
|
|
False -> Left RRCodeNotReservable <$ deleteRegistration db regId
|
|
|
|
-- | A queued order whose price is spent from the source's balance in the same transaction, where its amount is the one seen and nothing holds it.
|
|
createBalanceRegistration :: DB.Connection -> NewRegistration -> RegistrationId -> Int -> Int -> UTCTime -> IO (Either RegistrationRefused ())
|
|
createBalanceRegistration db new@NewRegistration {nrgId, nrgOwner, nrgOrder} source price seen now' = do
|
|
let now = truncateToSecond now'
|
|
inserted <- insertRegistration db new PQueued price Nothing Nothing (Just (source, price)) (orderHash nrgOwner nrgOrder (OTBalance source price seen Nothing)) now
|
|
if not inserted
|
|
then pure $ Left RRLiveOrder
|
|
else
|
|
spendBalance db source price seen now >>= \case
|
|
True -> pure $ Right ()
|
|
False -> Left RRBalanceChanged <$ deleteRegistration db nrgId
|
|
|
|
-- | The name invoice row goes with it.
|
|
deleteRegistration :: DB.Connection -> RegistrationId -> IO ()
|
|
deleteRegistration db (RegistrationId rid) = DB.execute db "DELETE FROM sx_badge_service_name_registrations WHERE registration_id = ?" (Only rid)
|
|
|
|
-- | False when the registration would be a second live one for its commitment, or for its owner and label hash.
|
|
-- fromBalance: the order whose balance pays, and the cents it pays.
|
|
insertRegistration :: DB.Connection -> NewRegistration -> NamePhase -> Int -> Maybe ServicePaymentMethod -> Maybe Int64 -> Maybe (RegistrationId, Int) -> ByteString -> UTCTime -> IO Bool
|
|
insertRegistration db NewRegistration {nrgId = RegistrationId rid, nrgOwner, nrgOrder, nrgYears} phase price method codeId fromBalance hash now =
|
|
(> 0)
|
|
<$> executeChanging
|
|
db
|
|
[sql|
|
|
INSERT INTO sx_badge_service_name_registrations
|
|
(registration_id, version, label_hash, length, duration, owner, commitment, sealed, phase, years,
|
|
price, method, name_prepaid_code_id, live, order_hash, moves, created_at, updated_at, balance_source, balance_used, agreed_terms)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?, ?,?,?,?,?,?,?,?,?,?, ?)
|
|
ON CONFLICT DO NOTHING
|
|
|]
|
|
( (rid, initialVersion, Binary lh, ofLength, int64 ofDuration, Binary $ unAddress nrgOwner, Binary ofCommitment, ofSealed, phase, nrgYears)
|
|
:. (price, methodText <$> method, codeId, BI True, Binary hash, 0 :: Int, now, now, (\(RegistrationId s, _) -> s) <$> fromBalance, maybe 0 snd fromBalance)
|
|
:. Only ofAgreedTerms
|
|
)
|
|
where
|
|
OrderFields {ofLabelHash = LabelHash lh, ofLength, ofDuration, ofCommitment, ofSealed, ofAgreedTerms} = nrgOrder
|
|
|
|
registrationColumns :: Query
|
|
registrationColumns =
|
|
[sql|
|
|
SELECT r.registration_id, r.version, r.label_hash, r.length, r.duration, r.owner, r.commitment, r.sealed, r.phase, r.years,
|
|
r.price, r.method, ni.invoice_id, r.name_prepaid_code_id, r.label, r.secret, r.contact_link, r.channel_link, r.intent_nonce, r.intent_deadline,
|
|
r.intent_signature, r.commit_tx, r.commit_block, r.register_tx, r.register_block, r.reveal_after, r.reveal_before, r.expires_at, r.registered_at,
|
|
r.failed_at, r.error, r.live, r.order_hash, r.moves, r.links_refused_at, r.created_at, r.updated_at, r.balance_source, r.balance_used,
|
|
r.agreed_terms
|
|
|]
|
|
|
|
registrationFrom :: Query
|
|
registrationFrom =
|
|
" "
|
|
<> [sql|
|
|
FROM sx_badge_service_name_registrations r
|
|
LEFT JOIN sx_badge_service_name_invoices ni ON ni.registration_id = r.registration_id
|
|
|]
|
|
|
|
selectRegistrations :: Query
|
|
selectRegistrations = registrationColumns <> registrationFrom
|
|
|
|
type RegistrationRow =
|
|
(Text, Int64, Binary ByteString, Int, Int64, Binary ByteString, Binary ByteString, Text, NamePhase, Int)
|
|
:. (Int, Maybe Text, Maybe Text, Maybe Int64, Maybe Text, Maybe (Binary ByteString), Maybe Text, Maybe Text, Maybe Int64, Maybe Int64)
|
|
:. (Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe Int64, Maybe (Binary ByteString), Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime)
|
|
:. (Maybe UTCTime, Maybe Text, BoolInt, Binary ByteString, Int, Maybe UTCTime, UTCTime, UTCTime, Maybe Text, Int)
|
|
:. Only Text
|
|
|
|
toRegistration :: RegistrationRow -> Either Text NameRegistration
|
|
toRegistration
|
|
( (rid, nrVersion, Binary lh, nrLength, duration, Binary owner, Binary nrCommitment, nrSealed, nrPhase, nrYears)
|
|
:. (nrPrice, method_, invoiceId_, codeId_, label_, secret_, nrContactLink, nrChannelLink, nonce_, deadline_)
|
|
:. (signature_, commitTx, commitBlock, registerTx, registerBlock, revealAfter, revealBefore, expiresAt, nrRegisteredAt)
|
|
:. (nrFailedAt, nrError, BI nrLive, Binary nrOrderHash, nrMoves, nrLinksRefusedAt, nrCreatedAt, nrUpdatedAt, source_, nrBalanceUsed)
|
|
:. Only nrAgreedTerms
|
|
) = do
|
|
nrOwner <- decodeAddress owner
|
|
nrPayment <- case (method_, invoiceId_, codeId_) of
|
|
(Just m, Just invId, Nothing) -> (`RPInvoice` InvoiceId invId) <$> note ("name_registrations.method: " <> m) (textMethod m)
|
|
(Nothing, Nothing, Just codeId) -> Right $ RPCode codeId
|
|
(Nothing, Nothing, Nothing) | isJust source_ -> Right RPBalance
|
|
_ -> Left $ "name_registrations payment: " <> rid
|
|
nrReveal <- case (label_, secret_, nonce_, deadline_, signature_) of
|
|
(Nothing, Nothing, Nothing, Nothing, Nothing) -> Right Nothing
|
|
(Just rvLabel, Just (Binary rvSecret), Just nonce, Just deadline, Just (Binary sig)) -> do
|
|
rvSignature <- decodeSignature sig
|
|
Right $ Just NameReveal {rvLabel, rvSecret, rvContactLink = nrContactLink, rvChannelLink = nrChannelLink, rvNonce = word64 nonce, rvDeadline = word64 deadline, rvSignature}
|
|
_ -> Left $ "name_registrations reveal: " <> rid
|
|
pure
|
|
NameRegistration
|
|
{ nrId = RegistrationId rid,
|
|
nrVersion,
|
|
nrLabelHash = LabelHash lh,
|
|
nrLength,
|
|
nrDuration = word64 duration,
|
|
nrOwner,
|
|
nrCommitment,
|
|
nrSealed,
|
|
nrAgreedTerms,
|
|
nrPhase,
|
|
nrYears,
|
|
nrPrice,
|
|
nrPayment,
|
|
nrBalanceSource = RegistrationId <$> source_,
|
|
nrBalanceUsed,
|
|
nrReveal,
|
|
nrCommitTx = txHash <$> commitTx,
|
|
nrCommitBlock = word64 <$> commitBlock,
|
|
nrRegisterTx = txHash <$> registerTx,
|
|
nrRegisterBlock = word64 <$> registerBlock,
|
|
nrRevealAfter = word64 <$> revealAfter,
|
|
nrRevealBefore = word64 <$> revealBefore,
|
|
nrExpiresAt = word64 <$> expiresAt,
|
|
nrRegisteredAt,
|
|
nrFailedAt,
|
|
nrError,
|
|
nrLive,
|
|
nrOrderHash,
|
|
nrMoves,
|
|
nrLinksRefusedAt,
|
|
nrCreatedAt,
|
|
nrUpdatedAt
|
|
}
|
|
|
|
getRegistration :: DB.Connection -> RegistrationId -> IO (Maybe NameRegistration)
|
|
getRegistration db (RegistrationId rid) =
|
|
firstDecoded toRegistration $ DB.query db (selectRegistrations <> " WHERE r.registration_id = ?") (Only rid)
|
|
|
|
-- | The newest stored order, live or not, with exactly these fields.
|
|
getRegistrationByOrderHash :: DB.Connection -> ByteString -> IO (Maybe NameRegistration)
|
|
getRegistrationByOrderHash db hash =
|
|
firstDecoded toRegistration $
|
|
DB.query db (selectRegistrations <> " WHERE r.order_hash = ? ORDER BY r.created_at DESC, r.registration_id DESC LIMIT 1") (Only $ Binary hash)
|
|
|
|
-- | Overdue whatever status the poller has marked, as long as the invoice is not paid and holds no money, as the sweep decides.
|
|
getLiveOrders :: DB.Connection -> ByteString -> Address -> LabelHash -> UTCTime -> IO [LiveOrder]
|
|
getLiveOrders db commitment owner (LabelHash lh) now =
|
|
DB.query
|
|
db
|
|
( registrationColumns
|
|
<> ", CASE WHEN r.phase = ? AND i.status <> ? AND i.expires_at < ?"
|
|
<> unfunded
|
|
<> " THEN 1 ELSE 0 END"
|
|
<> registrationFrom
|
|
<> " LEFT JOIN sx_badge_service_invoices i ON i.invoice_id = ni.invoice_id"
|
|
<> " WHERE r.live = 1 AND (r.commitment = ? OR (r.owner = ? AND r.label_hash = ?))"
|
|
)
|
|
(PUnpaid, invoiceStatusText ISPaid, truncateToSecond now, Binary commitment, Binary $ unAddress owner, Binary lh)
|
|
>>= decodeAll toLiveOrder
|
|
where
|
|
toLiveOrder (row :. Only (BI loOverdue)) = (\loRegistration -> LiveOrder {loRegistration, loOverdue}) <$> toRegistration row
|
|
|
|
-- | The sweep's own check, on the invoice joined as i.
|
|
unfunded :: Query
|
|
unfunded = unfundedQuery "i.invoice_id"
|
|
|
|
-- | Run in the transaction that inserts the next order, so the unique constraints let it in; the registration's new version.
|
|
-- Nothing when the order changed since it was read, or its invoice is no longer open, not past its expiry, or holds money: the caller rolls back,
|
|
-- since the invoice is written before the registration, as settlement, cancel and the sweep write them. A balance it held is released.
|
|
closeOverdue :: DB.Connection -> NameRegistration -> UTCTime -> IO (Maybe Int64)
|
|
closeOverdue db r@NameRegistration {nrId = regId@(RegistrationId rid), nrVersion, nrPayment} now = do
|
|
-- locked first, so the funding check below, a later statement, sees a payment a concurrent settlement committed
|
|
mapM_ (\invId -> lockInvoice db invId now) $ case nrPayment of
|
|
RPInvoice _ invId -> Just invId
|
|
RPCode _ -> Nothing
|
|
RPBalance -> Nothing
|
|
expired <-
|
|
executeChanging
|
|
db
|
|
( [sql|
|
|
UPDATE sx_badge_service_invoices SET status = ?, updated_at = ?
|
|
WHERE status = ? AND invoice_id IN (
|
|
SELECT i.invoice_id FROM sx_badge_service_name_invoices ni
|
|
JOIN sx_badge_service_name_registrations r ON r.registration_id = ni.registration_id
|
|
JOIN sx_badge_service_invoices i ON i.invoice_id = ni.invoice_id
|
|
WHERE ni.registration_id = ? AND r.version = ? AND r.live = 1 AND i.expires_at < ?
|
|
|]
|
|
<> unfunded
|
|
<> " )"
|
|
)
|
|
(invoiceStatusText ISExpired, truncateToSecond now, invoiceStatusText ISOpen, rid, nrVersion, truncateToSecond now)
|
|
if expired > 0
|
|
then
|
|
updateRegistration db r {nrLive = False} now >>= \case
|
|
Written v -> Just v <$ releaseHeldBalance db regId now
|
|
_ -> pure Nothing
|
|
else pure Nothing
|
|
|
|
-- | Whether an order can still progress to a registration for its name. The unique live indexes allow one such order per commitment,
|
|
-- and one per owner and label hash, so a wallet can order a name again once its registration is done.
|
|
-- An unpaid order is live while its invoice is open, which nrLive carries; a taken order is final, its worth kept as a balance.
|
|
orderLive :: NameRegistration -> Bool
|
|
orderLive NameRegistration {nrPhase, nrLive} = case nrPhase of
|
|
PUnpaid -> nrLive
|
|
PQueued -> True
|
|
PCommitting -> True
|
|
PCommitted -> True
|
|
PRegistering -> True
|
|
PFailed -> True
|
|
PTaken -> False
|
|
PRegistered -> False
|
|
|
|
-- | Writes every changeable field of a registration read at its nrVersion, unless it changed since, with live as orderLive decides.
|
|
-- A closed order is never made live again, and a write that would make the order meet another live one changes nothing.
|
|
updateRegistration :: DB.Connection -> NameRegistration -> UTCTime -> IO VersionWrite
|
|
updateRegistration db r now = do
|
|
changed <-
|
|
executeChanging
|
|
db
|
|
[sql|
|
|
UPDATE sx_badge_service_name_registrations
|
|
SET version = version + 1, label_hash = ?, length = ?, duration = ?, commitment = ?, sealed = ?, agreed_terms = ?, phase = ?, price = ?,
|
|
label = ?, secret = ?, contact_link = ?, channel_link = ?, intent_nonce = ?, intent_deadline = ?, intent_signature = ?,
|
|
commit_tx = ?, commit_block = ?, register_tx = ?, register_block = ?, reveal_after = ?, reveal_before = ?, expires_at = ?,
|
|
registered_at = ?, failed_at = ?, error = ?, live = ?, moves = ?, links_refused_at = ?, updated_at = ?
|
|
WHERE registration_id = ? AND version = ?
|
|
AND (? = 0 OR (live = 1 AND NOT EXISTS (
|
|
SELECT 1 FROM sx_badge_service_name_registrations o
|
|
WHERE o.live = 1 AND o.registration_id <> ? AND (o.commitment = ? OR (o.owner = ? AND o.label_hash = ?))
|
|
)))
|
|
|]
|
|
( (Binary lh, nrLength, int64 nrDuration, Binary nrCommitment, nrSealed, nrAgreedTerms, nrPhase, nrPrice)
|
|
:. (rvLabel <$> nrReveal, Binary . rvSecret <$> nrReveal, rvContactLink =<< nrReveal, rvChannelLink =<< nrReveal, int64 . rvNonce <$> nrReveal, int64 . rvDeadline <$> nrReveal, Binary . serializeSignature . rvSignature <$> nrReveal)
|
|
:. (txHashField <$> nrCommitTx, int64 <$> nrCommitBlock, txHashField <$> nrRegisterTx, int64 <$> nrRegisterBlock, int64 <$> nrRevealAfter, int64 <$> nrRevealBefore, int64 <$> nrExpiresAt)
|
|
:. (truncateToSecond <$> nrRegisteredAt, truncateToSecond <$> nrFailedAt, nrError, BI live, nrMoves, truncateToSecond <$> nrLinksRefusedAt, truncateToSecond now)
|
|
:. (rid, nrVersion, BI live, rid, Binary nrCommitment, Binary $ unAddress nrOwner, Binary lh)
|
|
)
|
|
if changed > 0 then pure $ Written (nrVersion + 1) else refusal
|
|
where
|
|
NameRegistration {nrId = RegistrationId rid, nrVersion, nrLabelHash = LabelHash lh, nrLength, nrDuration, nrOwner, nrCommitment, nrSealed, nrAgreedTerms, nrPhase, nrPrice, nrReveal} = r
|
|
NameRegistration {nrCommitTx, nrCommitBlock, nrRegisterTx, nrRegisterBlock, nrRevealAfter, nrRevealBefore, nrExpiresAt} = r
|
|
NameRegistration {nrRegisteredAt, nrFailedAt, nrError, nrMoves, nrLinksRefusedAt} = r
|
|
live = orderLive r
|
|
refusal =
|
|
DB.query db "SELECT version, live FROM sx_badge_service_name_registrations WHERE registration_id = ?" (Only rid) >>= \case
|
|
[(version, BI storedLive)] | version == nrVersion -> pure $ if storedLive then LiveOrderExists else NotLive
|
|
_ -> pure VersionChanged
|
|
|
|
-- | Another name, or other links, with the terms agreed for it: queued again with the reveal, transactions, reveal times and link refusal cleared.
|
|
-- The order hash stays the original order's, so a resend of that order still finds it.
|
|
retargetRegistration :: DB.Connection -> NameRegistration -> OrderFields -> Int -> UTCTime -> IO VersionWrite
|
|
retargetRegistration db r OrderFields {ofLabelHash, ofLength, ofDuration, ofCommitment, ofSealed, ofAgreedTerms} price =
|
|
updateRegistration
|
|
db
|
|
r
|
|
{ nrLabelHash = ofLabelHash,
|
|
nrLength = ofLength,
|
|
nrDuration = ofDuration,
|
|
nrCommitment = ofCommitment,
|
|
nrSealed = ofSealed,
|
|
nrAgreedTerms = ofAgreedTerms,
|
|
nrPrice = price,
|
|
nrPhase = PQueued,
|
|
nrMoves = nrMoves r + 1,
|
|
nrReveal = Nothing,
|
|
nrCommitTx = Nothing,
|
|
nrCommitBlock = Nothing,
|
|
nrRegisterTx = Nothing,
|
|
nrRegisterBlock = Nothing,
|
|
nrRevealAfter = Nothing,
|
|
nrRevealBefore = Nothing,
|
|
nrLinksRefusedAt = Nothing,
|
|
nrFailedAt = Nothing,
|
|
nrError = Nothing
|
|
}
|
|
|
|
-- | Newest first: findLimit of them, then every older one holding a balance above 0, since a balance never expires.
|
|
-- Each with the order holding its balance, as getBalance reads the hold at the given time.
|
|
findRegistrations :: DB.Connection -> Address -> UTCTime -> IO [(NameRegistration, Maybe RegistrationId)]
|
|
findRegistrations db owner now = do
|
|
newest <- DB.query db (selectHeld <> " WHERE r.owner = ?" <> newestFirst <> " LIMIT ?") (holdParams :. (Binary $ unAddress owner, findLimit)) >>= decodeAll found
|
|
holding <- DB.query db (selectHeld <> " WHERE r.owner = ?" <> withBalance <> newestFirst) (holdParams :. Only (Binary $ unAddress owner)) >>= decodeAll found
|
|
pure $ newest <> filter ((`notElem` map (nrId . fst) newest) . nrId . fst) holding
|
|
where
|
|
selectHeld =
|
|
registrationColumns
|
|
<> ", (SELECT b.reserved_by FROM sx_badge_service_name_balances b"
|
|
<> " JOIN sx_badge_service_name_registrations h ON h.registration_id = b.reserved_by"
|
|
<> " JOIN sx_badge_service_name_invoices hni ON hni.registration_id = b.reserved_by"
|
|
<> " JOIN sx_badge_service_invoices i ON i.invoice_id = hni.invoice_id"
|
|
<> " WHERE b.registration_id = r.registration_id AND NOT ("
|
|
<> holdOverdue
|
|
<> "))"
|
|
<> registrationFrom
|
|
holdParams = (PUnpaid, invoiceStatusText ISPaid, truncateToSecond now)
|
|
found :: RegistrationRow :. Only (Maybe Text) -> Either Text (NameRegistration, Maybe RegistrationId)
|
|
found (row :. Only holder) = (\r -> (r, RegistrationId <$> holder)) <$> toRegistration row
|
|
newestFirst = " ORDER BY r.created_at DESC, r.registration_id DESC"
|
|
withBalance = " AND EXISTS (SELECT 1 FROM sx_badge_service_name_balances b WHERE b.registration_id = r.registration_id AND b.amount > 0)"
|
|
|
|
isLabelRegistering :: DB.Connection -> LabelHash -> IO Bool
|
|
isLabelRegistering db (LabelHash lh) =
|
|
not . null
|
|
<$> ( DB.query db "SELECT 1 FROM sx_badge_service_name_registrations WHERE label_hash = ? AND phase = ? LIMIT 1" (Binary lh, PRegistering) ::
|
|
IO [Only Int]
|
|
)
|
|
|
|
-- | The registrations a worker moves on, oldest first.
|
|
activeRegistrations :: DB.Connection -> IO [NameRegistration]
|
|
activeRegistrations db =
|
|
DB.query db (selectRegistrations <> " WHERE r.phase IN (?,?,?,?) ORDER BY r.created_at, r.registration_id") (PQueued, PCommitting, PCommitted, PRegistering)
|
|
>>= decodeAll toRegistration
|
|
|
|
-- | Unpaid orders whose invoice expired before the cutoff, without a recorded payment.
|
|
unpaidCleanupCandidates :: DB.Connection -> UTCTime -> IO [(RegistrationId, InvoiceId)]
|
|
unpaidCleanupCandidates db expiredBefore =
|
|
map (\(rid, invId) -> (RegistrationId rid, InvoiceId invId))
|
|
<$> DB.query
|
|
db
|
|
[sql|
|
|
SELECT r.registration_id, ni.invoice_id
|
|
FROM sx_badge_service_name_registrations r
|
|
JOIN sx_badge_service_name_invoices ni ON ni.registration_id = r.registration_id
|
|
JOIN sx_badge_service_invoices i ON i.invoice_id = ni.invoice_id
|
|
WHERE r.phase = ? AND i.expires_at < ?
|
|
AND NOT EXISTS (SELECT 1 FROM sx_badge_service_payments p WHERE p.invoice_id = ni.invoice_id)
|
|
ORDER BY i.expires_at, r.registration_id
|
|
|]
|
|
(PUnpaid, truncateToSecond expiredBefore)
|
|
|
|
-- | Deletes an unpaid registration and its invoice, unless a payment was recorded for it or it holds a balance.
|
|
deleteUnpaidRegistration :: DB.Connection -> RegistrationId -> IO (Either CleanupKept ())
|
|
deleteUnpaidRegistration db (RegistrationId rid) =
|
|
DB.query db "SELECT registration_id FROM sx_badge_service_name_balances WHERE reserved_by = ?" (Only rid) >>= \case
|
|
Only source : _ -> pure $ Left $ CKHolder $ RegistrationId source
|
|
[] -> do
|
|
invoiceIds <- DB.query db "SELECT invoice_id FROM sx_badge_service_name_invoices WHERE registration_id = ?" (Only rid)
|
|
deleted <-
|
|
executeChanging
|
|
db
|
|
[sql|
|
|
DELETE FROM sx_badge_service_name_registrations
|
|
WHERE registration_id = ? AND phase = ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM sx_badge_service_name_invoices ni
|
|
JOIN sx_badge_service_payments p ON p.invoice_id = ni.invoice_id
|
|
WHERE ni.registration_id = ?
|
|
)
|
|
|]
|
|
(rid, PUnpaid, rid)
|
|
if deleted > 0
|
|
then Right () <$ mapM_ (DB.execute db "DELETE FROM sx_badge_service_invoices WHERE invoice_id = ?" . Only . fromOnly) (invoiceIds :: [Only Text])
|
|
else pure $ Left CKPaid
|
|
|
|
-- | Code orders failed before the cutoff that still hold their unspent code.
|
|
failedCodeOrdersBefore :: DB.Connection -> UTCTime -> IO [(RegistrationId, Int64)]
|
|
failedCodeOrdersBefore db cutoff =
|
|
map (\(rid, codeId) -> (RegistrationId rid, codeId))
|
|
<$> DB.query
|
|
db
|
|
[sql|
|
|
SELECT r.registration_id, c.name_prepaid_code_id
|
|
FROM sx_badge_service_name_registrations r
|
|
JOIN sx_badge_service_name_prepaid_codes c ON c.reserved_by = r.registration_id
|
|
WHERE r.phase = ? AND r.failed_at < ? AND c.spent_at IS NULL
|
|
ORDER BY r.failed_at, r.registration_id
|
|
|]
|
|
(PFailed, truncateToSecond cutoff)
|
|
|
|
-- | The hold, unless it is overdue, which reads as none.
|
|
heldBy :: Balance -> Maybe BalanceHold
|
|
heldBy Balance {blHold} = case blHold of
|
|
Just BalanceHold {bhOverdue = False} -> blHold
|
|
_ -> Nothing
|
|
|
|
-- | A holder h with invoice i is overdue as getLiveOrders reads an order overdue: its invoice past its expiry, not paid, and holding no money.
|
|
-- Takes the unpaid phase, the paid status and the time.
|
|
holdOverdue :: Query
|
|
holdOverdue = " h.phase = ? AND i.status <> ? AND i.expires_at < ?" <> unfunded
|
|
|
|
getBalance :: DB.Connection -> RegistrationId -> UTCTime -> IO (Maybe Balance)
|
|
getBalance db (RegistrationId rid) now =
|
|
firstDecoded toBalance $
|
|
DB.query
|
|
db
|
|
( "SELECT b.registration_id, b.amount, b.credited, b.refunded, b.reserved_by, i.expires_at,"
|
|
<> " CASE WHEN"
|
|
<> holdOverdue
|
|
<> " THEN 1 ELSE 0 END, CASE WHEN i.invoice_id IS NOT NULL"
|
|
<> unfunded
|
|
<> " THEN 0 ELSE 1 END, b.refunded_at"
|
|
<> " FROM sx_badge_service_name_balances b"
|
|
<> " LEFT JOIN sx_badge_service_name_registrations h ON h.registration_id = b.reserved_by"
|
|
<> " LEFT JOIN sx_badge_service_name_invoices ni ON ni.registration_id = b.reserved_by"
|
|
<> " LEFT JOIN sx_badge_service_invoices i ON i.invoice_id = ni.invoice_id"
|
|
<> " WHERE b.registration_id = ?"
|
|
)
|
|
(PUnpaid, invoiceStatusText ISPaid, truncateToSecond now, rid)
|
|
|
|
toBalance :: (Text, Int, Int, Int, Maybe Text, Maybe UTCTime, BoolInt, BoolInt, Maybe UTCTime) -> Either Text Balance
|
|
toBalance (rid, blAmount, blCredited, blRefunded, holder_, until_, BI bhOverdue, BI bhFunded, blRefundedAt) = do
|
|
blHold <- case (holder_, until_) of
|
|
(Nothing, _) -> Right Nothing
|
|
(Just holder, Just bhUntil) -> Right $ Just BalanceHold {bhHolder = RegistrationId holder, bhUntil, bhOverdue, bhFunded}
|
|
(Just _, Nothing) -> Left $ "name_balances holder without an invoice: " <> rid
|
|
pure Balance {blRegistration = RegistrationId rid, blAmount, blCredited, blRefunded, blHold, blRefundedAt}
|
|
|
|
-- | The order holding the balance, when its hold is overdue, for the next spend to close.
|
|
overdueHolder :: DB.Connection -> RegistrationId -> UTCTime -> IO (Maybe NameRegistration)
|
|
overdueHolder db rid now =
|
|
getBalance db rid now >>= \case
|
|
Just Balance {blHold = Just BalanceHold {bhHolder, bhOverdue = True}} -> getRegistration db bhHolder
|
|
_ -> pure Nothing
|
|
|
|
-- | The order a support reference names, by its registration id or its name invoice's id, with its balance.
|
|
balanceByReference :: DB.Connection -> Text -> UTCTime -> IO (Maybe (RegistrationId, Maybe Balance))
|
|
balanceByReference db ref now =
|
|
DB.query
|
|
db
|
|
"SELECT registration_id FROM sx_badge_service_name_registrations WHERE registration_id = ? UNION ALL SELECT registration_id FROM sx_badge_service_name_invoices WHERE invoice_id = ?"
|
|
(ref, ref)
|
|
>>= \case
|
|
Only rid : _ -> (\balance -> Just (RegistrationId rid, balance)) <$> getBalance db (RegistrationId rid) now
|
|
[] -> pure Nothing
|
|
|
|
-- | From the order back along balance_source to the first, following it only from orders past unpaid.
|
|
balanceChain :: DB.Connection -> RegistrationId -> IO [BalanceLink]
|
|
balanceChain db rid =
|
|
getRegistration db rid >>= \case
|
|
Nothing -> pure []
|
|
Just NameRegistration {nrPhase, nrPayment, nrBalanceSource, nrBalanceUsed} -> do
|
|
let pastUnpaid = nrPhase /= PUnpaid
|
|
lkPaid <- case nrPayment of
|
|
RPInvoice _ (InvoiceId invId) | pastUnpaid -> maybe 0 fromOnly . listToMaybe <$> (DB.query db "SELECT amount FROM sx_badge_service_invoices WHERE invoice_id = ?" (Only invId) :: IO [Only Int])
|
|
_ -> pure 0
|
|
lkLateCredits <- case nrPayment of
|
|
RPInvoice _ invId -> lateCredits db invId
|
|
_ -> pure []
|
|
let link = BalanceLink {lkRegistration = rid, lkPayment = nrPayment, lkPaid, lkFromBalance = if pastUnpaid then nrBalanceUsed else 0, lkLateCredits}
|
|
(link :) <$> maybe (pure []) (balanceChain db) (if pastUnpaid then nrBalanceSource else Nothing)
|
|
|
|
lateCredits :: DB.Connection -> InvoiceId -> IO [LateCredit]
|
|
lateCredits db (InvoiceId invId) =
|
|
map toLateCredit
|
|
<$> DB.query
|
|
db
|
|
"SELECT invoice_id, credit_no, registration_id, units, invoice_rate, current_rate, rate_used, cents FROM sx_badge_service_name_late_credits WHERE invoice_id = ? ORDER BY credit_no"
|
|
(Only invId)
|
|
where
|
|
toLateCredit (inv, lcCreditNo, reg, lcUnits, lcInvoiceRate, lcCurrentRate, lcRateUsed, lcCents) =
|
|
LateCredit {lcInvoice = InvoiceId inv, lcCreditNo, lcRegistration = RegistrationId reg, lcUnits, lcInvoiceRate, lcCurrentRate, lcRateUsed, lcCents}
|
|
|
|
-- | Adds to the order's balance, making it on its first credit.
|
|
creditBalance :: DB.Connection -> RegistrationId -> Int -> UTCTime -> IO ()
|
|
creditBalance db (RegistrationId rid) cents now' =
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
INSERT INTO sx_badge_service_name_balances (registration_id, amount, credited, refunded, created_at, updated_at)
|
|
VALUES (?,?,?,0,?,?)
|
|
ON CONFLICT (registration_id) DO UPDATE SET
|
|
amount = sx_badge_service_name_balances.amount + excluded.amount,
|
|
credited = sx_badge_service_name_balances.credited + excluded.credited,
|
|
updated_at = excluded.updated_at
|
|
|]
|
|
(rid, cents, cents, now, now)
|
|
where
|
|
now = truncateToSecond now'
|
|
|
|
-- | The price, above 0, spent at once, where the amount is the one seen and nothing holds it.
|
|
spendBalance :: DB.Connection -> RegistrationId -> Int -> Int -> UTCTime -> IO Bool
|
|
spendBalance db (RegistrationId rid) price seen now =
|
|
(> 0)
|
|
<$> executeChanging
|
|
db
|
|
"UPDATE sx_badge_service_name_balances SET amount = amount - ?, updated_at = ? WHERE registration_id = ? AND amount = ? AND amount >= ? AND ? > 0 AND reserved_by IS NULL"
|
|
(price, now, rid, seen, price, price)
|
|
|
|
-- | Held by a difference order, where the amount is the one seen, above 0, and nothing holds it.
|
|
holdBalance :: DB.Connection -> RegistrationId -> Int -> RegistrationId -> UTCTime -> IO Bool
|
|
holdBalance db (RegistrationId rid) seen (RegistrationId holder) now =
|
|
(> 0)
|
|
<$> executeChanging
|
|
db
|
|
"UPDATE sx_badge_service_name_balances SET reserved_by = ?, updated_at = ? WHERE registration_id = ? AND amount = ? AND ? > 0 AND reserved_by IS NULL"
|
|
(holder, now, rid, seen, seen)
|
|
|
|
-- | The holder's hold spent and cleared, where this holder holds the balance and it still has the cents, above 0.
|
|
spendHeldBalance :: DB.Connection -> RegistrationId -> Int -> UTCTime -> IO Bool
|
|
spendHeldBalance db (RegistrationId holder) used now =
|
|
(> 0)
|
|
<$> executeChanging
|
|
db
|
|
"UPDATE sx_badge_service_name_balances SET amount = amount - ?, reserved_by = NULL, updated_at = ? WHERE reserved_by = ? AND amount >= ? AND ? > 0"
|
|
(used, truncateToSecond now, holder, used, used)
|
|
|
|
releaseHeldBalance :: DB.Connection -> RegistrationId -> UTCTime -> IO Bool
|
|
releaseHeldBalance db (RegistrationId holder) now =
|
|
(> 0)
|
|
<$> executeChanging
|
|
db
|
|
"UPDATE sx_badge_service_name_balances SET reserved_by = NULL, updated_at = ? WHERE reserved_by = ?"
|
|
(truncateToSecond now, holder)
|
|
|
|
-- | All of the balance, where it is the cents given, above 0, and nothing holds it; refunded counts every refund.
|
|
refundBalance :: DB.Connection -> RegistrationId -> Int -> UTCTime -> IO Bool
|
|
refundBalance db (RegistrationId rid) cents now' =
|
|
(> 0)
|
|
<$> executeChanging
|
|
db
|
|
[sql|
|
|
UPDATE sx_badge_service_name_balances SET amount = 0, refunded = refunded + ?, refunded_at = ?, updated_at = ?
|
|
WHERE registration_id = ? AND amount = ? AND amount > 0 AND reserved_by IS NULL
|
|
|]
|
|
(cents, now, now, rid, cents)
|
|
where
|
|
now = truncateToSecond now'
|
|
|
|
-- | A name invoice with its order, its invoice row and its payment row.
|
|
lateFrom :: Query
|
|
lateFrom =
|
|
" FROM sx_badge_service_name_invoices ni"
|
|
<> " JOIN sx_badge_service_name_registrations r ON r.registration_id = ni.registration_id"
|
|
<> " JOIN sx_badge_service_invoices i ON i.invoice_id = ni.invoice_id"
|
|
<> " JOIN sx_badge_service_payments p ON p.invoice_id = ni.invoice_id"
|
|
|
|
-- | Its order closed unpaid, whatever the invoice's status, since settlement queues only a live order; takes the unpaid phase.
|
|
closedUnpaid :: Query
|
|
closedUnpaid = " r.phase = ? AND r.live = 0"
|
|
|
|
cardInvoice :: Query
|
|
cardInvoice = " i.payment_crypto_currency IS NULL"
|
|
|
|
cryptoInvoice :: Query
|
|
cryptoInvoice = " i.payment_crypto_currency IS NOT NULL"
|
|
|
|
-- | No credit rows sum to 0, not NULL.
|
|
creditedTotal :: Query -> Query
|
|
creditedTotal column = " (SELECT " <> integerSum ("COALESCE(SUM(c." <> column <> "), 0)") <> " FROM sx_badge_service_name_late_credits c WHERE c.invoice_id = ni.invoice_id)"
|
|
|
|
integerSum :: Query -> Query
|
|
#if defined(dbPostgres)
|
|
-- Postgres sums a BIGINT as NUMERIC, which does not decode into an integer
|
|
integerSum s = "CAST(" <> s <> " AS BIGINT)"
|
|
#else
|
|
integerSum = id
|
|
#endif
|
|
|
|
-- | The first alert's old marks are NULL, which = never matches.
|
|
notDistinctFrom :: Query
|
|
#if defined(dbPostgres)
|
|
notDistinctFrom = " IS NOT DISTINCT FROM "
|
|
#else
|
|
notDistinctFrom = " IS "
|
|
#endif
|
|
|
|
-- | Confirmed money not all credited: cents for card, units for crypto.
|
|
uncreditedMoney :: Query
|
|
uncreditedMoney =
|
|
" ((" <> cardInvoice <> " AND p.confirmed_amount >" <> creditedTotal "cents" <> ")"
|
|
<> " OR (" <> cryptoInvoice <> " AND p.confirmed_units >" <> creditedTotal "units" <> "))"
|
|
|
|
-- | Why a closed order's money is not all credited, by the first case that holds, else NULL.
|
|
-- Confirmed money waiting comes first, so its alert never asks for a refund by hand.
|
|
pendingCase :: Query
|
|
pendingCase =
|
|
" CASE WHEN" <> uncreditedMoney <> " THEN " <> literal LPUncredited
|
|
<> " WHEN COALESCE(p.amount, 0) = 0 AND (p.crypto_paid IS NOT NULL OR p.paid_in_full = 1) THEN " <> literal LPConfirming
|
|
<> " WHEN" <> cardInvoice <> " AND COALESCE(p.amount, 0) > p.confirmed_amount THEN " <> literal LPUnconfirmed
|
|
<> " WHEN p.received_units > COALESCE(p.confirmed_units, 0) THEN " <> literal LPUnconfirmed
|
|
<> " WHEN" <> cryptoInvoice <> " AND p.received_units IS NULL AND COALESCE(p.amount, 0) > 0 THEN " <> literal LPUncounted
|
|
<> " END"
|
|
where
|
|
literal p = fromString $ "'" <> T.unpack (textEncode p) <> "'"
|
|
|
|
-- | What an alert names as received: units for crypto, and cents for card or for crypto that could not be counted in units.
|
|
receivedFigure :: Query
|
|
receivedFigure = " CASE WHEN" <> cardInvoice <> " THEN p.amount ELSE COALESCE(p.received_units, p.amount) END"
|
|
|
|
lateColumns :: Query
|
|
lateColumns =
|
|
"SELECT ni.invoice_id, ni.registration_id, r.phase, r.live, i.payment_crypto_currency, ni.late_support, COALESCE(p.amount, 0), p.confirmed_amount,"
|
|
<> " p.received_units, p.confirmed_units, p.invoice_rate,"
|
|
<> creditedTotal "units"
|
|
<> ","
|
|
<> creditedTotal "cents"
|
|
<> ", (SELECT COUNT(*) FROM sx_badge_service_name_late_credits c WHERE c.invoice_id = ni.invoice_id), p.created_at"
|
|
|
|
type LateRow = (Text, Text, NamePhase, BoolInt, Maybe Text, Maybe LateSupport, Int, Int, Maybe Int64, Maybe Int64) :. (Maybe Text, Int64, Int, Int, UTCTime)
|
|
|
|
toLateState :: LateRow -> Either Text LateCreditState
|
|
toLateState
|
|
( (inv, reg, lsPhase, BI lsLive, coin_, lsSupport, lsReceivedCents, lsConfirmedCents, lsReceivedUnits, lsConfirmedUnits)
|
|
:. (lsInvoiceRate, lsCreditedUnits, lsCreditedCents, lsCredits, lsPaymentCreatedAt)
|
|
) = do
|
|
lsCoin <- traverse (\c -> note ("invoices.payment_crypto_currency: " <> c) (textToCryptoCurrency c)) coin_
|
|
pure LateCreditState {lsInvoice = InvoiceId inv, lsRegistration = RegistrationId reg, lsPhase, lsLive, lsCoin, lsSupport, lsReceivedCents, lsConfirmedCents, lsReceivedUnits, lsConfirmedUnits, lsInvoiceRate, lsCreditedUnits, lsCreditedCents, lsCredits, lsPaymentCreatedAt}
|
|
|
|
-- | Nothing without a payment row.
|
|
lateCreditState :: DB.Connection -> InvoiceId -> IO (Maybe LateCreditState)
|
|
lateCreditState db (InvoiceId invId) = firstDecoded toLateState $ DB.query db (lateColumns <> lateFrom <> " WHERE ni.invoice_id = ?") (Only invId)
|
|
|
|
-- | Run after the invoice's lock. The order's balance is credited only when the credit's row is new, so a taken credit number changes neither.
|
|
creditLatePayment :: DB.Connection -> LateCredit -> UTCTime -> IO Bool
|
|
creditLatePayment db LateCredit {lcInvoice = InvoiceId invId, lcCreditNo, lcRegistration = regId@(RegistrationId rid), lcUnits, lcInvoiceRate, lcCurrentRate, lcRateUsed, lcCents} now' = do
|
|
inserted <-
|
|
executeChanging
|
|
db
|
|
[sql|
|
|
INSERT INTO sx_badge_service_name_late_credits (invoice_id, credit_no, registration_id, units, invoice_rate, current_rate, rate_used, cents, created_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT DO NOTHING
|
|
|]
|
|
(invId, lcCreditNo, rid, lcUnits, lcInvoiceRate, lcCurrentRate, lcRateUsed, lcCents, now)
|
|
when (inserted > 0) $ creditBalance db regId lcCents now
|
|
pure $ inserted > 0
|
|
where
|
|
now = truncateToSecond now'
|
|
|
|
-- | Why the closed order's money on the invoice is not all credited; the one rule of the pending log and the alert.
|
|
latePending :: DB.Connection -> InvoiceId -> IO (Maybe LatePending)
|
|
latePending db (InvoiceId invId) =
|
|
maybe Nothing fromOnly . listToMaybe
|
|
<$> DB.query db ("SELECT" <> pendingCase <> lateFrom <> " WHERE ni.invoice_id = ? AND" <> closedUnpaid) (invId, PUnpaid)
|
|
|
|
-- | Nothing once all of the closed order's money is credited, whatever support reason was given before.
|
|
lateWait :: DB.Connection -> InvoiceId -> IO (Maybe LateWait)
|
|
lateWait db (InvoiceId invId) =
|
|
maybe Nothing waitOf . listToMaybe
|
|
<$> DB.query db ("SELECT" <> pendingCase <> ", ni.late_support" <> lateFrom <> " WHERE ni.invoice_id = ? AND" <> closedUnpaid) (invId, PUnpaid)
|
|
where
|
|
waitOf :: (Maybe LatePending, Maybe LateSupport) -> Maybe LateWait
|
|
waitOf = \case
|
|
(Nothing, _) -> Nothing
|
|
(Just _, Just _) -> Just LWSupport
|
|
(Just LPUncounted, Nothing) -> Just LWSupport
|
|
(Just LPUncredited, Nothing) -> Just LWCrediting
|
|
(Just LPConfirming, Nothing) -> Just LWConfirming
|
|
(Just LPUnconfirmed, Nothing) -> Just LWConfirming
|
|
|
|
-- | Closed orders' invoices with confirmed money not all credited, for the poller's credit pass; none a rate below the floor sent to support.
|
|
uncreditedLateInvoices :: DB.Connection -> IO [InvoiceId]
|
|
uncreditedLateInvoices db =
|
|
map (InvoiceId . fromOnly)
|
|
<$> DB.query
|
|
db
|
|
("SELECT ni.invoice_id" <> lateFrom <> " WHERE" <> closedUnpaid <> " AND" <> uncreditedMoney <> " AND (ni.late_support IS NULL OR ni.late_support <> ?) ORDER BY p.created_at, ni.invoice_id")
|
|
(PUnpaid, LSRateFloor)
|
|
|
|
-- | Pending money whose payment row was created before the cutoff, never alerted, or alerted for less money than now received.
|
|
lateAlertDue :: DB.Connection -> UTCTime -> IO [LateAlert]
|
|
lateAlertDue db cutoff =
|
|
DB.query
|
|
db
|
|
( lateColumns
|
|
<> ","
|
|
<> pendingCase
|
|
<> ","
|
|
<> receivedFigure
|
|
<> ", ni.late_alerted_at, ni.late_alerted_units"
|
|
<> lateFrom
|
|
<> " WHERE"
|
|
<> closedUnpaid
|
|
<> " AND p.created_at < ? AND"
|
|
<> pendingCase
|
|
<> " IS NOT NULL AND (ni.late_alerted_at IS NULL OR"
|
|
<> receivedFigure
|
|
<> " > COALESCE(ni.late_alerted_units, -1)) ORDER BY p.created_at, ni.invoice_id"
|
|
)
|
|
(PUnpaid, truncateToSecond cutoff)
|
|
>>= decodeAll toAlert
|
|
where
|
|
toAlert :: LateRow :. (Maybe LatePending, Maybe Int64, Maybe UTCTime, Maybe Int64) -> Either Text LateAlert
|
|
toAlert (row :. (pending_, laReceived, laAlertedAt, laAlertedReceived)) = case pending_ of
|
|
Just laPending -> (\laState -> LateAlert {laState, laPending, laReceived, laAlertedAt, laAlertedReceived}) <$> toLateState row
|
|
Nothing -> Left "name invoice alert without pending money"
|
|
|
|
-- | Marks the alert sent, with the support reason it gives unless one is set, unless another instance marked it since it was read;
|
|
-- only the instance that marks it logs it.
|
|
claimLateAlert :: DB.Connection -> LateAlert -> Maybe LateSupport -> UTCTime -> IO Bool
|
|
claimLateAlert db LateAlert {laState = LateCreditState {lsInvoice = InvoiceId invId}, laReceived, laAlertedAt, laAlertedReceived} support now =
|
|
(> 0)
|
|
<$> executeChanging
|
|
db
|
|
( "UPDATE sx_badge_service_name_invoices SET late_alerted_at = ?, late_alerted_units = ?, late_support = COALESCE(late_support, ?)"
|
|
<> " WHERE invoice_id = ? AND late_alerted_at"
|
|
<> notDistinctFrom
|
|
<> "? AND late_alerted_units"
|
|
<> notDistinctFrom
|
|
<> "?"
|
|
)
|
|
(truncateToSecond now, laReceived, support, invId, laAlertedAt, laAlertedReceived)
|
|
|
|
-- | The first reason given stays.
|
|
setLateSupport :: DB.Connection -> InvoiceId -> LateSupport -> IO ()
|
|
setLateSupport db (InvoiceId invId) support =
|
|
DB.execute db "UPDATE sx_badge_service_name_invoices SET late_support = COALESCE(late_support, ?) WHERE invoice_id = ?" (support, invId)
|
|
|
|
insertPrepaidCode :: DB.Connection -> NewPrepaidCode -> UTCTime -> IO (Either CodeHashTaken Int64)
|
|
insertPrepaidCode db NewPrepaidCode {npcCodeHash, npcMinLength, npcYears, npcPaymentStatus, npcExpiresAt} now = do
|
|
inserted <-
|
|
executeChanging
|
|
db
|
|
[sql|
|
|
INSERT INTO sx_badge_service_name_prepaid_codes (code_hash, min_length, years, code_payment_status, expires_at, created_at)
|
|
VALUES (?,?,?,?,?,?)
|
|
ON CONFLICT DO NOTHING
|
|
|]
|
|
(Binary npcCodeHash, npcMinLength, npcYears, npcPaymentStatus, truncateToSecond <$> npcExpiresAt, truncateToSecond now)
|
|
if inserted > 0 then Right <$> insertedRowId db else pure $ Left CodeHashTaken
|
|
|
|
prepaidCodeColumns :: Query
|
|
prepaidCodeColumns =
|
|
[sql|
|
|
SELECT name_prepaid_code_id, code_hash, min_length, years, code_payment_status, reserved_by, reserved_at, spent_at, revoked_at, expires_at, created_at
|
|
FROM sx_badge_service_name_prepaid_codes
|
|
|]
|
|
|
|
type PrepaidCodeRow = (Int64, Binary ByteString, Int, Int, BadgeCodePaymentStatus, Maybe Text, Maybe UTCTime, Maybe UTCTime, Maybe UTCTime, Maybe UTCTime) :. Only UTCTime
|
|
|
|
toPrepaidCode :: PrepaidCodeRow -> PrepaidCode
|
|
toPrepaidCode ((pcId, Binary pcCodeHash, pcMinLength, pcYears, pcPaymentStatus, reservedBy, pcReservedAt, pcSpentAt, pcRevokedAt, pcExpiresAt) :. Only pcCreatedAt) =
|
|
PrepaidCode {pcId, pcCodeHash, pcMinLength, pcYears, pcPaymentStatus, pcReservedBy = RegistrationId <$> reservedBy, pcReservedAt, pcSpentAt, pcRevokedAt, pcExpiresAt, pcCreatedAt}
|
|
|
|
getPrepaidCode :: DB.Connection -> ByteString -> IO (Maybe PrepaidCode)
|
|
getPrepaidCode db codeHash =
|
|
maybeFirstRow toPrepaidCode $ DB.query db (prepaidCodeColumns <> " WHERE code_hash = ?") (Only $ Binary codeHash)
|
|
|
|
getPrepaidCodeById :: DB.Connection -> Int64 -> IO (Maybe PrepaidCode)
|
|
getPrepaidCodeById db codeId =
|
|
maybeFirstRow toPrepaidCode $ DB.query db (prepaidCodeColumns <> " WHERE name_prepaid_code_id = ?") (Only codeId)
|
|
|
|
-- | Only an unused code: not reserved, spent or revoked, paid or free, and not expired.
|
|
reservePrepaidCode :: DB.Connection -> Int64 -> RegistrationId -> UTCTime -> IO Bool
|
|
reservePrepaidCode db codeId (RegistrationId rid) now' =
|
|
(> 0)
|
|
<$> executeChanging
|
|
db
|
|
[sql|
|
|
UPDATE sx_badge_service_name_prepaid_codes SET reserved_by = ?, reserved_at = ?
|
|
WHERE name_prepaid_code_id = ? AND reserved_by IS NULL AND spent_at IS NULL AND revoked_at IS NULL
|
|
AND code_payment_status <> ? AND (expires_at IS NULL OR expires_at > ?)
|
|
|]
|
|
(rid, now, codeId, CPSUnpaid, now)
|
|
where
|
|
now = truncateToSecond now'
|
|
|
|
releasePrepaidCode :: DB.Connection -> Int64 -> RegistrationId -> IO Bool
|
|
releasePrepaidCode db codeId (RegistrationId rid) =
|
|
(> 0)
|
|
<$> executeChanging
|
|
db
|
|
[sql|
|
|
UPDATE sx_badge_service_name_prepaid_codes SET reserved_by = NULL, reserved_at = NULL
|
|
WHERE name_prepaid_code_id = ? AND reserved_by = ? AND spent_at IS NULL
|
|
|]
|
|
(codeId, rid)
|
|
|
|
-- | A spent code keeps reserved_by, naming the registration it paid for.
|
|
spendPrepaidCode :: DB.Connection -> Int64 -> RegistrationId -> UTCTime -> IO Bool
|
|
spendPrepaidCode db codeId (RegistrationId rid) now =
|
|
(> 0)
|
|
<$> executeChanging
|
|
db
|
|
"UPDATE sx_badge_service_name_prepaid_codes SET spent_at = ? WHERE name_prepaid_code_id = ? AND reserved_by = ? AND spent_at IS NULL"
|
|
(truncateToSecond now, codeId, rid)
|
|
|
|
-- | For a registration whose register went through: spends the code it holds, or one released and still usable; False otherwise.
|
|
spendLandedPrepaidCode :: DB.Connection -> Int64 -> RegistrationId -> UTCTime -> IO Bool
|
|
spendLandedPrepaidCode db codeId (RegistrationId rid) now' =
|
|
(> 0)
|
|
<$> executeChanging
|
|
db
|
|
[sql|
|
|
UPDATE sx_badge_service_name_prepaid_codes SET reserved_by = ?, reserved_at = COALESCE(reserved_at, ?), spent_at = ?
|
|
WHERE name_prepaid_code_id = ? AND spent_at IS NULL
|
|
AND (reserved_by = ? OR (reserved_by IS NULL AND revoked_at IS NULL AND code_payment_status <> ? AND (expires_at IS NULL OR expires_at > ?)))
|
|
|]
|
|
(rid, now, now, codeId, rid, CPSUnpaid, now)
|
|
where
|
|
now = truncateToSecond now'
|
|
|
|
-- | A reserved code can be revoked, and its order keeps it; a spent one cannot.
|
|
revokePrepaidCode :: DB.Connection -> ByteString -> UTCTime -> IO RevokeResult
|
|
revokePrepaidCode db codeHash now = do
|
|
revoked <-
|
|
executeChanging
|
|
db
|
|
"UPDATE sx_badge_service_name_prepaid_codes SET revoked_at = ? WHERE code_hash = ? AND revoked_at IS NULL AND spent_at IS NULL"
|
|
(truncateToSecond now, Binary codeHash)
|
|
maybe NoSuchCode (result revoked) <$> getPrepaidCode db codeHash
|
|
where
|
|
result revoked PrepaidCode {pcId, pcRevokedAt}
|
|
| revoked > 0 = Revoked pcId
|
|
| isJust pcRevokedAt = AlreadyRevoked pcId
|
|
| otherwise = AlreadyRedeemed
|
|
|
|
txKind :: TxFor -> (Text, Maybe Text, Maybe Int64)
|
|
txKind = \case
|
|
TxCommit (RegistrationId rid) -> ("commit", Just rid, Nothing)
|
|
TxRegister (RegistrationId rid) -> ("register", Just rid, Nothing)
|
|
TxEdit editId -> ("edit", Nothing, Just editId)
|
|
|
|
-- | Saved once signed and before it is sent, so a restart can rebroadcast it.
|
|
insertTransaction :: DB.Connection -> TxFor -> NewTransaction -> UTCTime -> IO Int64
|
|
insertTransaction db for NewTransaction {ntxRaw, ntxHash, ntxNonce} now = do
|
|
let (kind, rid, editId) = txKind for
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
INSERT INTO sx_badge_service_name_transactions
|
|
(registration_id, name_record_edit_id, kind, raw, tx_hash, registrar_nonce, sent_at, status)
|
|
VALUES (?,?,?,?,?,?,?,?)
|
|
|]
|
|
(rid, editId, kind, Binary ntxRaw, txHashField ntxHash, int64 ntxNonce, truncateToSecond now, TSPending)
|
|
insertedRowId db
|
|
|
|
-- | Moves a transaction on from the status the caller read, so an outcome is never overwritten unseen; False when it is not in that status.
|
|
recordTransactionOutcome :: DB.Connection -> TxHash -> TxStatus -> TxStatus -> Maybe Word64 -> IO Bool
|
|
recordTransactionOutcome db hash from status block =
|
|
(> 0)
|
|
<$> executeChanging
|
|
db
|
|
"UPDATE sx_badge_service_name_transactions SET status = ?, mined_block = ? WHERE tx_hash = ? AND status = ?"
|
|
(status, int64 <$> block, txHashField hash, from)
|
|
|
|
transactionColumns :: Query
|
|
transactionColumns =
|
|
[sql|
|
|
SELECT name_transaction_id, kind, registration_id, name_record_edit_id, raw, tx_hash, registrar_nonce, sent_at, mined_block, status
|
|
FROM sx_badge_service_name_transactions
|
|
|]
|
|
|
|
type TransactionRow = (Int64, Text, Maybe Text, Maybe Int64, Binary ByteString, Binary ByteString, Int64, UTCTime, Maybe Int64, TxStatus)
|
|
|
|
toTransaction :: TransactionRow -> Either Text NameTransaction
|
|
toTransaction (ntId, kind, rid_, editId_, Binary ntRaw, hash, nonce, ntSentAt, block, ntStatus) = do
|
|
ntFor <- case (kind, rid_, editId_) of
|
|
("commit", Just rid, Nothing) -> Right $ TxCommit $ RegistrationId rid
|
|
("register", Just rid, Nothing) -> Right $ TxRegister $ RegistrationId rid
|
|
("edit", Nothing, Just editId) -> Right $ TxEdit editId
|
|
_ -> Left $ "name_transactions.kind: " <> kind
|
|
pure NameTransaction {ntId, ntFor, ntRaw, ntHash = txHash hash, ntNonce = word64 nonce, ntSentAt, ntMinedBlock = word64 <$> block, ntStatus}
|
|
|
|
-- | Those without a receipt, in nonce order, for the sender to resume.
|
|
pendingTransactions :: DB.Connection -> IO [NameTransaction]
|
|
pendingTransactions db =
|
|
DB.query db (transactionColumns <> " WHERE status = ? ORDER BY registrar_nonce, name_transaction_id") (Only TSPending)
|
|
>>= decodeAll toTransaction
|
|
|
|
registrationTransactions :: DB.Connection -> RegistrationId -> IO [NameTransaction]
|
|
registrationTransactions db (RegistrationId rid) =
|
|
DB.query db (transactionColumns <> " WHERE registration_id = ? ORDER BY name_transaction_id") (Only rid)
|
|
>>= decodeAll toTransaction
|
|
|
|
editTransactions :: DB.Connection -> Int64 -> IO [NameTransaction]
|
|
editTransactions db editId =
|
|
DB.query db (transactionColumns <> " WHERE name_record_edit_id = ? ORDER BY name_transaction_id") (Only editId)
|
|
>>= decodeAll toTransaction
|
|
|
|
pendingEdit :: Text
|
|
pendingEdit = "pending"
|
|
|
|
minedEdit :: Text
|
|
minedEdit = "mined"
|
|
|
|
revertedEdit :: Text
|
|
revertedEdit = "reverted"
|
|
|
|
-- | Nothing when a pending or mined edit already holds the owner's nonce.
|
|
insertRecordEdit :: DB.Connection -> NewRecordEdit -> UTCTime -> IO (Maybe Int64)
|
|
insertRecordEdit db NewRecordEdit {nreLabelHash = LabelHash lh, nreOwner, nreNonce, nreChange, nreDeadline, nreSignature, nreBodyHash} now = do
|
|
let (key, value) = case nreChange of
|
|
RCSetText k v -> (Just k, Just v)
|
|
RCClear -> (Nothing, Nothing)
|
|
inserted <-
|
|
executeChanging
|
|
db
|
|
[sql|
|
|
INSERT INTO sx_badge_service_name_record_edits
|
|
(label_hash, owner, nonce, record_key, record_value, deadline, signature, body_hash, state, created_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT DO NOTHING
|
|
|]
|
|
( (Binary lh, Binary $ unAddress nreOwner, int64 nreNonce, key, value, int64 nreDeadline, Binary $ serializeSignature nreSignature, Binary nreBodyHash)
|
|
:. (pendingEdit, truncateToSecond now)
|
|
)
|
|
if inserted > 0 then Just <$> insertedRowId db else pure Nothing
|
|
|
|
recordEditColumns :: Query
|
|
recordEditColumns =
|
|
[sql|
|
|
SELECT name_record_edit_id, label_hash, owner, nonce, record_key, record_value, deadline, signature, body_hash, state, revert, mined_tx, created_at
|
|
FROM sx_badge_service_name_record_edits
|
|
|]
|
|
|
|
type RecordEditRow =
|
|
(Int64, Binary ByteString, Binary ByteString, Int64, Maybe Text, Maybe Text, Int64, Binary ByteString, Binary ByteString, Text)
|
|
:. (Maybe Text, Maybe (Binary ByteString), UTCTime)
|
|
|
|
toRecordEdit :: RecordEditRow -> Either Text RecordEdit
|
|
toRecordEdit ((reId, Binary lh, Binary owner, nonce, key_, value_, deadline, Binary sig, Binary reBodyHash, state) :. (revert, minedTx, reCreatedAt)) = do
|
|
reOwner <- decodeAddress owner
|
|
reSignature <- decodeSignature sig
|
|
reChange <- case (key_, value_) of
|
|
(Just key, Just value) -> Right $ RCSetText key value
|
|
(Nothing, Nothing) -> Right RCClear
|
|
_ -> Left $ "name_record_edits record: " <> tshow reId
|
|
reState <- decodeState
|
|
pure RecordEdit {reId, reLabelHash = LabelHash lh, reOwner, reNonce = word64 nonce, reChange, reDeadline = word64 deadline, reSignature, reBodyHash, reState, reCreatedAt}
|
|
where
|
|
decodeState
|
|
| state == pendingEdit = Right ESPending
|
|
| state == minedEdit = Right $ ESMined $ txHash <$> minedTx
|
|
| state == revertedEdit = Right $ ESReverted revert
|
|
| otherwise = Left $ "name_record_edits.state: " <> state
|
|
|
|
getRecordEdit :: DB.Connection -> Int64 -> IO (Maybe RecordEdit)
|
|
getRecordEdit db editId =
|
|
firstDecoded toRecordEdit $ DB.query db (recordEditColumns <> " WHERE name_record_edit_id = ?") (Only editId)
|
|
|
|
-- | The newest edit sent with this signed body.
|
|
getRecordEditByBody :: DB.Connection -> ByteString -> IO (Maybe RecordEdit)
|
|
getRecordEditByBody db bodyHash =
|
|
firstDecoded toRecordEdit $
|
|
DB.query db (recordEditColumns <> " WHERE body_hash = ? ORDER BY name_record_edit_id DESC LIMIT 1") (Only $ Binary bodyHash)
|
|
|
|
-- | The pending or mined edit holding the owner's nonce.
|
|
getRecordEditByNonce :: DB.Connection -> Address -> Word64 -> IO (Maybe RecordEdit)
|
|
getRecordEditByNonce db owner nonce =
|
|
firstDecoded toRecordEdit $
|
|
DB.query db (recordEditColumns <> " WHERE owner = ? AND nonce = ? AND state IN (?,?)") (Binary $ unAddress owner, int64 nonce, pendingEdit, minedEdit)
|
|
|
|
-- | Settles a pending edit; False when it was settled already.
|
|
settleRecordEdit :: DB.Connection -> Int64 -> EditState -> IO Bool
|
|
settleRecordEdit db editId state =
|
|
(> 0)
|
|
<$> executeChanging
|
|
db
|
|
"UPDATE sx_badge_service_name_record_edits SET state = ?, revert = ?, mined_tx = ? WHERE name_record_edit_id = ? AND state = ?"
|
|
(stateText, revert, txHashField <$> minedTx, editId, pendingEdit)
|
|
where
|
|
(stateText, revert, minedTx) = case state of
|
|
ESPending -> (pendingEdit, Nothing, Nothing)
|
|
ESMined tx -> (minedEdit, Nothing, tx)
|
|
ESReverted name -> (revertedEdit, name, Nothing)
|
|
|
|
pendingEditIds :: DB.Connection -> IO [Int64]
|
|
pendingEditIds db = map fromOnly <$> DB.query db "SELECT name_record_edit_id FROM sx_badge_service_name_record_edits WHERE state = ? ORDER BY name_record_edit_id" (Only pendingEdit)
|
|
|
|
deleteUnsentEdit :: DB.Connection -> Int64 -> IO ()
|
|
deleteUnsentEdit db editId =
|
|
DB.execute
|
|
db
|
|
"DELETE FROM sx_badge_service_name_record_edits WHERE name_record_edit_id = ? AND state = ? AND NOT EXISTS (SELECT 1 FROM sx_badge_service_name_transactions WHERE name_record_edit_id = ?)"
|
|
(editId, pendingEdit, editId)
|
|
|
|
-- | When each edit relayed for the name since the given time was sent, oldest first.
|
|
relayedEditTimes :: DB.Connection -> LabelHash -> UTCTime -> IO [UTCTime]
|
|
relayedEditTimes db (LabelHash lh) since =
|
|
map fromOnly
|
|
<$> DB.query
|
|
db
|
|
"SELECT created_at FROM sx_badge_service_name_record_edits WHERE label_hash = ? AND created_at > ? ORDER BY created_at"
|
|
(Binary lh, truncateToSecond since)
|
|
|
|
int64 :: Word64 -> Int64
|
|
int64 = fromIntegral
|
|
|
|
txHash :: Binary ByteString -> TxHash
|
|
txHash (Binary h) = TxHash h
|
|
|
|
txHashField :: TxHash -> Binary ByteString
|
|
txHashField (TxHash h) = Binary h
|
|
|
|
word64 :: Int64 -> Word64
|
|
word64 = fromIntegral
|
|
|
|
-- | Address has no exported constructor; its parser takes lowercase hex without the EIP-55 check.
|
|
decodeAddress :: ByteString -> Either Text Address
|
|
decodeAddress bs = either (const $ Left "address column") Right $ strDecode $ convertToBase Base16 bs
|
|
|
|
decodeSignature :: ByteString -> Either Text RecoverableSignature
|
|
decodeSignature bs = either (Left . ("signature column: " <>) . tshow) Right $ parseSignature $ hex bs
|
|
|
|
note :: Text -> Maybe a -> Either Text a
|
|
note err = maybe (Left err) Right
|
|
|
|
decodeAll :: (row -> Either Text a) -> [row] -> IO [a]
|
|
decodeAll decode = either (E.throwIO . StoreDecodeError) pure . traverse decode
|
|
|
|
firstDecoded :: (row -> Either Text a) -> IO [row] -> IO (Maybe a)
|
|
firstDecoded decode rows = rows >>= fmap listToMaybe . decodeAll decode . take 1
|