Smart Contracts & Solidity

Chapter 10
Testing, Debugging and Contract Analytics

Advance Diploma in Blockchain
Smart Contracts & Solidity   •   Advance Diploma

Testing, Debugging and Contract Analytics

Introduction

Smart contracts are immutable programs that manage assets, permissions, and business rules in an adversarial environment. A defect may therefore cause permanent financial loss, prevent legitimate users from accessing funds, or create an exploitable governance or accounting condition. Compilation proves only that source code can be translated into bytecode; it does not prove that the program satisfies its intended specification. Professional Solidity development consequently requires a layered evidence process combining unit tests, integration tests, fuzz testing, invariant testing, fork testing, debugging, code coverage, gas analysis, and on-chain analytics.

Testing is not a single activity performed immediately before deployment. It is a risk-management discipline. Unit tests provide local evidence about individual functions, integration tests examine interactions between contracts, fuzz tests explore broad input spaces, invariant tests examine properties that should remain true across many transaction sequences, and fork tests evaluate behaviour against realistic deployed protocols and historical state. Debugging explains failures, while analytics supports decisions after deployment.

This chapter treats testing as an engineering argument: a development team should be able to explain what was tested, under which assumptions, what evidence was obtained, what remains uncertain, and how the evidence affects deployment decisions.

Learning Objectives

By the end of this chapter, a learner should be able to:

  1. Design unit, integration, fuzz, invariant, and fork tests for Solidity systems.
  2. Interpret revert messages, stack traces, call traces, logs, and transaction receipts.
  3. Distinguish statement, branch, function, and path coverage and identify their limitations.
  4. Estimate and compare gas consumption using reproducible measurements.
  5. Use events, RPC methods, and indexed logs to analyse contract activity.
  6. Construct evidence-based release decisions and communicate residual risk.

1. Testing as a Layered Assurance Model

A useful testing strategy moves from narrow, inexpensive tests to broad, realistic tests. The layers are complementary rather than interchangeable.

LayerMain questionTypical subjectMain limitation
UnitDoes one function behave correctly for controlled inputs?A contract or library functionMay miss interaction failures
IntegrationDo components interact correctly?Token, vault, oracle, and access controlMore setup and diagnosis complexity
FuzzDoes behaviour remain correct for many generated inputs?Input boundaries and unusual combinationsRandomness does not guarantee relevant sequences
InvariantDoes a property remain true after arbitrary actions?Solvency, supply, permissionsRequires carefully designed properties
ForkDoes the system work with realistic deployed state?Protocol integrations and upgrade pathsDepends on fork block and external assumptions
On-chain analyticsWhat actually happened in production?Events, balances, calls, gas, and usersObservations may be incomplete or misinterpreted

A test should have a clear oracle: a rule that determines whether the observed result is correct. Oracles may be exact values, expected reverts, state-transition properties, conservation laws, or comparisons with a reference implementation. A test without a meaningful oracle can execute successfully while failing to detect a defect.

Test environments and reproducibility

Common environments include a local in-memory EVM, a local node with a selected chain state, a public testnet, and a mainnet fork. Each provides a different balance between speed, realism, cost, and determinism. Test accounts should be explicitly funded and configured. Time, block number, chain ID, base fee, and external contract addresses should be controlled where possible.

A reproducible test records at least the compiler version, optimizer settings, EVM target, dependency versions, fork source, fork block, environment variables, and test command. A fork test without a pinned block can produce different results as external state changes.

2. Unit and Integration Testing

Unit tests

A unit test normally follows the arrange–act–assert pattern:

  1. Arrange the caller, initial state, balances, and parameters.
  2. Act by invoking the target function.
  3. Assert on return values, state changes, emitted events, and expected reverts.

For a withdrawal function, a robust unit test should not check only that the call succeeds. It should verify that the caller's balance decreases by the requested amount, the recipient receives the correct amount, the vault's accounting changes consistently, and the appropriate event is emitted. It should also test zero values, maximum values, insufficient balances, unauthorized callers, paused states, and repeated calls.

Revert tests should assert the relevant custom error or error selector where practical. Checking only that “some transaction reverted” can conceal an unintended failure. Conversely, tests should avoid depending on unstable implementation details when the public specification does not require them.

A state-transition test can be expressed conceptually as:

St+1 = F(St, at)

Here, St is the state before action at, F is the contract transition function, and St+1 is the resulting state. The notation is conceptual: an EVM transaction may also consume gas, emit logs, update block-related values, and call other contracts. The test oracle should identify which components of the transition are required by the specification.

Integration tests

Integration tests combine contracts and external dependencies. Examples include:

  • a vault depositing and withdrawing an ERC-20 token;
  • a lending market consulting an oracle and transferring collateral;
  • a governance contract executing a proposal through a timelock;
  • a router interacting with a decentralized exchange;
  • a proxy forwarding calls to an implementation.

Integration tests should model both successful and adversarial dependencies. A token may return false rather than revert, charge a transfer fee, invoke callbacks, or have non-standard decimals. An oracle may return stale data, revert, or provide a value outside an acceptable range. Mock contracts are useful, but a mock that is too cooperative can create false confidence. At least some tests should use realistic implementations or forked deployments.

Test doubles

A mock is a controlled replacement that returns predetermined results. A stub supplies simplified behaviour. A spy records interactions. A malicious test double deliberately violates assumptions, for example by re-entering during a token callback or returning malformed data. Security-relevant integrations should be tested with both honest and hostile doubles.

3. Fuzz Testing

Fuzz testing generates many inputs rather than relying exclusively on manually selected examples. It is particularly effective for boundary conditions, arithmetic, authorization combinations, and interactions among parameters.

A fuzz test should constrain generated values only when necessary to satisfy the domain. Excessive assumptions weaken the test. For example, constraining an amount to be non-zero is reasonable when zero is invalid and tested separately; constraining it to a narrow range merely to make the test pass may hide an overflow or accounting defect.

Important fuzzing dimensions include:

  • numerical extremes: zero, one, maximum integers, and values near thresholds;
  • addresses: the zero address, the owner, an approved account, and unrelated accounts;
  • time and block values near expiry or maturity;
  • array lengths, duplicate entries, and ordering;
  • combinations of permissions and paused states;
  • sequences such as deposit–withdraw–deposit or approve–transferFrom–revoke.

Modern Solidity testing frameworks generally record a failing input and attempt to shrink it to a smaller counterexample. The shrunk case is valuable because it often reveals the essential defect. A team should preserve the counterexample as a regression test after fixing the bug.

Fuzzing is probabilistic exploration, not proof of correctness. If a test samples inputs from distribution P(x), the absence of a failure means only that no failure was observed in the tested samples under the selected generator and assumptions. It does not establish that all possible inputs are safe.

4. Invariant Testing

An invariant is a property expected to remain true before and after arbitrary valid actions. Invariants are stronger than isolated examples because they test relationships across sequences.

Typical financial invariants include:

  • total internal balances equal the assets that the contract is responsible for, subject to explicitly defined fees and rounding;
  • token supply equals the sum of balances, where the token specification requires this relationship;
  • a user cannot withdraw more than their recorded entitlement;
  • only authorized roles can perform privileged actions;
  • a paused contract does not execute operations prohibited during pause;
  • debt does not become negative;
  • a conservation relationship holds after deposits, withdrawals, transfers, and liquidations.

Suppose a vault records user shares qi and has an asset balance A. A simplified accounting invariant may be written as:

Aaccounted = ∑i=1n qi + F

Here, Aaccounted is the internally accounted asset quantity, qi is the entitlement of user i, and F represents explicitly recognised fees or reserves. This is valid only if all quantities use the same asset units and if the contract's specification defines the relationship in this way. Rebasing tokens, transfer fees, accrued interest, and rounding may require a different invariant.

Invariant testing requires a handler or action model. The handler selects actions and actors, applies valid preconditions, and then checks the invariant. If the handler never calls a liquidation function, the test cannot reveal a liquidation defect. Action selection must therefore reflect realistic and adversarial workflows.

A useful distinction is between safety properties and liveness properties. “Funds cannot be withdrawn by an unauthorized account” is a safety property. “A valid withdrawal eventually succeeds” is closer to a liveness property and may require assumptions about external contracts, gas limits, and governance actions. Invariant frameworks are generally better suited to safety properties than to proving liveness.

5. Fork Testing

Fork testing runs transactions against a local copy of a public blockchain state. It is appropriate when correctness depends on deployed contracts, liquidity, oracle configuration, token balances, proxy implementations, or historical governance state.

A fork test should specify:

  • network and RPC source;
  • fork block number;
  • deployed addresses and their provenance;
  • test account impersonation rules;
  • whether transactions are isolated or executed sequentially;
  • assumptions about external state;
  • treatment of timestamps, base fees, and chain-specific behaviour.

For example, an upgrade test can fork the chain at a block before the upgrade, impersonate the authorized account, execute the upgrade, and verify storage compatibility and post-upgrade behaviour. It should also test that unauthorized accounts cannot perform the same operation.

Fork tests are not a substitute for independent review. They reproduce observed state, not necessarily every production condition. A fork may omit mempool competition, validator ordering, future oracle updates, bridge messages, or changes after the selected block. Results must therefore be interpreted as conditional evidence.

6. Debugging Failures and Interpreting Traces

A failing transaction provides evidence, not an explanation. Debugging proceeds from the external symptom toward the first incorrect state transition.

Receipts, events, and revert data

A transaction receipt commonly contains status, block information, gas used, logs, and transaction identifiers. A successful status does not prove business correctness; a transaction can execute an unintended state change without reverting. Revert data may contain a standard error, a custom error, a panic code, or arbitrary returned bytes.

Events are logs emitted during execution. An event typically contains a non-indexed data section and up to a protocol-defined number of indexed topics, including the event signature topic. Indexed fields are useful for filtering, while non-indexed fields are usually decoded from the data payload. Events are evidence of what the contract emitted, not an independent truth source. A defective contract can emit misleading events, and events do not by themselves prove that an off-chain listener processed them correctly.

Call traces

A call trace represents nested calls and their order. It can reveal:

  • which contract initiated a call;
  • whether a proxy delegated to the expected implementation;
  • where a revert first occurred;
  • whether a callback caused re-entrancy;
  • whether a transfer used the expected token;
  • which call consumed substantial gas.

The first visible revert is not always the root cause. An inner call may revert, causing an outer wrapper to revert with a generic message. Debugging should identify the deepest failing frame and compare the actual calldata, caller, value, and state with the intended preconditions.

A disciplined debugging workflow

  1. Reproduce the failure with the smallest available test or transaction.
  2. Record chain state, caller, calldata, value, and relevant block parameters.
  3. Decode the revert data and inspect the trace.
  4. Identify the first state or assumption that differs from the specification.
  5. Add a regression test that fails before the fix.
  6. Implement the fix and confirm the regression test passes.
  7. Run the complete suite, including fuzz and invariant tests.
  8. Review whether the fix introduces a gas, access-control, upgrade, or compatibility consequence.

Debug logging and trace instrumentation are valuable during development but should not be confused with production observability. Diagnostic events can increase gas costs and may expose information that users did not expect to be public.

7. Coverage and Its Interpretation

Coverage measures which portions of an instrumented program were exercised by tests. Common measures include:

  • statement coverage: executed statements;
  • function coverage: invoked functions;
  • branch coverage: explored true and false outcomes of conditions;
  • path coverage: explored execution paths.

For statement coverage, a simple ratio is:

Cs = \frac{Nexecuted\ statements}{Ntotal\ statements} × 100\%

Here, Cs is statement coverage and both counts refer to the same instrumented build and measurement scope. The percentage is descriptive, not a probability that the program is correct. A test may execute every statement while failing to assert meaningful outcomes. Path coverage can become impractical because the number of paths may grow rapidly with branches and loops.

Coverage should be combined with mutation testing, review of untested branches, and quality of assertions. Mutation testing changes the program in small ways, such as reversing a comparison or removing an event, and checks whether the test suite detects the change. A high coverage result with surviving meaningful mutants indicates weak oracles.

Coverage reports must account for compiler-generated code, inherited functions, modifiers, libraries, and excluded files. Comparing percentages across different configurations can be misleading.

8. Gas Measurement and Performance Analysis

Gas is a unit of computational resource consumption in the EVM. The transaction fee paid in the native asset is commonly represented as:

Fee = G × P

where G is gas used, measured in gas units, and P is the effective gas price, measured in native-asset units per gas unit. The result is denominated in the native asset. Under fee mechanisms with separate components, the effective price and refund rules must be interpreted according to the relevant network specification.

A gas report should distinguish deployment gas from execution gas and should record compiler version, optimizer runs, calldata, storage state, and whether a transaction is the first or a subsequent access. Storage writes, cold accesses, memory expansion, logs, and external calls can have materially different costs. A benchmark that uses an empty state may not represent production behaviour.

For a set of transactions, an arithmetic mean can be calculated as:

\bar{G} = \frac{1}{m}∑j=1m Gj

where Gj is gas used by transaction j and m is the number of observations. The mean is useful for a stable workload but can conceal outliers. Percentiles, maximum gas, and separate measurements for cold and warm paths may be more informative.

Gas optimisation should follow correctness and security. Packing storage, reducing redundant reads, using calldata appropriately, and avoiding unnecessary writes can help, but an optimisation that complicates authorization or arithmetic deserves careful review. The relevant decision is not simply “lowest gas”; it is acceptable cost for the required safety, maintainability, and user experience.

9. On-Chain Contract Analytics

Contract analytics converts transactions, logs, traces, state reads, and token transfers into evidence about system behaviour. Typical questions include:

  • How many unique addresses interacted with the contract?
  • What proportion of calls succeeded or reverted?
  • Which functions consume the most gas?
  • Did an upgrade alter event frequency or failure rates?
  • Are balances, reserves, and liabilities consistent?
  • Are privileged functions being used according to governance policy?

A reliable analytic pipeline should preserve raw transaction hashes, block numbers, transaction indices, log indices, contract addresses, chain IDs, and decoding metadata. Block number and log index help establish ordering. Addresses should be normalised consistently, and proxy implementations should be tracked across upgrades.

Event queries commonly filter by contract address, event signature, and indexed topics. Analysts must account for chain reorganisations, duplicate ingestion, proxy address changes, missing or malformed logs, and events emitted by nested contracts. A production indexer should support confirmation policies and reorganisation handling.

Analytics should distinguish observed facts from interpretations. “The contract emitted 500 withdrawal events” is an observation. “The protocol has 500 successful withdrawals” is an interpretation that may be false if events were emitted before a later off-chain reconciliation, if multiple events represent one operation, or if the event semantics were misunderstood.

A useful operational dashboard may combine success rate, revert categories, gas percentiles, asset balances, event counts, privileged actions, oracle freshness, and invariant checks. Alerts should be tied to explicit thresholds and escalation procedures rather than visual intuition alone.

10. Illustrative Case: Testing a Tokenised Vault

The following is a fictional illustrative case. It demonstrates how evidence from different test types can support a release decision.

Background

Northstar Vault accepts an ERC-20 asset and issues proportional vault shares. Users may deposit, withdraw, and transfer shares. The vault uses an external price feed to value assets and includes an administrator pause function.

Problem

Before deployment, the team must determine whether the vault preserves accounting, rejects unsafe oracle data, prevents unauthorized administration, and remains usable with a token that charges a transfer fee.

Analysis

The team identifies the following risks:

  1. The vault may credit the nominal deposit amount even when the token transfers less.
  2. A stale or manipulated price may permit excessive share issuance.
  3. A withdrawal callback may re-enter the vault.
  4. Pause checks may be applied to deposits but omitted from withdrawals.
  5. Rounding may allow repeated small operations to extract value.

Approach

Unit tests cover share calculations, zero amounts, maximum values, access control, pause behaviour, oracle freshness, and custom errors. Integration tests use a fee-on-transfer token, a standard token, a reverting token, and a callback-capable token. Fuzz tests generate deposits and withdrawals near numerical boundaries. An invariant handler executes sequences of deposits, withdrawals, transfers, and pauses, then checks that recorded liabilities do not exceed assets under the documented fee and rounding model.

A fork test uses the deployed oracle interface and realistic token metadata at a pinned block. The team also performs an upgrade test to verify that storage variables retain their meanings. Coverage identifies an untested branch in the stale-price condition. Mutation testing shows that removing the pause check causes a test failure, while changing an event parameter does not; the team adds an assertion for the event.

Gas measurements are taken for first-time deposits and repeat deposits because storage access patterns differ. The report records median and maximum gas rather than one unrepresentative transaction.

Outcome

The tests reveal that the vault credits the requested amount rather than the amount actually received from the fee-on-transfer token. The team changes accounting to measure the balance difference before and after transfer, documents the supported token model, adds a regression test, and repeats the invariant and fork suites. The release is approved only after the stale-price branch, event semantics, and fee-token behaviour are covered and reviewed.

Learning points

The case illustrates that no single metric establishes safety. Coverage located an untested branch, fuzzing explored numerical boundaries, integration testing exposed token behaviour, invariants evaluated system-level accounting, and fork testing examined realistic external state. The release decision was based on converging evidence and explicit residual assumptions.

11. Governance, Ethics, and Contemporary Practice

Testing evidence influences users' financial and safety decisions; it must therefore be reported honestly. Teams should not advertise “100% tested” as equivalent to “secure,” suppress failed fuzz cases, or present a fork result without its block number and assumptions. Test data can also contain addresses and behavioural information that may be sensitive in an organisational context, even though public blockchain data is generally transparent.

Release governance should define severity levels, required approvals, audit responses, emergency pause authority, and conditions for deployment. A reproducible build, signed artifacts, verified source code, documented deployment addresses, and monitored upgrade events improve accountability.

AI-assisted test generation can suggest edge cases, invariant candidates, and regression tests. However, generated tests may encode the implementation rather than the specification, omit adversarial assumptions, or produce assertions that are technically correct but meaningless. Human review remains necessary, especially for oracle assumptions, economic invariants, access control, and upgrade safety.

Contemporary practice increasingly combines property-based testing with symbolic execution, static analysis, differential testing, and runtime monitoring. Differential testing compares a contract with a trusted reference model or an alternative implementation. Static tools can detect patterns such as re-entrancy risks or unreachable code, but tool findings require interpretation and do not replace dynamic testing or expert review.

12. Best-Practice Release Checklist

Before deployment, a team should be able to answer “yes” to the following questions:

  • Are requirements expressed as testable behaviours and properties?
  • Are success, failure, boundary, and authorization cases covered?
  • Are external contracts tested both honestly and adversarially?
  • Are fuzz campaigns reproducible and are counterexamples preserved?
  • Are invariants meaningful, reachable, and checked after realistic actions?
  • Are fork tests pinned to documented blocks and networks?
  • Can failures be traced to a specific call and decoded error?
  • Are coverage results interpreted alongside assertions and mutation results?
  • Are gas benchmarks measured under representative state and calldata?
  • Are events documented and indexed consistently?
  • Are analytics resilient to proxy upgrades and chain reorganisations?
  • Are residual risks, assumptions, and deployment approvals recorded?

Chapter Summary

Testing smart contracts is a layered assurance process rather than a final software check. Unit tests validate local behaviour; integration tests examine component interactions; fuzz tests explore broad input spaces; invariant tests verify properties across action sequences; and fork tests reproduce realistic external state. Debugging depends on receipts, revert data, events, and call traces, with special attention to the deepest failing call and the first violated assumption.

Coverage describes exercised code but does not measure correctness. Gas analysis requires controlled benchmarks and should distinguish execution paths, state conditions, and outliers. On-chain analytics combines transactions, logs, traces, and state to support operational and governance decisions, but observed data must be distinguished from interpretation. A defensible release decision combines these forms of evidence, documents assumptions, and communicates residual uncertainty honestly.

Key Terms

Test oracle: A rule used to determine whether observed behaviour is correct.

Unit test: A test of a focused function, contract, or library in isolation.

Integration test: A test of interactions among contracts or external dependencies.

Fuzz testing: Automated testing using many generated inputs.

Invariant: A property that should remain true across permitted state transitions.

Handler: A test component that selects and executes actions during invariant testing.

Fork testing: Testing against a local copy of a blockchain’s deployed state.

Regression test: A test preserved to prevent a previously fixed defect from returning.

Revert data: Return data explaining why EVM execution failed.

Call trace: A representation of nested calls and execution flow.

Event: A Solidity-emitted log used to communicate state-related information to off-chain systems.

Coverage: A measurement of which program elements tests exercised.

Mutation testing: Testing test-suite strength by introducing controlled code changes.

Gas: The EVM resource unit consumed by computation and state operations.

Gas report: A measurement of gas usage for selected functions or transactions.

RPC: A remote procedure call interface for communicating with a blockchain node.

Indexer: Software that extracts, decodes, stores, and queries blockchain data.

Invariant monitoring: Operational checking of required properties after deployment.

Residual risk: Risk that remains after controls and testing have been applied.

Review Questions

Short Answer Questions

  1. What is the principal difference between unit testing and integration testing?
  2. Why is a test oracle necessary?
  3. What information can a call trace reveal during debugging?
  4. Why does high statement coverage not prove contract correctness?
  5. Why should fork tests normally use a pinned block number?

Long Answer Questions

  1. Explain how unit, integration, fuzz, invariant, and fork testing complement one another in a smart-contract release process.
  2. Describe a disciplined workflow for diagnosing a reverted transaction using receipt data, revert data, and call traces.
  3. Explain the difference between statement, branch, function, and path coverage, including the limitations of coverage metrics.
  4. Discuss how gas reports should be designed so that their results are meaningful and reproducible.
  5. Explain the evidential and governance responsibilities involved in publishing on-chain analytics about a deployed protocol.

Analytical / Application Questions

  1. A vault passes all unit tests but fails an invariant after a sequence of deposit, transfer, and withdrawal actions. Design a method to minimise the failing sequence, identify the violated accounting assumption, and create a regression test.
  2. A fork test succeeds at block 18,000,000 but fails at block 18,100,000. Analyse at least four possible causes and explain what evidence would distinguish environmental change from a contract defect.
  3. A gas report shows that the median withdrawal uses 95,000 gas while the maximum uses 420,000 gas. Propose an investigation using traces, state variation, and workload classification before recommending an optimisation.

Multiple Choice Questions with Answers

Question 1

Which testing method is primarily designed to verify properties across many sequences of actions?

A. Unit testing

B. Invariant testing

C. Compilation testing

D. Interface testing

Correct Answer: B. Invariant testing

Question 2

What is the main value of a pinned block number in a fork test?

A. It guarantees zero gas consumption

B. It makes external blockchain state reproducible

C. It removes the need for assertions

D. It prevents all contract reverts

Correct Answer: B. It makes external blockchain state reproducible

Question 3

Which statement best describes fuzz testing?

A. It proves every possible input is safe

B. It tests only manually selected examples

C. It explores generated inputs and can reveal boundary failures

D. It measures only deployment bytecode size

Correct Answer: C. It explores generated inputs and can reveal boundary failures

Question 4

In debugging, which artefact best reveals nested external calls and the point at which an inner call reverted?

A. A call trace

B. A source-code comment

C. A compiler version string

D. A wallet address book

Correct Answer: A. A call trace

Question 5

What does statement coverage measure?

A. The percentage of users who executed a function

B. The percentage of instrumented statements executed by tests

C. The probability that the contract is secure

D. The amount of gas refunded by a transaction

Correct Answer: B. The percentage of instrumented statements executed by tests

Question 6

If a transaction uses 80,000 gas at an effective gas price of 25 gwei, what is the fee in native asset units?

A. 0.000002 native units

B. 0.00002 native units

C. 0.002 native units

D. 2 native units

Correct Answer: B. 0.00002 native units

Question 7

Why should a fee-on-transfer token be included in integration testing for a vault?

A. It always prevents re-entrancy

B. It may cause the amount received to differ from the nominal transfer amount

C. It guarantees lower gas usage

D. It disables event emission

Correct Answer: B. It may cause the amount received to differ from the nominal transfer amount

Question 8

Which statement about events is most accurate?

A. Events independently prove that the intended business result occurred

B. Events are logs that can support off-chain analysis but may reflect defective emission logic

C. Events are never visible to off-chain systems

D. Events automatically enforce access control

Correct Answer: B. Events are logs that can support off-chain analysis but may reflect defective emission logic

Question 9

What is mutation testing intended to evaluate?

A. Whether the blockchain can be reorganised

B. Whether tests detect deliberate small changes in program behaviour

C. Whether a token has transferable balances

D. Whether an RPC endpoint is geographically close

Correct Answer: B. Whether tests detect deliberate small changes in program behaviour

Question 10

Which practice improves the reliability of an on-chain analytics pipeline?

A. Ignoring chain reorganisations

B. Storing only decoded event names

C. Preserving transaction, block, log-index, chain, and decoding metadata

D. Treating every emitted event as proof of economic success

Correct Answer: C. Preserving transaction, block, log-index, chain, and decoding metadata

Smart Contracts & Solidity
Chapter 10 of 15