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
15 changes: 15 additions & 0 deletions packages/client/src/abis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const NEG_RISK_REDEEM_POSITIONS_FUNCTION = AbiFunction.from(
'function redeemPositions(bytes32 _conditionId, uint256[] _amounts)',
);
const SAFE_MULTISEND_FUNCTION = AbiFunction.from('function multiSend(bytes)');
const PROXY_FACTORY_FUNCTION = AbiFunction.from(
'function proxy((uint8 typeCode, address to, uint256 value, bytes data)[] calls) returns (bytes[])',
);
const BINARY_OUTCOME_PARTITION = [1n, 2n] as const;
const BINARY_OUTCOME_INDEX_SETS = [1n, 2n] as const;

Expand Down Expand Up @@ -298,6 +301,18 @@ function expectUint256(value: bigint, label: string): bigint {
return value;
}

/** @internal */
export function encodeProxyCall(calls: readonly TransactionCall[]): HexString {
return AbiFunction.encodeData(PROXY_FACTORY_FUNCTION, [
calls.map((call) => ({
typeCode: 1,
to: call.to,
value: call.value ?? 0n,
data: call.data,
})),
]);
}

/** @internal */
export function encodeSafeMultisendCall(
calls: readonly TransactionCall[],
Expand Down
3 changes: 2 additions & 1 deletion packages/client/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ function proxyWalletBytecodeHash(config: WalletDerivationConfig): HexString {
return expectHexString(Hash.keccak256(`0x${bytecode}`));
}

function deriveProxyWalletAddress(
/** @internal */
export function deriveProxyWalletAddress(
signer: EvmAddress,
config: WalletDerivationConfig,
): EvmAddress {
Expand Down
90 changes: 69 additions & 21 deletions packages/client/src/actions/gasless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { createPublicClient } from '../clients';
import {
createRandomWalletClient,
deriveProxyAddress,
publicClientWithBuilderKey,
publicClientWithRelayerKey,
runMeteredTests,
Expand Down Expand Up @@ -108,19 +109,7 @@

expect(signRequest.value).toEqual({
kind: 'signGaslessMessage',
payload: expect.objectContaining({
domain: expect.objectContaining({
chainId: secureClient.environment.chainId,
verifyingContract: secureClient.account.wallet,
}),
message: expect.objectContaining({
data: call.data,
operation: 0,
to: call.to,
value: 0n,
}),
primaryType: 'SafeTx',
}),
payload: expect.stringMatching(/^0x[0-9a-f]{64}$/),
});
});

Expand Down Expand Up @@ -159,18 +148,77 @@

expect(signRequest.value).toEqual({
kind: 'signGaslessMessage',
payload: expect.objectContaining({
message: expect.objectContaining({
data: expect.stringMatching(/^0x8d80ff0a/),
operation: 1,
to: secureClient.environment.safeMultisend,
value: 0n,
}),
}),
payload: expect.stringMatching(/^0x[0-9a-f]{64}$/),
});
});
});

describe('prepareGaslessTransaction (proxy)', () => {
Comment thread
cursor[bot] marked this conversation as resolved.
it('prepares a proxy workflow yielding a raw hash signing request', async () => {
const signerAddress = expectEvmAddress(walletClient.account.address);
const proxyWallet = deriveProxyAddress(signerAddress);

const secureClient = await publicClientWithRelayerKey
.beginAuthentication({ wallet: proxyWallet })
.then(authenticateWith(walletClient));

expect(secureClient.account.walletType).toBe(WalletType.POLY_PROXY);

const call = erc20ApprovalCall(
secureClient.environment.collateralToken,
secureClient.environment.standardExchange,
1n,
);
const workflow = await prepareGaslessTransaction(secureClient, {
calls: [call],
metadata: 'Proxy gasless transaction',
});

const addressRequest = await workflow.next();

expect(addressRequest).toEqual({
done: false,
value: { kind: 'requestAddress' },
});

const signRequest = await workflow.next(secureClient.account.signer);

expect(signRequest.done).toBe(false);

if (signRequest.done) {
return;
}

expect(signRequest.value.kind).toBe('signGaslessMessage');
expect(signRequest.value).toMatchObject({
kind: 'signGaslessMessage',
payload: expect.stringMatching(/^0x[0-9a-f]{64}$/),
});
Comment thread
cursor[bot] marked this conversation as resolved.
});

it('rejects for EOA wallet type', async () => {
const eoaClient = createRandomWalletClient();
const secureClient = await createPublicClient()
.beginAuthentication({ wallet: eoaClient.account.address })
.then(authenticateWith(eoaClient));

expect(secureClient.account.walletType).toBe(WalletType.EOA);

await expect(
prepareGaslessTransaction(secureClient, {
calls: [
erc20ApprovalCall(
secureClient.environment.collateralToken,
secureClient.environment.standardExchange,
1n,
),
],
metadata: 'Should fail',
}),
).rejects.toThrow();
});
});

describe('prepareGaslessWallet', () => {
it.runIf(runMeteredTests)(
'deploys a Safe wallet for a new signer',
Expand All @@ -193,7 +241,7 @@

expect(handle.wallet).toBe(safeWallet);

annotate(`Deployment transaction: ${handle.transactionHash}`);

Check notice on line 244 in packages/client/src/actions/gasless.test.ts

View workflow job for this annotation

GitHub Actions / Tests / Run

Deployment transaction: 0xa1e32ef6eaa07102d19b6d6098505ba366e666182cbe118605fd2cfc0f1ad399

await expect(handle.wait()).resolves.toBeTruthy();

Expand Down
120 changes: 105 additions & 15 deletions packages/client/src/actions/gasless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ import {
unwrap,
ZERO_ADDRESS,
} from '@polymarket/types';
import { Bytes, Hash, TypedData as OxTypedData } from 'ox';
import { z } from 'zod';
import { encodeSafeMultisendCall } from '../abis';
import { encodeProxyCall, encodeSafeMultisendCall } from '../abis';
import { deriveSafeWalletAddress } from '../account';
import type { BaseClient, BaseSecureClient } from '../clients';
import {
Expand Down Expand Up @@ -345,8 +346,9 @@ export async function prepareGaslessTransaction(
'Gasless transactions require a Relayer API Key or Builder API Key in the client configuration.',
);
invariant(
client.account.walletType === WalletType.POLY_GNOSIS_SAFE,
'Gasless transaction preparation currently supports Safe-backed accounts only',
client.account.walletType === WalletType.POLY_GNOSIS_SAFE ||
client.account.walletType === WalletType.POLY_PROXY,
'Gasless transaction preparation supports Safe-backed and proxy-backed accounts',
);

return async function* (): GaslessWorkflow {
Expand All @@ -357,6 +359,60 @@ export async function prepareGaslessTransaction(
'Wallet client address does not match the authenticated signer',
);

if (client.account.walletType === WalletType.POLY_PROXY) {
const executeParams = await fetchExecuteParams(client, {
address: client.account.signer,
type: RelayerTransactionType.PROXY,
});

const to = client.environment.walletDerivation.proxyFactory;
const data = encodeProxyCall(params.calls);
const relayerFee = '0';
// gasPrice is included in the signed hash but the relayer only validates
// that it is non-empty — it does not use this value when submitting the
// transaction on-chain (it applies its own gas pricing). Any non-empty
// string satisfies the protocol.
const gasPrice = '0';
// gasLimit is likewise part of the signed hash but is not used by the
// relayer when executing the transaction — the relayer applies its own
// gas estimation at submission time. The validator only checks non-empty.
const gasLimit = '10000000';
const relayHub = client.environment.relayHub;
const relay = ZERO_ADDRESS;

const hash = buildProxyTransactionHash(
client.account.signer,
to,
data,
relayerFee,
gasPrice,
gasLimit,
executeParams.nonce,
relayHub,
relay,
);

const signature = expectEvmSignature(yield signGaslessMessage(hash));

return executeGasless(client, {
data,
from: client.account.signer,
metadata: params.metadata,
nonce: executeParams.nonce,
proxyWallet: client.account.wallet,
signature,
signatureParams: {
gasLimit,
gasPrice,
relay,
relayHub,
relayerFee,
},
to,
type: RelayerTransactionType.PROXY,
});
}

const executeParams = await fetchExecuteParams(client, {
address: client.account.signer,
type: RelayerTransactionType.SAFE,
Expand All @@ -366,17 +422,23 @@ export async function prepareGaslessTransaction(
client.environment.safeMultisend,
);

const safePayload = createSafeTypedDataPayload({
chainId: client.environment.chainId,
data: transaction.data,
nonce: executeParams.nonce,
operation: transaction.operation,
safeAddress: client.account.wallet,
to: transaction.to,
value: transaction.value,
});

const signature = expectEvmSignature(
yield signGaslessMessage(
createSafeTypedDataPayload({
chainId: client.environment.chainId,
data: transaction.data,
nonce: executeParams.nonce,
operation: transaction.operation,
safeAddress: client.account.wallet,
to: transaction.to,
value: transaction.value,
}),
expectHexString(
OxTypedData.getSignPayload(
safePayload as Parameters<typeof OxTypedData.getSignPayload>[0],
),
),
),
);

Expand Down Expand Up @@ -535,15 +597,43 @@ function signGaslessTypedData(
};
}

function signGaslessMessage(
payload: TypedDataPayload,
): SignGaslessMessageRequest {
function signGaslessMessage(payload: HexString): SignGaslessMessageRequest {
return {
kind: 'signGaslessMessage',
payload,
};
}

function buildProxyTransactionHash(
from: EvmAddress,
to: EvmAddress,
data: HexString,
relayerFee: string,
gasPrice: string,
gasLimit: string,
nonce: string,
relayHub: EvmAddress,
relay: EvmAddress,
): HexString {
return expectHexString(
Hash.keccak256(
Bytes.concat(
Bytes.fromString('rlx:'),
Bytes.fromHex(from),
Bytes.fromHex(to),
Bytes.fromHex(data),
Bytes.fromNumber(BigInt(relayerFee), { size: 32 }),
Bytes.fromNumber(BigInt(gasPrice), { size: 32 }),
Bytes.fromNumber(BigInt(gasLimit), { size: 32 }),
Bytes.fromNumber(BigInt(nonce), { size: 32 }),
Bytes.fromHex(relayHub),
Bytes.fromHex(relay),
),
{ as: 'Hex' },
),
);
}

function packSafeSignature(signature: EvmSignature): HexString {
const prefixlessSignature = signature.slice(2);
const v = Number.parseInt(prefixlessSignature.slice(128, 130), 16);
Expand Down
19 changes: 18 additions & 1 deletion packages/client/src/clients.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { InvariantError } from '@polymarket/types';
import { WalletType } from '@polymarket/bindings/gamma';
import { expectEvmAddress, InvariantError } from '@polymarket/types';
import { describe, expect, it } from 'vitest';
import { fetchApiKeys } from './actions';
import {
createRandomWalletClient,
deriveProxyAddress,
publicClient,
safeWalletAddress,
walletClient,
Expand Down Expand Up @@ -41,6 +43,21 @@ describe('clients', () => {
await expect(fetchApiKeys(secondClient)).resolves.toBeDefined();
});

it('classifies a deterministic proxy wallet as POLY_PROXY', async () => {
const signerAddress = expectEvmAddress(walletClient.account.address);
const proxyWallet = deriveProxyAddress(signerAddress);

const secureClient = await publicClient
.beginAuthentication({ wallet: proxyWallet })
.then(authenticateWith(walletClient));

expect(secureClient.account.walletType).toBe(WalletType.POLY_PROXY);
expect(secureClient.account.wallet).toBe(proxyWallet);
expect(secureClient.account.signer).toBe(signerAddress);

await expect(fetchApiKeys(secureClient)).resolves.toBeDefined();
});

it('authenticates as EOA when wallet equals signer address', async () => {
const signerAddress = walletClient.account.address;
const secureClient = await publicClient
Expand Down
3 changes: 3 additions & 0 deletions packages/client/src/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export type EnvironmentConfig = {
/** @internal */
safeMultisend: EvmAddress;
/** @internal */
relayHub: EvmAddress;
/** @internal */
clob: string;
/** @internal */
relayer: string;
Expand Down Expand Up @@ -72,6 +74,7 @@ export const production: EnvironmentConfig = {
'0xC5d563A36AE78145C45a50134d48A1215220f80a',
),
safeMultisend: expectEvmAddress('0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761'),
relayHub: expectEvmAddress('0xD216153c06E857cD7f72665E0aF1d7D82172F494'),
clob: 'https://clob.polymarket.com',
relayer: 'https://relayer-v2.polymarket.com',
gamma: 'https://gamma-api.polymarket.com',
Expand Down
10 changes: 2 additions & 8 deletions packages/client/src/ethers-v5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,16 +155,10 @@ async function signTypedData(
}
}

async function signMessage(signer: EthersV5Signer, payload: TypedDataPayload) {
async function signMessage(signer: EthersV5Signer, message: HexString) {
try {
const digest = ethers.utils._TypedDataEncoder.hash(
payload.domain,
removeEip712Domain(payload.types),
payload.message,
);

return expectEvmSignature(
await signer.signMessage(ethers.utils.arrayify(digest)),
await signer.signMessage(ethers.utils.arrayify(message)),
);
} catch (error) {
throwSigningWorkflowError(error);
Expand Down
Loading
Loading