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 + [![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 @@ -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 +}