Skip to content

Commit

Permalink
[Metadata API] Add methods left to complete metadata flow (#15)
Browse files Browse the repository at this point in the history
* Add metadata api methods to create appDataDoc and upload it to IPFS

* Add unit tests for Metadata API methods

* Update README with new metadata methods

* Iprove ifps utils + Refactor ipfs context type

* Update readme

* set file type as unknown for pinJSONToIPFS method
  • Loading branch information
matextrem authored Apr 29, 2022
1 parent 499f3f4 commit dd02548
Show file tree
Hide file tree
Showing 5 changed files with 274 additions and 30 deletions.
88 changes: 66 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
</p>

# CoW protocol SDK

[![Styled With Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://prettier.io/)
[![Coverage Status](https://coveralls.io/repos/github/cowprotocol/cow-sdk/badge.svg?branch=main)](https://coveralls.io/github/cowprotocol/cow-sdk?branch=main)


> ⚠️⚠️ THE SDK IS IN Beta ⚠️⚠️
> It is being currently develop and is a work in progress, also it's API is subjected to change.
> It is being currently develop and is a work in progress, also it's API is subjected to change.
> If you experience any problems, please open an issue in Github trying to describe your problem.
### Getting started
Expand All @@ -35,17 +35,18 @@ The SDK will expose the CoW API operations (`cowSdk.cowApi`) and some convenient
const trades = await cowSdk.cowApi.getOrders({
owner: '0x00000000005ef87f8ca7014309ece7260bbcdaeb', // Trader
limit: 5,
offset: 0
offset: 0,
})
console.log(trades)
```

Let's see a full example on how to submit an order to CowSwap.

> ⚠️ Before starting, the protocol requires you to approve the sell token before the order can be considered.
> ⚠️ Before starting, the protocol requires you to approve the sell token before the order can be considered.
> For more details see https://docs.cow.fi/tutorials/how-to-submit-orders-via-the-api/1.-set-allowance-for-the-sell-token
In this example, we will:

- 1. **Instantiate the SDK and a wallet**: Used for signing orders
- 2. **Get a price/fee quote from the API**: Get current market price and required protocol fee to settle your trade.
- 3. **Sign the order using your wallet**: Only signed orders are considered by the protocol.
Expand All @@ -65,7 +66,7 @@ const cowSdk = new CowSdk(4, { signer: wallet })
const quoteResponse = await cowSdk.cowApi.getQuote({
kind: OrderKind.SELL, // Sell order (could also be BUY)
sellToken: '0xc778417e063141139fce010982780140aa0cd5ab', // WETH
buyToken: '0x4dbcdf9b62e891a7cec5a2568c3f4faf9e8abe2b', // USDC
buyToken: '0x4dbcdf9b62e891a7cec5a2568c3f4faf9e8abe2b', // USDC
amount: '1000000000000000000', // 1 WETH
userAddress: '0x1811be0994930fe9480eaede25165608b093ad7a', // Trader
validTo: 2524608000,
Expand Down Expand Up @@ -100,14 +101,14 @@ console.log(`https://explorer.cow.fi/rinkeby/orders/${orderId}`)
SDK also includes a Metadata API to interact with AppData documents and IPFS CIDs

```js
const chainId = 4 // Rinkeby
const cowSdk = new CowSdk(chainId)
let hash = '0xa6c81f4ca727252a05b108f1742a07430f28d474d2a3492d8f325746824d22e5'
// Decode AppData document given a CID hash
const appDataDoc = await cowSdk.metadataApi.decodeAppData(hash)
console.log(appDataDoc)
/* {
const chainId = 4 // Rinkeby
const cowSdk = new CowSdk(chainId)
let hash = '0xa6c81f4ca727252a05b108f1742a07430f28d474d2a3492d8f325746824d22e5'

// Decode AppData document given a CID hash
const appDataDoc = await cowSdk.metadataApi.decodeAppData(hash)
console.log(appDataDoc)
/* {
"appCode": "CowSwap",
"metadata": {
"referrer": {
Expand All @@ -118,17 +119,60 @@ SDK also includes a Metadata API to interact with AppData documents and IPFS CID
"version": "0.1.0"
} */

const cid = 'QmUf2TrpSANVXdgcYfAAACe6kg551cY3rAemB7xfEMjYvs'

// Decode CID hash to AppData Hex
const decodedAppDataHex = await cowSdk.metadataApi.cidToAppDataHex(cid)
console.log(decodedAppDataHex) //0x5ddb2c8207c10b96fac92cb934ef9ba004bc007a073c9e5b13edc422f209ed80
const cid = 'QmUf2TrpSANVXdgcYfAAACe6kg551cY3rAemB7xfEMjYvs'

// Decode CID hash to AppData Hex
const decodedAppDataHex = await cowSdk.metadataApi.cidToAppDataHex(cid)
console.log(decodedAppDataHex) //0x5ddb2c8207c10b96fac92cb934ef9ba004bc007a073c9e5b13edc422f209ed80

hash = '0x5ddb2c8207c10b96fac92cb934ef9ba004bc007a073c9e5b13edc422f209ed80'

// Decode AppData Hex to CID
const decodedAppDataHex = await cowSdk.metadataApi.appDataHexToCid(hash)
console.log(decodedAppDataHex) //QmUf2TrpSANVXdgcYfAAACe6kg551cY3rAemB7xfEMjYvs

/*Create an AppData Document with empty metadata and default appCode
generateAppDataDoc receives as parameters:
- metadata: MetadataDoc (Default: {})
- appCode: string (Default: 'Cowswap')
*/
const appDataDoc = cowSdk.metadataApi.generateAppDataDoc({})
/* {
version: '0.1.0',
appCode: 'CowSwap',
metadata: {},
}
*/

// Create an AppData Document with custom metadata and appCode
const appDataDoc = cowSdk.metadataApi.generateAppDataDoc(
{
referrer: {
address: '0x1f5B740436Fc5935622e92aa3b46818906F416E9',
version: '0.1.0',
},
},
'CowApp'
)
/* {
version: '0.1.0',
appCode: 'CowApp',
metadata: {
referrer: {
address: '0x1f5B740436Fc5935622e92aa3b46818906F416E9',
version: '0.1.0',
},
},
}
*/

hash = '0x5ddb2c8207c10b96fac92cb934ef9ba004bc007a073c9e5b13edc422f209ed80'
// Upload AppDataDoc to IPFS (Pinata)
const cowSdk = new CowSdk(4, {
ipfs: { pinataApiKey: 'YOUR_PINATA_API_KEY', pinataApiSecret: 'YOUR_PINATA_API_SECRET' },
})

// Decode AppData Hex to CID
const decodedAppDataHex = await cowSdk.metadataApi.appDataHexToCid(hash)
console.log(decodedAppDataHex) //QmUf2TrpSANVXdgcYfAAACe6kg551cY3rAemB7xfEMjYvs
await cowSdk.metadataApi.uploadMetadataDocToIpfs(appDataDoc)
/* 0x5ddb2c8207c10b96fac92cb934ef9ba004bc007a073c9e5b13edc422f209ed80 */
```

#### Querying the Cow Subgraph
Expand Down
28 changes: 24 additions & 4 deletions src/api/metadata/index.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
import log from 'loglevel'
import { Context } from '../../utils/context'
import { getSerializedCID, loadIpfsFromCid } from '../../utils/appData'
import { AppDataDoc } from './types'
import { pinJSONToIPFS } from '../../utils/ipfs'
import { AppDataDoc, MetadataDoc } from './types'
import { CowError } from '../../utils/common'

const DEFAULT_APP_CODE = 'CowSwap'
const DEFAULT_APP_VERSION = '0.1.0'

export class MetadataApi {
context: Context

constructor(context: Context) {
this.context = context
}

generateAppDataDoc(metadata: MetadataDoc = {}, appCode: string = DEFAULT_APP_CODE): AppDataDoc {
return {
version: DEFAULT_APP_VERSION,
appCode,
metadata: {
...metadata,
},
}
}

async decodeAppData(hash: string): Promise<void | AppDataDoc> {
try {
const cidV0 = await getSerializedCID(hash)
if (!cidV0) throw new CowError('Error getting serialized CID')
return await loadIpfsFromCid(cidV0)
} catch (error) {
return loadIpfsFromCid(cidV0)
} catch (e) {
const error = e as CowError
log.error('Error decoding AppData:', error)
throw new CowError('Error decoding AppData: ' + error)
throw new CowError('Error decoding AppData: ' + error.message)
}
}

Expand All @@ -34,4 +49,9 @@ export class MetadataApi {
if (!cidV0) throw new CowError('Error getting serialized CID')
return cidV0
}

async uploadMetadataDocToIpfs(appDataDoc: AppDataDoc): Promise<string | void> {
const { IpfsHash } = await pinJSONToIPFS(appDataDoc, this.context.ipfs)
return this.cidToAppDataHex(IpfsHash)
}
}
126 changes: 126 additions & 0 deletions src/api/metadata/metadata.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'
import { CowSdk } from '../../CowSdk'
import { CowError } from '../../utils/common'

enableFetchMocks()

const chainId = 4 //Rinkeby

const cowSdk = new CowSdk(chainId)

const HTTP_STATUS_OK = 200
const HTTP_STATUS_INTERNAL_ERROR = 500

const DEFAULT_APP_DATA_DOC = {
version: '0.1.0',
appCode: 'CowSwap',
metadata: {},
}

const CUSTOM_APP_DATA_DOC = {
...DEFAULT_APP_DATA_DOC,
metadata: {
referrer: {
address: '0x1f5B740436Fc5935622e92aa3b46818906F416E9',
version: '0.1.0',
},
},
}

const IPFS_HASH = 'QmUf2TrpSANVXdgcYfAAACe6kg551cY3rAemB7xfEMjYvs'

const APP_DATA_HEX = '0x5ddb2c8207c10b96fac92cb934ef9ba004bc007a073c9e5b13edc422f209ed80'

beforeEach(() => {
fetchMock.resetMocks()
})

afterEach(() => {
jest.restoreAllMocks()
})

test('Valid: Create appDataDoc with empty metadata ', () => {
const appDataDoc = cowSdk.metadataApi.generateAppDataDoc({})
expect(appDataDoc.version).toEqual(DEFAULT_APP_DATA_DOC.version)
expect(appDataDoc.appCode).toEqual(DEFAULT_APP_DATA_DOC.appCode)
expect(appDataDoc.metadata).toEqual(DEFAULT_APP_DATA_DOC.metadata)
})

test('Valid: Create appDataDoc with custom metadata ', () => {
const appDataDoc = cowSdk.metadataApi.generateAppDataDoc(CUSTOM_APP_DATA_DOC.metadata)
expect(appDataDoc.metadata.referrer?.address).toEqual(CUSTOM_APP_DATA_DOC.metadata.referrer.address)
expect(appDataDoc.metadata.referrer?.version).toEqual(CUSTOM_APP_DATA_DOC.metadata.referrer.version)
})

test('Invalid: Upload to IPFS without passing credentials', async () => {
const appDataDoc = cowSdk.metadataApi.generateAppDataDoc(CUSTOM_APP_DATA_DOC.metadata)
try {
await cowSdk.metadataApi.uploadMetadataDocToIpfs(appDataDoc)
} catch (e) {
const error = e as CowError
expect(error.message).toEqual('You need to pass IPFS api credentials.')
}
})

test('Valid: Upload AppDataDoc to IPFS', async () => {
fetchMock.mockResponseOnce(JSON.stringify({ IpfsHash: IPFS_HASH }), { status: HTTP_STATUS_OK })
const appDataDoc = cowSdk.metadataApi.generateAppDataDoc(CUSTOM_APP_DATA_DOC.metadata)
const cowSdk1 = new CowSdk(chainId, { ipfs: { pinataApiKey: 'validApiKey', pinataApiSecret: 'ValidApiSecret' } })
const appDataHex = await cowSdk1.metadataApi.uploadMetadataDocToIpfs(appDataDoc)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(appDataHex).toEqual(APP_DATA_HEX)
})

test('Invalid: Upload AppDataDoc to IPFS with wrong credentials', async () => {
fetchMock.mockResponseOnce(JSON.stringify({ error: { details: 'IPFS api keys are invalid' } }), {
status: HTTP_STATUS_INTERNAL_ERROR,
})
const appDataDoc = cowSdk.metadataApi.generateAppDataDoc({})
const cowSdk1 = new CowSdk(chainId, { ipfs: { pinataApiKey: 'InvalidApiKey', pinataApiSecret: 'InvValidApiSecret' } })
try {
await cowSdk1.metadataApi.uploadMetadataDocToIpfs(appDataDoc)
await expect(cowSdk1.metadataApi.uploadMetadataDocToIpfs(appDataDoc)).rejects.toThrow('IPFS api keys are invalid')
} catch (e) {
const error = e as CowError
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(error.message).toEqual('IPFS api keys are invalid')
}
})

test('Valid: Decode appData ', async () => {
fetchMock.mockResponseOnce(JSON.stringify(CUSTOM_APP_DATA_DOC), { status: HTTP_STATUS_OK })
const appDataDoc = await cowSdk.metadataApi.decodeAppData(APP_DATA_HEX)

expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
'https://gnosis.mypinata.cloud/ipfs/QmUf2TrpSANVXdgcYfAAACe6kg551cY3rAemB7xfEMjYvs'
)
expect(appDataDoc?.version).toEqual(CUSTOM_APP_DATA_DOC.version)
expect(appDataDoc?.appCode).toEqual(CUSTOM_APP_DATA_DOC.appCode)
expect(appDataDoc?.metadata.referrer?.address).toEqual(CUSTOM_APP_DATA_DOC.metadata.referrer.address)
expect(appDataDoc?.metadata.referrer?.version).toEqual(CUSTOM_APP_DATA_DOC.metadata.referrer.version)
})

test('Invalid: Decode appData with wrong hash format', async () => {
fetchMock.mockResponseOnce(JSON.stringify({}), { status: HTTP_STATUS_INTERNAL_ERROR })
try {
await cowSdk.metadataApi.decodeAppData('invalidHash')
} catch (e) {
const error = e as CowError
expect(error.message).toEqual('Error decoding AppData: Incorrect length')
}
})

test('Valid: AppData to CID ', async () => {
const decodedAppDataHex = await cowSdk.metadataApi.appDataHexToCid(APP_DATA_HEX)
expect(decodedAppDataHex).toEqual(IPFS_HASH)
})

test('Invalid: AppData to CID with wrong format ', async () => {
try {
await cowSdk.metadataApi.appDataHexToCid('invalidHash')
} catch (e) {
const error = e as CowError
expect(error.message).toEqual('Incorrect length')
}
})
18 changes: 14 additions & 4 deletions src/utils/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,27 @@ import { CowError, logPrefix } from './common'
import { SupportedChainId as ChainId } from '../constants/chains'
import { DEFAULT_APP_DATA_HASH, DEFAULT_IPFS_GATEWAY_URI } from '../constants'

export interface Ipfs {
uri?: string
pinataApiKey?: string
pinataApiSecret?: string
}

export interface CowContext {
appDataHash?: string
isDevEnvironment?: boolean
signer?: Signer
ipfsUri?: string
ipfs?: Ipfs
}

export const DefaultCowContext = {
appDataHash: DEFAULT_APP_DATA_HASH,
isDevEnvironment: false,
ipfsUri: DEFAULT_IPFS_GATEWAY_URI,
ipfs: {
uri: DEFAULT_IPFS_GATEWAY_URI,
apiKey: undefined,
apiSecret: undefined,
},
}

/**
Expand Down Expand Up @@ -83,7 +93,7 @@ export class Context implements Partial<CowContext> {
return this.#context.signer
}

get ipfsUri(): string {
return this.#context.ipfsUri ?? DefaultCowContext.ipfsUri
get ipfs(): Ipfs {
return this.#context.ipfs ?? DefaultCowContext.ipfs
}
}
Loading

0 comments on commit dd02548

Please sign in to comment.