Skip to main content

Kiểm định thành phần

Viết hợp đồng thông minh và kiểm định lại là hành động song hành. Các thử nghiệm cũng là một cách tuyệt vời để kiểm tra hoạt động của hợp đồng thông minh một cách thuận tiện thay vì sử dụng cardano-cli node cục bộ.

Bây giờ chúng ta đã viết hợp đồng thông minh của mình và xác định các hoạt động cần thiết trên đó, hãy xem liệu nó có hoạt động như mong đợi hay không.

Bộ thử nghiệm của chúng ta là một trình trên mô hình đơn giản của Plutus  được tạo bởi MLabs.

note

chúng ta sử dụng một bản fork tùy chỉnh của mô hình đơn giản Plutus.

Tại sao không chỉ sử dụng "Mô hình đơn giản của Plutus" thay vì (wrapper)?

  1. Khả năng sử dụng lại : Trước tiên, để duy trì khả năng tương thích với chuỗi công cụ của chúng ta. Chẳng hạn, các hoạt động của chúng ta đang sử dụng GYTxQueryMonad đơn nguyên và do đó để có thể sử dụng lại các hoạt động tương tự đó, chúng ta cần xác định một thể hiện cho nó.
  2. Kiểm tra bổ sung : Nhưng thứ hai và quan trọng hơn, mô hình đơn giản của plutus thiếu một số kiểm tra cơ bản, ví dụ:
    • UTxO có đáp ứng yêu cầu ADA tối thiểu hay không.
    • Yêu cầu phí giao dịch.
    • Yêu cầu chữ ký giao dịch, v.v.

Chúng ta đã xử lý những trường hợp này bằng cách sử dụng máy móc xây dựng giao dịch của mình và do đó, các bài kiểm tra được viết ở đây phản ánh môi trường thực tế nhiều hơn.

note

Đối với hướng dẫn này, không cần phải xem qua tài liệu về mô hình đơn giản của plutus nhưng điều này không có nghĩa là không nên. Nó rất rõ ràng và mất vài phút để trang trải và có thể được truy cập bằng cách nhân bản chúng kho lưu trữ, vào docs thư mục và chạy mdbook serve --open.

Kiểm tra đơn vị để đặt hoạt động đặt cược

note

Toàn bộ tệp mã cho các bài kiểm tra liên quan đến hoạt động này có sẵn tại đây. Lưu ý rằng chúng ta đang sử dụng tasty để viết các bài kiểm tra của chúng ta và một tệp gọi các Unit tests riêng lẻ này ở đây.

Mục tiêu của chúng ta ở đây là viết các bài kiểm tra cho từng hoạt động của chúng ta, do đó có tên là "Unit tests". Mặc dù người ta cũng có thể viết các loại thử nghiệm khác, bao gồm cả các loại thử nghiệm dựa trên thuộc tính.

Xác định Run cho hoạt động đặt cược

Trước khi có bất kỳ lời nói jibber-jabber nào, hãy xem mã để chúng ta biết nó không phức tạp như vẻ ngoài của nó:

placeBetRun :: GYTxOutRef -> BetRefParams -> OracleAnswerDatum -> GYValue -> Maybe GYTxOutRef -> GYTxMonadRun GYTxId
placeBetRun refScript brp guess bet mPreviousBetsUtxoRef = do
addr <- ownAddress
skeleton <- placeBet refScript brp guess bet addr mPreviousBetsUtxoRef
sendSkeleton skeleton
note

Tại sao chúng ta gọi nó là "Run"? Chà, nếu bạn đã xem qua tài liệu về mô hình đơn giản của plutus, bạn sẽ biết rằng họ có đơn vị "Run" này, nơi thực sự hầu hết mã kiểm tra được thực thi và chúng ta có trình bao xung quanh loại này, mà chúng ta gọi là GYTxMonadRun (được định nghĩa trong Run.hs). Nhưng với tư cách là end developer, bạn không cần phải hiểu về nó.

Ngoài ra, GYTxMonadRun của chúng ta cũng có một ví dụ về GYTxQueryMonad.

Ý tưởng ở đây là bất kỳ thử nghiệm nào chúng ta thực hiện liên quan đến việc thực hiện thao tác đặt cược sẽ cần gọi hàm placeBet mà chúng ta đã xác định trước đó. Do đó, chúng ta đã xác định một Run để gọi chức năng này. Hàm của chúng ta placeBetRun lấy tất cả các tham số mà placeBet hàm yêu cầu, ngoại trừ địa chỉ mà chúng ta có thể lấy bằng hàm ownAddress (Xem ghi chú 1). ownAddress chức năng được xác định trong tập tin Run.hs nơi thực sự hầu hết các mã liên quan đến cuộc sống trình bao của chúng ta và nó cung cấp địa chỉ của ví đang chạy Run này như chúng ta sẽ sớm thấy.

Cuối cùng sendSkeleton có thể được hiểu là gửi giao dịch. Nó sẽ cập nhật trạng thái sổ cái giả và trả lại id giao dịch cho giao dịch đã gửi. Lưu ý rằng nó sẽ đưa ra một ngoại lệ trong trường hợp không thể gửi giao dịch.

Hiểu biết testRun

Trước khi chúng ta thấy dấu vết gọi lần chạy mà chúng ta vừa xác định, hãy lưu ý rằng trong testGroup, chúng ta có bài kiểm tra đầu tiên được viết là:

testRun "Balance checks after placing first bet" $ firstBetTrace (OracleAnswerDatum 3) (valueFromLovelace 20_000_000) 0_182_793

testRun là gì?

Chức năng này (được định nghĩa trong Utils.hs) lấy một chuỗi để biểu thị tên của bài kiểm tra và hàm tiếp tục (thuộc loại Wallets -> Run a), sau đó tạo ví bên trong để cung cấp cho hàm tiếp tục của chúng ta.

Kiểu Wallets được định nghĩa là:

data Wallets = Wallets
{ w1 :: !Wallet
, w2 :: !Wallet
, w3 :: !Wallet
, w4 :: !Wallet
, w5 :: !Wallet
, w6 :: !Wallet
, w7 :: !Wallet
, w8 :: !Wallet
, w9 :: !Wallet
} deriving (Show, Eq, Ord)

trong đó Wallet

data Wallet = Wallet
{ walletPaymentSigningKey :: !GYPaymentSigningKey
, walletNetworkId :: !GYNetworkId
, walletName :: !String
}
deriving (Show, Eq, Ord)

Do đó, testRun chức năng của chúng ta, tạo ra 9 ví này trong đó mỗi ví có ba tài sản sau:

  • Triệu ada. Lưu ý: Số tiền này được chia thành hai UTxO, một trong số đó dành cho tài sản thế chấp, có số tiền là 5 ada.
  • Triệu fakeGold.
  • Triệu fakeIron.

trong đó fakeGoldfakeIron là hai tài sản không bản địa của chúng ta.

Mỗi cuộc gọi đến testRun (như bạn có thể thấy - chúng ta có nhiều thử nghiệm, tất cả đều bắt đầu bằng testRun) chạy thử nghiệm nhất định với trạng thái sổ cái chuỗi khối mới (mới) đã cung cấp số dư trên cho 9 ví đó.

Trong trường hợp của chúng ta, "Balance checks after placing first bet" là tên của bài kiểm tra và firstBetTrace (OracleAnswerDatum 3) (valueFromLovelace 20_000_000) 0_182_793 là chức năng tiếp tục của chúng ta.

Xác định dấu vết để gọi placeBetRun

Bây giờ hãy xem định nghĩa firstBetTrace mà chúng ta đã gặp ngắn gọn ở trên:

-- | Trace for placing the first bet.
firstBetTrace :: OracleAnswerDatum -- ^ Guess
-> GYValue -- ^ Bet
-> Integer -- ^ Expected fees
-> Wallets -> Run () -- Our continuation function
firstBetTrace dat bet expectedFees ws@Wallets{..} = do
-- First step: Get the required parameters for initializing our parameterized script and add the corresponding reference script
(brp, refScript) <- computeParamsAndAddRefScript 40 100 (valueFromLovelace 200_000_000) ws
void $ runWallet w1 $ do -- following operations are ran by first wallet, `w1`
-- Second step: Perform the actual run.
withWalletBalancesCheck [w1 := valueNegate (valueFromLovelace expectedFees <> bet)] $ do
placeBetRun refScript brp dat bet Nothing

Ở đây, đối số cuối cùng thuộc loại Wallets như chúng ta đã lưu ý.

Lưu ý rằng chức năng này bắt đầu bằng cách gọi computeParamsAndAddRefScript, do đó hãy xem về nó:

-- | Function to compute the parameters for the contract and add the corresponding refernce script.
computeParamsAndAddRefScript
:: Integer -- ^ Bet Until slot
-> Integer -- ^ Bet Reveal slot
-> GYValue -- ^ Bet step value
-> Wallets -> Run (BetRefParams, GYTxOutRef) -- Our continuation
computeParamsAndAddRefScript betUntil' betReveal' betStep Wallets{..} = do
let betUntil = slotFromApi (fromInteger betUntil')
betReveal = slotFromApi (fromInteger betReveal')
fmap fromJust $ runWallet w1 $ do
betUntilTime <- slotToBeginTime betUntil
betRevealTime <- slotToBeginTime betReveal
let brp = BetRefParams (pubKeyHashToPlutus $ walletPubKeyHash w8) (timeToPlutus betUntilTime) (timeToPlutus betRevealTime) (valueToPlutus betStep) -- let oracle be wallet `w8`.
mORef <- addRefScript (walletAddress w9) (betRefValidator' brp)
case mORef of
Nothing -> fail "Couldn't find index of the Reference Script in outputs"
Just refScript -> return (brp, refScript)

Bước đầu tiên của chúng ta là xây dựng tham số (BetRefParams) cho hợp đồng được tham số hóa của chúng ta. Nhớ lại loại của nó là:

data BetRefParams = BetRefParams
{
brpOraclePkh :: PubKeyHash -- ^ Oracle's payment public key hash. This is needed to assert that UTxO being looked at indeed belongs to the Oracle.
, brpBetUntil :: POSIXTime -- ^ Time until which bets can be placed.
, brpBetReveal :: POSIXTime -- ^ Time at which Oracle will reveal the correct match result.
, brpBetStep :: Value -- ^ Each newly placed bet must be more than previous bet by `brpBetStep` amount.
}

Đối với brpBetUntil, chúng ta chọn vị trí 40 nhưng vì plutus hoạt động theo thời gian vị trí, chúng ta cần nhập một đơn nguyên có phiên bản GYTxQueryMonad để nhận thời gian vị trí từ vị trí và do đó phép tính đó xảy ra bên trong runWallet w1. Tương tự như vậy, brpBetReveal chúng ta đã chọn vị trí 100.

note

runWallet là một hàm tiện ích (được định nghĩa lại trong Utils.hs) cho phép chúng ta cung cấp cho môi trường. Hừm.. bạn hỏi môi trường nào? Nói chung, khi xây dựng giao dịch từ bộ khung, chúng ta cần một số ngữ cảnh, chẳng hạn như ai đang thực sự gửi giao dịch này? Vì chúng ta sẽ cần địa chỉ của họ để cung cấp cho họ đầu ra thay đổi. runWallet lấy làm đối số đầu tiên, ví để tạo ngữ cảnh từ đó và sau đó Run thực tế để chạy theo ngữ cảnh này.

Bây giờ quay lại với các tham số của chúng ta, đối với brpOraclePkh tham số, chúng ta đã chọn tham số đó cho ví 8. Và chúng ta lấy số bước của mình là 200 ada.

Mặc dù thao tác này không bắt buộc (nơi chúng ta đặt cược đầu tiên) nhưng vì placeBet chức năng của chúng ta bị quá tải để chấp nhận trường hợp đặt cược tiếp theo - chúng ta cần cung cấp tham chiếu cho tập lệnh tham chiếu chứa UTxO. Để làm được điều đó, chúng ta có một hàm trợ giúp được định nghĩa trong Utils.hs được gọi addRefScript để thêm tập lệnh đã cho tại một địa chỉ nhất định (chúng ta đã chọn tập lệnh đó cho ví 9) và trả về tham chiếu cho tập lệnh đó (trong Maybe).

Bây giờ chúng ta gần như đã hoàn thành việc gọi đường chạy của mình chỉ với một dòng nữa để hiểu.

withWalletBalancesCheck lấy một danh sách của bộ (Xem Ghi chú 2)  trong đó phần tử đầu tiên của bộ là ví và phần tử thứ hai biểu thị sự khác biệt về giá trị của ví mà chúng ta mong đợi sau khi thực hiện thao tác được xác định bên trong do khối của nó. Ở đây, chúng ta muốn số dư của ví 1 (là ví thực sự gọi hoạt động này) giảm theo số tiền đặt cược và cả phí.

note

Làm thế nào để chúng ta biết các khoản phí? Tốt bằng cách chạy thử nghiệm mà không có nó và sau đó ghi lại phí giao dịch từ các thông điệp tường trình.

Và điều này bao gồm bài kiểm tra đầu tiên của chúng ta 🥳.

Theo dõi nhiều cược

Bây giờ hãy viết một dấu vết liên quan hơn một chút. Lần này, chúng ta sẽ làm cho thông số theo dõi của chúng ta dựa trên các thông số hợp đồng được yêu cầu.

Đây là chữ ký của dấu vết của chúng ta:

-- | Trace which allows for multiple bets.
multipleBetsTraceWrapper
:: Integer -- ^ slot for betUntil
-> Integer -- ^ slot for betReveal
-> GYValue -- ^ bet step
-> [(Wallets -> Wallet, OracleAnswerDatum, GYValue)] -- ^ List denoting the bets
-> Wallets -> Run () -- Our continuation function
multipleBetsTraceWrapper betUntil' betReveal' betStep walletBets ws = do
-- First step: Get the required parameters for initializing our parameterized script and add the corresponding reference script
(brp, refScript) <- computeParamsAndAddRefScript betUntil' betReveal' betStep ws
-- Second step: Perform the actual bet operations
multipleBetsTraceCore brp refScript walletBets ws

Ba tham số đầu tiên tương ứng với các tham số của hợp đồng.

Tham số thứ tư biểu thị các cược khác nhau.

Chẳng hạn, chúng ta có thể gọi hàm này như sau:

testRun "Balance checks with multiple bets" $ multipleBetsTraceWrapper 400 1000 (valueFromLovelace 10_000_000) 
[ (w1, OracleAnswerDatum 1, valueFromLovelace 10_000_000)
, (w2, OracleAnswerDatum 2, valueFromLovelace 20_000_000)
, (w3, OracleAnswerDatum 3, valueFromLovelace 30_000_000)
, (w2, OracleAnswerDatum 4, valueFromLovelace 50_000_000)
, (w4, OracleAnswerDatum 5, valueFromLovelace 65_000_000 <> fakeGold 1000)
]

Tiếp theo, chúng ta muốn thêm tập lệnh tham chiếu của mình và tính toán các tham số hợp đồng thực tế (chuyển đổi vị trí thành posix) - một lần nữa được xử lý như trước đây.

Sau đó, chúng ta muốn thực hiện các hoạt động đặt cược thực tế. Nhưng lần này, chúng ta sẽ không quan tâm nhiều đến các khoản phí thực tế mà thay vào đó lấy ngưỡng 1 ada. Cách tiếp cận của chúng ta ở đây là so sánh số dư trước khi thực hiện bất kỳ hoạt động nào và sau khi thực hiện tất cả các hoạt động, sau đó thấy rằng mỗi ví đã mất số tiền đặt cược mà họ đã đặt khi xem xét các khoản phí ngưỡng.

Lưu ý: chúng ta sử dụng balance chức năng để lấy số dư cho ví đã cho.

-- | Trace which allows for multiple bets.
multipleBetsTraceCore
:: BetRefParams
-> GYTxOutRef -- ^ Reference script
-> [(Wallets -> Wallet, OracleAnswerDatum, GYValue)] -- ^ List denoting the bets
-> Wallets -> Run () -- Our continuation function
multipleBetsTraceCore brp refScript walletBets ws@Wallets{..} = do
let
-- | Perform the actual bet operation by the corresponding wallet.
performBetOperations [] _ = return ()
performBetOperations ((getWallet, dat, bet) : remWalletBets) isFirst = do
if isFirst then do
void $ runWallet (getWallet ws) $ do
void $ placeBetRun refScript brp dat bet Nothing
performBetOperations remWalletBets False
else do
-- need to get previous bet utxo
void $ runWallet (getWallet ws) $ do
betRefAddr <- betRefAddress brp
[_scriptUtxo@GYUTxO {utxoRef}] <- utxosToList <$> utxosAtAddress betRefAddr
void $ placeBetRun refScript brp dat bet (Just utxoRef)
performBetOperations remWalletBets False
-- | To sum the bet amount for the corresponding wallet.
sumWalletBets _wallet [] acc = acc
sumWalletBets wallet ((getWallet, _dat, bet) : remWalletBets) acc = sumWalletBets wallet remWalletBets (if getWallet ws == wallet then acc <> valueNegate bet else acc)
-- | Idea here is that for each wallet, we want to know how much has been bet. If we encounter a new wallet, i.e., wallet for whose we haven't yet computed value lost, we call `sumWalletBets` on it.
getBalanceDiff [] _set acc = acc
getBalanceDiff wlBets@((getWallet, _dat, _bet) : remWalletBets) set acc =
let wallet = getWallet ws
wallet'sName = walletName wallet
in
if Set.member wallet'sName set then getBalanceDiff remWalletBets set acc
else
getBalanceDiff remWalletBets (Set.insert wallet'sName set) ((wallet := sumWalletBets wallet wlBets mempty) : acc)
balanceDiffWithoutFees = getBalanceDiff walletBets Set.empty []
balanceBeforeAllTheseOps <- fmap fromJust $ runWallet w1 $ traverse (\(wallet, _value) -> balance wallet) balanceDiffWithoutFees
performBetOperations walletBets True
balanceAfterAllTheseOps <- fmap fromJust $ runWallet w1 $ traverse (\(wallet, _value) -> balance wallet) balanceDiffWithoutFees
void $ runWallet w1 $ verify (zip3 balanceDiffWithoutFees balanceBeforeAllTheseOps balanceAfterAllTheseOps)
where
-- | Function to verify that the wallet indeed lost by /roughly/ the bet amount. We say /roughly/ as fees is assumed to be within (0, 1 ada].
verify [] = return ()
verify (((wallet, diff), vBefore, vAfter) : xs) =
let vAfterWithoutFees = vBefore <> diff
(expectedAdaWithoutFees, expectedOtherAssets) = valueSplitAda vAfterWithoutFees
(actualAda, actualOtherAssets) = valueSplitAda vAfter
-- threshold = valueFromLovelace 1_000_000 -- 1 ada
threshold = 1_000_000 -- 1 ada
in if expectedOtherAssets == actualOtherAssets && actualAda < expectedAdaWithoutFees && expectedAdaWithoutFees - threshold <= actualAda then verify xs
-- valueGreater vAfterWithoutFees vAfter && valueGreaterOrEqual vAfter (valueMinus vAfterWithoutFees threshold) then verify xs
else fail ("For wallet " <> walletName wallet <> " expected value (without fees) " <> show vAfterWithoutFees <> " but actual is " <> show vAfter)
note

Một con mắt đại bàng có thể nhận thấy hai nhận xét bên trong verify chức năng.

Đầu tiên, hãy lưu ý rằng valueSplitAda chia chúng ta GYValue thành những dây buộc tình yêu và những gì còn lại bên cạnh nó. Vì phí không ảnh hưởng đến mã thông báo không phải ada (chưa), nên chúng ta so sánh ngưỡng bằng cách sử dụng mã thông báo ada.

chúng ta cũng có thể so sánh GYValue trực tiếp bằng cách sử dụng valueGreater (cũng có valueGreaterOrEqual) như được thực hiện trong nhận xét nhưng cách hiện tại tối ưu hơn một chút vì chúng ta không cần so sánh lại trên các mã thông báo không phải ada.

Nhưng đôi khi chúng ta muốn một bài kiểm tra thất bại!

Điều gì xảy ra nếu số tiền đặt cược mới đặt không nhiều hơn brpBetStep số tiền tối thiểu? Điều gì xảy ra nếu khung giao dịch hơi sai, chẳng hạn như chúng ta đã không đặt mustBeSignedBy? Điều gì sẽ xảy ra nếu ai đó cố gắng đặt cược sau đó brpBetUntil? Chuyện gì xảy ra nếu...

Đối với tất cả các trường hợp như vậy, chúng ta có thể khẳng định rằng một dấu vết nhất định phải thất bại khi sử dụng mustFail như:

testRun "Not adding atleast bet step amount should fail" $ mustFail . multipleBetsTrace 400 1000 (valueFromLovelace 10_000_000) 
[ (w1, OracleAnswerDatum 1, valueFromLovelace 10_000_000)
, (w2, OracleAnswerDatum 2, valueFromLovelace 20_000_000)
, (w3, OracleAnswerDatum 3, valueFromLovelace 30_000_000)
, (w2, OracleAnswerDatum 4, valueFromLovelace 50_000_000)
, (w4, OracleAnswerDatum 5, valueFromLovelace 55_000_000 <> fakeGold 1000)]

Ở đây, ví w4 không tăng tiền đặt cược lên 10 ada và do đó phải thất bại.

Unit tests để lấy tiền đặt cược

note

Toàn bộ tệp mã cho các bài kiểm tra liên quan đến hoạt động này có sẵn tại đây.

Trên các dòng tương tự như trước, trước tiên hãy xác định hoạt động của chúng ta takeBets :

-- | Run to call the `takeBets` operation.
takeBetsRun :: GYTxOutRef -> BetRefParams -> GYTxOutRef -> GYTxOutRef -> GYTxMonadRun GYTxId
takeBetsRun refScript brp toConsume refInput = do
addr <- ownAddress
skeleton <- takeBets refScript brp toConsume addr refInput
sendSkeleton skeleton

Tiếp theo, chúng ta sẽ xác định dấu vết của chúng ta để gọi Run này :

-- | Trace for taking bet pot.
takeBetsTrace :: Integer -- ^ slot for betUntil
-> Integer -- ^ slot for betReveal
-> GYValue -- ^ bet step
-> [(Wallets -> Wallet, OracleAnswerDatum, GYValue)] -- ^ List denoting the bets
-> Integer -- ^ Actual answer
-> (Wallets -> Wallet) -- ^ Taker
-> Maybe Integer -- ^ Expected fees
-> Wallets -> Run () -- Our continuation function
takeBetsTrace betUntil' betReveal' betStep walletBets answer getTaker mExpectedFees ws@Wallets{..} = do
(brp, refScript) <- computeParamsAndAddRefScript betUntil' betReveal' betStep ws
multipleBetsTraceCore brp refScript walletBets ws
-- Now lets take the bet
mMRef <- runWallet w1 $ addRefInput True (walletAddress w8) (datumFromPlutusData $ OracleAnswerDatum answer)
let taker = getTaker ws
case mMRef of
Just (Just refInput) -> do
void $ runWallet taker $ do
betRefAddr <- betRefAddress brp
[_scriptUtxo@GYUTxO {utxoRef, utxoValue}] <- utxosToList <$> utxosAtAddress betRefAddr
waitUntilSlot $ slotFromApi (fromInteger betReveal')
case mExpectedFees of
Just expectedFees ->
withWalletBalancesCheck [taker := utxoValue <> valueNegate (valueFromLovelace expectedFees)] $ do
takeBetsRun refScript brp utxoRef refInput
Nothing -> takeBetsRun refScript brp utxoRef refInput
_anyOtherMatch -> fail "Couldn't place reference input successfully"

Ở đây, trước tiên chúng ta thực hiện bước chung là tính toán các tham số tập lệnh cần thiết và thêm tập lệnh tham chiếu.

Sau đó, chúng ta đã sử dụng addRefInput định nghĩa trong Utils.hs có mục đích ở đây sẽ trở nên rõ ràng bằng cách xem tài liệu cá tuyết chấm đen của nó dưới đây:

-- | Adds an input (whose datum we'll refer later) and returns the reference to it.
addRefInput:: Bool -- ^ Whether to inline this datum?
-> GYAddress -- ^ Where to place this output?
-> GYDatum -- ^ Our datum.
-> GYTxMonadRun (Maybe GYTxOutRef)

Tiếp theo, chúng ta chỉ cần đợi cho đến khi thời gian đặt cược được tiết lộ và yêu cầu tiền thưởng của chúng ta!

Bây giờ chúng ta đã có dấu vết của mình để lấy bet pot, chúng ta có thể thử kiểm tra các điều kiện khác - ví dụ cho một số được viết trong TakeBetPot.hs tài liệu.

Chú thích

  1. Để truyền tải thông điệp tốt hơn, chúng ta có một từ đồng nghĩa mẫu được xác định trong Utils.hs tài liệu:

    pattern (:=) :: x -> y -> (x, y) 
    pattern (:=) x y = (x, y)
  2. Vì chúng ta yêu cầu phải có chữ ký trong bộ khung, chúng ta không thể đặt cược thay cho bất kỳ ai khác. 


Picture