diff --git a/.gitignore b/.gitignore index 3ecf9a0..2d1aff5 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,7 @@ node_modules /cache-hh /artifacts-tron /cache_hardhat-tron +sui/**/build/ .DS_Store +.cursor/rules/ diff --git a/deployments/prod/addresses/sui.json b/deployments/prod/addresses/sui.json new file mode 100644 index 0000000..daded16 --- /dev/null +++ b/deployments/prod/addresses/sui.json @@ -0,0 +1,12 @@ +{ + "SuiRFQVaultPackage": "0x4479fe1d936051823526e9a0c890c3d582001d28c6d57ce26d7bb9159a8c5638", + "SuiRFQVault": "0xae8664656a8a5644cc8d7f7c4d1fcf7c5123bdb093a588be46aba55e06f71e6c", + "SuiRFQVaultUpgradeCap": "0x7675e98b7034e816ca3ce4e48fa140fd109d75ec354a74f38b8dbf7e5c540ae2", + "SuiRFQVaultPublishTx": "2gpj3tNCDfrWqZdmdVs5v9Czp8P3nS8RKmbMgZYDoJfx", + "SuiRFQVaultUpgradeCapTransferTx": "5MEH4NLVbZNzSptnABSrpMrSmpqYmipM9uBDVbECRhVj", + "SuiRFQVaultCreateTx": "Ha4PwcktQhbEYpGSFJ7PCZNpFFH7bVkQadEdz79QbqoW", + "SuiRFQVaultOwner": "0xe1947e77f5eaffdecf67a4447220c66a76278b7e1e04b353c59ba5939fc29a07", + "SuiRFQVaultSolverPublicKey": "0x026afa8274da660478f59aa43f38527ccf05ada96080a2b431d6c2dd76593fbf9e", + "SuiRFQVaultDomain": "SOCKET_RFQ_VAULT_SUI_MAINNET", + "SuiRFQVaultNetworkAlias": "mainnet" +} diff --git a/package.json b/package.json index b5b69ff..a0da79b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,10 @@ "deploy:cctp-claim-executor:all": "ts-node scripts/deploy/deployCctpClaimExecutorAllChains.ts", "deploy:rfq-vault-executor:all": "ts-node scripts/deploy/deployRFQVaultExecutorAllChains.ts", "deploy:rfq-vault-executor": "hardhat run scripts/deploy/deployRFQVaultExecutor.ts --network", + "deploy:sui-rfq-vault": "ts-node scripts/sui/deployRfqVault.ts", + "sui:rfq-vault:kms-solver": "ts-node scripts/sui/printKmsSolverKey.ts", + "sui:rfq-vault:build": "sui move build --path sui/rfq-vault", + "sui:rfq-vault:test": "sui move test --path sui/rfq-vault", "check:openrouter": "hardhat run scripts/deploy/checkOpenRouterDeployment.ts", "verify:openrouter": "ts-node scripts/deploy/verifyOpenRouterBatch.ts", "check:allowance-holder": "hardhat run scripts/deploy/checkAllowanceHolderDeployment.ts", @@ -27,6 +31,7 @@ "slither": "bash scripts/docker-slither.sh" }, "devDependencies": { + "@noble/hashes": "^1.4.0", "@arbitrum/sdk": "^4.0.5", "@nomicfoundation/hardhat-foundry": "^1.1.2", "@nomicfoundation/hardhat-toolbox": "^5.0.0", diff --git a/scripts/sui/deployRfqVault.ts b/scripts/sui/deployRfqVault.ts new file mode 100644 index 0000000..9c7e113 --- /dev/null +++ b/scripts/sui/deployRfqVault.ts @@ -0,0 +1,467 @@ +import { spawnSync } from 'child_process'; +import { config as dotenvConfig } from 'dotenv'; +import { mkdtemp, readFile, rm, writeFile, mkdir } from 'fs/promises'; +import { dirname, resolve } from 'path'; +import { tmpdir } from 'os'; +import { + SUI_RFQ_VAULT_DEPLOYMENT_STAGE, + SUI_RFQ_VAULT_DOMAIN, + SUI_RFQ_VAULT_GAS_BUDGET, + SUI_RFQ_VAULT_NETWORK_ALIAS, + SUI_RFQ_VAULT_OWNER_ADDRESS, + SUI_RFQ_VAULT_PACKAGE_PATH, + SUI_RFQ_VAULT_RPC_URL, + SUI_RFQ_VAULT_SOLVER_PUBLIC_KEY, + SUI_RFQ_VAULT_VERIFY_SOURCE, +} from './rfqVaultDeploymentConfig'; + +dotenvConfig({ path: resolve(__dirname, '../../.env') }); + +type SuiObjectChange = { + type?: string; + packageId?: string; + objectId?: string; + objectType?: string; +}; + +type SuiCommandResult = { + digest?: string; + objectChanges?: SuiObjectChange[]; +}; + +type DeploymentConfig = { + networkAlias: string; + rpcUrl: string; + ownerAddress: string; + solverPublicKey: string; + domain: string; + gasBudget: string; + packagePath: string; + stage: string; + verifySource: boolean; +}; + +type DeployerConfig = { + secret: string; + scheme: string; +}; + +type DeploymentRecord = { + SuiRFQVaultPackage: string; + SuiRFQVault: string; + SuiRFQVaultUpgradeCap?: string; + SuiRFQVaultPublishTx?: string; + SuiRFQVaultUpgradeCapTransferTx?: string; + SuiRFQVaultCreateTx?: string; + SuiRFQVaultOwner: string; + SuiRFQVaultSolverPublicKey: string; + SuiRFQVaultDomain: string; + SuiRFQVaultNetworkAlias: string; +}; + +const REPO_ROOT = resolve(__dirname, '../..'); +const DEPLOYER_ALIAS = 'rfq-vault-deployer'; +const TEMP_ENV_ALIAS_PREFIX = 'rfqvault_'; +const SUI_ADDRESS_RE = /^0x[a-fA-F0-9]{64}$/; +const HEX_RE = /^0x[a-fA-F0-9]*$/; +const DEPLOYER_SCHEMES = new Set(['ed25519', 'secp256k1', 'secp256r1']); + +function requireConfig(name: string, value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error( + `Fill ${name} in scripts/sui/rfqVaultDeploymentConfig.ts before deploying`, + ); + } + + return trimmed; +} + +function resolveDeploymentConfig(): DeploymentConfig { + const config = { + networkAlias: requireConfig( + 'SUI_RFQ_VAULT_NETWORK_ALIAS', + SUI_RFQ_VAULT_NETWORK_ALIAS, + ), + rpcUrl: requireConfig('SUI_RFQ_VAULT_RPC_URL', SUI_RFQ_VAULT_RPC_URL), + ownerAddress: requireConfig( + 'SUI_RFQ_VAULT_OWNER_ADDRESS', + SUI_RFQ_VAULT_OWNER_ADDRESS, + ), + solverPublicKey: requireConfig( + 'SUI_RFQ_VAULT_SOLVER_PUBLIC_KEY', + SUI_RFQ_VAULT_SOLVER_PUBLIC_KEY, + ), + domain: requireConfig('SUI_RFQ_VAULT_DOMAIN', SUI_RFQ_VAULT_DOMAIN), + gasBudget: requireConfig( + 'SUI_RFQ_VAULT_GAS_BUDGET', + SUI_RFQ_VAULT_GAS_BUDGET, + ), + packagePath: SUI_RFQ_VAULT_PACKAGE_PATH, + stage: SUI_RFQ_VAULT_DEPLOYMENT_STAGE, + verifySource: SUI_RFQ_VAULT_VERIFY_SOURCE, + }; + + assertSuiAddress('SUI_RFQ_VAULT_OWNER_ADDRESS', config.ownerAddress); + assertHexBytes('SUI_RFQ_VAULT_SOLVER_PUBLIC_KEY', config.solverPublicKey, 33); + assertPositiveInteger('SUI_RFQ_VAULT_GAS_BUDGET', config.gasBudget); + + return config; +} + +function resolveDeployerConfig(): DeployerConfig { + const secret = process.env.SUI_DEPLOYER?.trim(); + if (!secret) { + throw new Error('SUI_DEPLOYER is required'); + } + + const scheme = ( + process.env.SUI_DEPLOYER_SCHEME?.trim() || 'ed25519' + ).toLowerCase(); + if (!DEPLOYER_SCHEMES.has(scheme)) { + throw new Error( + `SUI_DEPLOYER_SCHEME must be one of ${Array.from(DEPLOYER_SCHEMES).join(', ')}`, + ); + } + + return { secret, scheme }; +} + +function assertSuiAddress(name: string, value: string): void { + if (!SUI_ADDRESS_RE.test(value)) { + throw new Error(`${name} must be a 32-byte Sui address`); + } +} + +function assertHexBytes(name: string, value: string, byteLength: number): void { + if (!HEX_RE.test(value) || value.length !== 2 + byteLength * 2) { + throw new Error(`${name} must be ${byteLength} bytes of 0x-prefixed hex`); + } +} + +function assertPositiveInteger(name: string, value: string): void { + if (!/^[1-9][0-9]*$/.test(value)) { + throw new Error(`${name} must be a positive integer`); + } +} + +function hexToBytes(hex: string): number[] { + const stripped = hex.slice(2); + return Array.from({ length: stripped.length / 2 }, (_, index) => + Number.parseInt(stripped.slice(index * 2, index * 2 + 2), 16), + ); +} + +function stringToBytes(value: string): number[] { + return [...Buffer.from(value, 'utf8')]; +} + +function vectorArg(bytes: number[]): string { + return `[${bytes.join(',')}]`; +} + +function childEnv(configDir: string): NodeJS.ProcessEnv { + return { + PATH: process.env.PATH || '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin', + HOME: configDir, + SUI_CONFIG_DIR: configDir, + }; +} + +function redact(value: string, redactions: readonly string[]): string { + const withExplicitRedactions = redactions.reduce( + (acc, secret) => (secret ? acc.split(secret).join('[redacted]') : acc), + value, + ); + return withExplicitRedactions.replace( + /secret recovery phrase\s*:\s*\[[^\]]*]/gi, + 'secret recovery phrase : [redacted]', + ); +} + +function runSui(args: string[], params: { + cwd?: string; + env?: NodeJS.ProcessEnv; + label: string; + redactions?: readonly string[]; + suppressFailureOutput?: boolean; +}): string { + console.log(`[sui] ${params.label}`); + const result = spawnSync('sui', args, { + cwd: params.cwd ?? REPO_ROOT, + env: params.env, + encoding: 'utf8', + }); + + const redactions = params.redactions ?? []; + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + if (params.suppressFailureOutput) { + throw new Error( + `${params.label} failed; rerun the same Sui CLI step manually for full output if needed`, + ); + } + const output = redact( + [result.stdout, result.stderr].filter(Boolean).join('\n'), + redactions, + ); + throw new Error(`${params.label} failed\n${output}`); + } + + return result.stdout.trim(); +} + +function runSuiJson(args: string[], params: { + cwd?: string; + env?: NodeJS.ProcessEnv; + label: string; + redactions?: readonly string[]; +}): T { + const stdout = runSui(args, params); + try { + return JSON.parse(stdout) as T; + } catch (err) { + throw new Error( + `${params.label} returned invalid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +function findPublishedPackageId(result: SuiCommandResult): string { + const packageId = result.objectChanges?.find( + (change) => change.type === 'published' && change.packageId, + )?.packageId; + if (!packageId) { + throw new Error('Published package ID not found in Sui publish result'); + } + + return packageId; +} + +function findCreatedObjectId( + result: SuiCommandResult, + objectTypeSuffix: string, +): string | undefined { + return result.objectChanges?.find( + (change) => + change.type === 'created' && + change.objectId && + change.objectType?.endsWith(objectTypeSuffix), + )?.objectId; +} + +function deploymentsPath(stage: string): string { + return resolve(REPO_ROOT, 'deployments', stage, 'addresses', 'sui.json'); +} + +function tempEnvAlias(networkAlias: string): string { + return `${TEMP_ENV_ALIAS_PREFIX}${networkAlias.replace(/[^A-Za-z0-9_]/g, '_')}`; +} + +async function persistDeployment( + stage: string, + record: DeploymentRecord, +): Promise { + const filePath = deploymentsPath(stage); + let deployments: Record = {}; + + try { + deployments = JSON.parse(await readFile(filePath, 'utf8')) as Record< + string, + unknown + >; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + Object.assign(deployments, record); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(deployments, null, 2)}\n`, 'utf8'); + + return filePath; +} + +async function main() { + const config = resolveDeploymentConfig(); + const deployer = resolveDeployerConfig(); + const packagePath = resolve(REPO_ROOT, config.packagePath); + const configDir = await mkdtemp(resolve(tmpdir(), 'sui-rfq-vault-deploy-')); + const clientConfig = resolve(configDir, 'client.yaml'); + const deployNetworkAlias = tempEnvAlias(config.networkAlias); + const env = childEnv(configDir); + + try { + runSui(['move', 'build', '--path', packagePath], { + label: 'build Move package', + }); + runSui( + [ + 'client', + '--client.config', + clientConfig, + '-y', + 'new-env', + '--alias', + deployNetworkAlias, + '--rpc', + config.rpcUrl, + ], + { env, label: 'create temporary Sui client env' }, + ); + runSui( + [ + 'keytool', + 'import', + '--alias', + DEPLOYER_ALIAS, + '--json', + deployer.secret, + deployer.scheme, + ], + { + env, + label: 'import SUI_DEPLOYER into temporary keystore', + redactions: [deployer.secret], + }, + ); + runSui( + [ + 'client', + '--client.config', + clientConfig, + 'switch', + '--env', + deployNetworkAlias, + ], + { env, label: 'select Sui env' }, + ); + runSui( + [ + 'client', + '--client.config', + clientConfig, + 'switch', + '--address', + DEPLOYER_ALIAS, + ], + { env, label: 'select Sui deployer' }, + ); + + const publishResult = runSuiJson( + [ + 'client', + '--client.config', + clientConfig, + '--json', + 'publish', + packagePath, + '--gas-budget', + config.gasBudget, + ], + { env, label: 'publish RFQ vault package' }, + ); + const packageId = findPublishedPackageId(publishResult); + const upgradeCapId = findCreatedObjectId( + publishResult, + '0x2::package::UpgradeCap', + ); + let upgradeCapTransferDigest: string | undefined; + if (upgradeCapId) { + const upgradeCapTransferResult = runSuiJson( + [ + 'client', + '--client.config', + clientConfig, + '--json', + 'transfer', + '--to', + config.ownerAddress, + '--object-id', + upgradeCapId, + '--gas-budget', + config.gasBudget, + ], + { env, label: 'transfer RFQ vault package UpgradeCap to owner' }, + ); + upgradeCapTransferDigest = upgradeCapTransferResult.digest; + } + + const createVaultResult = runSuiJson( + [ + 'client', + '--client.config', + clientConfig, + '--json', + 'call', + '--package', + packageId, + '--module', + 'vault', + '--function', + 'create_vault', + '--args', + config.ownerAddress, + vectorArg(hexToBytes(config.solverPublicKey)), + vectorArg(stringToBytes(config.domain)), + '--gas-budget', + config.gasBudget, + ], + { env, label: 'create shared RFQ vault' }, + ); + const vaultId = findCreatedObjectId(createVaultResult, '::vault::Vault'); + if (!vaultId) { + throw new Error( + 'Shared RFQ vault object ID not found in create_vault result', + ); + } + + const filePath = await persistDeployment(config.stage, { + SuiRFQVaultPackage: packageId, + SuiRFQVault: vaultId, + SuiRFQVaultUpgradeCap: upgradeCapId, + SuiRFQVaultPublishTx: publishResult.digest, + SuiRFQVaultUpgradeCapTransferTx: upgradeCapTransferDigest, + SuiRFQVaultCreateTx: createVaultResult.digest, + SuiRFQVaultOwner: config.ownerAddress, + SuiRFQVaultSolverPublicKey: config.solverPublicKey, + SuiRFQVaultDomain: config.domain, + SuiRFQVaultNetworkAlias: config.networkAlias, + }); + + if (config.verifySource) { + runSui( + [ + 'client', + '--client.config', + clientConfig, + '--json', + 'verify-source', + packagePath, + '--verify-deps', + ], + { env, label: 'verify published source' }, + ); + } + + console.log(''); + console.log('Sui RFQ vault deployed'); + console.log(`Package: ${packageId}`); + console.log(`Vault: ${vaultId}`); + if (upgradeCapId) { + console.log(`UpgradeCap: ${upgradeCapId}`); + } + console.log(`Deployment: ${filePath}`); + } finally { + await rm(configDir, { recursive: true, force: true }); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/sui/printKmsSolverKey.ts b/scripts/sui/printKmsSolverKey.ts new file mode 100644 index 0000000..6abe3d5 --- /dev/null +++ b/scripts/sui/printKmsSolverKey.ts @@ -0,0 +1,209 @@ +import { execFileSync } from 'child_process'; +import { createPublicKey } from 'crypto'; +import { config as dotenvConfig } from 'dotenv'; +import { resolve } from 'path'; +import { blake2b } from '@noble/hashes/blake2b'; +import { keccak256 } from 'ethers'; + +dotenvConfig({ path: resolve(__dirname, '../../.env') }); + +type AwsKmsPublicKeyResponse = { + KeyId?: string; + KeySpec?: string; + PublicKey?: string; + SigningAlgorithms?: string[]; +}; + +type ParsedArgs = { + keyId?: string; + region?: string; +}; + +const SUI_SECP256K1_SCHEME_FLAG = 0x01; + +function parseArgs(argv: string[]): ParsedArgs { + const args: ParsedArgs = {}; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--key-id') { + args.keyId = argv[index + 1]; + index += 1; + continue; + } + if (arg.startsWith('--key-id=')) { + args.keyId = arg.slice('--key-id='.length); + continue; + } + if (arg === '--region') { + args.region = argv[index + 1]; + index += 1; + continue; + } + if (arg.startsWith('--region=')) { + args.region = arg.slice('--region='.length); + continue; + } + if (arg === '-h' || arg === '--help') { + printUsage(); + process.exit(0); + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return args; +} + +function printUsage(): void { + console.log(`Usage: + AWS_KMS_KEY_ID= AWS_KMS_REGION= yarn sui:rfq-vault:kms-solver + yarn sui:rfq-vault:kms-solver -- --key-id --region + +Prints the compressed secp256k1 public key for SUI_RFQ_VAULT_SOLVER_PUBLIC_KEY +and the Sui secp256k1 address derived from the same AWS KMS key.`); +} + +function requireValue(name: string, value: string | undefined): string { + const trimmed = value?.trim(); + if (!trimmed) { + throw new Error(`${name} is required`); + } + return trimmed; +} + +function getKmsPublicKey(keyId: string, region: string): AwsKmsPublicKeyResponse { + try { + const stdout = execFileSync( + 'aws', + [ + 'kms', + 'get-public-key', + '--key-id', + keyId, + '--region', + region, + '--output', + 'json', + ], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + return JSON.parse(stdout) as AwsKmsPublicKeyResponse; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `Unable to read AWS KMS public key. Ensure the AWS CLI is installed, authenticated, and allowed to call kms:GetPublicKey. ${message}`, + ); + } +} + +function compressSecp256k1PublicKey(spkiDer: Buffer): { + compressedPublicKey: Buffer; + uncompressedPublicKey: Buffer; +} { + const key = createPublicKey({ + key: spkiDer, + format: 'der', + type: 'spki', + }); + const details = key.asymmetricKeyDetails; + if (key.asymmetricKeyType !== 'ec') { + throw new Error(`KMS public key must be elliptic curve; got ${key.asymmetricKeyType}`); + } + if (details?.namedCurve && details.namedCurve !== 'secp256k1') { + throw new Error(`KMS public key must use secp256k1; got ${details.namedCurve}`); + } + + const jwk = key.export({ format: 'jwk' }) as { + crv?: string; + x?: string; + y?: string; + }; + if (!jwk.x || !jwk.y) { + throw new Error('KMS public key did not export x/y coordinates'); + } + + const x = leftPad32(base64UrlToBuffer(jwk.x)); + const y = leftPad32(base64UrlToBuffer(jwk.y)); + const yIsOdd = (y[y.length - 1] & 1) === 1; + const compressedPublicKey = Buffer.concat([ + Buffer.from([yIsOdd ? 0x03 : 0x02]), + x, + ]); + + return { + compressedPublicKey, + uncompressedPublicKey: Buffer.concat([x, y]), + }; +} + +function base64UrlToBuffer(value: string): Buffer { + const base64 = value + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(Math.ceil(value.length / 4) * 4, '='); + return Buffer.from(base64, 'base64'); +} + +function leftPad32(value: Buffer): Buffer { + if (value.length > 32) { + throw new Error('Invalid secp256k1 coordinate length'); + } + if (value.length === 32) { + return value; + } + return Buffer.concat([Buffer.alloc(32 - value.length), value]); +} + +function toHex(value: Uint8Array | Buffer): string { + return `0x${Buffer.from(value).toString('hex')}`; +} + +function deriveSuiSecp256k1Address(compressedPublicKey: Buffer): string { + const digest = blake2b( + Buffer.concat([ + Buffer.from([SUI_SECP256K1_SCHEME_FLAG]), + compressedPublicKey, + ]), + { dkLen: 32 }, + ); + return toHex(digest); +} + +function deriveEvmAddress(uncompressedPublicKey: Buffer): string { + return `0x${keccak256(uncompressedPublicKey).slice(-40)}`; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const keyId = requireValue('AWS_KMS_KEY_ID or --key-id', args.keyId ?? process.env.AWS_KMS_KEY_ID); + const region = requireValue('AWS_KMS_REGION or --region', args.region ?? process.env.AWS_KMS_REGION); + const response = getKmsPublicKey(keyId, region); + + if (response.KeySpec !== 'ECC_SECG_P256K1') { + throw new Error(`KMS key must be ECC_SECG_P256K1; got ${response.KeySpec ?? 'unknown'}`); + } + if (!response.PublicKey) { + throw new Error('KMS response did not include PublicKey'); + } + + const { compressedPublicKey, uncompressedPublicKey } = + compressSecp256k1PublicKey(Buffer.from(response.PublicKey, 'base64')); + + console.log('AWS KMS solver key'); + console.log(`KeySpec: ${response.KeySpec}`); + console.log(`SigningAlgorithms: ${(response.SigningAlgorithms ?? []).join(', ') || 'unknown'}`); + console.log(''); + console.log(`SUI_RFQ_VAULT_SOLVER_PUBLIC_KEY=${toHex(compressedPublicKey)}`); + console.log(`Sui secp256k1 address: ${deriveSuiSecp256k1Address(compressedPublicKey)}`); + console.log(`EVM address from same key: ${deriveEvmAddress(uncompressedPublicKey)}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/sui/rfqVaultDeploymentConfig.ts b/scripts/sui/rfqVaultDeploymentConfig.ts new file mode 100644 index 0000000..87a66b8 --- /dev/null +++ b/scripts/sui/rfqVaultDeploymentConfig.ts @@ -0,0 +1,13 @@ +export const SUI_RFQ_VAULT_PACKAGE_PATH = 'sui/rfq-vault'; + +export const SUI_RFQ_VAULT_NETWORK_ALIAS = 'mainnet'; +export const SUI_RFQ_VAULT_RPC_URL = 'https://fullnode.mainnet.sui.io:443'; +export const SUI_RFQ_VAULT_OWNER_ADDRESS = + '0xe1947e77f5eaffdecf67a4447220c66a76278b7e1e04b353c59ba5939fc29a07'; +export const SUI_RFQ_VAULT_SOLVER_PUBLIC_KEY = + '0x026afa8274da660478f59aa43f38527ccf05ada96080a2b431d6c2dd76593fbf9e'; +export const SUI_RFQ_VAULT_DOMAIN = 'SOCKET_RFQ_VAULT_SUI_MAINNET'; +export const SUI_RFQ_VAULT_GAS_BUDGET = '1000000000'; + +export const SUI_RFQ_VAULT_DEPLOYMENT_STAGE = 'prod'; +export const SUI_RFQ_VAULT_VERIFY_SOURCE = true; diff --git a/sui/rfq-vault/Move.lock b/sui/rfq-vault/Move.lock new file mode 100644 index 0000000..349921a --- /dev/null +++ b/sui/rfq-vault/Move.lock @@ -0,0 +1,41 @@ +# Generated by move; do not edit +# This file should be checked in. + +[move] +version = 4 + +[pinned.mainnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "718ae563a42fb4ba0d055588f81c704dcef58c25" } +use_environment = "mainnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.mainnet.RfqVault] +source = { root = true } +use_environment = "mainnet" +manifest_digest = "E41BBD67BE8940D26C79D78B028477EF5B33BA217A1282C78ACB344CF8A5ECF6" +deps = { std = "MoveStdlib", sui = "Sui" } + +[pinned.mainnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "718ae563a42fb4ba0d055588f81c704dcef58c25" } +use_environment = "mainnet" +manifest_digest = "CD547CB1ACCE0880C835DAED2D8FFCB91D56C833AE5240D3AA5B918398263195" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.testnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "b124567746b3a78a7e294ac2de265f693401ec9d" } +use_environment = "testnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.testnet.RfqVault] +source = { root = true } +use_environment = "testnet" +manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" +deps = { std = "MoveStdlib", sui = "Sui" } + +[pinned.testnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "b124567746b3a78a7e294ac2de265f693401ec9d" } +use_environment = "testnet" +manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" +deps = { MoveStdlib = "MoveStdlib" } diff --git a/sui/rfq-vault/Move.toml b/sui/rfq-vault/Move.toml new file mode 100644 index 0000000..d893017 --- /dev/null +++ b/sui/rfq-vault/Move.toml @@ -0,0 +1,6 @@ +[package] +name = "RfqVault" +edition = "2024.beta" + +[addresses] +rfq_vault = "0x0" diff --git a/sui/rfq-vault/Published.toml b/sui/rfq-vault/Published.toml new file mode 100644 index 0000000..8841b5f --- /dev/null +++ b/sui/rfq-vault/Published.toml @@ -0,0 +1,12 @@ +# Generated by Move +# This file contains metadata about published versions of this package in different environments +# This file SHOULD be committed to source control + +[published.mainnet] +chain-id = "35834a8a" +published-at = "0x4479fe1d936051823526e9a0c890c3d582001d28c6d57ce26d7bb9159a8c5638" +original-id = "0x4479fe1d936051823526e9a0c890c3d582001d28c6d57ce26d7bb9159a8c5638" +version = 1 +toolchain-version = "1.74.0" +build-config = { flavor = "sui", edition = "2024" } +upgrade-capability = "0x7675e98b7034e816ca3ce4e48fa140fd109d75ec354a74f38b8dbf7e5c540ae2" diff --git a/sui/rfq-vault/sources/vault.move b/sui/rfq-vault/sources/vault.move new file mode 100644 index 0000000..38538be --- /dev/null +++ b/sui/rfq-vault/sources/vault.move @@ -0,0 +1,408 @@ +#[allow(lint(public_entry))] +module rfq_vault::vault { + use std::bcs; + use std::ascii::{Self, String}; + use std::type_name; + use sui::bag::{Self, Bag}; + use sui::balance::{Self, Balance}; + use sui::coin::{Self, Coin}; + use sui::ecdsa_k1; + use sui::event; + use sui::table::{Self, Table}; + + const ACTION_FULFIL: u64 = 1; + const ACTION_REFUND: u64 = 2; + const ACTION_MARK_FOR_REFUND: u64 = 4; + + const HASH_SHA256: u8 = 1; + const QUOTE_ID_LENGTH: u64 = 32; + const SECP256K1_PUBLIC_KEY_LENGTH: u64 = 33; + const SECP256K1_SIGNATURE_LENGTH: u64 = 64; + + const E_INVALID_QUOTE_ID: u64 = 1; + const E_INVALID_SIGNATURE: u64 = 2; + const E_PAUSED: u64 = 3; + const E_ONLY_OWNER: u64 = 4; + const E_INVALID_SOLVER_PUBKEY: u64 = 5; + const E_INVALID_DOMAIN: u64 = 6; + const E_INVALID_OWNER: u64 = 7; + + public struct Vault has key { + id: UID, + owner: address, + solver_pubkey: vector, + domain: vector, + paused: bool, + balances: Bag, + quote_used: Table, bool>, + marked_for_refund: Table, bool>, + } + + public struct Deposited has copy, drop { + quote_id: vector, + token_type: String, + amount: u64, + sender: address, + } + + public struct Fulfilled has copy, drop { + quote_id: vector, + token_type: String, + amount: u64, + receiver: address, + } + + public struct MarkedForRefund has copy, drop { + quote_id: vector, + } + + public struct Refunded has copy, drop { + quote_id: vector, + token_type: String, + amount: u64, + receiver: address, + } + + public struct SettlementMessage has drop { + domain: vector, + vault: address, + action: u64, + quote_id: vector, + nonce: u64, + token_type: vector, + amount: u64, + receiver: address, + } + + public struct MarkForRefundMessage has drop { + domain: vector, + vault: address, + action: u64, + quote_id: vector, + nonce: u64, + } + + public entry fun create_vault( + owner: address, + solver_pubkey: vector, + domain: vector, + ctx: &mut TxContext, + ) { + assert!(owner != @0x0, E_INVALID_OWNER); + assert_valid_solver_pubkey(&solver_pubkey); + assert!(domain.length() > 0, E_INVALID_DOMAIN); + + let vault = Vault { + id: object::new(ctx), + owner, + solver_pubkey, + domain, + paused: false, + balances: bag::new(ctx), + quote_used: table::new(ctx), + marked_for_refund: table::new(ctx), + }; + + transfer::share_object(vault); + } + + public entry fun deposit( + vault: &mut Vault, + quote_id: vector, + coin: Coin, + ctx: &mut TxContext, + ) { + assert_not_paused(vault); + assert_valid_quote_id("e_id); + + let amount = coin::value(&coin); + coin::put(balance_mut_or_create(vault), coin); + + event::emit(Deposited { + quote_id, + token_type: token_type(), + amount, + sender: tx_context::sender(ctx), + }); + } + + public entry fun fulfil( + vault: &mut Vault, + quote_id: vector, + nonce: u64, + amount: u64, + receiver: address, + signature: vector, + ctx: &mut TxContext, + ) { + assert_not_paused(vault); + + let message = fulfil_message(vault, copy quote_id, nonce, amount, receiver); + verify_signature(vault, &message, &signature); + mark_quote_id(vault, copy quote_id); + + let coin = coin::take(existing_balance_mut(vault), amount, ctx); + transfer::public_transfer(coin, receiver); + + event::emit(Fulfilled { + quote_id, + token_type: token_type(), + amount, + receiver, + }); + } + + public entry fun mark_for_refund( + vault: &mut Vault, + quote_id: vector, + nonce: u64, + signature: vector, + ) { + assert_not_paused(vault); + + let message = mark_for_refund_message(vault, copy quote_id, nonce); + verify_signature(vault, &message, &signature); + mark_quote_id(vault, copy quote_id); + table::add, bool>(&mut vault.marked_for_refund, copy quote_id, true); + + event::emit(MarkedForRefund { quote_id }); + } + + public entry fun refund( + vault: &mut Vault, + quote_id: vector, + nonce: u64, + amount: u64, + receiver: address, + signature: vector, + ctx: &mut TxContext, + ) { + assert_not_paused(vault); + + let message = refund_message(vault, copy quote_id, nonce, amount, receiver); + verify_signature(vault, &message, &signature); + mark_quote_id(vault, copy quote_id); + + let coin = coin::take(existing_balance_mut(vault), amount, ctx); + transfer::public_transfer(coin, receiver); + + event::emit(Refunded { + quote_id, + token_type: token_type(), + amount, + receiver, + }); + } + + public entry fun fund( + vault: &mut Vault, + coin: Coin, + ctx: &mut TxContext, + ) { + assert_owner(vault, ctx); + coin::put(balance_mut_or_create(vault), coin); + } + + public entry fun rescue( + vault: &mut Vault, + amount: u64, + receiver: address, + ctx: &mut TxContext, + ) { + assert_owner(vault, ctx); + + let coin = coin::take(existing_balance_mut(vault), amount, ctx); + transfer::public_transfer(coin, receiver); + } + + public entry fun pause(vault: &mut Vault, ctx: &mut TxContext) { + assert_owner(vault, ctx); + vault.paused = true; + } + + public entry fun unpause(vault: &mut Vault, ctx: &mut TxContext) { + assert_owner(vault, ctx); + vault.paused = false; + } + + public entry fun set_solver_pubkey( + vault: &mut Vault, + solver_pubkey: vector, + ctx: &mut TxContext, + ) { + assert_owner(vault, ctx); + assert_valid_solver_pubkey(&solver_pubkey); + vault.solver_pubkey = solver_pubkey; + } + + public entry fun transfer_ownership( + vault: &mut Vault, + new_owner: address, + ctx: &mut TxContext, + ) { + assert_owner(vault, ctx); + assert!(new_owner != @0x0, E_INVALID_OWNER); + vault.owner = new_owner; + } + + public fun owner(vault: &Vault): address { + vault.owner + } + + public fun solver_pubkey(vault: &Vault): vector { + copy vault.solver_pubkey + } + + public fun domain(vault: &Vault): vector { + copy vault.domain + } + + public fun is_paused(vault: &Vault): bool { + vault.paused + } + + public fun is_quote_used(vault: &Vault, quote_id: vector): bool { + assert_valid_quote_id("e_id); + table::contains, bool>(&vault.quote_used, quote_id) + } + + public fun is_marked_for_refund(vault: &Vault, quote_id: vector): bool { + assert_valid_quote_id("e_id); + table::contains, bool>(&vault.marked_for_refund, quote_id) + } + + public fun vault_balance(vault: &Vault): u64 { + let key = token_key(); + if (bag::contains_with_type, Balance>(&vault.balances, copy key)) { + balance::value(bag::borrow, Balance>(&vault.balances, key)) + } else { + 0 + } + } + + public fun fulfil_message( + vault: &Vault, + quote_id: vector, + nonce: u64, + amount: u64, + receiver: address, + ): vector { + assert_valid_quote_id("e_id); + settlement_message(vault, ACTION_FULFIL, quote_id, nonce, amount, receiver) + } + + public fun refund_message( + vault: &Vault, + quote_id: vector, + nonce: u64, + amount: u64, + receiver: address, + ): vector { + assert_valid_quote_id("e_id); + settlement_message(vault, ACTION_REFUND, quote_id, nonce, amount, receiver) + } + + public fun mark_for_refund_message( + vault: &Vault, + quote_id: vector, + nonce: u64, + ): vector { + assert_valid_quote_id("e_id); + bcs::to_bytes(&MarkForRefundMessage { + domain: copy vault.domain, + vault: object::uid_to_address(&vault.id), + action: ACTION_MARK_FOR_REFUND, + quote_id, + nonce, + }) + } + + public fun signature_hash_algorithm(): u8 { + HASH_SHA256 + } + + fun settlement_message( + vault: &Vault, + action: u64, + quote_id: vector, + nonce: u64, + amount: u64, + receiver: address, + ): vector { + bcs::to_bytes(&SettlementMessage { + domain: copy vault.domain, + vault: object::uid_to_address(&vault.id), + action, + quote_id, + nonce, + token_type: token_key(), + amount, + receiver, + }) + } + + fun verify_signature(vault: &Vault, message: &vector, signature: &vector) { + assert!(signature.length() == SECP256K1_SIGNATURE_LENGTH, E_INVALID_SIGNATURE); + assert!( + ecdsa_k1::secp256k1_verify( + signature, + &vault.solver_pubkey, + message, + HASH_SHA256, + ), + E_INVALID_SIGNATURE, + ); + } + + fun balance_mut_or_create(vault: &mut Vault): &mut Balance { + let key = token_key(); + if (!bag::contains_with_type, Balance>(&vault.balances, copy key)) { + bag::add, Balance>( + &mut vault.balances, + copy key, + balance::zero(), + ); + }; + + bag::borrow_mut, Balance>(&mut vault.balances, key) + } + + fun existing_balance_mut(vault: &mut Vault): &mut Balance { + bag::borrow_mut, Balance>(&mut vault.balances, token_key()) + } + + fun mark_quote_id(vault: &mut Vault, quote_id: vector) { + assert!( + !table::contains, bool>(&vault.quote_used, copy quote_id), + E_INVALID_QUOTE_ID, + ); + table::add, bool>(&mut vault.quote_used, quote_id, true); + } + + fun assert_not_paused(vault: &Vault) { + assert!(!vault.paused, E_PAUSED); + } + + fun assert_owner(vault: &Vault, ctx: &TxContext) { + assert!(tx_context::sender(ctx) == vault.owner, E_ONLY_OWNER); + } + + fun assert_valid_quote_id(quote_id: &vector) { + assert!(quote_id.length() == QUOTE_ID_LENGTH, E_INVALID_QUOTE_ID); + } + + fun assert_valid_solver_pubkey(solver_pubkey: &vector) { + assert!( + solver_pubkey.length() == SECP256K1_PUBLIC_KEY_LENGTH, + E_INVALID_SOLVER_PUBKEY, + ); + } + + fun token_key(): vector { + ascii::into_bytes(token_type()) + } + + fun token_type(): String { + type_name::into_string(type_name::with_original_ids()) + } +} diff --git a/sui/rfq-vault/tests/vault_tests.move b/sui/rfq-vault/tests/vault_tests.move new file mode 100644 index 0000000..830a48b --- /dev/null +++ b/sui/rfq-vault/tests/vault_tests.move @@ -0,0 +1,489 @@ +#[test_only, allow(deprecated_usage, implicit_const_copy)] +module rfq_vault::vault_tests; + +use rfq_vault::vault::{Self, Vault}; +use sui::coin::{Self, Coin, TreasuryCap}; +use sui::ecdsa_k1; +use sui::test_scenario::{Self, Scenario}; +use sui::transfer; +use sui::url; + +public struct VAULT_TESTS has drop {} + +const OWNER: address = @0xA11CE; +const USER: address = @0xB0B; +const RECEIVER: address = @0xCAFE; +const OTHER: address = @0xBEEF; + +const AMOUNT: u64 = 1000; +const DOMAIN: vector = b"socket-rfq-vault-sui-test-v1"; +const SEED: vector = b"Some random seed, 32 bytes long."; + +#[test] +fun deposit_custodies_coin_and_duplicate_deposit_is_allowed() { + let mut scenario = setup(); + scenario.next_tx(USER); + { + let mut vault = scenario.take_shared(); + let mut treasury = scenario.take_from_address>(OWNER); + + vault::deposit( + &mut vault, + quote_id(1), + mint(&mut treasury, AMOUNT, &mut scenario), + scenario.ctx(), + ); + vault::deposit( + &mut vault, + quote_id(1), + mint(&mut treasury, 2 * AMOUNT, &mut scenario), + scenario.ctx(), + ); + + assert!(vault::vault_balance(&vault) == 3 * AMOUNT); + assert!(!vault::is_quote_used(&vault, quote_id(1))); + + transfer::public_transfer(treasury, OWNER); + test_scenario::return_shared(vault); + }; + scenario.end(); +} + +#[test] +fun fulfil_with_solver_signature_releases_funds_and_blocks_replay() { + let mut scenario = setup_funded_vault(2 * AMOUNT); + scenario.next_tx(OTHER); + { + let mut vault = scenario.take_shared(); + let quote_id = quote_id(2); + let nonce = 7; + let signature = sign_fulfil(&vault, copy quote_id, nonce, AMOUNT, RECEIVER); + + vault::fulfil( + &mut vault, + copy quote_id, + nonce, + AMOUNT, + RECEIVER, + signature, + scenario.ctx(), + ); + + assert!(vault::vault_balance(&vault) == AMOUNT); + assert!(vault::is_quote_used(&vault, copy quote_id)); + test_scenario::return_shared(vault); + }; + scenario.next_tx(RECEIVER); + { + let coin = scenario.take_from_address>(RECEIVER); + assert!(coin.value() == AMOUNT); + transfer::public_transfer(coin, OWNER); + }; + scenario.end(); +} + +#[test, expected_failure(abort_code = vault::E_INVALID_QUOTE_ID)] +fun fulfil_replay_aborts() { + let mut scenario = setup_funded_vault(2 * AMOUNT); + scenario.next_tx(OTHER); + { + let mut vault = scenario.take_shared(); + let quote_id = quote_id(3); + let signature = sign_fulfil(&vault, copy quote_id, 8, AMOUNT, RECEIVER); + + vault::fulfil( + &mut vault, + copy quote_id, + 8, + AMOUNT, + RECEIVER, + copy signature, + scenario.ctx(), + ); + vault::fulfil( + &mut vault, + quote_id, + 8, + AMOUNT, + RECEIVER, + signature, + scenario.ctx(), + ); + + test_scenario::return_shared(vault); + }; + scenario.end(); +} + +#[test, expected_failure(abort_code = vault::E_INVALID_QUOTE_ID)] +fun fulfil_after_refund_aborts() { + let mut scenario = setup_funded_vault(2 * AMOUNT); + scenario.next_tx(OTHER); + { + let mut vault = scenario.take_shared(); + let quote_id = quote_id(4); + let refund_signature = sign_refund(&vault, copy quote_id, 9, AMOUNT, RECEIVER); + vault::refund( + &mut vault, + copy quote_id, + 9, + AMOUNT, + RECEIVER, + refund_signature, + scenario.ctx(), + ); + + let fulfil_signature = sign_fulfil(&vault, copy quote_id, 10, AMOUNT, RECEIVER); + vault::fulfil( + &mut vault, + quote_id, + 10, + AMOUNT, + RECEIVER, + fulfil_signature, + scenario.ctx(), + ); + + test_scenario::return_shared(vault); + }; + scenario.end(); +} + +#[test] +fun mark_for_refund_consumes_quote_id_and_blocks_refund() { + let mut scenario = setup_funded_vault(AMOUNT); + scenario.next_tx(OTHER); + { + let mut vault = scenario.take_shared(); + let quote_id = quote_id(5); + let signature = sign_mark_for_refund(&vault, copy quote_id, 11); + + vault::mark_for_refund(&mut vault, copy quote_id, 11, signature); + + assert!(vault::is_quote_used(&vault, copy quote_id)); + assert!(vault::is_marked_for_refund(&vault, copy quote_id)); + test_scenario::return_shared(vault); + }; + scenario.end(); +} + +#[test, expected_failure(abort_code = vault::E_INVALID_QUOTE_ID)] +fun refund_after_mark_for_refund_aborts() { + let mut scenario = setup_funded_vault(AMOUNT); + scenario.next_tx(OTHER); + { + let mut vault = scenario.take_shared(); + let quote_id = quote_id(6); + let mark_signature = sign_mark_for_refund(&vault, copy quote_id, 12); + vault::mark_for_refund(&mut vault, copy quote_id, 12, mark_signature); + + let refund_signature = sign_refund(&vault, copy quote_id, 13, AMOUNT, RECEIVER); + vault::refund( + &mut vault, + quote_id, + 13, + AMOUNT, + RECEIVER, + refund_signature, + scenario.ctx(), + ); + + test_scenario::return_shared(vault); + }; + scenario.end(); +} + +#[test] +fun refund_with_solver_signature_releases_funds() { + let mut scenario = setup_funded_vault(AMOUNT); + scenario.next_tx(OTHER); + { + let mut vault = scenario.take_shared(); + let quote_id = quote_id(7); + let signature = sign_refund(&vault, copy quote_id, 14, AMOUNT, RECEIVER); + + vault::refund( + &mut vault, + copy quote_id, + 14, + AMOUNT, + RECEIVER, + signature, + scenario.ctx(), + ); + + assert!(vault::vault_balance(&vault) == 0); + assert!(vault::is_quote_used(&vault, quote_id)); + test_scenario::return_shared(vault); + }; + scenario.next_tx(RECEIVER); + { + let coin = scenario.take_from_address>(RECEIVER); + assert!(coin.value() == AMOUNT); + transfer::public_transfer(coin, OWNER); + }; + scenario.end(); +} + +#[test, expected_failure(abort_code = vault::E_INVALID_QUOTE_ID)] +fun refund_replay_aborts() { + let mut scenario = setup_funded_vault(2 * AMOUNT); + scenario.next_tx(OTHER); + { + let mut vault = scenario.take_shared(); + let quote_id = quote_id(8); + let signature = sign_refund(&vault, copy quote_id, 15, AMOUNT, RECEIVER); + + vault::refund( + &mut vault, + copy quote_id, + 15, + AMOUNT, + RECEIVER, + copy signature, + scenario.ctx(), + ); + vault::refund( + &mut vault, + quote_id, + 15, + AMOUNT, + RECEIVER, + signature, + scenario.ctx(), + ); + + test_scenario::return_shared(vault); + }; + scenario.end(); +} + +#[test, expected_failure(abort_code = vault::E_INVALID_QUOTE_ID)] +fun refund_after_fulfil_aborts() { + let mut scenario = setup_funded_vault(2 * AMOUNT); + scenario.next_tx(OTHER); + { + let mut vault = scenario.take_shared(); + let quote_id = quote_id(9); + let fulfil_signature = sign_fulfil(&vault, copy quote_id, 16, AMOUNT, RECEIVER); + vault::fulfil( + &mut vault, + copy quote_id, + 16, + AMOUNT, + RECEIVER, + fulfil_signature, + scenario.ctx(), + ); + + let refund_signature = sign_refund(&vault, copy quote_id, 17, AMOUNT, RECEIVER); + vault::refund( + &mut vault, + quote_id, + 17, + AMOUNT, + RECEIVER, + refund_signature, + scenario.ctx(), + ); + + test_scenario::return_shared(vault); + }; + scenario.end(); +} + +#[test, expected_failure(abort_code = vault::E_PAUSED)] +fun paused_vault_blocks_deposit() { + let mut scenario = setup(); + scenario.next_tx(OWNER); + { + let mut vault = scenario.take_shared(); + vault::pause(&mut vault, scenario.ctx()); + test_scenario::return_shared(vault); + }; + scenario.next_tx(USER); + { + let mut vault = scenario.take_shared(); + let mut treasury = scenario.take_from_address>(OWNER); + vault::deposit( + &mut vault, + quote_id(10), + mint(&mut treasury, AMOUNT, &mut scenario), + scenario.ctx(), + ); + + transfer::public_transfer(treasury, OWNER); + test_scenario::return_shared(vault); + }; + scenario.end(); +} + +#[test] +fun owner_can_rescue_and_update_admin_state() { + let mut scenario = setup_funded_vault(AMOUNT); + scenario.next_tx(OWNER); + { + let mut vault = scenario.take_shared(); + let new_keypair = ecdsa_k1::secp256k1_keypair_from_seed( + &b"Another random 32 byte test seed", + ); + + vault::set_solver_pubkey(&mut vault, *new_keypair.public_key(), scenario.ctx()); + vault::pause(&mut vault, scenario.ctx()); + assert!(vault::is_paused(&vault)); + vault::unpause(&mut vault, scenario.ctx()); + assert!(!vault::is_paused(&vault)); + vault::rescue(&mut vault, AMOUNT, RECEIVER, scenario.ctx()); + vault::transfer_ownership(&mut vault, OTHER, scenario.ctx()); + + assert!(vault::owner(&vault) == OTHER); + assert!(vault::vault_balance(&vault) == 0); + test_scenario::return_shared(vault); + }; + scenario.end(); +} + +#[test, expected_failure(abort_code = vault::E_ONLY_OWNER)] +fun non_owner_cannot_rescue() { + let mut scenario = setup_funded_vault(AMOUNT); + scenario.next_tx(OTHER); + { + let mut vault = scenario.take_shared(); + vault::rescue(&mut vault, AMOUNT, RECEIVER, scenario.ctx()); + test_scenario::return_shared(vault); + }; + scenario.end(); +} + +#[test, expected_failure(abort_code = vault::E_INVALID_QUOTE_ID)] +fun deposit_rejects_non_32_byte_quote_id() { + let mut scenario = setup(); + scenario.next_tx(USER); + { + let mut vault = scenario.take_shared(); + let mut treasury = scenario.take_from_address>(OWNER); + vault::deposit( + &mut vault, + b"too-short", + mint(&mut treasury, AMOUNT, &mut scenario), + scenario.ctx(), + ); + + transfer::public_transfer(treasury, OWNER); + test_scenario::return_shared(vault); + }; + scenario.end(); +} + +#[test] +fun create_vault_can_set_owner_different_from_deployer() { + let mut scenario = test_scenario::begin(OTHER); + let keypair = solver_keypair(); + vault::create_vault(OWNER, *keypair.public_key(), DOMAIN, scenario.ctx()); + + scenario.next_tx(OWNER); + { + let vault = scenario.take_shared(); + assert!(vault::owner(&vault) == OWNER); + test_scenario::return_shared(vault); + }; + scenario.end(); +} + +fun setup(): Scenario { + let mut scenario = test_scenario::begin(OWNER); + let keypair = solver_keypair(); + let (treasury, metadata) = coin::create_currency( + VAULT_TESTS {}, + 6, + b"TEST", + b"Test Coin", + b"RFQ vault test coin", + option::some(url::new_unsafe_from_bytes(b"https://socket.tech")), + scenario.ctx(), + ); + + transfer::public_freeze_object(metadata); + transfer::public_transfer(treasury, OWNER); + vault::create_vault(OWNER, *keypair.public_key(), DOMAIN, scenario.ctx()); + + scenario +} + +fun setup_funded_vault(amount: u64): Scenario { + let mut scenario = setup(); + scenario.next_tx(OWNER); + { + let mut vault = scenario.take_shared(); + let mut treasury = scenario.take_from_address>(OWNER); + vault::fund( + &mut vault, + mint(&mut treasury, amount, &mut scenario), + scenario.ctx(), + ); + transfer::public_transfer(treasury, OWNER); + test_scenario::return_shared(vault); + }; + + scenario +} + +fun mint( + treasury: &mut TreasuryCap, + amount: u64, + scenario: &mut Scenario, +): Coin { + coin::from_balance(treasury.mint_balance(amount), scenario.ctx()) +} + +fun sign_fulfil( + vault: &Vault, + quote_id: vector, + nonce: u64, + amount: u64, + receiver: address, +): vector { + ecdsa_k1::secp256k1_sign( + solver_keypair().private_key(), + &vault::fulfil_message(vault, quote_id, nonce, amount, receiver), + vault::signature_hash_algorithm(), + false, + ) +} + +fun sign_refund( + vault: &Vault, + quote_id: vector, + nonce: u64, + amount: u64, + receiver: address, +): vector { + ecdsa_k1::secp256k1_sign( + solver_keypair().private_key(), + &vault::refund_message(vault, quote_id, nonce, amount, receiver), + vault::signature_hash_algorithm(), + false, + ) +} + +fun sign_mark_for_refund(vault: &Vault, quote_id: vector, nonce: u64): vector { + ecdsa_k1::secp256k1_sign( + solver_keypair().private_key(), + &vault::mark_for_refund_message(vault, quote_id, nonce), + vault::signature_hash_algorithm(), + false, + ) +} + +fun solver_keypair(): ecdsa_k1::KeyPair { + ecdsa_k1::secp256k1_keypair_from_seed(&SEED) +} + +fun quote_id(id: u8): vector { + vector[ + id, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + ] +}