diff --git a/package-lock.json b/package-lock.json index 4e12916ec..dac90a05b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7501,6 +7501,10 @@ "resolved": "packages/cardano-contracts/inverse-whirlpool", "link": true }, + "node_modules/@paima/mina-delegation": { + "resolved": "packages/contracts/mina-delegation", + "link": true + }, "node_modules/@paima/mw-core": { "resolved": "packages/paima-sdk/paima-mw-core", "link": true @@ -15162,6 +15166,15 @@ "node": ">=14.16" } }, + "node_modules/cachedir": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", + "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -18212,6 +18225,13 @@ "strip-bom": "^3.0.0" } }, + "node_modules/eslint-plugin-o1js": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-o1js/-/eslint-plugin-o1js-0.4.0.tgz", + "integrity": "sha512-12qI6OvAMtUIh8x9lB5uVzJbRMSR6tGrbCRM98fcCmll1FNvVSUIaat3CWhH17tkcjoyVSaFy0I/WzZcqPqaUA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/eslint-plugin-prettier": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", @@ -23275,6 +23295,16 @@ "node": ">=0.10.0" } }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -28190,6 +28220,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/o1js": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/o1js/-/o1js-1.6.0.tgz", + "integrity": "sha512-5pHCM0qIHG+MhLlcRJZRK8YFC7MOCjlxwSYbmrLDKER267eUip7JRDtWATytIRxtCPqOBvqKeJb0hR/mvjWxqg==", + "license": "Apache-2.0", + "dependencies": { + "blakejs": "1.2.1", + "cachedir": "^2.4.0", + "isomorphic-fetch": "^3.0.0", + "js-sha256": "^0.9.0", + "reflect-metadata": "^0.1.13", + "tslib": "^2.3.0" + }, + "bin": { + "snarky-run": "src/build/run.js" + }, + "engines": { + "node": ">=18.14.0" + } + }, + "node_modules/o1js/node_modules/reflect-metadata": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", + "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", + "license": "Apache-2.0" + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -37994,6 +38050,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -38683,6 +38745,18 @@ "hardhat": "^2.19.3" } }, + "packages/contracts/mina-delegation": { + "name": "@paima/mina-delegation", + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "o1js": "^1.6.0" + }, + "devDependencies": { + "eslint-plugin-o1js": "^0.4.0", + "typescript": "^5.3.3" + } + }, "packages/engine/paima-funnel": { "name": "@paima/funnel", "version": "1.0.0", diff --git a/packages/batcher/batcher-transaction-poster/tsconfig.json b/packages/batcher/batcher-transaction-poster/tsconfig.json index 5a95b94ff..a82df4e8a 100644 --- a/packages/batcher/batcher-transaction-poster/tsconfig.json +++ b/packages/batcher/batcher-transaction-poster/tsconfig.json @@ -8,7 +8,7 @@ "references": [ { "path": "../utils" }, { "path": "../db" }, - { "path": "../../paima-sdk/paima-providers/tsconfig.build.json" }, + { "path": "../../paima-sdk/paima-providers" }, { "path": "../../paima-sdk/paima-concise/tsconfig.build.json" }, { "path": "../../paima-sdk/paima-utils/tsconfig.build.json" }, { "path": "../../paima-sdk/paima-events/tsconfig.build.json" }, diff --git a/packages/batcher/runtime/tsconfig.json b/packages/batcher/runtime/tsconfig.json index 23d7f5f6e..02ae7fec3 100644 --- a/packages/batcher/runtime/tsconfig.json +++ b/packages/batcher/runtime/tsconfig.json @@ -10,7 +10,7 @@ { "path": "../game-input-validator/" }, { "path": "../utils" }, { "path": "../batcher-transaction-poster" }, - { "path": "../../paima-sdk/paima-providers/tsconfig.build.json" }, + { "path": "../../paima-sdk/paima-providers" }, { "path": "../../paima-sdk/paima-utils/tsconfig.build.json" }, { "path": "../../node-sdk/paima-utils-backend" }, { "path": "../../paima-sdk/paima-mw-core/tsconfig.json" }, diff --git a/packages/batcher/utils/tsconfig.json b/packages/batcher/utils/tsconfig.json index 60459b856..a64763c38 100644 --- a/packages/batcher/utils/tsconfig.json +++ b/packages/batcher/utils/tsconfig.json @@ -7,7 +7,7 @@ "include": ["src/**/*"], "references": [ { "path": "../../paima-sdk/paima-utils/tsconfig.build.json" }, - { "path": "../../paima-sdk/paima-providers/tsconfig.build.json" }, + { "path": "../../paima-sdk/paima-providers" }, { "path": "../../paima-sdk/paima-mw-core/tsconfig.json" }, ] } diff --git a/packages/batcher/webserver/tsconfig.json b/packages/batcher/webserver/tsconfig.json index 73e5db230..e64ccd122 100644 --- a/packages/batcher/webserver/tsconfig.json +++ b/packages/batcher/webserver/tsconfig.json @@ -9,7 +9,7 @@ { "path": "../address-validator/" }, { "path": "../db/" }, { "path": "../utils" }, - { "path": "../../paima-sdk/paima-providers/tsconfig.build.json" }, + { "path": "../../paima-sdk/paima-providers" }, { "path": "../../paima-sdk/paima-concise/tsconfig.build.json" }, { "path": "../../paima-sdk/paima-utils/tsconfig.build.json" }, ] diff --git a/packages/contracts/mina-delegation/README.md b/packages/contracts/mina-delegation/README.md new file mode 100644 index 000000000..048235220 --- /dev/null +++ b/packages/contracts/mina-delegation/README.md @@ -0,0 +1,3 @@ +# Paima Mina contracts + +NPM package for Mina programs and contracts for Paima Engine and related utilities. diff --git a/packages/contracts/mina-delegation/package.json b/packages/contracts/mina-delegation/package.json new file mode 100644 index 000000000..eb91d3d59 --- /dev/null +++ b/packages/contracts/mina-delegation/package.json @@ -0,0 +1,37 @@ +{ + "name": "@paima/mina-delegation", + "version": "3.1.0", + "description": "Mina ZkProgram for EVM->Mina delegation", + "author": "Paima Studios", + "license": "MIT", + "type": "module", + "files": [ + "build" + ], + "main": "./build/index.js", + "types": "./build/index.d.ts", + "exports": { + ".": { + "default": "./build/index.js", + "types": "./build/index.d.ts" + } + }, + "scripts": { + "build": "tsc", + "clean": "rm -r tsconfig.tsbuildinfo build/", + "prepare": "npm run build", + "prepack": "npm run clean" + }, + "keywords": [ + "mina", + "zk", + "o1js" + ], + "dependencies": { + "o1js": "^1.6.0" + }, + "devDependencies": { + "eslint-plugin-o1js": "^0.4.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/contracts/mina-delegation/src/index.ts b/packages/contracts/mina-delegation/src/index.ts new file mode 100644 index 000000000..cb271d243 --- /dev/null +++ b/packages/contracts/mina-delegation/src/index.ts @@ -0,0 +1,186 @@ +import { + Bool, + Bytes, + Crypto, + DynamicProof, + Proof, + PublicKey, + Struct, + UInt8, + Void, + ZkProgram, + createEcdsaV2, + createForeignCurveV2, +} from 'o1js'; + +// ---------------------------------------------------------------------------- +// Common data types + +/** A Mina foreign curve for Secp256k1, like Ethereum uses. */ +export class Secp256k1 extends createForeignCurveV2(Crypto.CurveParams.Secp256k1) { + /** Convert a standard hex public key into this provable struct. */ + static fromHex(publicKey: string): Secp256k1 { + if (publicKey.startsWith('0x04') && publicKey.length === 4 + 64 + 64) { + return Secp256k1.from({ + x: BigInt('0x' + publicKey.substring(4, 4 + 64)), + y: BigInt('0x' + publicKey.substring(4 + 64, 4 + 64 + 64)), + }); + } else if (publicKey.startsWith('0x') && publicKey.length === 2 + 64 + 64) { + return Secp256k1.from({ + x: BigInt('0x' + publicKey.substring(2, 2 + 64)), + y: BigInt('0x' + publicKey.substring(2 + 64, 2 + 64 + 64)), + }); + } else { + throw new Error('Bad public key format'); + } + } +} + +/** A Mina-provable ECDSA signature on the Secp256k1 curve, like Ethereum uses. */ +export class Ecdsa extends createEcdsaV2(Secp256k1) { + // o1js-provided fromHex is good enough +} + +/** Ethereum's fixed prefix for `personal_sign` messages. **/ +const ethereumPrefix = Bytes.fromString('\x19Ethereum Signed Message:\n'); + +/** Pack 254 bits of key's X and 1 bit of isOdd into 32 bytes. */ +function encodeKey(k: PublicKey): UInt8[] { + const bytes = []; + const bits = [...k.x.toBits(254), k.isOdd]; + for (let i = 0; i < bits.length; i += 8) { + let value = new UInt8(0); + for (let j = 0; j < 8; j++) { + value = value.mul(2).add(boolToU8(bits[i + j] ?? Bool(false))); + } + bytes.push(value); + } + return bytes; +} + +function boolToU8(bool: Bool): UInt8 { + return UInt8.from(bool.toField()); +} + +export type DelegationCommandProof = Proof< + { + // real data + target: PublicKey; + signer: Secp256k1; + // sort of a type system marker that the interior is a DelegationCommand? + assertSignatureMatches(signature: Ecdsa): void; + }, + void +>; +type _DelegationCommandProof = DelegationCommandProof; + +/** + * Prepare a ZkProgram that verifies an EVM signature delegating to a Mina + * address under a specific prefix. The prefix will be seen by the user when + * signing the message and should clearly indicate the limited scope of the + * validity of the signature. + * + * @param prefix A prefix to distinguish these delegation orders from those for other systems. + * @returns An o1js Struct, ZkProgram, and Proof tied to that prefix. + * + * @example + * const { DelegationCommand, DelegationCommandProgram, DelegationCommandProof } = + * delegateEvmToMina('Click & Moo login: '); + */ +export function delegateEvmToMina(prefix: string) { + // ---------------------------------------------------------------------------- + // Per-prefix data types + const delegationPrefix = Bytes.fromString(prefix); + + class DelegationCommand extends Struct({ + /** Mina public key that the delegation order is issued for. */ + target: PublicKey, + /** Ethereum public key that signed the delegation order. */ + signer: Secp256k1.provable, + }) { + constructor(value: { target: PublicKey; signer: Secp256k1 }) { + if (!(value.target instanceof PublicKey)) { + // Compensate for the possibility of duplicate o1js libraries that aren't + // `instanceof` each other, which messes up checks inside o1js. Can be + // caused by `npm link`ing to this package, for example. + value = { ...value, target: PublicKey.fromBase58((value.target as PublicKey).toBase58()) }; + } + super(value); + } + + static #_innerMessage(target: PublicKey): Bytes { + return Bytes.from([ + ...delegationPrefix.bytes, + // Base64-encode encodeKey() + ...Bytes.from(encodeKey(target)).base64Encode().bytes, + ]); + } + + /** + * Get the message for an Etherum wallet to sign, WITHOUT the Ethereum prefix. + * This is printable and should be passed to something like `personal_sign`. + */ + static bytesToSign({ target }: { target: PublicKey }): Uint8Array { + // Accepts an object so you can pass just a PublicKey OR a DelegationCommand. + return this.#_innerMessage(target).toBytes(); + } + + /** Validate that the given Ethereum signature matches this order, WITH the Ethereum prefix. */ + assertSignatureMatches(signature: Ecdsa) { + const inner = DelegationCommand.#_innerMessage(this.target); + const fullMessage = Bytes.from([ + ...ethereumPrefix.bytes, + // NOTE: `inner.length` is effectively a constant so it's okay to bake it in. + ...Bytes.fromString(String(inner.length)).bytes, + ...inner.bytes, + ]); + signature.verifyV2(fullMessage, this.signer).assertTrue(); + } + } + + // ---------------------------------------------------------------------------- + // The provable program itself + + const DelegationCommandProgram = ZkProgram({ + name: `${prefix}DelegationCommandProgram`, + + publicInput: DelegationCommand, + + methods: { + sign: { + privateInputs: [Ecdsa.provable], + + async method(order: DelegationCommand, signature: Ecdsa) { + order.assertSignatureMatches(signature); + }, + }, + }, + }); + + class DelegationCommandProof + extends ZkProgram.Proof(DelegationCommandProgram) + implements _DelegationCommandProof {} + + class DynamicDelegationCommandProof extends DynamicProof { + static override publicInputType = DelegationCommand; + static override publicOutputType = Void; + static override maxProofsVerified = 0 as const; + } + + return { + /** + * An order that a particular EVM address has signed to authorize (delegate) + * a Mina address to act on its behalf. + */ + DelegationCommand, + /** + * A simple {@link ZkProgram} that proves that a valid signature exists for an + * input {@link DelegationCommand}. + */ + DelegationCommandProgram, + /** A verifiable proof of {@link DelegationCommandProgram}'s success. */ + DelegationCommandProof, + // /** A dynamic version of {@link DelegationCommandProof}. */ + // DynamicDelegationCommandProof, <- causes ts4904 until we explicitly declare the return type + }; +} diff --git a/packages/contracts/mina-delegation/tsconfig.json b/packages/contracts/mina-delegation/tsconfig.json new file mode 100644 index 000000000..34725b1ba --- /dev/null +++ b/packages/contracts/mina-delegation/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + + // TS decorator metadata is necessary for SmartContracts to compile. + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + }, + "include": ["./src"], +} diff --git a/packages/paima-sdk/paima-crypto/src/algorand.ts b/packages/paima-sdk/paima-crypto/src/algorand.ts index 8c364c80d..e27802bc3 100644 --- a/packages/paima-sdk/paima-crypto/src/algorand.ts +++ b/packages/paima-sdk/paima-crypto/src/algorand.ts @@ -1,6 +1,5 @@ import type { SignedTransaction, Transaction, SuggestedParams } from 'algosdk'; import { doLog, hexStringToUint8Array } from '@paima/utils'; -import web3UtilsPkg from 'web3-utils'; import type { IVerify } from './IVerify.js'; export class AlgorandCrypto implements IVerify { @@ -27,9 +26,11 @@ export class AlgorandCrypto implements IVerify { } }; - buildAlgorandTransaction = async (userAddress: string, message: string): Promise => { - const hexMessage = web3UtilsPkg.utf8ToHex(message).slice(2); - const msgArray = hexStringToUint8Array(hexMessage); + buildAlgorandTransaction = async ( + userAddress: string, + message: string | Uint8Array + ): Promise => { + const msgArray = message instanceof Uint8Array ? message : new TextEncoder().encode(message); const SUGGESTED_PARAMS: SuggestedParams = { fee: 0, firstRound: 10, diff --git a/packages/paima-sdk/paima-mw-core/tsconfig.json b/packages/paima-sdk/paima-mw-core/tsconfig.json index 183a10767..b31c3e461 100644 --- a/packages/paima-sdk/paima-mw-core/tsconfig.json +++ b/packages/paima-sdk/paima-mw-core/tsconfig.json @@ -9,6 +9,6 @@ { "path": "../paima-utils/tsconfig.build.json" }, { "path": "../paima-prando" }, { "path": "../paima-concise/tsconfig.build.json" }, - { "path": "../paima-providers/tsconfig.build.json" } + { "path": "../paima-providers" } ] } diff --git a/packages/paima-sdk/paima-providers/package.json b/packages/paima-sdk/paima-providers/package.json index e5fb20e42..9378372dd 100644 --- a/packages/paima-sdk/paima-providers/package.json +++ b/packages/paima-sdk/paima-providers/package.json @@ -5,8 +5,8 @@ "access": "public" }, "description": "Library for dApp connection for blockchains supported by Paima", - "main": "build/index.js", "type": "module", + "main": "build/index.js", "types": "build/index.d.ts", "files": [ "/build" @@ -20,7 +20,9 @@ "homepage": "https://docs.paimastudios.com", "scripts": { "lint:eslint": "eslint .", - "build": "tsc --build tsconfig.build.json", + "build": "tsc", + "postbuild": "node build/mina/build_worker.js", + "pretest": "npm run build", "test": "NODE_OPTIONS=--experimental-vm-modules jest --runInBand" }, "dependencies": { diff --git a/packages/paima-sdk/paima-providers/src/algorand.ts b/packages/paima-sdk/paima-providers/src/algorand.ts index 0cd483409..3b680ce0c 100644 --- a/packages/paima-sdk/paima-providers/src/algorand.ts +++ b/packages/paima-sdk/paima-providers/src/algorand.ts @@ -130,7 +130,7 @@ export class AlgorandProvider implements IProvider { address: this.address, }; }; - signMessage = async (message: string): Promise => { + signMessage = async (message: string | Uint8Array): Promise => { const txn = await CryptoManager.Algorand().buildAlgorandTransaction( this.getAddress().address, message diff --git a/packages/paima-sdk/paima-providers/src/cardano.ts b/packages/paima-sdk/paima-providers/src/cardano.ts index f0daf4251..3c18b6b12 100644 --- a/packages/paima-sdk/paima-providers/src/cardano.ts +++ b/packages/paima-sdk/paima-providers/src/cardano.ts @@ -1,5 +1,9 @@ -import { AddressType, hexStringToUint8Array, type UserSignature } from '@paima/utils'; -import { utf8ToHex } from 'web3-utils'; +import { + AddressType, + hexStringToUint8Array, + uint8ArrayToHexString, + type UserSignature, +} from '@paima/utils'; import { optionToActive } from './IProvider.js'; import type { ActiveConnection, @@ -166,8 +170,9 @@ export class CardanoProvider implements IProvider { address: this.address.bech32, }; }; - signMessage = async (message: string): Promise => { - const hexMessage = utf8ToHex(message).slice(2); + signMessage = async (message: string | Uint8Array): Promise => { + const msgArray = message instanceof Uint8Array ? message : new TextEncoder().encode(message); + const hexMessage = uint8ArrayToHexString(msgArray); const address = this.conn.metadata.name === 'nami' ? this.address.hex : this.address.bech32; const { signature, key } = await this.conn.api.signData(address, hexMessage); return `${signature}+${key}`; diff --git a/packages/paima-sdk/paima-providers/src/evm/ethers.ts b/packages/paima-sdk/paima-providers/src/evm/ethers.ts index 83515d508..fa2a4e708 100644 --- a/packages/paima-sdk/paima-providers/src/evm/ethers.ts +++ b/packages/paima-sdk/paima-providers/src/evm/ethers.ts @@ -9,7 +9,6 @@ import type { } from '../IProvider.js'; import { DEFAULT_GAS_LIMIT, type EvmAddress } from './types.js'; import { ProviderNotInitialized } from '../errors.js'; -import { utf8ToHex } from 'web3-utils'; import { AddressType } from '@paima/utils'; export type EthersApi = Signer; @@ -71,9 +70,8 @@ export class EthersEvmProvider implements IProvider { address: this.address.toLowerCase(), }; }; - signMessage = async (message: string): Promise => { - const hexMessage = utf8ToHex(message); - const buffer = Buffer.from(hexMessage.slice(2), 'hex'); + signMessage = async (message: string | Uint8Array): Promise => { + const buffer = message instanceof Uint8Array ? message : new TextEncoder().encode(message); const signature = await this.conn.api.signMessage(buffer); return signature; }; diff --git a/packages/paima-sdk/paima-providers/src/evm/injected.ts b/packages/paima-sdk/paima-providers/src/evm/injected.ts index f6f00e654..ce592c13e 100644 --- a/packages/paima-sdk/paima-providers/src/evm/injected.ts +++ b/packages/paima-sdk/paima-providers/src/evm/injected.ts @@ -9,10 +9,9 @@ import type { AddressAndType, } from '../IProvider.js'; import { optionToActive } from '../IProvider.js'; -import { utf8ToHex } from 'web3-utils'; import { ProviderApiError, ProviderNotInitialized, WalletNotFound } from '../errors.js'; import type { EvmAddress } from './types.js'; -import { AddressType } from '@paima/utils'; +import { AddressType, uint8ArrayToHexString } from '@paima/utils'; import { getWindow } from '../window.js'; type EIP1193Provider = MetaMaskInpageProvider; @@ -269,8 +268,9 @@ export class EvmInjectedProvider implements IProvider { address: this.address.toLowerCase(), }; }; - signMessage = async (message: string): Promise => { - const hexMessage = utf8ToHex(message); + signMessage = async (message: string | Uint8Array): Promise => { + const msgArray = message instanceof Uint8Array ? message : new TextEncoder().encode(message); + const hexMessage = '0x' + uint8ArrayToHexString(msgArray); const signature = await this.conn.api.request({ method: 'personal_sign', params: [hexMessage, this.getAddress().address, ''], diff --git a/packages/paima-sdk/paima-providers/src/mina.ts b/packages/paima-sdk/paima-providers/src/mina.ts index 5298c760f..06acd569a 100644 --- a/packages/paima-sdk/paima-providers/src/mina.ts +++ b/packages/paima-sdk/paima-providers/src/mina.ts @@ -18,6 +18,8 @@ import { import { getWindow } from './window.js'; import type AuroMinaApi from '@aurowallet/mina-provider'; +export { MinaDelegationCache } from './mina/delegation.js'; + export type MinaApi = AuroMinaApi; type MinaAddress = any; @@ -141,7 +143,7 @@ export class MinaProvider implements IProvider { signMessage = async (message: string): Promise => { // There is no way of choosing the signing account here. At most we could // monitor the changed events and erroring out if it changed. - const signed = await this.conn.api.signMessage({ message: message }); + const signed = await this.conn.api.signMessage({ message }); if ('signature' in signed) { const { signature } = signed; diff --git a/packages/paima-sdk/paima-providers/src/mina/build_worker.ts b/packages/paima-sdk/paima-providers/src/mina/build_worker.ts new file mode 100644 index 000000000..22dee9538 --- /dev/null +++ b/packages/paima-sdk/paima-providers/src/mina/build_worker.ts @@ -0,0 +1,12 @@ +import { build } from 'esbuild'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const root = dirname(fileURLToPath(import.meta.url)); + +await build({ + entryPoints: [`${root}/mina.worker.js`], + outfile: `${root}/mina.worker.txt`, + bundle: true, + format: 'esm', +}); diff --git a/packages/paima-sdk/paima-providers/src/mina/delegation.ts b/packages/paima-sdk/paima-providers/src/mina/delegation.ts new file mode 100644 index 000000000..efacf7115 --- /dev/null +++ b/packages/paima-sdk/paima-providers/src/mina/delegation.ts @@ -0,0 +1,169 @@ +import { DynamicProof, JsonProof, PrivateKey, PublicKey, VerificationKey } from 'o1js'; +import { extractPublicKey } from '@metamask/eth-sig-util'; +import { delegateEvmToMina } from '@paima/mina-delegation'; + +import { EvmInjectedConnector } from '../index.js'; + +import type { Methods, InitParams } from './mina.worker.js'; +import minaWorkerCode from './mina.worker.txt'; + +const workerBlobUrl = URL.createObjectURL(new Blob([minaWorkerCode], { type: 'text/javascript' })); + +class MinaWorker { + private readonly worker; + private readonly ready: Promise; + private readonly pending = new Map>(); + private next = 1; + + constructor(initParams: InitParams) { + // Needed otherwise async import() of the real script will result in + // onmessage event handler being added too late and our call being dropped. + const readyEvent = Promise.withResolvers(); + this.pending.set(0, readyEvent); + this.ready = readyEvent.promise; + + this.worker = new Worker(workerBlobUrl + '#' + new URLSearchParams(initParams), { + type: 'module', + }); + this.worker.addEventListener('error', console.error); + this.worker.addEventListener('messageerror', console.error); + this.worker.addEventListener('message', event => { + this.pending.get(event.data.id)?.resolve(event.data.result); + this.pending.delete(event.data.id); + }); + } + + method(method: M): Methods[M] { + return (async (...args: unknown[]) => { + await this.ready; + + const replyEvent = Promise.withResolvers(); + const id = this.next++; + this.pending.set(id, replyEvent); + this.worker.postMessage({ id, method, args }); + return await replyEvent.promise; + }) as Methods[M]; + } +} + +interface MinaDelegationCacheStorage { + privateKey?: string; + signature?: string; + verificationKey?: { + data: string; + hash: string; + }; + proof?: JsonProof; +} + +/** Caching Mina DelegationCommand proof generator. */ +export class MinaDelegationCache { + /** The randomly-generated Mina private key, for use. */ + readonly privateKey: PrivateKey; + /** The corresponding public key, which gets signed. */ + readonly publicKey: PublicKey; + /** The signed order. May be rejected by user cancelling. */ + readonly signature: Promise; + /** The verification key which can be used by {@link DynamicProof}. */ + readonly verificationKey: Promise; + /** The serialized ZK proof of the signature. */ + readonly proof: Promise; + + constructor({ + gameName, + onRejectSignature, + }: { + /** The proof namespace, like "My Game login: ". Shown in the signature prompt and used as the storage key. */ + gameName: string; + /** If provided, called when the user declines to sign. */ + onRejectSignature?: (err: unknown, askAgain: () => void) => void; + }) { + const prefix = `${gameName} login: `; + const storageKey = `MinaDelegationCache(${gameName})`; + const storage: MinaDelegationCacheStorage = JSON.parse( + localStorage.getItem(storageKey) ?? '{}' + ); + const { DelegationCommand } = delegateEvmToMina(prefix); + + // 1. key + if (storage.privateKey) { + this.privateKey = PrivateKey.fromBase58(storage.privateKey); + } else { + this.privateKey = PrivateKey.random(); + storage.privateKey = this.privateKey.toBase58(); + localStorage.setItem(storageKey, JSON.stringify(storage)); + } + const target = (this.publicKey = this.privateKey.toPublicKey()); + + // 2. signature + const signaturePromise = (this.signature = (async () => { + if (!storage.signature) { + const provider = await EvmInjectedConnector.instance().connectSimple({ + gameName, + gameChainId: undefined, + }); + const data = DelegationCommand.bytesToSign({ target }); + const stringData = new TextDecoder().decode(data); + if (onRejectSignature) { + while (true) { + try { + storage.signature = await provider.signMessage(stringData); + break; + } catch (err) { + const { promise, resolve } = Promise.withResolvers(); + onRejectSignature(err, resolve); + await promise; + } + } + } else { + storage.signature = await provider.signMessage(stringData); + } + localStorage.setItem(storageKey, JSON.stringify(storage)); + } + return storage.signature; + })()); + + // Lazy-initialize the background thread only if we need it. + let worker: MinaWorker; + function getWorker() { + return (worker ??= new MinaWorker({ prefix })); + } + + // 3. verification key + const vkPromise = (this.verificationKey = (async () => { + // If the VK isn't in local storage, start compiling concurrently + // with waiting for the signature. Also do this if !storage.proof because + // we need to call .compile() before proving. + if (!storage.verificationKey || !storage.proof) { + storage.verificationKey = JSON.parse(await getWorker().method('compile')()); + localStorage.setItem(storageKey, JSON.stringify(storage)); + } + // NB: fromJSON's signature accepts `string` but it actually wants an object, + // so do a horrifying cast. + return VerificationKey.fromJSON(storage.verificationKey as unknown as string /* wtf? */); + })()); + + // 3. proof + this.proof = (async () => { + if (!storage.proof) { + // Wait on dependencies. + await vkPromise; + const signature = await signaturePromise; + + // Turn the signature into a DelegationCommand and sign it. + const data = DelegationCommand.bytesToSign({ target }); + const ethPublicKey = extractPublicKey({ data, signature }); + const proof = await getWorker().method('sign')({ + target: target.toBase58(), + signer: ethPublicKey, + signature: signature, + }); + + // Save it back. + storage.proof = proof; + localStorage.setItem(storageKey, JSON.stringify(storage)); + } + return storage.proof; + })(); + } +} diff --git a/packages/paima-sdk/paima-providers/src/mina/mina.worker.ts b/packages/paima-sdk/paima-providers/src/mina/mina.worker.ts new file mode 100644 index 000000000..8e406dd7e --- /dev/null +++ b/packages/paima-sdk/paima-providers/src/mina/mina.worker.ts @@ -0,0 +1,50 @@ +import { JsonProof, PublicKey } from 'o1js'; +import { delegateEvmToMina, Ecdsa, Secp256k1 } from '@paima/mina-delegation'; + +export type Methods = typeof methods; +export type InitParams = { + prefix: string; +}; + +const initParams = Object.fromEntries(new URLSearchParams(new URL(import.meta.url).hash.substring(1))) as InitParams; +const { DelegationCommand, DelegationCommandProgram } = delegateEvmToMina(initParams.prefix); + +const methods = { + async compile(): Promise { + console.time('DelegationCommandProgram.compile'); + const { verificationKey } = await DelegationCommandProgram.compile(); + console.timeEnd('DelegationCommandProgram.compile'); + // NB: do not use VerificationKey.toJSON as it just returns the "data" field + // which is NOT the format that fromJSON expects. + return JSON.stringify(verificationKey); + }, + + async sign({ + target, + signer, + signature, + }: { + target: string; + signer: string; + signature: string; + }): Promise { + console.time('DelegationCommandProgram.sign'); + const proof = await DelegationCommandProgram.sign( + new DelegationCommand({ + target: PublicKey.fromBase58(target), + signer: Secp256k1.fromHex(signer), + }), + Ecdsa.fromHex(signature) + ); + console.timeEnd('DelegationCommandProgram.sign'); + return proof.toJSON(); + }, +} as const; + +self.addEventListener('message', async event => { + const { id, method, args } = event.data; + const handler = methods[method as keyof Methods] as (...args: unknown[]) => unknown; + const result = await handler(...args); + postMessage({ id, result }); +}); +postMessage({ id: 0 }); // indicate ready diff --git a/packages/paima-sdk/paima-providers/src/mina/txt.d.ts b/packages/paima-sdk/paima-providers/src/mina/txt.d.ts new file mode 100644 index 000000000..91d8830be --- /dev/null +++ b/packages/paima-sdk/paima-providers/src/mina/txt.d.ts @@ -0,0 +1,5 @@ +// https://esbuild.github.io/content-types/#text +declare module '*.txt' { + declare const code: string; + export default code; +} diff --git a/packages/paima-sdk/paima-providers/src/polkadot.ts b/packages/paima-sdk/paima-providers/src/polkadot.ts index 6ab58a0eb..97e513e96 100644 --- a/packages/paima-sdk/paima-providers/src/polkadot.ts +++ b/packages/paima-sdk/paima-providers/src/polkadot.ts @@ -1,4 +1,4 @@ -import { AddressType } from '@paima/utils'; +import { AddressType, uint8ArrayToHexString } from '@paima/utils'; import type { ActiveConnection, AddressAndType, @@ -12,7 +12,6 @@ import type { import { optionToActive } from './IProvider.js'; import { ProviderApiError, ProviderNotInitialized, WalletNotFound } from './errors.js'; import type { InjectedExtension, InjectedWindowProvider } from '@polkadot/extension-inject/types'; -import { utf8ToHex } from 'web3-utils'; import { getWindow } from './window.js'; export type PolkadotAddress = string; @@ -143,13 +142,14 @@ export class PolkadotProvider implements IProvider { address: this.address, }; }; - signMessage = async (message: string): Promise => { + signMessage = async (message: string | Uint8Array): Promise => { if (this.conn.api.signer.signRaw == null) { throw new ProviderApiError( `[polkadot] extension ${this.conn.metadata.name} does not support signRaw` ); } - const hexMessage = utf8ToHex(message); + const msgArray = message instanceof Uint8Array ? message : new TextEncoder().encode(message); + const hexMessage = '0x' + uint8ArrayToHexString(msgArray); const { signature } = await this.conn.api.signer.signRaw({ address: this.getAddress().address, data: hexMessage, diff --git a/packages/paima-sdk/paima-providers/tsconfig.build.json b/packages/paima-sdk/paima-providers/tsconfig.build.json deleted file mode 100644 index cb830922e..000000000 --- a/packages/paima-sdk/paima-providers/tsconfig.build.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "build", - "resolveJsonModule": true - }, - "include": [ - "src/**/*", - "src/**/*.json" - ] -} \ No newline at end of file diff --git a/packages/paima-sdk/paima-providers/tsconfig.json b/packages/paima-sdk/paima-providers/tsconfig.json index 3d0ed8e55..7a2efcb6f 100644 --- a/packages/paima-sdk/paima-providers/tsconfig.json +++ b/packages/paima-sdk/paima-providers/tsconfig.json @@ -1,12 +1,14 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "rootDir": ".", - "noEmit": true, + "rootDir": "src", + "outDir": "build", + "resolveJsonModule": true }, - "include": ["test/**/*", "src/**/*"], + "include": ["src/**/*", "src/**/*.json"], "references": [ { "path": "../paima-crypto/tsconfig.build.json" }, { "path": "../paima-utils/tsconfig.build.json" }, + { "path": "../../contracts/mina-delegation/tsconfig.json" }, ] } diff --git a/packages/paima-sdk/paima-sdk/tsconfig.json b/packages/paima-sdk/paima-sdk/tsconfig.json index e0f8a1088..e426cb2a4 100644 --- a/packages/paima-sdk/paima-sdk/tsconfig.json +++ b/packages/paima-sdk/paima-sdk/tsconfig.json @@ -14,7 +14,7 @@ { "path": "../paima-prando" }, { "path": "../paima-utils/tsconfig.build.json" }, { "path": "../paima-crypto/tsconfig.build.json" }, - { "path": "../paima-providers/tsconfig.build.json" }, + { "path": "../paima-providers" }, { "path": "../paima-concise/tsconfig.build.json" }, { "path": "../paima-executors" }, { "path": "../paima-mw-core" }, diff --git a/tsconfig.base.json b/tsconfig.base.json index a0f396c17..f42fc90c3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -42,6 +42,7 @@ "@paima/db/*": ["./packages/node-sdk/paima-db/*"], "@paima/events/*": ["./packages/paima-sdk/paima-events/*"], "@paima/evm-contracts/*": ["./packages/contracts/evm-contracts/*"], + "@paima/mina-delegation/*": ["./packages/contracts/mina-delegation/*"], "@paima/executors/*": ["./packages/paima-sdk/paima-executors/*"], "@paima/funnel/*": ["./packages/engine/paima-funnel/*"], "@paima/mw-core/*": ["./packages/paima-sdk/paima-mw-core/*"],