Skip to content
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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,12 @@ wallet. Never wait on a buyer - buyer-side is out of your scope:

If step 4 ever fails with funds stuck, stop and escalate - do not retry
blindly.

UPI/INR reads the live Chainlink Polygon mainnet proxy
`0xDA0F8Df6F5dB15b346f4B8D1156722027E194E60` (chain 137), inverts
USD per INR, and rounds the creation-time maker floor up. Configure its
read-only RPC with `upiCreationRateRpcUrl` or `upiCreationRateTransport`.
Alipay/CNY retains the Ethereum registry and `creationRateRpcUrl` /
`creationRateTransport`. UPI rejects the wrong chain, invalid rounds, and
observations older than 24 hours; market closures do not bypass freshness.
This does not change the staging-only UPI opt-in gate.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,12 @@ the package, not the contributor entry point.
## License

MIT

UPI/INR reads the live Chainlink Polygon mainnet proxy
`0xDA0F8Df6F5dB15b346f4B8D1156722027E194E60` (chain 137), inverts
USD per INR, and rounds the creation-time maker floor up. Configure its
read-only RPC with `upiCreationRateRpcUrl` or `upiCreationRateTransport`.
Alipay/CNY retains the Ethereum registry and `creationRateRpcUrl` /
`creationRateTransport`. UPI rejects the wrong chain, invalid rounds, and
observations older than 24 hours; market closures do not bypass freshness.
This does not change the staging-only UPI opt-in gate.
1 change: 1 addition & 0 deletions examples/upi-staging-cashout.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// UPI pricing reads Polygon mainnet; configure upiCreationRateRpcUrl for a dedicated provider.
import { createCashClient, usdc } from '@zkp2p/cash';
import type { WalletClient } from 'viem';

Expand Down
10 changes: 10 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,13 @@ Key facts:
API key or referral-enrollment transaction is required.
- [Tools manifest](src/tools/index.ts): JSON-schema definitions of the verbs
- [Codecs](src/codecs/): zod schemas + lossless JSON round-trips


UPI/INR reads the live Chainlink Polygon mainnet proxy
`0xDA0F8Df6F5dB15b346f4B8D1156722027E194E60` (chain 137), inverts
USD per INR, and rounds the creation-time maker floor up. Configure its
read-only RPC with `upiCreationRateRpcUrl` or `upiCreationRateTransport`.
Alipay/CNY retains the Ethereum registry and `creationRateRpcUrl` /
`creationRateTransport`. UPI rejects the wrong chain, invalid rounds, and
observations older than 24 hours; market closures do not bypass freshness.
This does not change the staging-only UPI opt-in gate.
18 changes: 18 additions & 0 deletions skills/peer-cash-integration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,21 @@ Prove both routes without waiting for a buyer:

If withdrawal fails with funds stuck: stop, do not retry blindly, escalate to
a human with the `depositId` and tx hashes.

UPI/INR reads the live Chainlink Polygon mainnet proxy
`0xDA0F8Df6F5dB15b346f4B8D1156722027E194E60` (chain 137), inverts
USD per INR, and rounds the creation-time maker floor up. Configure its
read-only RPC with `upiCreationRateRpcUrl` or `upiCreationRateTransport`.
Alipay/CNY retains the Ethereum registry and `creationRateRpcUrl` /
`creationRateTransport`. UPI rejects the wrong chain, invalid rounds, and
observations older than 24 hours; market closures do not bypass freshness.
This does not change the staging-only UPI opt-in gate.

Before a funded UPI QA run, call
`cash.estimate({ amount: 1000000n, platform: 'upi', currency: 'INR' }, { includeEta: false })`
using the intended live Polygon RPC. Require a positive finite rate,
`binding: 'deposit-creation'`, and `oracleUpdatedAt` no more than 86400 seconds
old and not in the future. Record the observation time and selected chain,
then stop before funding if the read fails. Unit-test fixtures prove routing,
not live availability. The authoritative feed listing is
https://data.chain.link/feeds/polygon/mainnet/inr-usd.
17 changes: 15 additions & 2 deletions src/client/createCashClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
type Transport,
type WalletClient,
} from 'viem';
import { base, mainnet } from 'viem/chains';
import { base, mainnet, polygon } from 'viem/chains';
import { Zkp2pClient, appendAttributionToCalldata, createCompositeDepositId } from '@zkp2p/sdk';
import type { CurrencyType, PreparedTransaction, RuntimeEnv, TxOverrides } from '../sdk-types';
import {
Expand Down Expand Up @@ -154,6 +154,10 @@ export interface CashClientOptions {
creationRateTransport?: Transport;
/** Convenience alternative to `creationRateTransport`. */
creationRateRpcUrl?: string;
/** Polygon transport used only to snapshot UPI/INR's creation-time rate. */
upiCreationRateTransport?: Transport;
/** Convenience alternative to `upiCreationRateTransport`; requires Polygon mainnet. */
upiCreationRateRpcUrl?: string;
/** Relay API configuration for source assets outside Base USDC. */
relay?: RelayOptions;
/** NEAR Intents 1Click configuration for externally funded source routes. */
Expand Down Expand Up @@ -545,6 +549,10 @@ export function createCashClient(options: CashClientOptions): CashClient {
chain: mainnet,
transport: creationRateTransport,
});
const upiCreationRateClient = createPublicClient({
chain: polygon,
transport: options.upiCreationRateTransport ?? http(options.upiCreationRateRpcUrl),
});
const readCashAttribution = createCashAttributionReader({
environment,
...(options.indexerUrl ? { indexerUrl: options.indexerUrl } : {}),
Expand Down Expand Up @@ -712,7 +720,11 @@ export function createCashClient(options: CashClientOptions): CashClient {
throw new Error(`No creation-time rate reader for ${platform}/${currency}`);
}
try {
return await readCashCreationRate(creationRateClient, platform, currency);
return await readCashCreationRate(
platform.toLowerCase() === 'upi' ? upiCreationRateClient : creationRateClient,
platform,
currency,
);
} catch (err) {
throw errors.oracleReadFailed(currency, err);
}
Expand Down Expand Up @@ -1244,6 +1256,7 @@ export function createCashClient(options: CashClientOptions): CashClient {
: {}),
etaReader: async (etaInput) => fillEtaFromSample(await getFillStatsSample(), etaInput),
creationRateClient,
upiCreationRateClient,
...(options.relay ? { relay: options.relay } : {}),
});
},
Expand Down
86 changes: 55 additions & 31 deletions src/client/creationRate.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { Address, PublicClient } from 'viem';
import { parseAbi, type Address, type PublicClient } from 'viem';

const CHAINLINK_FEED_REGISTRY = '0x47Fb2585D2C56Fe188D0E6ec628a38b74fCeeeDf';
const CNY_DENOMINATION = '0x000000000000000000000000000000000000009c';
const INR_DENOMINATION = '0x0000000000000000000000000000000000000164';
const USD_DENOMINATION = '0x0000000000000000000000000000000000000348';
// https://data.chain.link/feeds/polygon/mainnet/inr-usd
const POLYGON_INR_USD_FEED = '0xDA0F8Df6F5dB15b346f4B8D1156722027E194E60';
const DIRECT_FEED_ABI = parseAbi([
'function decimals() view returns (uint8)',
'function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)',
]);

const FEED_REGISTRY_ABI = [
{
Expand Down Expand Up @@ -64,9 +69,6 @@ function getCreationRateDenomination(platform: string, currency: string): Addres
if (platform.toLowerCase() === 'alipay' && currency.toUpperCase() === 'CNY') {
return CNY_DENOMINATION as Address;
}
if (platform.toLowerCase() === 'upi' && currency.toUpperCase() === 'INR') {
return INR_DENOMINATION as Address;
}
throw new Error(`No creation-time rate source for ${platform}/${currency}`);
}

Expand All @@ -75,53 +77,75 @@ function divideRoundUp(numerator: bigint, denominator: bigint): bigint {
}

/**
* Read CNY/USD from Chainlink's canonical Ethereum Feed Registry and convert it
* to CNY per USDC. The returned integer is rounded up so the on-chain maker
* floor is never weaker than the observed market rate.
* Read CNY/USD from Ethereum's Feed Registry or INR/USD from its direct Polygon
* Chainlink proxy. The caller supplies the corridor's read client. Invert into
* fiat per USDC and round up so the maker floor preserves the observed rate.
*/
export async function readCashCreationRate(
publicClient: PublicClient,
platform: string,
currency: string,
nowSeconds = Math.floor(Date.now() / 1000),
): Promise<CreationRateSnapshot> {
const args = [
getCreationRateDenomination(platform, currency),
USD_DENOMINATION as Address,
] as const;
const [decimals, round] = await Promise.all([
publicClient.readContract({
address: CHAINLINK_FEED_REGISTRY,
abi: FEED_REGISTRY_ABI,
functionName: 'decimals',
args,
}),
publicClient.readContract({
address: CHAINLINK_FEED_REGISTRY,
abi: FEED_REGISTRY_ABI,
functionName: 'latestRoundData',
args,
}),
]);
const isUpi = platform.toLowerCase() === 'upi' && currency.toUpperCase() === 'INR';
const pair = `${currency.toUpperCase()}/USD`;
let decimals: number;
let round: readonly [bigint, bigint, bigint, bigint, bigint];
if (isUpi) {
if ((await publicClient.getChainId()) !== 137) {
throw new Error('Chainlink INR/USD requires Polygon mainnet (137)');
}
[decimals, round] = await Promise.all([
publicClient.readContract({
address: POLYGON_INR_USD_FEED,
abi: DIRECT_FEED_ABI,
functionName: 'decimals',
}),
publicClient.readContract({
address: POLYGON_INR_USD_FEED,
abi: DIRECT_FEED_ABI,
functionName: 'latestRoundData',
}),
]);
} else {
const args = [
getCreationRateDenomination(platform, currency),
USD_DENOMINATION as Address,
] as const;
[decimals, round] = await Promise.all([
publicClient.readContract({
address: CHAINLINK_FEED_REGISTRY,
abi: FEED_REGISTRY_ABI,
functionName: 'decimals',
args,
}),
publicClient.readContract({
address: CHAINLINK_FEED_REGISTRY,
abi: FEED_REGISTRY_ABI,
functionName: 'latestRoundData',
args,
}),
]);
}

const [roundId, answer, , updatedAtRaw, answeredInRound] = round;
if (answer <= 0n || updatedAtRaw <= 0n || answeredInRound < roundId) {
throw new Error('Chainlink CNY/USD returned an invalid round');
throw new Error(`Chainlink ${pair} returned an invalid round`);
}

const updatedAt = Number(updatedAtRaw);
if (!Number.isSafeInteger(updatedAt) || updatedAt > nowSeconds) {
throw new Error('Chainlink CNY/USD returned an invalid timestamp');
throw new Error(`Chainlink ${pair} returned an invalid timestamp`);
}
if (nowSeconds - updatedAt > CREATION_RATE_MAX_STALENESS_SECONDS) {
throw new Error('Chainlink CNY/USD rate is stale');
throw new Error(`Chainlink ${pair} rate is stale`);
}

// The feed is USD per CNY. Invert it into CNY per USD/USDC at 1e18 precision.
// Both feeds are USD per fiat unit. Invert into fiat per USD/USDC at 1e18.
const rate1e18 = divideRoundUp(10n ** (BigInt(decimals) + 18n), answer);
const rate = Number(rate1e18) / 1e18;
if (!Number.isFinite(rate) || rate <= 0) {
throw new Error('Chainlink CNY/USD produced an invalid creation rate');
throw new Error(`Chainlink ${pair} produced an invalid creation rate`);
}

return { rate1e18, rate, updatedAt };
Expand Down
13 changes: 9 additions & 4 deletions src/client/estimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* idempotent, cacheable.
*
* Existing corridors read the same Chainlink feed the protocol uses when an
* intent is signaled. Creation-rate corridors read Chainlink's Ethereum feed
* and fix that fresh snapshot as the maker floor when preparing the deposit.
* intent is signaled. Creation-rate corridors read Chainlink on Ethereum (CNY)
* or Polygon (INR), fixing the fresh snapshot when preparing the deposit.
*/
import type { Address, PublicClient } from 'viem';
import type { Zkp2pClient } from '@zkp2p/sdk';
Expand Down Expand Up @@ -118,6 +118,7 @@ export async function readEstimate(
includeEta?: boolean;
relay?: RelayOptions;
creationRateClient?: PublicClient;
upiCreationRateClient?: PublicClient;
} = {},
): Promise<CashEstimate> {
const { currency } = input;
Expand Down Expand Up @@ -154,10 +155,14 @@ export async function readEstimate(
let rate: number;
let oracleUpdatedAt: number | undefined;
if (usesCreationRate) {
if (!context.creationRateClient) throw errors.oracleUnsupportedCurrency(currency);
const rateClient =
creationRatePlatform!.toLowerCase() === 'upi'
? context.upiCreationRateClient
: context.creationRateClient;
if (!rateClient) throw errors.oracleUnsupportedCurrency(currency);
try {
const snapshot = await readCashCreationRate(
context.creationRateClient,
rateClient,
creationRatePlatform!,
currency,
asOf,
Expand Down
80 changes: 68 additions & 12 deletions test/creationRate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import {
readCashCreationRate,
} from '../src/client/creationRate';

function clientFor(round: readonly [bigint, bigint, bigint, bigint, bigint]): PublicClient {
function clientFor(
round: readonly [bigint, bigint, bigint, bigint, bigint],
chainId = 137,
): PublicClient {
return {
getChainId: vi.fn(async () => chainId),
readContract: vi.fn(async ({ functionName }: { functionName: string }) =>
functionName === 'decimals' ? 8 : round,
),
Expand All @@ -23,18 +27,70 @@ describe('creation-time rates', () => {
expect(isCreationRateCorridor('wise', 'CNY')).toBe(false);
});

it('reads UPI/INR from the Chainlink INR denomination', async () => {
it('reads the Polygon INR/USD proxy and rounds the inverted maker floor up', async () => {
const now = 2_000_000_000;
const client = clientFor([1n, 1_200_000n, 0n, BigInt(now - 60), 1n]);
await readCashCreationRate(client, 'upi', 'INR', now);
expect(client.readContract).toHaveBeenCalledWith(
expect.objectContaining({
args: [
'0x0000000000000000000000000000000000000164',
'0x0000000000000000000000000000000000000348',
],
}),
);
const answer = 1_050_700n;
const client = clientFor([1n, answer, 0n, BigInt(now - 60), 1n]);
const snapshot = await readCashCreationRate(client, 'upi', 'INR', now);
expect(client.getChainId).toHaveBeenCalled();
expect(client.readContract).toHaveBeenCalledTimes(2);
for (const functionName of ['decimals', 'latestRoundData']) {
expect(client.readContract).toHaveBeenCalledWith(
expect.objectContaining({
address: '0xDA0F8Df6F5dB15b346f4B8D1156722027E194E60',
functionName,
}),
);
}
for (const [request] of vi.mocked(client.readContract).mock.calls) {
expect(request.args).toBeUndefined();
}
expect(snapshot.rate1e18).toBe(95_174_645_474_445_607_691n);
expect(snapshot.rate1e18 * answer).toBeGreaterThanOrEqual(10n ** 26n);
expect((snapshot.rate1e18 - 1n) * answer).toBeLessThan(10n ** 26n);
expect(snapshot.updatedAt).toBe(now - 60);
});

it('rejects a UPI reader on Ethereum before reading a price', async () => {
const client = clientFor([1n, 1_050_700n, 0n, 1n, 1n], 1);
await expect(readCashCreationRate(client, 'upi', 'INR')).rejects.toThrow(/Polygon|137/u);
expect(client.readContract).not.toHaveBeenCalled();
});

it.each([
['zero answer', 0n, -60, 1n, /INR\/USD.*invalid round/u],
['negative answer', -1n, -60, 1n, /INR\/USD.*invalid round/u],
['incomplete round', 1n, -60, 0n, /INR\/USD.*invalid round/u],
['future timestamp', 1n, 1, 1n, /INR\/USD.*invalid timestamp/u],
['stale timestamp', 1n, -CREATION_RATE_MAX_STALENESS_SECONDS - 1, 1n, /INR\/USD.*stale/u],
] as const)(
'rejects UPI %s with an INR-specific error',
async (_, answer, offset, answeredInRound, error) => {
const now = 2_000_000_000;
await expect(
readCashCreationRate(
clientFor([1n, answer, 0n, BigInt(now + offset), answeredInRound]),
'upi',
'INR',
now,
),
).rejects.toThrow(error);
},
);

it('accepts the exact freshness boundary but rejects a missing UPI timestamp', async () => {
const now = 2_000_000_000;
await expect(
readCashCreationRate(
clientFor([1n, 1_050_700n, 0n, BigInt(now - CREATION_RATE_MAX_STALENESS_SECONDS), 1n]),
'upi',
'INR',
now,
),
).resolves.toMatchObject({ updatedAt: now - CREATION_RATE_MAX_STALENESS_SECONDS });
await expect(
readCashCreationRate(clientFor([1n, 1n, 0n, 0n, 1n]), 'upi', 'INR', now),
).rejects.toThrow(/INR\/USD.*invalid round/u);
});

it('inverts Chainlink CNY/USD and rounds the maker floor up at 1e18', async () => {
Expand Down
Loading
Loading