diff --git a/README.md b/README.md index f233239..5bd0eec 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ TypeScript SDK for building on [Rome](https://github.com/rome-protocol) — EVM Repo-first — install from the repo, pinned to a release tag (npm publish is deferred until demand): ``` -npm install github:rome-protocol/rome-sdk-ts#v0.2.0 viem @solana/web3.js @solana/spl-token +npm install github:rome-protocol/rome-sdk-ts#v0.2.1 viem @solana/web3.js @solana/spl-token ``` Imports use the package name (`@rome-protocol/sdk`) unchanged. @@ -55,10 +55,12 @@ const { signature } = await submitRomeTxSolanaLane( The wallet **signs the Solana transaction and sends it to the Solana RPC** (the proxy is used only for account emulation). On-chain, `msg.sender` is the wallet's **synthetic address** — `syntheticAddress(pubkey)` = `keccak256(pubkey)[12:]`. -**The synthetic holds nothing at rest** — a Solana user's money lives in their wallet (as SPL USDC), and value flows *through* the synthetic. So bracket a lane write with: +**The synthetic holds nothing at rest.** A Solana user's spendable balance is their **wallet's SPL ATA, exposed 1:1 as an ERC20SPL wrapper** on the EVM side (e.g. `wUSDC`) — so your app moves it with ordinary ERC20 `transfer` / `transferFrom`, *not* native `msg.value`. Value flows *through* the synthetic: -- **Fund leg** (value in) — `buildFundLeg(...)` + `submitSolanaInstructions(...)`: move USDC from the wallet into the synthetic (`ActivateAta`) before the call. -- **Sweep leg** (value out) — `buildSweepLeg(...)`: push USDC from the synthetic back to the wallet (`HelperProgram.transfer_spl`) after a withdraw. +- **Fund leg** (value in) — `buildFundLeg(...)` + `submitSolanaInstructions(...)`: move USDC from the wallet into the synthetic's ATA (`ActivateAta`) before the call. +- **Sweep leg** (value out) — `buildSweepLeg(...)`: push USDC from the synthetic's ATA back to the wallet (`HelperProgram.transfer_spl`) after a withdraw. + +**Provisioning is automatic.** A brand-new synthetic's external-auth PDA doesn't exist until `create_pda` runs — and value-moving calls are signed by it. `submitRomeTxSolanaLane` provisions it transparently on first use; pass `autoProvision: false` and call `provisionSynthetic(deps)` yourself for an explicit one-time "Activate" step (gate a UI with `isSyntheticProvisioned`). `submitRomeTxSolanaLane` also handles what a hand-built transaction gets wrong: the **ComputeBudget** (raised CU + heap), the **treasure wallet** account, and emulating account discovery **with `value`** (so value-dependent storage slots are allocated). The user needs a little **SOL** (tx fee) + **USDC** (SPL) in their wallet; there is no faucet. Full walkthrough: **Build a dual-lane app** in the [docs](https://docs.rome.builders). @@ -90,6 +92,7 @@ Quote-first: the bridge API owns route + calldata; the client signs the quote's | `u64Le` / `u8` | little-endian primitives for inner Solana instruction data | | `submitRomeTxSolanaLane(deps, call)` | the Solana lane — a Solana wallet drives your EVM contract | | `buildFundLeg` / `buildSweepLeg` / `submitSolanaInstructions` | value-in (ActivateAta) / value-out (transfer_spl) / native-instruction submit | +| `provisionSynthetic` / `isSyntheticProvisioned` / `buildCreatePdaCall` | fresh-synthetic provisioning (`create_pda`) — auto on first `submitRomeTxSolanaLane` | | `syntheticAddress` / `emulateCallAccounts` / `buildDoTxUnsigned` / `buildActivateAtaInstruction` / `treasureWallet` / `balanceKeyPda` / `computeBudgetIxs` / `SyntheticNonceTracker` | Solana-lane primitives | | `PRECOMPILE_ADDRESSES` / `CPI_ABI` / `HELPER_ABI` / `WITHDRAW_ABI` / `SYSTEM_ABI` / `SELECTORS` / `EXTERNAL_AUTHORITY_SEED` | precompile addresses, ABIs, and verified selectors | diff --git a/package.json b/package.json index 9b3f0e9..0518f77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rome-protocol/sdk", - "version": "0.2.0", + "version": "0.2.1", "description": "Rome Protocol TypeScript SDK — submitRomeTx (the canonical Rome EVM write wrapper), the Solana lane (submitRomeTxSolanaLane — a Solana wallet driving your EVM app), fee/gas sizing, external-authority PDA + ATA derivation, and CPI calldata encoding for building on Rome (EVM chains that run on Solana).", "type": "module", "main": "./dist/index.js", diff --git a/src/solana.ts b/src/solana.ts index 3673d9a..966a613 100644 --- a/src/solana.ts +++ b/src/solana.ts @@ -392,10 +392,20 @@ export interface SolanaLaneDeps { */ export async function submitRomeTxSolanaLane( deps: SolanaLaneDeps, - call: EvmCall & { nonce?: bigint; fee?: FeeFields; extraAccounts?: AccountMeta[] }, + call: EvmCall & { nonce?: bigint; fee?: FeeFields; extraAccounts?: AccountMeta[]; autoProvision?: boolean }, ): Promise<{ signature: string; from: Hex }> { const fetchImpl = deps.fetchImpl ?? fetch; const from = syntheticAddress(deps.payer); + // First use of a brand-new synthetic: create its external-auth PDA. It doesn't + // exist until `create_pda` runs, and value-moving calls (the sweep's + // `transfer_spl`, ERC20SPL `transferFrom`) are signed by that PDA — so they + // revert until it's provisioned. Opt out with `autoProvision: false` to run + // your own one-time Activate step (see `provisionSynthetic`). + const createCall = buildCreatePdaCall(from); + const isCreatePdaCall = call.to === createCall.to && call.data === createCall.data; + if (call.autoProvision !== false && !isCreatePdaCall && !(await isSyntheticProvisioned(deps.connection, deps.programId, from))) { + await submitRomeTxSolanaLane(deps, { ...createCall, autoProvision: false }); + } const nonce = call.nonce ?? BigInt(await jsonRpc(deps.proxyUrl, "eth_getTransactionCount", [from, "pending"], fetchImpl)); const fee = @@ -602,3 +612,56 @@ export function buildSweepLeg(params: { ]; return { ensureWalletAtaIx, helperTo: HELPER_PROGRAM, calldata, extraAccounts }; } + +// ------------------------------------------- fresh-synthetic provisioning --- +// A Solana-native user's synthetic external-auth PDA is NOT lazy: it must be +// created once (`create_pda`) before any value-moving call, because the sweep's +// `transfer_spl` and ERC20SPL `transferFrom` are signed by that PDA. +// `submitRomeTxSolanaLane` auto-provisions on first use; these are the explicit +// primitives (e.g. for an app "Activate" step). + +const HELPER_CREATE_PDA_ABI = [ + { type: "function", name: "create_pda", stateMutability: "nonpayable", inputs: [{ name: "user", type: "address" }], outputs: [] }, +] as const; + +/** + * The one-time provisioning call — `create_pda(synthetic)` on the HelperProgram + * (`0xFF…09`). Submit once (via {@link submitRomeTxSolanaLane}) before the + * synthetic's first value-moving write; gate on {@link isSyntheticProvisioned}. + */ +export function buildCreatePdaCall(synthetic: Hex): EvmCall { + return { + to: HELPER_PROGRAM, + data: encodeFunctionData({ abi: HELPER_CREATE_PDA_ABI, functionName: "create_pda", args: [synthetic] }), + }; +} + +/** + * Whether the synthetic's external-auth PDA already exists on-chain (i.e. it's + * been provisioned). One Solana-RPC account read. + */ +export async function isSyntheticProvisioned( + connection: Connection, + programId: PublicKey | string, + synthetic: Hex, +): Promise { + return (await connection.getAccountInfo(deriveAuthorityPda(synthetic, pk(programId)))) !== null; +} + +/** + * Provision a fresh synthetic if needed: create its external-auth PDA via + * `create_pda`. Returns the signature if it created one, else + * `{ alreadyProvisioned: true }`. The explicit form of what + * {@link submitRomeTxSolanaLane}'s default `autoProvision` does — call it once + * for an app "Activate" step, then submit writes with `autoProvision: false`. + */ +export async function provisionSynthetic( + deps: SolanaLaneDeps, +): Promise<{ signature?: string; alreadyProvisioned: boolean }> { + const synthetic = syntheticAddress(deps.payer); + if (await isSyntheticProvisioned(deps.connection, deps.programId, synthetic)) { + return { alreadyProvisioned: true }; + } + const { signature } = await submitRomeTxSolanaLane(deps, { ...buildCreatePdaCall(synthetic), autoProvision: false }); + return { signature, alreadyProvisioned: false }; +} diff --git a/test/solana.test.ts b/test/solana.test.ts index e3de87d..06b7df7 100644 --- a/test/solana.test.ts +++ b/test/solana.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { PublicKey } from "@solana/web3.js"; +import { PublicKey, type Connection } from "@solana/web3.js"; import { ComputeBudgetProgram } from "@solana/web3.js"; -import { keccak256, parseTransaction, hexToBytes, type Hex } from "viem"; +import { keccak256, parseTransaction, hexToBytes, decodeFunctionData, type Hex } from "viem"; import { syntheticAddress, buildUnsignedEip1559Rlp, @@ -16,7 +16,12 @@ import { ownerInfoPda, buildFundLeg, buildSweepLeg, + buildCreatePdaCall, + isSyntheticProvisioned, + provisionSynthetic, + HELPER_PROGRAM, } from "../src/solana.js"; +import { SELECTORS } from "../src/selectors.js"; // A deterministic 32-byte Solana pubkey for tests (not the zero key). const PUBKEY_BYTES = Uint8Array.from({ length: 32 }, (_, i) => i + 1); @@ -197,3 +202,41 @@ describe("sweep leg (value-out)", () => { expect(s.ensureWalletAtaIx.programId.toBase58()).toBe("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); // ATA program }); }); + +describe("fresh-synthetic provisioning (create_pda)", () => { + it("buildCreatePdaCall targets the Helper precompile with create_pda(synthetic)", () => { + const synth = syntheticAddress(PUBKEY); + const call = buildCreatePdaCall(synth); + expect(call.to).toBe(HELPER_PROGRAM); + expect(call.data.startsWith(SELECTORS["create_pda(address)"])).toBe(true); + const decoded = decodeFunctionData({ + abi: [{ type: "function", name: "create_pda", stateMutability: "nonpayable", inputs: [{ name: "user", type: "address" }], outputs: [] }], + data: call.data, + }); + expect((decoded.args[0] as string).toLowerCase()).toBe(synth.toLowerCase()); + }); + + it("isSyntheticProvisioned reflects whether the external-auth PDA exists on-chain", async () => { + const synth = syntheticAddress(PUBKEY); + const present = { getAccountInfo: async () => ({ data: new Uint8Array(1) }) } as unknown as Connection; + const absent = { getAccountInfo: async () => null } as unknown as Connection; + expect(await isSyntheticProvisioned(present, PROGRAM, synth)).toBe(true); + expect(await isSyntheticProvisioned(absent, PROGRAM, synth)).toBe(false); + }); + + it("provisionSynthetic short-circuits when already provisioned (submits no tx)", async () => { + const signTransaction = vi.fn(); + const deps = { + connection: { getAccountInfo: async () => ({ data: new Uint8Array(1) }) }, + proxyUrl: "https://proxy.example", + programId: PROGRAM, + chainId: CHAIN_ID, + payer: PUBKEY, + signTransaction, + } as unknown as Parameters[0]; + const res = await provisionSynthetic(deps); + expect(res.alreadyProvisioned).toBe(true); + expect(res.signature).toBeUndefined(); + expect(signTransaction).not.toHaveBeenCalled(); + }); +});