diff --git a/README.md b/README.md
index db2476a6..fe165613 100644
--- a/README.md
+++ b/README.md
@@ -3,12 +3,12 @@
# CoW protocol SDK
+
[](https://prettier.io/)
[](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
@@ -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.
@@ -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,
@@ -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": {
@@ -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
diff --git a/src/api/metadata/index.ts b/src/api/metadata/index.ts
index b58f0753..6a0b39ab 100644
--- a/src/api/metadata/index.ts
+++ b/src/api/metadata/index.ts
@@ -1,9 +1,13 @@
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
@@ -11,14 +15,25 @@ export class MetadataApi {
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 {
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)
}
}
@@ -34,4 +49,9 @@ export class MetadataApi {
if (!cidV0) throw new CowError('Error getting serialized CID')
return cidV0
}
+
+ async uploadMetadataDocToIpfs(appDataDoc: AppDataDoc): Promise {
+ const { IpfsHash } = await pinJSONToIPFS(appDataDoc, this.context.ipfs)
+ return this.cidToAppDataHex(IpfsHash)
+ }
}
diff --git a/src/api/metadata/metadata.spec.ts b/src/api/metadata/metadata.spec.ts
new file mode 100644
index 00000000..d6a6fbe0
--- /dev/null
+++ b/src/api/metadata/metadata.spec.ts
@@ -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')
+ }
+})
diff --git a/src/utils/context.ts b/src/utils/context.ts
index dc03bdb7..3eadea85 100644
--- a/src/utils/context.ts
+++ b/src/utils/context.ts
@@ -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,
+ },
}
/**
@@ -83,7 +93,7 @@ export class Context implements Partial {
return this.#context.signer
}
- get ipfsUri(): string {
- return this.#context.ipfsUri ?? DefaultCowContext.ipfsUri
+ get ipfs(): Ipfs {
+ return this.#context.ipfs ?? DefaultCowContext.ipfs
}
}
diff --git a/src/utils/ipfs.ts b/src/utils/ipfs.ts
new file mode 100644
index 00000000..9ebbe663
--- /dev/null
+++ b/src/utils/ipfs.ts
@@ -0,0 +1,44 @@
+import { CowError } from './common'
+import { Ipfs } from './context'
+
+type PinataPinResponse = {
+ IpfsHash: string
+ PinSize: number
+ Timestamp: string
+}
+
+export async function pinJSONToIPFS(
+ file: unknown,
+ { uri, pinataApiKey = '', pinataApiSecret = '' }: Ipfs
+): Promise {
+ const { default: fetch } = await import('cross-fetch')
+
+ if (!pinataApiKey || !pinataApiSecret) {
+ throw new CowError('You need to pass IPFS api credentials.')
+ }
+
+ const body = JSON.stringify({
+ pinataContent: file,
+ pinataMetadata: { name: 'appData-affiliate' },
+ })
+
+ const pinataUrl = `${uri}/pinning/pinJSONToIPFS`
+
+ const response = await fetch(pinataUrl, {
+ method: 'POST',
+ body,
+ headers: {
+ 'Content-Type': 'application/json',
+ pinata_api_key: pinataApiKey,
+ pinata_secret_api_key: pinataApiSecret,
+ },
+ })
+
+ const data = await response.json()
+
+ if (response.status !== 200) {
+ throw new Error(data.error.details || data.error)
+ }
+
+ return data
+}