Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Metadata API] Add methods left to complete metadata flow #15

Merged
merged 6 commits into from
Apr 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 */
```

### Install Dependencies
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