Client: interact with new Scheme

You might want to update client library while working on Alchemy integration if you added new contract or updated subgraph

In this client tutorial we will extend client library to interact with the previous non-universal example scheme BuyInWithRageQuitOpt

Pre Work

Make sure you have cloned DAOstack client repo, if you have not already

Update Client

In order to extend client support for the new scheme you will have to add the following:

  • New Scheme Class
  • New Entity Class
  • Integration Test ( Merging code without testing is a risky business 😁)
  • Some case dependent updates

Add new scheme class

Create file client/src/schemes/BuyInWithRageQuitOpt.ts that exports the new scheme class to enable client to interact with the scheme contract

Please refer to Example Scheme Class

NOTE:

  • You will need to add abi of the contract in client/src, if it does not exist in @daostack/arc
  • Client library use toIOperationObservable to create observables to get 3rd confirmation update

Example Scheme class

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import BN = require('bn.js')
import { from } from 'rxjs'
import { concatMap } from 'rxjs/operators'
import buyInWithRageQuitOptScheme = require('./BuyInWithRageQuitOpt.json')
import {
  Operation,
  toIOperationObservable
} from '../operation'
import { Scheme } from '../scheme'
import { Deposit } from '../deposit'

export class BuyInWithRageQuitOptScheme {

  constructor(public scheme: Scheme) {

  }

  err = (error: Error): Error => { return error }

  public deposit(amount: BN): Operation <Deposit|null> {
    const observable = from(this.getContract()).pipe(
      concatMap((buyInWithRageQuitOpt) => {
        const transaction = { value: amount, tx: buyInWithRageQuitOpt.methods.deposit() }
        const map = (receipt: any) => {
          const event = receipt.events.buyIn
          if (!event) {
            return null
          }

          return new Deposit({
            amount: event.returnValues._amount,
            member: event.returnValues._member.toLowerCase(),
            dao: event.returnValues._avatar.toLowerCase(),
            rep: event.returnValues._rep
          }, this.scheme.context)
        }

        return this.scheme.context.sendTransaction(transaction, map, this.err)
      })
    )
    return toIOperationObservable(observable)
  }

  public quit(): Operation <Quit|null> {
    const observable = from(this.getContract()).pipe(
      concatMap((buyInWithRageQuitOpt) => {
        const transaction = buyInWithRageQuitOpt.methods.quit()
        const map = (receipt: any) => {
          const event = receipt.events.buyIn
          if (!event) {
            return null
          }
          return new Quit({
            amount: event.returnValues._amount,
            memberAddress: event.returnValues._memberAddress,
            dao: event.returnValues._avatar,
            rep: event.returnValues._rep
          }, this.scheme.context)
        }
        return this.scheme.context.sendTransaction(transaction, map, this.err)
      })
    )
    return toIOperationObservable(observable)
  }

  private async getContract() {
    const state = await this.scheme.fetchStaticState()
    return  this.scheme.context.getContract(state.address, buyInWithRageQuitOptScheme.abi)
  }
}

Add new Entity class

Enable client library to interact with the Entities added to subgraph during previous step (Upgrade subgraph)

  • Add relevant IEntityStaticState, IEntityState and IEntityQueryOptions interface
  • Each Entity class must have following methods:
    • state: that takes IEntityQueryOptions and returns Entity Observable from graphQL query
    • setStaticState: that sets IEntityStaticState
    • fetchStaticState: that returns IEntityStaticState observable
    • state: that returns IEntityState observable

Please refer to example Deposit Entity class

Example Entity Class

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
import gql from 'graphql-tag'
import { Observable } from 'rxjs'
import { first } from 'rxjs/operators'
import { Arc, IApolloQueryOptions } from './arc'
import { Address, ICommonQueryOptions, IStateful } from './types'
import { BN, createGraphQlQuery, isAddress } from './utils'

export interface IDepositStaticState {
  id?: string
  member: Address
  amount: typeof BN
  rep: typeof BN
  dao: Address

}

export interface IDepositState extends IDepositStaticState {
  id: string
}

export interface IDepositQueryOptions extends ICommonQueryOptions {
  where?: {
    id?: string
    member?: Address
    dao?: Address
    [key: string]: any
  }
}

export class Deposit implements IStateful<IDepositState> {
  /**
   * Deposit.search(context, options) searches for deposit entities
   * @param  context an Arc instance that provides connection information
   * @param  options the query options, cf. IDepositQueryOptions
   * @return         an observable of Deposit objects
   */
   public static search(
     context: Arc,
     options: IDepositQueryOptions = {},
     apolloQueryOptions: IApolloQueryOptions = {}
   ): Observable <Deposit[]> {
     if (!options.where) { options.where = {}}
     let where = ''
     let daoFilter: (r: any) => boolean
     daoFilter = () => true

     for (const key of Object.keys(options.where)) {
       if (options.where[key] === undefined) {
         continue
       }

       if (key === 'member' || key === 'dao') {
         const option = options.where[key] as string
         isAddress(option)
         options.where[key] = option.toLowerCase()
       } else {
         where += `${key}: "${options.where[key] as string}"\n`
       }
     }

     const query = gql`query DepositSearch
       {
         deposits ${createGraphQlQuery(options, where)} {
           id
           member
           amount
           avatar
           rep
         }
       }
     `

     return context.getObservableListWithFilter(
       query,
       (r: any) => {
         return new Deposit({
           id: r.id,
           member: r.member,
           amount: new BN(r.amount || 0),
           dao: r.avatar,
           rep: new BN(r.rep || 0)
         }, context)
       },
       daoFilter,
       apolloQueryOptions
     ) as Observable<Deposit[]>
   }

   public id: string|undefined
   public staticState: IDepositStaticState|undefined

   constructor(idOrOpts: string|IDepositStaticState, public context: Arc) {
     if (typeof idOrOpts === 'string') {
       this.id = idOrOpts
     } else {
       const opts = idOrOpts as IDepositStaticState
       this.id = opts.id
       this.setStaticState(opts)
     }
   }

   public setStaticState(opts: IDepositStaticState) {
     this.staticState = opts
   }

   public async fetchStaticState(): Promise<IDepositStaticState> {
     if (!!this.staticState) {
       return this.staticState
     } else {
       return await this.state().pipe(first()).toPromise()
     }
   }

   public state(apolloQueryOptions: IApolloQueryOptions = {}): Observable<IDepositState> {
     const query = gql`query DepositById {
       deposit (id: "${this.id}") {
         id
         memberAddress
         amount
         avatar
         rep
       }
     }`

     const itemMap = (item: any): IDepositState => {
       if (item === null) {
         throw Error(`Could not find a Vote with id ${this.id}`)
       }
       return {
         amount: item.amount,
         dao: item.dao,
         id: item.id,
         member: item.member,
         rep: item.rep
       }
     }
     return this.context.getObservableObject(query, itemMap, apolloQueryOptions)
   }
}

Integration Tests

  1. Add relevant integration test for the new scheme, client/test/scheme-buyInWithRageQuitOpt.spec.ts
  2. Start test watcher while you test and update the client
    1
    npm run test:watch:client -- test/scheme-buyInWithRageQuitOpt.spec.ts
    

Refer to example Test BuyInWithRageQuitOpt Scheme

Example Test BuyInWithRageQuitOpt Scheme

Following is an example integration test file to test the sample non-universal scheme we developed in this tutorial

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
import { Scheme } from '../src/scheme'
import { Arc } from '../src/arc'
import { DAO } from '../src/dao'
import { first } from 'rxjs/operators'
import { Reputation } from '../src/reputation'
import { Deposit } from '../src/deposit'

import {
  BN,
  getTestDAO,
  getTestAddresses,
  newArc,
  toWei,
  waitUntilTrue
 } from './utils'

jest.setTimeout(60000)
/**
 * Scheme test
 */
describe('Deposit to buy reputaion', () => {

  let addresses: any
  let arc: Arc
  let dao: DAO
  let scheme: Scheme
  let daoBalanceBefore: undefined
  let reputationBefore: undefined
  let eventLengthBefore: number
  let reputation: any
  let amount = toWei('0.1')
  let response: any

  const getEventLength = async () => {
    let deposits = await Deposit.search(
      arc,
      {where: {member: arc.web3.eth.defaultAccount}}, { fetchPolicy: "no-cache"}
    ).pipe(first()).toPromise()
    return deposits.length
  }

  beforeAll(async () => {
    arc = await newArc()
    addresses = getTestAddresses(arc)
    dao = await getTestDAO()
    scheme = new Scheme({
        address: '0x6d065a54f0a14cb03b949a146dbb58c14a0afc48',
        dao: dao.id,
        id: '0x992c72e5e965d11a318839b554b0330dcb3ac81dc2ac0e4e57ba2c15660a3564',
        name: 'BuyInWithRageQuitOpt',
        paramsHash: '0x0000000000000000000000000000000000000000000000000000000000000000'
    }, arc)
    reputation = new Reputation(addresses.dao.Reputation, arc)

    daoBalanceBefore = await dao.ethBalance().pipe(first()).toPromise()
    reputationBefore = await reputation.reputationOf(arc.web3.eth.defaultAccount).pipe(first()).toPromise()
    eventLengthBefore = await getEventLength()

    expect(scheme.BuyInWithRageQuitOpt).not.toBeFalsy()
    if (scheme.BuyInWithRageQuitOpt) {
      response = await scheme.BuyInWithRageQuitOpt.deposit(amount).send()
      expect(response)
    }
  })

  it('Should increase DAO balance by amount deposited', async () => {
    let daoBalanceAfter = await dao.ethBalance().pipe(first()).toPromise()
    expect(Number(daoBalanceAfter) - Number(daoBalanceBefore)).toEqual(Number(amount))
  })

  it('Should increase reputation of Member by amount deposited', async () => {
    let reputationAfter = new BN(await reputation.contract().methods.balanceOf(arc.web3.eth.defaultAccount).call())
    expect(Number(reputationAfter) - Number(reputationBefore)).toEqual(Number(amount))
  })

  it('Should index the deposit event', async () => {

      const state0 = await response.result.fetchStaticState()
      expect(state0).toMatchObject({
        amount: amount.toString(),
        member: arc.web3.eth.defaultAccount.toLowerCase(),
        dao: dao.id.toLowerCase(),
        rep: amount.toString()
      })

      let eventLengthAfter = eventLengthBefore

      const depositIsIndexed = async () => {
        eventLengthAfter = await getEventLength()
        return eventLengthAfter - eventLengthBefore > 0
      }

      await waitUntilTrue(depositIsIndexed)

      expect(eventLengthAfter-1).toEqual(eventLengthBefore)

  })

})

describe('Quit to refund funds', () => {

    // add more tests
})

Extra Interoperability updates (may differ per use case)

Apart from the above standard updates you might need to update some other files depending on the scheme you are adding.

For eg. In case of BuyInWithRageQuitOpt Scheme, we added to following files:

  • src/scheme.ts: To add BuyInWithRageQuitOpt to ISchemeState
  • src/operation.ts: To enable passing custom value to this.scheme.context.sendTransaction
  • test/utils.ts: To update LATEST_ARC_VERSION and to getTestAddresses of our newly created DAO instead of test DAO
  • test/migration.json: To use the migration file we got in the migration step (which has details of our DAO and new scheme`