mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 10:47:32 +00:00
796 lines
45 KiB
Haskell
796 lines
45 KiB
Haskell
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE TupleSections #-}
|
|
|
|
module Bots.StoreService.NamesSpendTests (namesSpendChatTests, namesSpendTests) where
|
|
|
|
import Bots.StoreService.BotTests (operatorCmd, withStoreService)
|
|
import Bots.StoreService.FakeBTCPay (FakeBTCPay (..), setInvoiceState, setRates, webhookEvent, webhookSigHeader, withFakeBTCPay)
|
|
import Bots.StoreService.FakeEthNode
|
|
import Bots.StoreService.NamesContractsTests (reservedNamesCall)
|
|
import Bots.StoreService.ConfigTests (termsVersion)
|
|
import Bots.StoreService.NamesOrdersTests (Ctx (..), Order (..), advance, aliceOrder, bakers, bumpedTerms, commitmentOf, communityTerms, configured, field, fieldsOf, invoiceRegistration, keyA, keyB, listName, malformedTerms, olderTerms, orderTime, ownerAText, placeOrder, placed, post, refusedWith, registrationById, registrationOf, registrationsOf, settleNow, termsChanged, withOrdersOn, yearsDuration)
|
|
import Bots.StoreService.NamesOwnerTests (findBody, postFind)
|
|
import Bots.StoreService.NamesStoreTests (at, order, ownerA, placePaid, reg, t0, write)
|
|
import Bots.StoreService.WebTests (StubCall (..), capturingStderr, StubState (..), jsonObject, rcv, sampleProviderInvoice, statusOf, stubCalls, webGet, webRequestBody, withServiceStores)
|
|
import ChatTests.DBUtils (TestParams)
|
|
import Control.Concurrent.Async (Async, async, cancel, concurrently, wait)
|
|
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
|
|
import Control.Concurrent.STM (atomically, check)
|
|
import Control.Monad (forM_, join, replicateM_, void)
|
|
import qualified Data.Aeson as J
|
|
import qualified Data.Aeson.KeyMap as KM
|
|
import qualified Data.Aeson.Types as JT
|
|
import qualified Data.ByteString.Lazy.Char8 as LB
|
|
import Data.Int (Int64)
|
|
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
|
|
import Data.List (sort)
|
|
import Data.Maybe (fromMaybe)
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Data.Text.Encoding (encodeUtf8)
|
|
import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime)
|
|
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
|
|
import Data.Word (Word64)
|
|
import Network.HTTP.Client (defaultManagerSettings, newManager)
|
|
import Network.HTTP.Types (hContentType)
|
|
import Network.Wai (Application)
|
|
import qualified Network.Wai.Handler.Warp as Warp
|
|
import Simplex.Chat.Controller (ChatController (..))
|
|
import Simplex.Chat.PaymentService.Types (CryptoCurrency (..), CurrencyAmount (..), InvoiceId (..), InvoiceStatus (..), PaymentProvider (..), ServicePaymentMethod (..))
|
|
import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction)
|
|
import qualified Simplex.Messaging.Crypto as C
|
|
import Simplex.Messaging.Crypto.Secp256k1 (signRecoverable)
|
|
import Simplex.Messaging.Eth.EIP712 (serializeSignature)
|
|
import Simplex.Messaging.SimplexName (LabelHash (..), labelHash)
|
|
import StoreService.Config (BTCPayConfig (..), ServiceConfig (..))
|
|
import StoreService.Names.Abi (hex, uint256)
|
|
import StoreService.Names.Balances (RefundRefusal (..), recordRefund)
|
|
import StoreService.Names.Orders (OrdersEnv (..))
|
|
import StoreService.Names.Phases (RegistrationId (..), phasesWaiting)
|
|
import StoreService.Names.Signatures (OrderFields (..), personalDigest, spendMessage)
|
|
import StoreService.Names.Store
|
|
import StoreService.Names.View (balanceJSON)
|
|
import StoreService.Orders (cancelOpenInvoice)
|
|
import StoreService.Poller (PollerEnv, creditPass, drainHints, forgetRates, newPollerEnv)
|
|
import StoreService.Providers (Funded (..), OrderDraft (..), PaymentSignal (..), Provider (..), ProviderError (..))
|
|
import StoreService.Providers.BTCPay (btcpayProvider)
|
|
import StoreService.Names.Turns (balanceTurnCount, labelTurnCount, withBalanceTurn)
|
|
import StoreService.Store.Invoices (InvoiceRow (..), getInvoice)
|
|
import qualified StoreService.Web.Http as Http (findLimit)
|
|
import StoreService.Web.Http (createLimit, holdMicros, readLimit)
|
|
import StoreService.Web.Names (Route (..), namesApp)
|
|
import StoreService.Web.Names.Orders (spendHandler)
|
|
import StoreService.Web.Names.Owner (findHandler)
|
|
import StoreService.Web.Names.Registration (registrationHandler)
|
|
import StoreService.Web.Server (WebEnv (..), webApp)
|
|
import System.Timeout (timeout)
|
|
import Test.Hspec
|
|
|
|
namesSpendTests :: Spec
|
|
namesSpendTests = describe "StoreService names: spend" $ do
|
|
describe "orders" $ do
|
|
it "queues an order whose price the balance covers, spending the price and keeping the rest, or all of it" testCoveredSpend
|
|
it "makes an invoice for the difference, the order holding all of the balance until the invoice's expiry" testDifferenceSpend
|
|
it "answers an exact resend with its order in any phase, by its own hash, with a new timestamp and after the balance changed" testSpendResend
|
|
it "keeps a spend and an /api/invoice order of the same fields apart, each meeting the other as order_exists" testInvoiceBodyApart
|
|
describe "refusals" $ do
|
|
it "answers balance_changed with the current balance for a stale amount, a held balance and an order with none" testBalanceChanged
|
|
it "refuses a method with a covered price, none with a difference, and prices and balances not positive" testMethodRule
|
|
it "refuses another signer and a stale timestamp, and answers an unknown order 404" testSignerAndId
|
|
it "answers name_taken, catalog_changed, name_check_unavailable, provider_unavailable and order_exists, spending nothing" testOrderChecks
|
|
it "runs its checks in the contract's order" testCheckOrder
|
|
it "refuses a card difference below the card minimum after the price check, accepting it by crypto and at the minimum" testCardMinimum
|
|
it "refuses sealed missing or over 11095 characters and a body over 16384 bytes, accepting the longest" testSpendSizes
|
|
it "counts spends against the create limit" testSpendLimit
|
|
describe "terms" $ do
|
|
it "answers terms_changed for an old version or the wrong variant, listed or not, and stores the community terms, covered and by difference" testSpendTermsChanged
|
|
it "answers an exact resend after the version changed, covered and by difference" testSpendResendAfterBump
|
|
it "checks the terms after the timestamp and the duration, before the balance and the chain" testSpendTermsCheckOrder
|
|
describe "holders" $ do
|
|
it "closes the balance's overdue unfunded holder in the spend's transaction, publishing it and cancelling its invoice" testOverdueHolderSpent
|
|
it "closes an overdue unfunded holder of another name for a difference spend, the new invoice holding the balance" testDifferenceClosesHolder
|
|
it "makes a new invoice of the same name over its overdue difference order, closing and cancelling that order once" testSpendAgain
|
|
it "goes through when the overdue holder was cancelled meanwhile" testHolderCancelledMeanwhile
|
|
it "answers balance_changed when the overdue holder was funded meanwhile, cancelling the new invoice" testHolderFundedMeanwhile
|
|
describe "races" $ do
|
|
it "lets one of two concurrent spends of one balance win, the other answered balance_changed" testConcurrentSpends
|
|
it "answers balance_changed and cancels the provider invoice when another store changed the balance after the check" testWriteRefusedAtBalance
|
|
it "lets one of a spend and a refund of the balance win" testSpendRacesRefund
|
|
it "takes the new name's turn and then the balance's" testSpendTurns
|
|
|
|
namesSpendChatTests :: SpecWith TestParams
|
|
namesSpendChatTests =
|
|
describe "StoreService names: spend end to end" $
|
|
it "spends a taken order's balance on a dearer name through BTCPay and the poller, credits a late payment, and refunds the rest" testSpendEndToEnd
|
|
|
|
-- fixtures
|
|
|
|
-- | Over a new database; the hook runs as each provider invoice is created.
|
|
withSpendHook :: (IORef (IO ()) -> Ctx -> IO a) -> IO a
|
|
withSpendHook action = do
|
|
hook <- newIORef (pure ())
|
|
withServiceStores $ \open -> withSpendOn open (\p -> p {pCreateInvoice = \m d -> join (readIORef hook) >> pCreateInvoice p m d}) (action hook)
|
|
|
|
withSpendOn :: IO DBStore -> (Provider -> Provider) -> (Ctx -> IO a) -> IO a
|
|
withSpendOn open via action = withOrdersOn open via (`serveSpend` action)
|
|
|
|
-- | The spend route beside find, the registration poll and the service's routes, over the context's settings.
|
|
serveSpend :: Ctx -> (Ctx -> IO a) -> IO a
|
|
serveSpend ctx@Ctx {cOrders, cWeb} action = do
|
|
let routes :: [Text] -> Maybe Route
|
|
routes = \case
|
|
["api", "registration", "find"] -> Just Route {rMethod = "POST", rLimit = Http.findLimit, rHandler = findHandler cOrders}
|
|
["api", "registration", rid, "spend"] -> Just Route {rMethod = "POST", rLimit = createLimit, rHandler = spendHandler cOrders (weConfig cWeb) (weProviders cWeb) (weWaiters cWeb) rid}
|
|
["api", "registration", rid] -> Just Route {rMethod = "GET", rLimit = readLimit, rHandler = registrationHandler cOrders holdMicros rid}
|
|
_ -> Nothing
|
|
app :: Application
|
|
app req respond = fromMaybe (webApp cWeb req respond) (namesApp (weHttp cWeb) routes req respond)
|
|
mgr <- newManager defaultManagerSettings
|
|
Warp.testWithApplication (pure app) $ \port -> action ctx {cClient = (mgr, "http://127.0.0.1:" <> show port)}
|
|
|
|
withSpend :: (Ctx -> IO a) -> IO a
|
|
withSpend action = withSpendHook (const action)
|
|
|
|
-- | Run by the next provider invoice made, once.
|
|
onCreate :: IORef (IO ()) -> IO () -> IO ()
|
|
onCreate hook act = writeIORef hook (writeIORef hook (pure ()) >> act)
|
|
|
|
-- | A taken paid order of owner A with a balance of the cents.
|
|
sourceOf :: Ctx -> Text -> Int -> IO RegistrationId
|
|
sourceOf Ctx {cStore} rid cents = do
|
|
placePaid cStore rid ownerA (order ("source-" <> rid) 1) {ofCommitment = C.sha256Hash $ encodeUtf8 rid} (at 60) `shouldReturn` Right ()
|
|
taken <- reg cStore rid
|
|
write cStore taken {nrPhase = PTaken} `shouldReturn` Written 2
|
|
credit cStore rid cents
|
|
pure $ RegistrationId rid
|
|
|
|
credit :: DBStore -> Text -> Int -> IO ()
|
|
credit st rid cents = withTransaction st $ \db -> creditBalance db (RegistrationId rid) cents t0
|
|
|
|
-- | What the page sends: the new order's fields and price, the balance it read, and a method only for a difference.
|
|
data SpendBody = SpendBody
|
|
{ sbOrder :: Order,
|
|
sbBalance :: Int,
|
|
sbMethod :: Maybe Text
|
|
}
|
|
|
|
-- | alicebakery, at 10000.
|
|
covered :: Int -> SpendBody
|
|
covered balance = SpendBody {sbOrder = aliceOrder, sbBalance = balance, sbMethod = Nothing}
|
|
|
|
difference :: Int -> SpendBody
|
|
difference balance = (covered balance) {sbMethod = Just "btc"}
|
|
|
|
-- | 7 letters, at the launch price of 20000 for 2 years.
|
|
dynamisOrder :: Order
|
|
dynamisOrder = aliceOrder {oLabel = "dynamis", oPrice = 20000, oCommitment = commitmentOf 2}
|
|
|
|
dynamis :: Int -> Maybe Text -> SpendBody
|
|
dynamis = SpendBody dynamisOrder
|
|
|
|
-- | Signed and timed at the clock's time, as a page acting later sends it.
|
|
timedAt :: UTCTime -> SpendBody -> SpendBody
|
|
timedAt now b@SpendBody {sbOrder} = b {sbOrder = orderAt now sbOrder}
|
|
|
|
orderAt :: UTCTime -> Order -> Order
|
|
orderAt now o = o {oTimestamp = seconds now, oDuration = yearsDuration 2 now}
|
|
|
|
spendBody :: Ctx -> RegistrationId -> SpendBody -> IO J.Object
|
|
spendBody Ctx {cRandom} source SpendBody {sbOrder = o@Order {oKey, oPrice, oTimestamp}, sbBalance, sbMethod} = do
|
|
sig <- either (error . show) id <$> signRecoverable cRandom oKey (personalDigest $ spendMessage source fields oTimestamp)
|
|
pure $
|
|
KM.fromList $
|
|
[ ("labelHash", J.String $ hex lh),
|
|
("length", J.toJSON ofLength),
|
|
("duration", J.toJSON ofDuration),
|
|
("commitment", J.String $ hex ofCommitment),
|
|
("sealed", J.String ofSealed),
|
|
("price", J.toJSON oPrice),
|
|
("balance", J.toJSON sbBalance),
|
|
("terms", J.String ofAgreedTerms),
|
|
("timestamp", J.toJSON oTimestamp),
|
|
("signature", J.String $ hex $ serializeSignature sig)
|
|
]
|
|
<> maybe [] (\m -> [("method", J.String m)]) sbMethod
|
|
where
|
|
fields@OrderFields {ofLabelHash = LabelHash lh, ofLength, ofDuration, ofCommitment, ofSealed, ofAgreedTerms} = fieldsOf o
|
|
|
|
postSpend :: Ctx -> RegistrationId -> J.Object -> IO (Int, Maybe J.Object)
|
|
postSpend ctx (RegistrationId rid) = post ctx ("/api/registration/" <> T.unpack rid <> "/spend") . J.encode
|
|
|
|
spend :: Ctx -> RegistrationId -> SpendBody -> IO (Int, Maybe J.Object)
|
|
spend ctx source b = spendBody ctx source b >>= postSpend ctx source
|
|
|
|
registrationAnswer :: Maybe J.Object -> RegistrationId
|
|
registrationAnswer = \case
|
|
Just v | KM.keys v == ["registrationId"], Just (J.String rid) <- field "registrationId" v -> RegistrationId rid
|
|
other -> error $ "not a registration id: " <> show other
|
|
|
|
invoiceIdOf :: Maybe J.Object -> InvoiceId
|
|
invoiceIdOf v = case field "invoiceId" =<< v of
|
|
Just (J.String t) -> InvoiceId t
|
|
other -> error $ "not an invoice view: " <> show other
|
|
|
|
registrationPath :: RegistrationId -> String
|
|
registrationPath (RegistrationId rid) = "/api/registration/" <> T.unpack rid
|
|
|
|
viewOf :: Ctx -> RegistrationId -> IO J.Object
|
|
viewOf Ctx {cClient} rid = webGet cClient (registrationPath rid) >>= jsonObject
|
|
|
|
-- | A registration poll at the order's version, once the server holds it.
|
|
heldPoll :: Ctx -> NameRegistration -> IO (Async (Int, J.Object))
|
|
heldPoll Ctx {cOrders, cClient} NameRegistration {nrId, nrVersion} = do
|
|
waiting <- atomically $ phasesWaiting (oePhases cOrders)
|
|
poll <- async $ webGet cClient (registrationPath nrId <> "?seenVersion=" <> show nrVersion) >>= \resp -> (statusOf resp,) <$> jsonObject resp
|
|
timeout pollSeconds (atomically $ phasesWaiting (oePhases cOrders) >>= check . (> waiting)) >>= maybe (error "the poll was not held") pure
|
|
pure poll
|
|
|
|
woken :: Async (Int, J.Object) -> IO (Int, J.Object)
|
|
woken poll = timeout pollSeconds (wait poll) >>= maybe (cancel poll >> error "the poll was not woken") pure
|
|
|
|
pollSeconds :: Int
|
|
pollSeconds = 5000000
|
|
|
|
balanceOf :: Ctx -> RegistrationId -> IO (Maybe Balance)
|
|
balanceOf Ctx {cStore, cClock} rid = readIORef cClock >>= \now -> withTransaction cStore $ \db -> getBalance db rid now
|
|
|
|
amountOf :: Ctx -> RegistrationId -> IO (Maybe Int)
|
|
amountOf ctx rid = fmap blAmount <$> balanceOf ctx rid
|
|
|
|
balanceChanged :: Maybe Balance -> (Int, Maybe J.Object)
|
|
balanceChanged b = (409, Just $ KM.fromList [("error", "balance_changed"), ("balance", maybe J.Null balanceJSON b)])
|
|
|
|
-- | The orders a balance paid.
|
|
paidFrom :: Ctx -> RegistrationId -> IO [NameRegistration]
|
|
paidFrom ctx source = filter ((== Just source) . nrBalanceSource) <$> registrationsOf ctx ownerA
|
|
|
|
cancels :: Ctx -> IO [Text]
|
|
cancels Ctx {cStub} = (\calls -> [r | StubCancel r <- calls]) <$> stubCalls cStub
|
|
|
|
providerRef :: Ctx -> InvoiceId -> IO Text
|
|
providerRef Ctx {cStore} invId = maybe (error "no invoice row") irProviderRef <$> getInvoice cStore invId
|
|
|
|
seconds :: UTCTime -> Word64
|
|
seconds = floor . utcTimeToPOSIXSeconds
|
|
|
|
btc :: ServicePaymentMethod
|
|
btc = SPMCrypto CCBtc
|
|
|
|
-- tests
|
|
|
|
testCoveredSpend :: IO ()
|
|
testCoveredSpend = withSpend $ \ctx -> do
|
|
src <- sourceOf ctx "src" 15000
|
|
rid <- registrationAnswer . snd <$> spend ctx src (covered 15000)
|
|
r <- registrationById ctx rid
|
|
(nrPhase r, nrLive r, nrPrice r, nrPayment r, nrBalanceSource r, nrBalanceUsed r) `shouldBe` (PQueued, True, 10000, RPBalance, Just src, 10000)
|
|
nrOrderHash r `shouldBe` orderHash ownerA (fieldsOf aliceOrder) (OTBalance src 10000 15000 Nothing)
|
|
nrAgreedTerms r `shouldBe` oTerms aliceOrder
|
|
view <- viewOf ctx rid
|
|
(field "phase" view, field "price" view, field "fromBalance" view, field "balance" view) `shouldBe` (Just "queued", Just (J.Number 10000), Just (J.Number 10000), Just J.Null)
|
|
amountOf ctx src `shouldReturn` Just 5000
|
|
exact <- sourceOf ctx "exact" 20000
|
|
rid' <- registrationAnswer . snd <$> spend ctx exact (dynamis 20000 Nothing)
|
|
nrBalanceUsed <$> registrationById ctx rid' `shouldReturn` 20000
|
|
fmap (\b -> (blAmount b, blHold b)) <$> balanceOf ctx exact `shouldReturn` Just (0, Nothing)
|
|
stubCalls (cStub ctx) `shouldReturn` []
|
|
|
|
testDifferenceSpend :: IO ()
|
|
testDifferenceSpend = withSpend $ \ctx -> do
|
|
src <- sourceOf ctx "src" 6000
|
|
(status, v) <- spend ctx src (difference 6000)
|
|
status `shouldBe` 200
|
|
let expiry = addUTCTime (45 * 60) orderTime
|
|
r = registrationOf <$> v
|
|
(field "status" =<< v, field "amount" =<< v, field "expiresAt" =<< v) `shouldBe` (Just "open", Just (J.Number 4000), Just (J.toJSON expiry))
|
|
(field "phase" =<< r, field "price" =<< r, field "fromBalance" =<< r) `shouldBe` (Just "unpaid", Just (J.Number 10000), Just (J.Number 6000))
|
|
let invId = invoiceIdOf v
|
|
holder <- invoiceRegistration ctx invId
|
|
stubCalls (cStub ctx) `shouldReturn` [StubCreate btc OrderDraft {odAmount = CurrencyAmount 4000, odCurrency = "usd", odReference = "name:" <> hex (nrOrderHash holder)}]
|
|
(nrPhase holder, nrLive holder, nrPrice holder, nrPayment holder, nrBalanceSource holder, nrBalanceUsed holder) `shouldBe` (PUnpaid, True, 10000, RPInvoice btc invId, Just src, 6000)
|
|
nrOrderHash holder `shouldBe` orderHash ownerA (fieldsOf aliceOrder) (OTBalance src 10000 6000 (Just btc))
|
|
nrAgreedTerms holder `shouldBe` oTerms aliceOrder
|
|
fmap irAmount <$> getInvoice (cStore ctx) invId `shouldReturn` Just (CurrencyAmount 4000)
|
|
field "balance" <$> viewOf ctx src `shouldReturn` Just (J.object ["amount" J..= (6000 :: Int), "held" J..= True, "heldUntil" J..= expiry, "refunded" J..= (0 :: Int)])
|
|
|
|
testSpendResend :: IO ()
|
|
testSpendResend = withSpend $ \ctx@Ctx {cStore, cClock} -> do
|
|
src <- sourceOf ctx "src" 6000
|
|
body <- spendBody ctx src (difference 6000)
|
|
(200, first') <- postSpend ctx src body
|
|
let invId = invoiceIdOf first'
|
|
credit cStore "src" 1000
|
|
advance ctx 60
|
|
spend ctx src (difference 6000) {sbOrder = aliceOrder {oTimestamp = seconds orderTime + 60}} `shouldReturn` (200, first')
|
|
advance ctx 700
|
|
postSpend ctx src body `shouldReturn` (200, first')
|
|
holder <- invoiceRegistration ctx invId
|
|
now <- readIORef cClock
|
|
Written _ <- withTransaction cStore $ \db -> updateRegistration db holder {nrPhase = PQueued} now
|
|
(\(status, v) -> (status, invoiceIdOf v, field "phase" . registrationOf =<< v)) <$> postSpend ctx src body `shouldReturn` (200, invId, Just "queued")
|
|
other <- sourceOf ctx "other" 25000
|
|
let paid = timedAt now $ dynamis 25000 Nothing
|
|
coveredBody <- spendBody ctx other paid
|
|
(200, answer) <- postSpend ctx other coveredBody
|
|
r <- registrationById ctx $ registrationAnswer answer
|
|
spend ctx other paid {sbOrder = (sbOrder paid) {oTimestamp = seconds now + 1}} `shouldReturn` (200, answer)
|
|
Written _ <- withTransaction cStore $ \db -> updateRegistration db r {nrPhase = PCommitted, nrRevealAfter = Just (seconds now), nrRevealBefore = Just (seconds now + 86400)} now
|
|
advance ctx 3600
|
|
postSpend ctx other coveredBody `shouldReturn` (200, answer)
|
|
length <$> stubCalls (cStub ctx) `shouldReturn` 1
|
|
amountOf ctx other `shouldReturn` Just 5000
|
|
|
|
testInvoiceBodyApart :: IO ()
|
|
testInvoiceBodyApart = withSpend $ \ctx -> do
|
|
src <- sourceOf ctx "src" 6000
|
|
fst <$> spend ctx src (difference 6000) `shouldReturn` 200
|
|
placeOrder ctx aliceOrder `shouldReturn` refusedWith 409 "order_exists"
|
|
fst <$> placeOrder ctx dynamisOrder `shouldReturn` 200
|
|
other <- sourceOf ctx "other" 30000
|
|
spend ctx other (dynamis 30000 Nothing) `shouldReturn` refusedWith 409 "order_exists"
|
|
amountOf ctx other `shouldReturn` Just 30000
|
|
|
|
testBalanceChanged :: IO ()
|
|
testBalanceChanged = withSpend $ \ctx -> do
|
|
src <- sourceOf ctx "src" 10000
|
|
stale <- balanceOf ctx src
|
|
spend ctx src (covered 12000) `shouldReturn` balanceChanged stale
|
|
spend ctx src (difference 9000) `shouldReturn` balanceChanged stale
|
|
held <- sourceOf ctx "held" 6000
|
|
fst <$> spend ctx held (dynamis 6000 (Just "btc")) `shouldReturn` 200
|
|
holding <- balanceOf ctx held
|
|
(fmap bhUntil . blHold =<< holding, fmap bhOverdue . blHold =<< holding) `shouldBe` (Just (addUTCTime (45 * 60) orderTime), Just False)
|
|
spend ctx held (difference 6000) `shouldReturn` balanceChanged holding
|
|
placePaid (cStore ctx) "none" ownerA (order "source-none" 3) (at 60) `shouldReturn` Right ()
|
|
spend ctx (RegistrationId "none") (covered 10000) `shouldReturn` balanceChanged Nothing
|
|
length <$> stubCalls (cStub ctx) `shouldReturn` 1
|
|
paidFrom ctx src `shouldReturn` []
|
|
|
|
testMethodRule :: IO ()
|
|
testMethodRule = withSpend $ \ctx -> do
|
|
src <- sourceOf ctx "src" 6000
|
|
good <- spendBody ctx src (difference 6000)
|
|
let with :: J.Key -> J.Value -> J.Object
|
|
with k v = KM.insert k v good
|
|
mapM_
|
|
(\body -> postSpend ctx src body `shouldReturn` refusedWith 400 "bad_request")
|
|
[ KM.delete "method" good,
|
|
with "method" "eth",
|
|
with "method" J.Null,
|
|
with "price" (J.Number 0),
|
|
with "price" (J.Number 100.5),
|
|
with "balance" (J.Number 0),
|
|
with "balance" (J.Number (-1)),
|
|
KM.delete "balance" good
|
|
]
|
|
spend ctx src (covered 6000) {sbOrder = aliceOrder {oPrice = 6000}, sbMethod = Just "btc"} `shouldReturn` refusedWith 400 "bad_request"
|
|
spend ctx src (difference 6000) {sbMethod = Nothing} `shouldReturn` refusedWith 400 "bad_request"
|
|
mapM_ (\len -> postSpend ctx src (with "length" (J.Number len)) `shouldReturn` refusedWith 400 "invalid_name") [5, 64]
|
|
stubCalls (cStub ctx) `shouldReturn` []
|
|
amountOf ctx src `shouldReturn` Just 6000
|
|
|
|
testSignerAndId :: IO ()
|
|
testSignerAndId = withSpend $ \ctx -> do
|
|
src <- sourceOf ctx "src" 15000
|
|
spend ctx src (covered 15000) {sbOrder = aliceOrder {oKey = keyB}} `shouldReturn` refusedWith 400 "bad_request"
|
|
mapM_
|
|
(\offset -> spend ctx src (covered 15000) {sbOrder = aliceOrder {oTimestamp = fromInteger (toInteger (seconds orderTime) + offset)}} `shouldReturn` refusedWith 400 "bad_request")
|
|
[-601, 601]
|
|
spend ctx (RegistrationId "unknown") (covered 15000) `shouldReturn` refusedWith 404 "not_found"
|
|
amountOf ctx src `shouldReturn` Just 15000
|
|
paidFrom ctx src `shouldReturn` []
|
|
|
|
testOrderChecks :: IO ()
|
|
testOrderChecks = withSpend $ \ctx@Ctx {cNode = node, cStub} -> do
|
|
src <- sourceOf ctx "src" 30000
|
|
answerCall node fakeController reservedNamesCall $ Returns $ uint256 (1 :: Int)
|
|
spend ctx src (covered 30000) `shouldReturn` refusedWith 409 "name_taken"
|
|
answerCall node fakeController reservedNamesCall $ Returns $ uint256 (0 :: Int)
|
|
spend ctx src (covered 30000) {sbOrder = aliceOrder {oPrice = 9000}} `shouldReturn` (409, Just $ KM.fromList [("error", "catalog_changed"), ("price", J.Number 10000)])
|
|
setFault node "eth_getBlockByNumber" HttpFailure
|
|
spend ctx src (covered 30000) `shouldReturn` refusedWith 503 "name_check_unavailable"
|
|
clearFault node "eth_getBlockByNumber"
|
|
low <- sourceOf ctx "low" 6000
|
|
modifyIORef' cStub $ \s -> s {ssCreateResult = Left (ProviderError "down")}
|
|
spend ctx low (difference 6000) `shouldReturn` refusedWith 503 "provider_unavailable"
|
|
modifyIORef' cStub $ \s -> s {ssCreateResult = Right sampleProviderInvoice}
|
|
fst <$> placeOrder ctx aliceOrder `shouldReturn` 200
|
|
spend ctx src (covered 30000) {sbOrder = aliceOrder {oCommitment = commitmentOf 3}} `shouldReturn` refusedWith 409 "order_exists"
|
|
mapM (amountOf ctx) [src, low] `shouldReturn` [Just 30000, Just 6000]
|
|
mapM (paidFrom ctx) [src, low] `shouldReturn` [[], []]
|
|
|
|
-- | Each request fails two checks, and is answered by the earlier.
|
|
testCheckOrder :: IO ()
|
|
testCheckOrder = withSpend $ \ctx@Ctx {cNode = node, cStub} -> do
|
|
src <- sourceOf ctx "src" 30000
|
|
low <- sourceOf ctx "low" 6000
|
|
let unknown = RegistrationId "unknown"
|
|
unsealed <- KM.delete "sealed" <$> spendBody ctx unknown (covered 30000)
|
|
postSpend ctx unknown unsealed `shouldReturn` refusedWith 400 "bad_request"
|
|
spend ctx unknown (covered 30000) {sbOrder = aliceOrder {oKey = keyB}} `shouldReturn` refusedWith 404 "not_found"
|
|
answerCall node fakeController reservedNamesCall $ Returns $ uint256 (1 :: Int)
|
|
spend ctx src (covered 31000) {sbOrder = aliceOrder {oTimestamp = seconds orderTime + 601}} `shouldReturn` refusedWith 400 "bad_request"
|
|
stale <- balanceOf ctx src
|
|
spend ctx src (covered 31000) `shouldReturn` balanceChanged stale
|
|
spend ctx src (covered 30000) {sbOrder = aliceOrder {oPrice = 9000}} `shouldReturn` refusedWith 409 "name_taken"
|
|
answerCall node fakeController reservedNamesCall $ Returns $ uint256 (0 :: Int)
|
|
modifyIORef' cStub $ \s -> s {ssCreateResult = Left (ProviderError "down")}
|
|
spend ctx low (difference 6000) {sbOrder = aliceOrder {oPrice = 9000}} `shouldReturn` (409, Just $ KM.fromList [("error", "catalog_changed"), ("price", J.Number 10000)])
|
|
modifyIORef' cStub $ \s -> s {ssCreateResult = Right sampleProviderInvoice}
|
|
fst <$> spend ctx src (covered 30000) `shouldReturn` 200
|
|
spend ctx src (covered 30000) {sbOrder = aliceOrder {oKey = keyB}} `shouldReturn` refusedWith 400 "bad_request"
|
|
spend ctx src (covered 31000) {sbOrder = aliceOrder {oCommitment = commitmentOf 3}} `shouldReturn` refusedWith 409 "order_exists"
|
|
amountOf ctx src `shouldReturn` Just 20000
|
|
|
|
testSpendTermsChanged :: IO ()
|
|
testSpendTermsChanged = withSpend $ \ctx0 -> serveSpend (configured (listName "dynamis") ctx0) $ \ctx -> do
|
|
src <- sourceOf ctx "src" 30000
|
|
forM_ [olderTerms, communityTerms termsVersion] $ \t ->
|
|
spend ctx src (covered 30000) {sbOrder = aliceOrder {oTerms = t}} `shouldReturn` termsChanged termsVersion Nothing
|
|
forM_ [termsVersion, communityTerms olderTerms] $ \t ->
|
|
spend ctx src (dynamis 30000 Nothing) {sbOrder = dynamisOrder {oTerms = t}} `shouldReturn` termsChanged termsVersion (Just bakers)
|
|
amountOf ctx src `shouldReturn` Just 30000
|
|
rid <- registrationAnswer . snd <$> spend ctx src (dynamis 30000 Nothing) {sbOrder = dynamisOrder {oTerms = communityTerms termsVersion}}
|
|
nrAgreedTerms <$> registrationById ctx rid `shouldReturn` communityTerms termsVersion
|
|
serveSpend (configured (listName "alicebakery") ctx) $ \listing -> do
|
|
low <- sourceOf listing "low" 4000
|
|
(status, v) <- spend listing low (difference 4000) {sbOrder = aliceOrder {oTerms = communityTerms termsVersion}}
|
|
status `shouldBe` 200
|
|
nrAgreedTerms <$> invoiceRegistration listing (invoiceIdOf v) `shouldReturn` communityTerms termsVersion
|
|
|
|
testSpendResendAfterBump :: IO ()
|
|
testSpendResendAfterBump = withSpend $ \ctx0 -> do
|
|
src <- sourceOf ctx0 "src" 15000
|
|
low <- sourceOf ctx0 "low" 5000
|
|
coveredBody <- spendBody ctx0 src (covered 15000)
|
|
(200, Just coveredAnswer) <- postSpend ctx0 src coveredBody
|
|
differenceBody <- spendBody ctx0 low (dynamis 5000 (Just "btc"))
|
|
(200, Just differenceView) <- postSpend ctx0 low differenceBody
|
|
serveSpend (configured bumpedTerms ctx0) $ \ctx -> do
|
|
advance ctx 60
|
|
postSpend ctx src coveredBody `shouldReturn` (200, Just coveredAnswer)
|
|
postSpend ctx low differenceBody `shouldReturn` (200, Just differenceView)
|
|
mapM (fmap length . paidFrom ctx) [src, low] `shouldReturn` [1, 1]
|
|
|
|
testSpendTermsCheckOrder :: IO ()
|
|
testSpendTermsCheckOrder = withSpend $ \ctx@Ctx {cNode = node} -> do
|
|
src <- sourceOf ctx "src" 15000
|
|
let old = aliceOrder {oTerms = olderTerms}
|
|
spend ctx src (covered 15000) {sbOrder = old {oTimestamp = seconds orderTime + 601}} `shouldReturn` refusedWith 400 "bad_request"
|
|
spend ctx src (covered 15000) {sbOrder = old {oDuration = oDuration old + 3601}} `shouldReturn` refusedWith 400 "bad_request"
|
|
spend ctx src (covered 14000) {sbOrder = old} `shouldReturn` termsChanged termsVersion Nothing
|
|
answerCall node fakeController reservedNamesCall $ Returns $ uint256 (1 :: Int)
|
|
spend ctx src (covered 15000) {sbOrder = old {oPrice = 9000}} `shouldReturn` termsChanged termsVersion Nothing
|
|
setFault node "eth_call" HttpFailure
|
|
spend ctx src (covered 15000) {sbOrder = old} `shouldReturn` termsChanged termsVersion Nothing
|
|
amountOf ctx src `shouldReturn` Just 15000
|
|
paidFrom ctx src `shouldReturn` []
|
|
|
|
testCardMinimum :: IO ()
|
|
testCardMinimum = withSpend $ \ctx -> do
|
|
small <- sourceOf ctx "small" 9951
|
|
spend ctx small (difference 9951) {sbOrder = aliceOrder {oPrice = 9990}, sbMethod = Just "card"} `shouldReturn` (409, Just $ KM.fromList [("error", "catalog_changed"), ("price", J.Number 10000)])
|
|
spend ctx small (difference 9951) {sbMethod = Just "card"} `shouldReturn` refusedWith 400 "bad_request"
|
|
stubCalls (cStub ctx) `shouldReturn` []
|
|
amountOf ctx small `shouldReturn` Just 9951
|
|
fst <$> spend ctx small (difference 9951) `shouldReturn` 200
|
|
least <- sourceOf ctx "least" 19950
|
|
fst <$> spend ctx least (dynamis 19950 (Just "card")) `shouldReturn` 200
|
|
calls <- stubCalls (cStub ctx)
|
|
[odAmount d | StubCreate _ d <- calls] `shouldBe` [CurrencyAmount 49, CurrencyAmount 50]
|
|
|
|
testSpendSizes :: IO ()
|
|
testSpendSizes = withSpend $ \ctx -> do
|
|
src <- sourceOf ctx "src" 15000
|
|
missing <- KM.delete "sealed" <$> spendBody ctx src (covered 15000)
|
|
postSpend ctx src missing `shouldReturn` refusedWith 400 "bad_request"
|
|
noTerms <- KM.delete "terms" <$> spendBody ctx src (covered 15000)
|
|
postSpend ctx src noTerms `shouldReturn` refusedWith 400 "bad_request"
|
|
forM_ malformedTerms $ \t -> spend ctx src (covered 15000) {sbOrder = aliceOrder {oTerms = t}} `shouldReturn` refusedWith 400 "bad_request"
|
|
spend ctx src (covered 15000) {sbOrder = aliceOrder {oSealed = T.replicate 11096 "a"}} `shouldReturn` refusedWith 400 "bad_request"
|
|
padded <- KM.insert "padding" (J.String $ T.replicate 16384 "a") <$> spendBody ctx src (covered 15000)
|
|
postSpend ctx src padded `shouldReturn` refusedWith 400 "bad_request"
|
|
longest <- spendBody ctx src (covered 15000) {sbOrder = aliceOrder {oSealed = T.replicate 11095 "a"}}
|
|
LB.length (J.encode longest) `shouldSatisfy` (< 16384)
|
|
fst <$> postSpend ctx src longest `shouldReturn` 200
|
|
|
|
testSpendLimit :: IO ()
|
|
testSpendLimit = withSpend $ \Ctx {cClient} -> do
|
|
let attempt = statusOf <$> webRequestBody cClient "POST" "/api/registration/src/spend" [(hContentType, "application/json"), ("X-Forwarded-For", "203.0.113.9")] "{}"
|
|
replicateM_ 5 $ attempt `shouldReturn` 400
|
|
attempt `shouldReturn` 429
|
|
|
|
testOverdueHolderSpent :: IO ()
|
|
testOverdueHolderSpent = withSpend $ \ctx@Ctx {cStore, cClock} -> do
|
|
src <- sourceOf ctx "src" 10000
|
|
-- by card, whose invoice does not expire by itself at the provider, past its 60 minutes
|
|
(200, held) <- spend ctx src (dynamis 10000 (Just "card"))
|
|
let holderInv = invoiceIdOf held
|
|
holder <- invoiceRegistration ctx holderInv
|
|
holderRef <- providerRef ctx holderInv
|
|
advance ctx (61 * 60)
|
|
now <- readIORef cClock
|
|
poll <- heldPoll ctx holder
|
|
rid <- registrationAnswer . snd <$> spend ctx src (timedAt now $ covered 10000)
|
|
(\(status, v) -> (status, field "version" v)) <$> woken poll `shouldReturn` (200, Just (J.toJSON $ nrVersion holder + 1))
|
|
fmap irStatus <$> getInvoice cStore holderInv `shouldReturn` Just ISExpired
|
|
(\r -> (nrLive r, nrVersion r)) <$> invoiceRegistration ctx holderInv `shouldReturn` (False, nrVersion holder + 1)
|
|
cancels ctx `shouldReturn` [holderRef]
|
|
(\r -> (nrPhase r, nrBalanceUsed r)) <$> registrationById ctx rid `shouldReturn` (PQueued, 10000)
|
|
fmap (\b -> (blAmount b, blHold b)) <$> balanceOf ctx src `shouldReturn` Just (0, Nothing)
|
|
|
|
-- | By card, whose invoice does not expire by itself at the provider, past its 60 minutes.
|
|
overdueCardHolder :: Ctx -> RegistrationId -> IO (NameRegistration, Text)
|
|
overdueCardHolder ctx src = do
|
|
(200, held) <- spend ctx src (difference 6000) {sbMethod = Just "card"}
|
|
let holderInv = invoiceIdOf held
|
|
holder <- invoiceRegistration ctx holderInv
|
|
holderRef <- providerRef ctx holderInv
|
|
advance ctx (61 * 60)
|
|
pure (holder, holderRef)
|
|
|
|
-- | Whether the holder is live, its version, and its invoice's status.
|
|
holderAfter :: Ctx -> NameRegistration -> IO (Bool, Int64, Maybe InvoiceStatus)
|
|
holderAfter ctx@Ctx {cStore} NameRegistration {nrPayment} = case nrPayment of
|
|
RPInvoice _ invId -> do
|
|
r <- invoiceRegistration ctx invId
|
|
status <- fmap irStatus <$> getInvoice cStore invId
|
|
pure (nrLive r, nrVersion r, status)
|
|
other -> error $ "not a paid order: " <> show other
|
|
|
|
testDifferenceClosesHolder :: IO ()
|
|
testDifferenceClosesHolder = withSpend $ \ctx@Ctx {cClock} -> do
|
|
src <- sourceOf ctx "src" 6000
|
|
(holder, holderRef) <- overdueCardHolder ctx src
|
|
now <- readIORef cClock
|
|
(status, v) <- spend ctx src (timedAt now $ dynamis 6000 (Just "btc"))
|
|
status `shouldBe` 200
|
|
next <- invoiceRegistration ctx (invoiceIdOf v)
|
|
holderAfter ctx holder `shouldReturn` (False, nrVersion holder + 1, Just ISExpired)
|
|
cancels ctx `shouldReturn` [holderRef]
|
|
fmap (fmap bhHolder . blHold) <$> balanceOf ctx src `shouldReturn` Just (Just $ nrId next)
|
|
|
|
-- | The page's "Get a new invoice": the same name with a new commitment, its earlier difference order overdue.
|
|
testSpendAgain :: IO ()
|
|
testSpendAgain = withSpend $ \ctx@Ctx {cClock} -> do
|
|
src <- sourceOf ctx "src" 6000
|
|
(holder, holderRef) <- overdueCardHolder ctx src
|
|
now <- readIORef cClock
|
|
(status, v) <- spend ctx src $ timedAt now SpendBody {sbOrder = aliceOrder {oCommitment = commitmentOf 5}, sbBalance = 6000, sbMethod = Just "card"}
|
|
status `shouldBe` 200
|
|
next <- invoiceRegistration ctx (invoiceIdOf v)
|
|
(nrLive next, nrLabelHash next) `shouldBe` (True, nrLabelHash holder)
|
|
holderAfter ctx holder `shouldReturn` (False, nrVersion holder + 1, Just ISExpired)
|
|
cancels ctx `shouldReturn` [holderRef]
|
|
fmap (fmap bhHolder . blHold) <$> balanceOf ctx src `shouldReturn` Just (Just $ nrId next)
|
|
|
|
testHolderCancelledMeanwhile :: IO ()
|
|
testHolderCancelledMeanwhile = withSpendHook $ \hook ctx@Ctx {cStore, cWeb, cClock} -> do
|
|
src <- sourceOf ctx "src" 6000
|
|
(200, held) <- spend ctx src (difference 6000)
|
|
let holderInv = invoiceIdOf held
|
|
advance ctx (46 * 60)
|
|
now <- readIORef cClock
|
|
onCreate hook $ cancelOpenInvoice cStore (weWaiters cWeb) Nothing holderInv now `shouldReturn` True
|
|
(status, v) <- spend ctx src (timedAt now $ dynamis 6000 (Just "btc"))
|
|
status `shouldBe` 200
|
|
next <- invoiceRegistration ctx (invoiceIdOf v)
|
|
fmap irStatus <$> getInvoice cStore holderInv `shouldReturn` Just ISExpired
|
|
fmap (fmap bhHolder . blHold) <$> balanceOf ctx src `shouldReturn` Just (Just $ nrId next)
|
|
cancels ctx `shouldReturn` []
|
|
|
|
testHolderFundedMeanwhile :: IO ()
|
|
testHolderFundedMeanwhile = withSpendHook $ \hook ctx@Ctx {cStore, cClock} -> do
|
|
src <- sourceOf ctx "src" 6000
|
|
(200, held) <- spend ctx src (difference 6000)
|
|
let holderInv = invoiceIdOf held
|
|
holder <- invoiceRegistration ctx holderInv
|
|
holderRef <- providerRef ctx holderInv
|
|
advance ctx (46 * 60)
|
|
now <- readIORef cClock
|
|
onCreate hook $ settleNow ctx holderInv (SigFunded (rcv 4000 (Just "0.0006")) PaidInFull) `shouldReturn` Right ISOpen
|
|
answer <- spend ctx src (timedAt now $ dynamis 6000 (Just "btc"))
|
|
current <- balanceOf ctx src
|
|
answer `shouldBe` balanceChanged current
|
|
(fmap blAmount current, fmap bhHolder . blHold =<< current) `shouldBe` (Just 6000, Just $ nrId holder)
|
|
calls <- stubCalls (cStub ctx)
|
|
length [() | StubCreate {} <- calls] `shouldBe` 2
|
|
refs <- cancels ctx
|
|
length refs `shouldBe` 1
|
|
refs `shouldNotBe` [holderRef]
|
|
(fmap irStatus <$> getInvoice cStore holderInv) `shouldReturn` Just ISOpen
|
|
nrLive <$> invoiceRegistration ctx holderInv `shouldReturn` True
|
|
length <$> paidFrom ctx src `shouldReturn` 1
|
|
|
|
testConcurrentSpends :: IO ()
|
|
testConcurrentSpends = withSpend $ \ctx -> do
|
|
src <- sourceOf ctx "src" 30000
|
|
(a, b) <- concurrently (spend ctx src (covered 30000)) (spend ctx src (dynamis 30000 Nothing))
|
|
sort [fst a, fst b] `shouldBe` [200, 409]
|
|
[won] <- paidFrom ctx src
|
|
current <- balanceOf ctx src
|
|
fmap blAmount current `shouldBe` Just (30000 - nrPrice won)
|
|
filter ((== 409) . fst) [a, b] `shouldBe` [balanceChanged current]
|
|
|
|
testWriteRefusedAtBalance :: IO ()
|
|
testWriteRefusedAtBalance = withSpendHook $ \hook ctx@Ctx {cOpenStore} -> do
|
|
src <- sourceOf ctx "src" 6000
|
|
other <- cOpenStore
|
|
onCreate hook $ credit other "src" 500
|
|
answered <- newIORef Nothing
|
|
logs <- capturingStderr $ spend ctx src (difference 6000) >>= writeIORef answered . Just
|
|
logs `shouldSatisfy` T.isInfixOf "the order was refused, so invoice pref-1 is cancelled at the provider"
|
|
Just answer <- readIORef answered
|
|
current <- balanceOf ctx src
|
|
(fmap blAmount current, blHold =<< current) `shouldBe` (Just 6500, Nothing)
|
|
answer `shouldBe` balanceChanged current
|
|
calls <- stubCalls (cStub ctx)
|
|
[() | StubCreate {} <- calls] `shouldBe` [()]
|
|
length <$> cancels ctx `shouldReturn` 1
|
|
paidFrom ctx src `shouldReturn` []
|
|
|
|
testSpendRacesRefund :: IO ()
|
|
testSpendRacesRefund = withSpend $ \ctx@Ctx {cStore, cOrders, cClock} -> do
|
|
src <- sourceOf ctx "src" 10000
|
|
now <- readIORef cClock
|
|
(spent, refunded) <- concurrently (spend ctx src (covered 10000)) (recordRefund (oeTurns cOrders) cStore "src" 10000 now)
|
|
current <- balanceOf ctx src
|
|
fmap blAmount current `shouldBe` Just 0
|
|
case refunded of
|
|
Right _ -> do
|
|
spent `shouldBe` balanceChanged current
|
|
paidFrom ctx src `shouldReturn` []
|
|
fmap blRefunded current `shouldBe` Just 10000
|
|
Left refusal -> do
|
|
(fst spent, refusal) `shouldBe` (200, RefundAmount 0)
|
|
length <$> paidFrom ctx src `shouldReturn` 1
|
|
fmap blRefunded current `shouldBe` Just 0
|
|
|
|
testSpendTurns :: IO ()
|
|
testSpendTurns = withSpend $ \ctx@Ctx {cOrders} -> do
|
|
src <- sourceOf ctx "src" 15000
|
|
let turns = oeTurns cOrders
|
|
inside <- newEmptyMVar
|
|
proceed <- newEmptyMVar
|
|
holding <- async $ withBalanceTurn turns src $ putMVar inside () >> takeMVar proceed
|
|
takeMVar inside
|
|
spent <- async $ spend ctx src (covered 15000)
|
|
-- the spend waits for the balance's turn while it holds the name's
|
|
waiting <- timeout pollSeconds $ atomically $ do
|
|
balanceTurn <- balanceTurnCount turns src
|
|
nameTurn <- labelTurnCount turns (labelHash "alicebakery")
|
|
check (balanceTurn == 2 && nameTurn == 1)
|
|
waiting `shouldBe` Just ()
|
|
putMVar proceed ()
|
|
wait holding
|
|
fst <$> wait spent `shouldReturn` 200
|
|
|
|
-- end to end
|
|
|
|
-- | The real BTCPay adapter against the fake, read by the poller when BTCPay's webhook asks, over the running service's store, so
|
|
-- //refund reads what the routes wrote; the clock is the wall clock, which the poller and //refund read.
|
|
withBTCPaySpend :: TestParams -> (ChatController -> FakeBTCPay -> PollerEnv -> Ctx -> IO ()) -> IO ()
|
|
withBTCPaySpend ps action = withStoreService ps $ \_ _ cc -> withFakeBTCPay $ \fake -> do
|
|
btcpay <- btcpayProvider (fbConfig fake)
|
|
let viaFake :: Provider -> Provider
|
|
viaFake p = case pProvider p of
|
|
PPCrypto -> btcpay
|
|
_ -> p
|
|
withSpendOn (pure $ chatStore cc) viaFake $ \ctx@Ctx {cStore, cOrders, cWeb, cClock} -> do
|
|
getCurrentTime >>= writeIORef cClock
|
|
poller <- newPollerEnv cStore (weWaiters cWeb) (Just $ oePhases cOrders) (weHints cWeb) (weProviders cWeb) (poll $ weConfig cWeb)
|
|
action cc fake poller ctx
|
|
|
|
-- | BTCPay shows the invoice in the state and sends the event, and the poller reads the invoice the event names.
|
|
reported :: FakeBTCPay -> PollerEnv -> Ctx -> Text -> Text -> [JT.Pair] -> IO ()
|
|
reported fake poller Ctx {cClient} event ref state = do
|
|
setInvoiceState fake ref state
|
|
let body = webhookEvent event ref
|
|
statusOf <$> webRequestBody cClient "POST" "/webhooks/btcpay" (webhookSigHeader (bWebhookSecret $ fbConfig fake) body) body `shouldReturn` 200
|
|
drainHints poller
|
|
|
|
-- | Paid in full and settled: $100.00 at the fixtures' rate of 108000.12 dollars a bitcoin, as the invoice asked.
|
|
settledState :: [JT.Pair]
|
|
settledState = ["status" J..= ("Settled" :: Text), "amount" J..= ("0.00092593" :: Text), "paymentMethodPaid" J..= ("0.00092593" :: Text)]
|
|
|
|
-- | 0.0005 bitcoin confirmed after the $100.00 invoice of 0.00092593 expired, credited as its share of the price, 5399 cents.
|
|
latePaid :: [JT.Pair]
|
|
latePaid =
|
|
[ "additionalStatus" J..= ("PaidPartial" :: Text),
|
|
"amount" J..= ("0.00092593" :: Text),
|
|
"paymentMethodPaid" J..= lateBtc,
|
|
"payments" J..= [J.object ["id" J..= ("late-tx" :: Text), "receivedDate" J..= (1700003600 :: Int), "value" J..= lateBtc, "fee" J..= ("0.00000000" :: Text), "status" J..= ("Settled" :: Text)]]
|
|
]
|
|
where
|
|
lateBtc = "0.00050000" :: Text
|
|
|
|
ratesDown, ratesUp :: J.Value
|
|
ratesDown = J.toJSON [J.object ["currencyPair" J..= ("BTC_USD" :: Text), "errors" J..= ["no rate source answered" :: Text], "rate" J..= J.Null]]
|
|
ratesUp = J.toJSON [J.object ["currencyPair" J..= ("BTC_USD" :: Text), "errors" J..= ([] :: [Text]), "rate" J..= ("108000.12" :: Text)]]
|
|
|
|
-- | The worker's write when another wallet holds the name: taken, with the order's whole price credited in the same transaction.
|
|
takenNow :: Ctx -> RegistrationId -> IO ()
|
|
takenNow ctx@Ctx {cStore, cClock} rid = do
|
|
r <- registrationById ctx rid
|
|
now <- readIORef cClock
|
|
withTransaction cStore (\db -> updateRegistration db r {nrPhase = PTaken} now >>= \w -> w <$ creditBalance db rid (nrPrice r) now)
|
|
`shouldReturn` Written (nrVersion r + 1)
|
|
|
|
-- | Owner A's orders as find lists them: each one's id, the order whose balance paid it, and the order holding its balance.
|
|
links :: Ctx -> IO [(Text, Maybe Text, Maybe Text)]
|
|
links ctx@Ctx {cClock} = do
|
|
now <- readIORef cClock
|
|
findBody ctx keyA ownerAText (seconds now) >>= postFind ctx >>= \case
|
|
(200, Just v) | Just found <- JT.parseMaybe (\o -> o J..: "registrations" >>= mapM entry) v -> pure (sort found)
|
|
other -> error $ "find refused: " <> show other
|
|
where
|
|
entry :: J.Value -> JT.Parser (Text, Maybe Text, Maybe Text)
|
|
entry = J.withObject "registration" $ \r -> (,,) <$> r J..: "registrationId" <*> r J..: "balanceSource" <*> r J..: "heldBy"
|
|
|
|
balanceView :: Ctx -> RegistrationId -> IO (Maybe J.Value)
|
|
balanceView ctx rid = field "balance" <$> viewOf ctx rid
|
|
|
|
-- | A balance nothing holds.
|
|
balanceObject :: Int -> Int -> Maybe J.Value
|
|
balanceObject amount refunded = Just $ J.object ["amount" J..= amount, "held" J..= False, "heldUntil" J..= J.Null, "refunded" J..= refunded]
|
|
|
|
ridText :: RegistrationId -> Text
|
|
ridText (RegistrationId t) = t
|
|
|
|
testSpendEndToEnd :: TestParams -> IO ()
|
|
testSpendEndToEnd ps = withBTCPaySpend ps $ \cc fake poller ctx@Ctx {cClock} -> do
|
|
now <- readIORef cClock
|
|
let report :: Text -> InvoiceId -> [JT.Pair] -> IO ()
|
|
report event inv state = providerRef ctx inv >>= \ref -> reported fake poller ctx event ref state
|
|
orderOf :: InvoiceId -> IO RegistrationId
|
|
orderOf inv = nrId <$> invoiceRegistration ctx inv
|
|
dynamisAgain :: Int -> SpendBody
|
|
dynamisAgain n = timedAt now $ (dynamis 10000 (Just "btc")) {sbOrder = dynamisOrder {oCommitment = commitmentOf n}}
|
|
-- alicebakery paid at BTCPay and queued by the poller, then taken by another wallet, its price kept as its balance
|
|
(srcInv@(InvoiceId srcInvText), _) <- placed ctx (orderAt now aliceOrder)
|
|
report "InvoiceSettled" srcInv settledState
|
|
src <- orderOf srcInv
|
|
nrPhase <$> registrationById ctx src `shouldReturn` PQueued
|
|
takenNow ctx src
|
|
balanceView ctx src `shouldReturn` balanceObject 10000 0
|
|
let s = ridText src
|
|
-- the balance on dynamis, with an invoice for the difference that holds all of it until BTCPay expires it
|
|
(200, held) <- spend ctx src (dynamisAgain 2)
|
|
let firstInv = invoiceIdOf held
|
|
first <- orderOf firstInv
|
|
let f = ridText first
|
|
links ctx `shouldReturn` sort [(s, Nothing, Just f), (f, Just s, Nothing)]
|
|
report "InvoiceExpired" firstInv ["status" J..= ("Expired" :: Text)]
|
|
(\r -> (nrPhase r, nrLive r)) <$> registrationById ctx first `shouldReturn` (PUnpaid, False)
|
|
balanceView ctx src `shouldReturn` balanceObject 10000 0
|
|
links ctx `shouldReturn` sort [(s, Nothing, Nothing), (f, Just s, Nothing)]
|
|
-- money paid into the expired invoice: the poller's read cannot credit it while the rate read fails, and its next credit pass does
|
|
setRates fake ratesDown
|
|
report "InvoicePaymentSettled" firstInv latePaid
|
|
(\v -> (field "latePayment" v, field "balance" v)) <$> viewOf ctx first `shouldReturn` (Just $ J.object ["waiting" J..= ("crediting" :: Text)], Just J.Null)
|
|
setRates fake ratesUp
|
|
void $ capturingStderr $ forgetRates poller >> getCurrentTime >>= creditPass poller
|
|
(\v -> (field "latePayment" v, field "balance" v)) <$> viewOf ctx first `shouldReturn` (Just J.Null, balanceObject 5399 0)
|
|
balanceView ctx src `shouldReturn` balanceObject 10000 0
|
|
-- dynamis again with a new commitment: BTCPay settles the difference, and the poller queues the order and spends the hold
|
|
(200, again) <- spend ctx src (dynamisAgain 3)
|
|
let dearerInv@(InvoiceId dearerInvText) = invoiceIdOf again
|
|
dearer <- orderOf dearerInv
|
|
let d = ridText dearer
|
|
links ctx `shouldReturn` sort [(s, Nothing, Just d), (f, Just s, Nothing), (d, Just s, Nothing)]
|
|
report "InvoiceSettled" dearerInv settledState
|
|
(\r -> (nrPhase r, nrPrice r, nrBalanceSource r, nrBalanceUsed r)) <$> registrationById ctx dearer `shouldReturn` (PQueued, 20000, Just src, 10000)
|
|
balanceView ctx src `shouldReturn` balanceObject 0 0
|
|
links ctx `shouldReturn` sort [(s, Nothing, Nothing), (f, Just s, Nothing), (d, Just s, Nothing)]
|
|
-- that order taken in turn keeps its whole price; a covered spend of alicebakery takes half, and support refunds the rest
|
|
takenNow ctx dearer
|
|
covering <- registrationAnswer . snd <$> spend ctx dearer (timedAt now $ (covered 20000) {sbOrder = aliceOrder {oCommitment = commitmentOf 4}})
|
|
(\r -> (nrPhase r, nrBalanceSource r, nrBalanceUsed r)) <$> registrationById ctx covering `shouldReturn` (PQueued, Just dearer, 10000)
|
|
let paidBy = "order " <> d <> ": $100.00 by btc on invoice " <> dearerInvText <> ", $100.00 from the balance of order " <> s <> "; order " <> s <> ": $100.00 by btc on invoice " <> srcInvText
|
|
operatorCmd cc ("//refund " <> d)
|
|
`shouldReturn` Right ("Order " <> d <> ": balance $100.00 (credited $200.00, refunded $0.00). Paid by: " <> paidBy <> ". To record its refund: //refund " <> d <> " 10000")
|
|
operatorCmd cc ("//refund " <> dearerInvText <> " 10000") `shouldReturn` Right ("Recorded a refund of $100.00 for order " <> d <> ". Refund it now, by: " <> paidBy <> ".")
|
|
balanceView ctx dearer `shouldReturn` balanceObject 0 10000
|