Solution for the Vault Owner Misconduct

In October of this year, we revealed an incident of a Vault owner cheating and illegally acquiring rewards meant for Delegators, and the details have been disclosed here.

To avoid innocent losses in the community, we will provide full subsidy to all victims through the mining reward addresses. The Delegators who incurred losses in the event can claim subsidy on this page now.

Please convey this message to each other in the community, and visit this page to claim your own subsidy.

For Multisig addresses owners, please check the your subsidy number in the list first, and execute the claim transaction via Polkadot.js. Here is the tutorial that how to claim it via Polkadot.js.

And for all the cheating Vault owners, please return the funds to this address “436H4jat7TobTbNYLdSJ3cmNy9K4frmE4Yuc4R2nNnaf56DL”, or return to your delegators directly.
We, our team and all the delegators who incurred losses, reserve the right to legally address this incident and may take action at any time.

How many $PHAs will be paid

This depends on the impact you suffered in this issue.

You can find the subsidy list here.

The Sheet “Compensation list” includes the Compensation list and the list of cheating vault owners. For delegators, you can check how much funds can be paid back (from each pools and a total number). And for Vault owners, you will know how much funds you should return.

How is it calculated?

As mentioned in the incident report, the Vault owner acquired delegators’ staking rewards by maliciously adjusting commissions. Therefore, we traced back all on-chain records of the Vault owner’s reward claiming (specific code can be found in Note-1), manually filtered out all malicious events, and calculated the losses of delegators in all malicious events (calculation code can be found in Note-2).

This resulted in the final compensation list above.

Three kinds of addresses will not be compensated

We don’t mind compensating the delegators at the same time as the vault owner return the funds to delegators, althrough this would result in double compensation. Just hope that the delegators won’t suffer any loss.

However, we don’t want cheaters to reap more benefits, such as staking to their own pool with other accounts, then collecting part of the rewards as a pool owner, and receiving a sum of money from our compensation.

So, this three kinds of addresses will not be compensated:

  • Vault owner itself
  • The addresses which received owner rewards from the cheating Vault
  • Addresses which has close financial turnover connections with above 2 kinds of addresses

Address list of above 3 kinds, are listed in the sheet “Cheating address/Elimination list”. You can check here to track it.

Timeline review of the issue

  • 6th Oct to 7th Oct, the issue occurred, cheating vault owners claimed the rewards.
  • 7th Oct to 8th Oct, the community started sharing feedback with the team.
  • Night of 8th Oct, the influence was confirmed, and work started on a solution to prevent the risk.
  • 9th Oct, the motion for an on-chain upgrade was proposed.
  • 10th Oct, the on-chain upgrade was completed, the issue was mitigated.
  • 11th Oct, the situation was announced to the community.
  • 16th Oct, a list of vault owners’ claims was prepared.
  • 23th Oct, all malicious events were manually filtered out.
  • 24th Oct, a list of losses for each address was created.
  • 7th Nov, the “Elimination list” was tracked and filtered.
  • 8th Nov to 22th Nov, the compensation list was internally double-checked with some community members.
  • 23th Nov, the compensation list was published.
  • 27th Nov, the claims processing function was launched.
  • 7th Dec, the claims processing page was launched.

Thank you again for the help from the community

As I wrote following the post in the forum:

A belated thank you to the Tucuman team who raised doubts about the Vault reward logic in February. Even though we decided to stick to the original logic of the Vault after multiple discussions at that time, it’s clear that our decision was incorrect.

To rectify this mistake, we categorize this incident as a “High-Level” issue internally, and I will apply for a belated Bug Bounty for the Tucuman team, valued at 4500 USD.

At the same time, we also need to thank community member “High/Stake” for sending the vulnerability report on October 1st, and community member “Suge” for continuous assistance and research after the incident. These efforts have been instrumental in our understanding of the incident. I will submit the proposal to distribute additional bounties to both, each valued at 1800 USD.

The healthy development of the community is inseparable from the hard work of developers and the collaboration of the community members. We appreciate those who pointed out our shortcomings and offered help, and look forward to more community contributors stepping forward to contribute to the growth of the Phala Network.


Note-1 SQL for tracing back all on-chain records of the Vault owner’s reward claiming.

  select
  event.id,
  event.name,
  block.height as block,
  event.index_in_block,
  block.timestamp,
  block.hash,
  (event.args ->> 'pid')::integer as pid,
  event.args ->> 'shares' as shares,
  event.args ->> 'checkoutPrice' as checkout_price
from
  public.event
  join public.block on event.block_id = public.block.id
where
  event.name = 'PhalaVault.OwnerSharesGained'
  and (event.args ->> 'pid')::integer = any ($1)
order by
  block.height asc;

Note-2 Code for calculating the losses of delegators in all malicious events

import {Parser} from '@json2csv/plainjs'
import csv from 'csvtojson'
import Decimal from 'decimal.js'
import _ from 'lodash'
import {createApi, stakeInfoToShares} from '../phala/lib'
import {log} from '../utils'

Decimal.set({toExpPos: 99, toExpNeg: -99})

const api = await createApi('khala')

const BASE_POOL = '42qnPyfw3sbWMGGtTPPc2YFNZRKPGXswRszyQQjGs2FDxdim'

const json = await csv().fromFile(
  './src/commissionCheat/out/commission_events_mark.csv',
)

const cheatPools = [
  3473, 3481, 3601, 3612, 3617, 3618, 3714, 3730, 3749, 3752, 3796, 3924, 4034,
  4343, 4686, 4720, 4850, 4852, 4880, 4927, 4951,
]

const vaultGainAddress = new Map([
  [3473, '45BsgW5wSLE38P2AeogXRp67wge9pvP1sdm2ZgSWkLEPTBgX'],
  [3481, '45t484rzjQzsDTc6RhbrpkZb3SFvfPZicicjDBH2u4TEcmBs'],
  [3601, '46HgLRQTJ6oGDfiF213BpqRGCaW22zQ564fjDLmcnw8j3Pba'],
  [3612, '41ffwYTKytjq6CvuGZH6956owzm4hQcmi9hx7hoeAAmWFdV4'],
  [3617, '43Wu9TQZ3Dh3WUjtu2uMRtRDMv4ueJzY9p1uu2goL2scUd4h'],
  [3618, '43Wu9TQZ3Dh3WUjtu2uMRtRDMv4ueJzY9p1uu2goL2scUd4h'],
  [3714, '41dFyFd21YnzkjFAWJoMrnShDjfWr46fMxAG9ms8UDtM6JCs'],
  [3730, '41KL4ipqhHovt2U7cXtXQYU2iRnVmuQrotAn2tWzTbYtoxp9'],
  [3749, '42CcPMQbyUsRShqDcEF1ioC8itJQjRcdMZ4QaK9eZgDsuNS2'],
  [3752, '41AVnddVZ9zrmvL5XGyAQjoXA1B4RjwQPNMrwgoMH8cHM9ff'],
  [3796, '42qr283P7XXnNrYCHxZtaVvjx25dfbeyEstwuzFMaQiZ5N8i'],
  [3924, '41KL4ipqhHovt2U7cXtXQYU2iRnVmuQrotAn2tWzTbYtoxp9'],
  [4034, '45etpeyNYjT6W9B4i15kUzPteUvVLgFRU5zgbGJNgKmtYsnk'],
  [4343, '41KL4ipqhHovt2U7cXtXQYU2iRnVmuQrotAn2tWzTbYtoxp9'],
  [4686, '42htLs2GGqUavbp4swXUVPnEZvHSRZJVzDvmCRSdFj7Q7tpa'],
  [4720, '3zxRSK5DquqD1f53e8CXTjqMb9wVBJxPDqb534w77147b5Cz'],
  [4850, '3zz9XwgFmBUh86tEBdWXgoiPmFARSvoDjSgCPkL9r36xAbQ7'],
  [4852, '44qTASZSLp9TxEMazC9GpizhQXvWq17WjMhWfxYyFFkG42jV'],
  [4880, '3zz9XwgFmBUh86tEBdWXgoiPmFARSvoDjSgCPkL9r36xAbQ7'],
  [4927, '45bFuYvSPzT9KBWmkrfkELLH8RYsk5aoPWVpUCjHgU8MrUQh'],
  [4951, '43Wu9TQZ3Dh3WUjtu2uMRtRDMv4ueJzY9p1uu2goL2scUd4h'],
])

const parser = new Parser()

const out = await Promise.all(
  cheatPools.map(async (pid) => {
    const gainAddress = vaultGainAddress.get(pid)
    const events = json.filter(
      (x) =>
        x.pid === pid.toString() && (x.is_cheat === '1'),
    )

    const cid = await api.query.phalaBasePool
      .pools(pid)
      .then((x) => x.unwrap().asVault.basepool.cid.toNumber())

    log(pid, cid)

    const res = []

    for (const event of events) {
      if (event.name !== 'PhalaVault.OwnerSharesGained') continue
      const cheatShares = event.shares
      const {
        block: {
          header: {parentHash},
        },
      } = await api.rpc.chain.getBlock(event.hash)

      const apiAt = await api.at(parentHash)

      const totalShares = await apiAt.query.phalaBasePool
        .pools(pid)
        .then(
          (r) => new Decimal(r.unwrap().asVault.basepool.totalShares.toHex()),
        )
      const withdrawQueue = await apiAt.query.phalaBasePool
        .pools(pid)
        .then((r) => r.unwrap().asVault.basepool.withdrawQueue)

      const nfts = await apiAt.query.rmrkCore.nfts.entries(cid).then((r) =>
        r.map((x) => {
          const owner = x[1].unwrap().owner.asAccountId.toString()
          const nftId = x[0].args[1].toNumber()
          let user: string | undefined = owner
          if (owner === BASE_POOL) {
            const withdrawal = withdrawQueue.find(
              (x) => x.nftId.toNumber() === nftId,
            )
            if (withdrawal != null) {
              user = withdrawal.user.toString()
            }
          }
          return {
            ...event,
            totalShares: totalShares.div(1e12).toString(),
            cid,
            nftId,
            owner,
            user,
            isVaultOwner: user === gainAddress,
          }
        }),
      )

      const nftsInfo = await apiAt.query.rmrkCore.properties
        .multi(nfts.map((x) => [x.cid, x.nftId, 'stake-info']))
        .then((r) =>
          r.map((x) => {
            let shares = new Decimal(0)
            let error = false
            try {
              shares = new Decimal(stakeInfoToShares(x.unwrap()).toString())
            } catch (err) {
              error = true
            }
            const nftShares = shares.div(1e12).toString()
            const nftCheatShares = shares.div(totalShares).times(cheatShares)
            return {
              nftShares,
              nftCheatShares: nftCheatShares.toString(),
              nftCheatValue: nftCheatShares
                .times(event.checkout_price)
                .toDP(12, 0)
                .toString(),
              error,
            }
          }),
        )

      _.merge(nfts, nftsInfo)

      res.push(...nfts.filter((x) => x.nftShares !== '0'))
    }

    return res
  }),
)

await Bun.write(`./src/commissionCheat/out/nfts.csv`, parser.parse(out.flat()))

2 Likes

Bounty issued as follow:
for Tucuman team: Subscan | Aggregate Substrate ecological network high-precision Web3 explorer
for high/stake: Subscan | Aggregate Substrate ecological network high-precision Web3 explorer
for Suge: Subscan | Aggregate Substrate ecological network high-precision Web3 explorer