Skip to content

Commit

Permalink
feat(dapp-connector): CIP-30 Add api.getCollateral TX reogranisation …
Browse files Browse the repository at this point in the history
…support (#3279)
  • Loading branch information
michaeljscript authored Jun 4, 2024
1 parent 4ba0864 commit c1b7f22
Show file tree
Hide file tree
Showing 6 changed files with 147 additions and 82 deletions.
7 changes: 7 additions & 0 deletions apps/wallet-mobile/src/features/Discover/common/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ export const createDappConnector = (options: CreateDappConnectorOptions) => {
const rootKey = await signTx(cbor)
return cip30.signTx(rootKey, cbor, partial)
},
sendReorganisationTx: async () => {
const unsignedTx = await cip30.buildReorganisationTx()
const tx = await unsignedTx.unsignedTx.txBuilder.build()
const rootKey = await signTx(await tx.toHex())
const signedTx = await wallet.signTx(unsignedTx, rootKey)
return cip30.sendReorganisationTx(signedTx)
},
}
const storage = connectionStorageMaker({storage: appStorage.join('dapp-connections/')})
const manager = dappConnectorMaker(storage, handlerWallet, api)
Expand Down
57 changes: 43 additions & 14 deletions apps/wallet-mobile/src/yoroi-wallets/cardano/cip30.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {BigNumber} from 'bignumber.js'
import {Buffer} from 'buffer'
import _ from 'lodash'

import {RawUtxo, YoroiUnsignedTx} from '../types'
import {RawUtxo, YoroiSignedTx, YoroiUnsignedTx} from '../types'
import {asQuantity, Utxos} from '../utils'
import {Cardano, CardanoMobile} from '../wallets'
import {toAssetNameHex, toPolicyId} from './api'
Expand All @@ -25,20 +25,22 @@ export const cip30ExtensionMaker = (wallet: YoroiWallet) => {

const getCSL = () => wrappedCsl()

const recreateValue = async (value: CSL.Value) => {
return CardanoMobile.Value.fromHex(await value.toHex())
const copy = async <T extends {toHex: () => Promise<string>}>(
creator: {fromHex: (hex: string) => Promise<T>},
value: T,
): Promise<T> => {
return creator.fromHex(await value.toHex())
}

const recreateMultiple = async <T>(items: T[], recreate: (item: T) => Promise<T>) => {
return Promise.all(items.map(recreate))
const copyMultiple = async <T extends {toHex: () => Promise<string>}>(
items: T[],
creator: {fromHex: (hex: string) => Promise<T>},
) => {
return Promise.all(items.map((item) => copy(creator, item)))
}

const recreateTransactionUnspentOutput = async (utxo: CSL.TransactionUnspentOutput) => {
return CardanoMobile.TransactionUnspentOutput.fromHex(await utxo.toHex())
}

const recreateWitnessSet = async (witnessSet: CSL.TransactionWitnessSet) => {
return CardanoMobile.TransactionWitnessSet.fromHex(await witnessSet.toHex())
return copy(CardanoMobile.TransactionUnspentOutput, utxo)
}

class CIP30Extension {
Expand All @@ -48,7 +50,7 @@ class CIP30Extension {
const {csl, release} = getCSL()
try {
const value = await _getBalance(csl, tokenId, this.wallet.utxos, this.wallet.primaryTokenInfo.id)
return recreateValue(value)
return copy(CardanoMobile.Value, value)
} finally {
release()
}
Expand Down Expand Up @@ -92,7 +94,7 @@ class CIP30Extension {
const valueStr = value?.trim() ?? collateralConfig.minLovelace.toString()
const valueNum = new BigNumber(valueStr)

if (valueNum.gte(collateralConfig.maxLovelace)) {
if (valueNum.gte(new BigNumber(collateralConfig.maxLovelace))) {
throw new Error('Collateral value is too high')
}

Expand All @@ -111,7 +113,7 @@ class CIP30Extension {

const multipleUtxosCollateral = await _drawCollateralInMultipleUtxos(csl, this.wallet, asQuantity(valueNum))
if (multipleUtxosCollateral && multipleUtxosCollateral.length > 0) {
return recreateMultiple(multipleUtxosCollateral, recreateTransactionUnspentOutput)
return copyMultiple(multipleUtxosCollateral, CardanoMobile.TransactionUnspentOutput)
}

return null
Expand Down Expand Up @@ -146,7 +148,34 @@ class CIP30Extension {
const keys = await Promise.all(signers.map(async (signer) => createRawTxSigningKey(rootKey, signer)))
const signedTxBytes = await signRawTransaction(csl, cbor, keys)
const signedTx = await csl.Transaction.fromBytes(signedTxBytes)
return recreateWitnessSet(await signedTx.witnessSet())
return copy(CardanoMobile.TransactionWitnessSet, await signedTx.witnessSet())
} finally {
release()
}
}

async buildReorganisationTx(): Promise<YoroiUnsignedTx> {
const bech32Address = this.wallet.externalAddresses[0]
const amounts = {[this.wallet.primaryTokenInfo.id]: asQuantity(collateralConfig.minLovelace)}
return this.wallet.createUnsignedTx([{address: bech32Address, amounts}])
}

async sendReorganisationTx(signedTx: YoroiSignedTx): Promise<CSL.TransactionUnspentOutput> {
const {csl, release} = getCSL()
try {
const tx = await csl.Transaction.fromBytes(signedTx.signedTx.encodedTx)
const txId = signedTx.signedTx.id
const txIndex = 0
const body = await tx.body()
const originalOutput = await (await body.outputs()).get(txIndex)

const txHash = txId.split(':')[0]
const input = await csl.TransactionInput.new(await csl.TransactionHash.fromHex(txHash), txIndex)
const value = await originalOutput.amount()
const receiver = await originalOutput.address()
const output = await csl.TransactionOutput.new(receiver, value)
await this.wallet.submitTransaction(Buffer.from(signedTx.signedTx.encodedTx).toString('base64'))
return copy(CardanoMobile.TransactionUnspentOutput, await csl.TransactionUnspentOutput.new(input, output))
} finally {
release()
}
Expand Down
2 changes: 2 additions & 0 deletions packages/dapp-connector/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
"@types/react-test-renderer": "^18.0.7",
"@yoroi/types": "1.5.4",
"axios-mock-adapter": "^1.21.5",
"bignumber.js": "^9.0.1",
"commitlint": "^17.0.2",
"del-cli": "^5.0.0",
"dependency-cruiser": "^13.1.1",
Expand All @@ -178,6 +179,7 @@
},
"peerDependencies": {
"@react-native-async-storage/async-storage": ">= 1.19.3 <= 1.20.0",
"bignumber.js": "^9.0.1",
"react": ">= 16.8.0 <= 19.0.0",
"react-native-mmkv": "^2.11.0",
"react-query": "^3.39.3"
Expand Down
70 changes: 52 additions & 18 deletions packages/dapp-connector/src/dapp-connector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,25 +93,15 @@ describe('DappConnector', () => {
const event = createEvent('unknown')
const sendMessage = jest.fn()
await dappConnector.handleEvent(event, trustedUrl, sendMessage)
expect(sendMessage).toHaveBeenCalledWith(
'1',
null,
new Error(
`Unknown method 'unknown' with params {"args":[],"browserContext":{"origin":"https://yoroi-wallet.com"}}`,
),
)
expect(sendMessage).toHaveBeenCalledWith('1', null, new Error(`Unknown method 'unknown'`))
})

it('should throw an error if the event malformed', async () => {
const dappConnector = getDappConnector()
const event = JSON.stringify({method: 'test'})
const sendMessage = jest.fn()
await dappConnector.handleEvent(event, trustedUrl, sendMessage)
expect(sendMessage).toHaveBeenCalledWith(
undefined,
null,
new Error(`Unknown method 'test' with params undefined`),
)
expect(sendMessage).toHaveBeenCalledWith(undefined, null, new Error(`Unknown method 'test'`))
})

it('should throw an error if the origins do not match', async () => {
Expand Down Expand Up @@ -205,15 +195,23 @@ describe('DappConnector', () => {
})

describe('api calls', () => {
it('should throw an error if api method is not known', async () => {
it('should throw error if method is not set', async () => {
const dappConnector = getDappConnector()
const sendMessage = jest.fn()
await dappConnector.handleEvent(createEvent(''), trustedUrl, sendMessage)
expect(sendMessage).toHaveBeenCalledWith('1', null, new Error(`Method is required`))
})

it('should throw an error if api method is not known', async () => {
const dappConnector = getDappConnector()
const sendMessage1 = jest.fn()

await dappConnector.handleEvent(createEvent('api.unknown'), trustedUrl, sendMessage)
expect(sendMessage).toHaveBeenCalledWith('1', null, new Error(`Unknown method api.unknown`))
await dappConnector.handleEvent(createEvent('api.unknown'), trustedUrl, sendMessage1)
expect(sendMessage1).toHaveBeenCalledWith('1', null, new Error(`Unknown method 'api.unknown'`))

await dappConnector.handleEvent(createEvent('api.unknown.something'), trustedUrl, sendMessage)
expect(sendMessage).toHaveBeenCalledWith('1', null, new Error(`Invalid method api.unknown.something`))
const sendMessage2 = jest.fn()
await dappConnector.handleEvent(createEvent('api.unknown.something'), trustedUrl, sendMessage2)
expect(sendMessage2).toHaveBeenCalledWith('1', null, new Error(`Unknown method 'api.unknown.something'`))
})

it('should throw an error if user has not approved connection', async () => {
Expand Down Expand Up @@ -370,13 +368,46 @@ describe('DappConnector', () => {
})

it('should resolve getCollateral with null if not enough funds', async () => {
const dappConnector = getDappConnector()
const dappConnector = getDappConnector({
...mockWallet,
getCollateral: () => Promise.resolve([]),
getBalance: () => CSL.Value.fromHex('00'),
})
const sendMessage = jest.fn()
await dappConnector.addConnection({walletId, dappOrigin: 'https://yoroi-wallet.com'})
await dappConnector.handleEvent(createEvent('api.getCollateral', {args: ['100000000']}), trustedUrl, sendMessage)
expect(sendMessage).toHaveBeenCalledWith('1', null)
})

it('should resolve getCollateral with reorganisation tx if there are enough funds', async () => {
const dappConnector = getDappConnector({
...mockWallet,
getCollateral: () => Promise.resolve([]),
getBalance: async () => CSL.Value.new(await CSL.BigNum.fromStr('20000000')),
sendReorganisationTx: async () => ({toHex: () => '00'} as any),
})
const sendMessage = jest.fn()
await dappConnector.addConnection({walletId, dappOrigin: 'https://yoroi-wallet.com'})
await dappConnector.handleEvent(createEvent('api.getCollateral', {args: ['10000000']}), trustedUrl, sendMessage)
expect(sendMessage).toHaveBeenCalledWith('1', ['00'])

await dappConnector.handleEvent(createEvent('api.getCollateral', {args: []}), trustedUrl, sendMessage)
expect(sendMessage).toHaveBeenCalledWith('1', ['00'])
})

it('should resolve getCollateral with null if reorganisation fails', async () => {
const dappConnector = getDappConnector({
...mockWallet,
getCollateral: () => Promise.resolve([]),
getBalance: async () => CSL.Value.new(await CSL.BigNum.fromStr('20000000')),
sendReorganisationTx: async () => Promise.reject(new Error('Reorganisation failed')),
})
const sendMessage = jest.fn()
await dappConnector.addConnection({walletId, dappOrigin: 'https://yoroi-wallet.com'})
await dappConnector.handleEvent(createEvent('api.getCollateral', {args: ['10000000']}), trustedUrl, sendMessage)
expect(sendMessage).toHaveBeenCalledWith('1', null)
})

it('should resolve getUnusedAddresses with mocked data', async () => {
const dappConnector = getDappConnector({
...mockWallet,
Expand Down Expand Up @@ -470,5 +501,8 @@ const mockWallet: ResolverWallet = {
getUtxos: () => Promise.resolve([]),
getCollateral: () => Promise.resolve([]),
submitTx: () => Promise.resolve('tx-id'),
sendReorganisationTx: async () => {
throw new Error('Not implemented')
},
}
const trustedUrl = 'https://yoroi-wallet.com/'
62 changes: 40 additions & 22 deletions packages/dapp-connector/src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {isKeyOf, isRecord, createTypeGuardFromSchema} from '@yoroi/common'
import {Storage} from './adapters/async-storage'
import {z} from 'zod'
import {Address, TransactionUnspentOutput, TransactionWitnessSet, Value} from '@emurgo/cross-csl-core'
import BigNumber from 'bignumber.js'

type Context = {
browserOrigin: string
Expand Down Expand Up @@ -97,13 +98,28 @@ export const resolver: Resolver = {
getCollateral: async (params: unknown, context: Context) => {
assertOriginsMatch(context)
await assertWalletAcceptedConnection(context)

const defaultCollateral = '1000000'
const value =
isRecord(params) && Array.isArray(params.args) && typeof params.args[0] === 'string'
? params.args[0]
: undefined
: defaultCollateral
const result = await context.wallet.getCollateral(value)

if (result === null || (result.length === 0 && typeof value === 'string')) return null
if (result === null || result.length === 0) {
const balance = await context.wallet.getBalance('*')
const coin = BigNumber(await (await balance.coin()).toStr())
if (coin.isGreaterThan(BigNumber(value))) {
try {
const utxo = await context.wallet.sendReorganisationTx()
return [await utxo.toHex()]
} catch {
return null
}
}

return null
}

return Promise.all(result.map((u) => u.toHex()))
},
Expand Down Expand Up @@ -212,27 +228,28 @@ const handleMethod = async (
supportedExtensions: trustedContext.supportedExtensions,
}

if (method === 'cardano_enable') {
return resolver.enable(params, context)
}

if (method === 'cardano_is_enabled') {
return resolver.isEnabled(params, context)
}

if (method === LOG_MESSAGE_EVENT) {
return resolver.logMessage(params, context)
}

if (method.startsWith('api.')) {
const methodParts = method.split('.')
if (methodParts.length !== 2) throw new Error(`Invalid method ${method}`)
const apiMethod = methodParts[1]
if (!isKeyOf(apiMethod, resolver.api)) throw new Error(`Unknown method ${method}`)
return resolver.api[apiMethod](params, context)
}
if (!method) throw new Error('Method is required')
const isValidMethod = isKeyOf(method, methods)
if (!isValidMethod) throw new Error(`Unknown method '${method}'`)
return methods[method](params, context)
}

throw new Error(`Unknown method '${method}' with params ${JSON.stringify(params)}`)
const methods = {
'cardano_enable': resolver.enable,
'cardano_is_enabled': resolver.isEnabled,
'log_message': resolver.logMessage,
'api.getBalance': resolver.api.getBalance,
'api.getChangeAddress': resolver.api.getChangeAddress,
'api.getNetworkId': resolver.api.getNetworkId,
'api.getRewardAddresses': resolver.api.getRewardAddresses,
'api.getUsedAddresses': resolver.api.getUsedAddresses,
'api.getExtensions': resolver.api.getExtensions,
'api.getUnusedAddresses': resolver.api.getUnusedAddresses,
'api.getUtxos': resolver.api.getUtxos,
'api.getCollateral': resolver.api.getCollateral,
'api.submitTx': resolver.api.submitTx,
'api.signTx': resolver.api.signTx,
'api.signData': resolver.api.signData,
}

export const resolverHandleEvent = async (
Expand Down Expand Up @@ -267,6 +284,7 @@ export type ResolverWallet = {
submitTx: (cbor: string) => Promise<string>
signTx: (txHex: string, partialSign?: boolean) => Promise<TransactionWitnessSet>
signData: (address: string, payload: string) => Promise<{signature: string; key: string}>
sendReorganisationTx: () => Promise<TransactionUnspentOutput>
}

type Pagination = {
Expand Down
Loading

0 comments on commit c1b7f22

Please sign in to comment.