Skip to content
Open
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,26 @@ const quoteRequest: QuoteRequest = {
const quote = await OneClickService.getQuote(quoteRequest);
```


## Quote Signature Verification

`OneClickService.getQuote()` automatically verifies the Ed25519 signature returned by the 1Click quote endpoint before resolving. If the signature is missing, malformed, or does not match the canonical quote payload, the returned promise rejects with `QuoteSignatureVerificationError`.

The SDK also exports helper functions for applications that persist quotes and want to re-check them later:

```typescript
import {
QuoteSignatureVerificationError,
verifyQuoteResponseSignature,
verifyQuoteResponseOrThrow,
} from '@defuse-protocol/one-click-sdk-typescript';

const isAuthentic = verifyQuoteResponseSignature(savedQuote);
verifyQuoteResponseOrThrow(savedQuote);
```

Signature verification is a defense-in-depth authenticity check. Applications should still use TLS, authenticate the API endpoint, validate every quote field, and enforce their own risk controls.

## API Methods

[See official API docs](https://docs.near-intents.org/near-intents/integration/distribution-channels/1click-api) for more info on endpoints.
Expand Down
16 changes: 15 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"fetch-api": "curl -o openapi.yaml https://1click.chaindefuser.com/docs/v0/openapi.yaml",
"generate": "openapi --input openapi.yaml --output ./src --client axios",
"generate": "pnpm run generate:openapi && node scripts/apply-customizations.cjs",
"generate:fresh": "pnpm run fetch-api && pnpm run generate",
"clean": "rm -rf dist node_modules",
"prepublishOnly": "pnpm run clean && pnpm install && pnpm run generate:fresh && pnpm run build",
"prepack": "pnpm run build",
"release": "pnpm run prepublishOnly && pnpm publish --access public"
"release": "pnpm run prepublishOnly && pnpm publish --access public",
"generate:openapi": "openapi --input openapi.yaml --output ./src --client axios",
"test:e2e": "pnpm build && node scripts/test-generation-and-verification.cjs"
},
"repository": {
"type": "git",
Expand All @@ -56,7 +58,9 @@
},
"dependencies": {
"axios": "^1.6.8",
"form-data": "^4.0.0"
"form-data": "^4.0.0",
"js-sha256": "^0.11.1",
"tweetnacl": "^1.0.3"
},
"packageManager": "pnpm@8.15.4",
"publishConfig": {
Expand Down
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 58 additions & 0 deletions scripts/apply-customizations.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const fs = require('fs');
const path = require('path');

const repoRoot = path.resolve(__dirname, '..');
const quoteSignatureTemplate = path.join(__dirname, 'templates', 'quoteSignature.ts');
const quoteSignatureTarget = path.join(repoRoot, 'src', 'quoteSignature.ts');
const servicePath = path.join(repoRoot, 'src', 'services', 'OneClickService.ts');
const indexPath = path.join(repoRoot, 'src', 'index.ts');

const replaceOrThrow = (source, search, replacement, description) => {
if (!source.includes(search)) {
throw new Error(`Unable to apply 1Click SDK customization: ${description}`);
}
return source.replace(search, replacement);
};

fs.mkdirSync(path.dirname(quoteSignatureTarget), { recursive: true });
fs.copyFileSync(quoteSignatureTemplate, quoteSignatureTarget);

let service = fs.readFileSync(servicePath, 'utf8');
if (!service.includes("../quoteSignature")) {
service = replaceOrThrow(
service,
"import { request as __request } from '../core/request';\n",
"import { request as __request } from '../core/request';\nimport { withQuoteSignatureVerification } from '../quoteSignature';\n",
'could not find generated request import in OneClickService',
);
}

if (!service.includes('return withQuoteSignatureVerification(__request(OpenAPI, {')) {
service = replaceOrThrow(
service,
" return __request(OpenAPI, {\n method: 'POST',\n url: '/v0/quote',",
" return withQuoteSignatureVerification(__request(OpenAPI, {\n method: 'POST',\n url: '/v0/quote',",
'could not find generated getQuote request call',
);
const wrappedGetQuoteIndex = service.indexOf('return withQuoteSignatureVerification(__request(OpenAPI, {');
const requestTerminatorIndex = service.indexOf(" });\n }", wrappedGetQuoteIndex);
if (requestTerminatorIndex === -1) {
throw new Error('Unable to apply 1Click SDK customization: could not find generated getQuote request terminator');
}
service = service.slice(0, requestTerminatorIndex)
+ " }));\n }"
+ service.slice(requestTerminatorIndex + " });\n }".length);
}
fs.writeFileSync(servicePath, service);

let index = fs.readFileSync(indexPath, 'utf8');
const quoteSignatureExport = "export { QuoteSignatureVerificationError, getCanonicalQuoteHash, getCanonicalQuotePayload, verifyQuoteResponseOrThrow, verifyQuoteResponseSignature } from './quoteSignature';\n";
if (!index.includes(quoteSignatureExport)) {
index = replaceOrThrow(
index,
"export type { OpenAPIConfig } from './core/OpenAPI';\n",
"export type { OpenAPIConfig } from './core/OpenAPI';\n" + quoteSignatureExport,
'could not find OpenAPIConfig export insertion point',
);
}
fs.writeFileSync(indexPath, index);
134 changes: 134 additions & 0 deletions scripts/templates/quoteSignature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { sha256 } from 'js-sha256';
import nacl from 'tweetnacl';

import { CancelablePromise } from './core/CancelablePromise';
import type { QuoteResponse } from './models/QuoteResponse';

const ONE_CLICK_PUBLIC_KEY = 'ed25519:reYaWhvwu8Jzo3WUM3zhn6VrhuMEF4eADL17qtRVifc';
const SIGNATURE_PREFIX = 'ed25519:';
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const textEncoder = new TextEncoder();

export class QuoteSignatureVerificationError extends Error {
public readonly quoteResponse: QuoteResponse;

constructor(message: string, quoteResponse: QuoteResponse) {
super(message);
this.name = 'QuoteSignatureVerificationError';
this.quoteResponse = quoteResponse;
}
}

type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue | undefined };

const isPlainObject = (value: unknown): value is Record<string, JsonValue | undefined> => {
return typeof value === 'object' && value !== null && !Array.isArray(value);
};

const stableStringify = (value: JsonValue): string => {
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
}

if (isPlainObject(value)) {
return `{${Object.keys(value)
.filter((key) => value[key] !== undefined)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key] as JsonValue)}`)
.join(',')}}`;
}

return JSON.stringify(value);
};

const decodeBase58 = (value: string): Uint8Array => {
const bytes = [0];

for (const char of value) {
const index = BASE58_ALPHABET.indexOf(char);
if (index === -1) {
throw new Error(`Invalid base58 character: ${char}`);
}

let carry = index;
for (let i = 0; i < bytes.length; i += 1) {
carry += bytes[i] * 58;
bytes[i] = carry & 0xff;
carry >>= 8;
}

while (carry > 0) {
bytes.push(carry & 0xff);
carry >>= 8;
}
}

for (const char of value) {
if (char !== '1') {
break;
}
bytes.push(0);
}

return new Uint8Array(bytes.reverse());
};

const getEd25519Value = (value: string, fieldName: string): string => {
if (!value.startsWith(SIGNATURE_PREFIX)) {
throw new Error(`${fieldName} must start with ${SIGNATURE_PREFIX}`);
}

return value.slice(SIGNATURE_PREFIX.length);
};

export const getCanonicalQuotePayload = (quoteResponse: QuoteResponse): string => {
return `{${[
`${JSON.stringify('quoteRequest')}:${stableStringify(quoteResponse.quoteRequest as JsonValue)}`,
`${JSON.stringify('quote')}:${stableStringify(quoteResponse.quote as JsonValue)}`,
`${JSON.stringify('timestamp')}:${stableStringify(quoteResponse.timestamp as JsonValue)}`,
].join(',')}}`;
};

export const getCanonicalQuoteHash = (quoteResponse: QuoteResponse): string => {
return sha256(getCanonicalQuotePayload(quoteResponse));
};

const verifyQuoteResponseSignatureStrict = (quoteResponse: QuoteResponse): boolean => {
const signature = decodeBase58(getEd25519Value(quoteResponse.signature, 'Quote signature'));
const publicKey = decodeBase58(getEd25519Value(ONE_CLICK_PUBLIC_KEY, '1Click public key'));
const message = textEncoder.encode(getCanonicalQuoteHash(quoteResponse));

return nacl.sign.detached.verify(message, signature, publicKey);
};

export const verifyQuoteResponseSignature = (quoteResponse: QuoteResponse): boolean => {
try {
return verifyQuoteResponseSignatureStrict(quoteResponse);
} catch {
return false;
}
};

export const verifyQuoteResponseOrThrow = (quoteResponse: QuoteResponse): QuoteResponse => {
if (!verifyQuoteResponseSignature(quoteResponse)) {
throw new QuoteSignatureVerificationError('1Click quote signature verification failed', quoteResponse);
}

return quoteResponse;
};

export const withQuoteSignatureVerification = (promise: CancelablePromise<QuoteResponse>): CancelablePromise<QuoteResponse> => {
return new CancelablePromise<QuoteResponse>((resolve, reject, onCancel) => {
onCancel(() => promise.cancel());

promise
.then((quoteResponse) => {
try {
resolve(verifyQuoteResponseOrThrow(quoteResponse));
} catch (error) {
reject(error);
}
})
.catch(reject);
});
};
Loading