diff --git a/packages/blockchain-api-client/src/contracts/governance.ts b/packages/blockchain-api-client/src/contracts/governance.ts index fbf4b0481..819f49380 100644 --- a/packages/blockchain-api-client/src/contracts/governance.ts +++ b/packages/blockchain-api-client/src/contracts/governance.ts @@ -28,6 +28,8 @@ import { SplitPositionResponseSchema, TransferPositionInputSchema, TransferPositionResponseSchema, + TransferPositionOwnershipInputSchema, + TransferPositionOwnershipResponseSchema, UnassignProxiesInputSchema, UnassignProxiesResponseSchema, UndelegateInputSchema, @@ -124,6 +126,18 @@ export const governanceContract = oc .errors({ BAD_REQUEST, NOT_FOUND, INSUFFICIENT_FUNDS }) .output(TransferPositionResponseSchema), + transferPositionOwnership: oc + .route({ + method: "POST", + path: "/positions/{positionMint}/transfer-ownership", + summary: "Transfer position ownership", + description: + "Transfer ownership of a position NFT to another wallet. Both the current owner and new owner must sign the transaction.", + }) + .input(TransferPositionOwnershipInputSchema) + .errors({ BAD_REQUEST, NOT_FOUND, INSUFFICIENT_FUNDS }) + .output(TransferPositionOwnershipResponseSchema), + delegatePositions: oc .route({ method: "POST", diff --git a/packages/blockchain-api-client/src/schemas/governance.ts b/packages/blockchain-api-client/src/schemas/governance.ts index 73192869e..1b2f80f39 100644 --- a/packages/blockchain-api-client/src/schemas/governance.ts +++ b/packages/blockchain-api-client/src/schemas/governance.ts @@ -111,6 +111,16 @@ export const TransferPositionInputSchema = z.object({ .describe("Raw token amount to transfer (in smallest unit)"), }); +export const TransferPositionOwnershipInputSchema = z.object({ + from: WalletAddressSchema.describe( + "Wallet address that currently owns the position" + ), + to: WalletAddressSchema.describe( + "Wallet address that will receive the position" + ), + positionMint: PublicKeySchema.describe("Mint address of the position NFT"), +}); + export const DelegatePositionInputSchema = z.object({ walletAddress: WalletAddressSchema.describe( "Wallet address that owns the positions" @@ -251,6 +261,7 @@ export const SplitPositionResponseSchema = createTypedTransactionResponse( SplitPositionMetadataSchema, ); export const TransferPositionResponseSchema = createTransactionResponse(); +export const TransferPositionOwnershipResponseSchema = createTransactionResponse(); export const ExtendDelegationResponseSchema = createTransactionResponse(); // --------------------------------------------------------------------------- @@ -285,6 +296,7 @@ export type FlipLockupKindInput = z.infer; export type ResetLockupInput = z.infer; export type SplitPositionInput = z.infer; export type TransferPositionInput = z.infer; +export type TransferPositionOwnershipInput = z.infer; export type DelegatePositionInput = z.infer; export type ExtendDelegationInput = z.infer; export type UndelegateInput = z.infer; @@ -314,6 +326,9 @@ export type SplitPositionResponse = z.infer; export type TransferPositionResponse = z.infer< typeof TransferPositionResponseSchema >; +export type TransferPositionOwnershipResponse = z.infer< + typeof TransferPositionOwnershipResponseSchema +>; export type DelegatePositionsResponse = z.infer< typeof DelegatePositionsResponseSchema >; diff --git a/packages/blockchain-api/Dockerfile b/packages/blockchain-api/Dockerfile index b7e1f5ca7..0ef4a23ec 100644 --- a/packages/blockchain-api/Dockerfile +++ b/packages/blockchain-api/Dockerfile @@ -31,10 +31,18 @@ ARG SENTRY_ORG ARG SENTRY_PROJECT ARG SENTRY_RELEASE + ENV NEXT_PUBLIC_PRIVY_APP_ID="__NEXT_PUBLIC_PRIVY_APP_ID__" \ NEXT_PUBLIC_SOLANA_CLUSTER="__NEXT_PUBLIC_SOLANA_CLUSTER__" \ NEXT_PUBLIC_SOLANA_URL="__NEXT_PUBLIC_SOLANA_URL__" \ NEXT_PUBLIC_WORLD_HELIUM_URL="__NEXT_PUBLIC_WORLD_HELIUM_URL__" \ + PG_USER="build_placeholder" \ + PG_NAME="build_placeholder" \ + PG_HOST="build_placeholder" \ + PG_PORT="5432" \ + PRIVY_APP_SECRET="build_placeholder" \ + BRIDGE_API_KEY="build_placeholder" \ + JUPITER_API_KEY="build_placeholder" \ NODE_ENV=production \ NODE_OPTIONS="--max-old-space-size=4096" diff --git a/packages/blockchain-api/src/lib/utils/transaction-tags.ts b/packages/blockchain-api/src/lib/utils/transaction-tags.ts index 5301abc66..095ba5263 100644 --- a/packages/blockchain-api/src/lib/utils/transaction-tags.ts +++ b/packages/blockchain-api/src/lib/utils/transaction-tags.ts @@ -54,6 +54,7 @@ export const TRANSACTION_TYPES = { POSITION_RESET_LOCKUP: "position_reset_lockup", POSITION_SPLIT: "position_split", POSITION_TRANSFER: "position_transfer", + POSITION_TRANSFER_OWNERSHIP: "position_transfer_ownership", // Governance - Delegation DELEGATION_DELEGATE: "delegation_delegate", diff --git a/packages/blockchain-api/src/middleware.ts b/packages/blockchain-api/src/middleware.ts new file mode 100644 index 000000000..c92285287 --- /dev/null +++ b/packages/blockchain-api/src/middleware.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; + +const ALLOWED_ORIGIN_PATTERNS = [ + /^https?:\/\/localhost(:\d+)?$/, + /^https?:\/\/.*\.helium\.io$/, + /^https?:\/\/.*\.test-helium\.com$/, +]; + +function isAllowedOrigin(origin: string): boolean { + return ALLOWED_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin)); +} + +export function middleware(request: NextRequest) { + const origin = request.headers.get("origin") ?? ""; + + if (request.method === "OPTIONS") { + const response = new NextResponse(null, { status: 204 }); + if (isAllowedOrigin(origin)) { + response.headers.set("Access-Control-Allow-Origin", origin); + response.headers.set("Access-Control-Allow-Credentials", "true"); + } + response.headers.set( + "Access-Control-Allow-Methods", + "GET, POST, PUT, PATCH, DELETE, OPTIONS", + ); + response.headers.set( + "Access-Control-Allow-Headers", + "Content-Type, Authorization", + ); + response.headers.set("Access-Control-Max-Age", "86400"); + return response; + } + + const response = NextResponse.next(); + if (isAllowedOrigin(origin)) { + response.headers.set("Access-Control-Allow-Origin", origin); + response.headers.set("Access-Control-Allow-Credentials", "true"); + } + return response; +} + +export const config = { + matcher: ["/api/:path*", "/rpc/:path*"], +}; diff --git a/packages/blockchain-api/src/server/api/routers/governance/procedures/positions/transfer-ownership.ts b/packages/blockchain-api/src/server/api/routers/governance/procedures/positions/transfer-ownership.ts new file mode 100644 index 000000000..aabbd85c0 --- /dev/null +++ b/packages/blockchain-api/src/server/api/routers/governance/procedures/positions/transfer-ownership.ts @@ -0,0 +1,105 @@ +import { publicProcedure } from "@/server/api/procedures"; +import { createSolanaConnection } from "@/lib/solana"; +import { getTransactionFee } from "@/lib/utils/balance-validation"; +import { + generateTransactionTag, + TRANSACTION_TYPES, +} from "@/lib/utils/transaction-tags"; +import { toTokenAmountOutput } from "@/lib/utils/token-math"; +import { + buildVersionedTransaction, + serializeTransaction, +} from "@/lib/utils/build-transaction"; +import { init as initVsr, positionKey } from "@helium/voter-stake-registry-sdk"; +import { NATIVE_MINT } from "@solana/spl-token"; +import { PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; +import { validatePositionOwnership } from "../helpers"; + +export const transferOwnership = + publicProcedure.governance.transferPositionOwnership.handler( + async ({ input, errors }) => { + const { from, to, positionMint } = input; + + const { connection, provider } = createSolanaConnection(from); + const fromPubkey = new PublicKey(from); + const toPubkey = new PublicKey(to); + const positionMintPubkey = new PublicKey(positionMint); + + const vsrProgram = await initVsr(provider); + + const [positionPubkey] = positionKey(positionMintPubkey); + const positionAcc = + await vsrProgram.account.positionV0.fetchNullable(positionPubkey); + + if (!positionAcc) { + throw errors.NOT_FOUND({ message: "Position not found" }); + } + + const ownership = await validatePositionOwnership( + connection, + positionMintPubkey, + fromPubkey, + ); + + if (!ownership.isOwner) { + throw errors.BAD_REQUEST({ + message: "From wallet does not own the position", + }); + } + + const ix = await vsrProgram.methods + .transferPositionV0() + .accountsPartial({ + payer: fromPubkey, + position: positionPubkey, + mint: positionMintPubkey, + from: fromPubkey, + to: toPubkey, + }) + .instruction(); + + const tx = await buildVersionedTransaction({ + connection, + draft: { instructions: [ix], feePayer: fromPubkey }, + }); + + const txFee = getTransactionFee(tx); + + const walletBalance = await connection.getBalance(fromPubkey); + if (walletBalance < txFee) { + throw errors.INSUFFICIENT_FUNDS({ + message: "Insufficient SOL balance for transaction fees", + data: { required: txFee, available: walletBalance }, + }); + } + + const tag = generateTransactionTag({ + type: TRANSACTION_TYPES.POSITION_TRANSFER_OWNERSHIP, + from, + to, + positionMint, + }); + + return { + transactionData: { + transactions: [ + { + serializedTransaction: serializeTransaction(tx), + metadata: { + type: "position_transfer_ownership", + description: "Transfer position ownership to another wallet", + }, + }, + ], + parallel: false, + tag, + actionMetadata: { type: "position_transfer_ownership", positionMint, from, to }, + }, + estimatedSolFee: toTokenAmountOutput( + new BN(txFee), + NATIVE_MINT.toBase58(), + ), + }; + }, + ); diff --git a/packages/blockchain-api/src/server/api/routers/governance/router.ts b/packages/blockchain-api/src/server/api/routers/governance/router.ts index 641c91628..89b1dd2c7 100644 --- a/packages/blockchain-api/src/server/api/routers/governance/router.ts +++ b/packages/blockchain-api/src/server/api/routers/governance/router.ts @@ -7,6 +7,7 @@ import { flipLockupKind } from "./procedures/positions/flip-lockup-kind"; import { resetLockup } from "./procedures/positions/reset-lockup"; import { split as splitPosition } from "./procedures/positions/split"; import { transfer as transferPosition } from "./procedures/positions/transfer"; +import { transferOwnership as transferPositionOwnership } from "./procedures/positions/transfer-ownership"; import { delegate } from "./procedures/delegation/delegate"; import { extend as extendDelegation } from "./procedures/delegation/extend"; import { undelegate } from "./procedures/delegation/undelegate"; @@ -25,6 +26,7 @@ export const governanceRouter = implement(governanceContract).router({ resetLockup, splitPosition, transferPosition, + transferPositionOwnership, delegatePositions: delegate, claimDelegationRewards: claimRewards, undelegatePosition: undelegate, diff --git a/packages/blockchain-api/tests/e2e/governance.test.ts b/packages/blockchain-api/tests/e2e/governance.test.ts index acedfacb2..b0d53f35d 100644 --- a/packages/blockchain-api/tests/e2e/governance.test.ts +++ b/packages/blockchain-api/tests/e2e/governance.test.ts @@ -10,8 +10,8 @@ import { positionKey, voteMarkerKey, } from "@helium/voter-stake-registry-sdk"; -import { Keypair, PublicKey, SYSVAR_CLOCK_PUBKEY } from "@solana/web3.js"; -import { NATIVE_MINT } from "@solana/spl-token"; +import { Keypair, LAMPORTS_PER_SOL, PublicKey, SYSVAR_CLOCK_PUBKEY, VersionedTransaction } from "@solana/web3.js"; +import { getAssociatedTokenAddressSync, NATIVE_MINT } from "@solana/spl-token"; import { expect } from "chai"; import { after, before, describe, it } from "mocha"; import { isDefinedError } from "@orpc/client"; @@ -19,7 +19,7 @@ import { stopNextServer } from "./helpers/next"; import { stopSurfpool } from "./helpers/surfpool"; import { setupTestCtx, TestCtx } from "./helpers/context"; import { signAndSubmitTransactionData } from "./helpers/tx"; -import { ensureTokenBalance } from "./helpers/wallet"; +import { ensureFunds, ensureTokenBalance } from "./helpers/wallet"; import { DEFAULT_HPL_CRONS_TASK_QUEUE, TEST_PROXY_ADDRESS, @@ -431,6 +431,89 @@ describe("governance", () => { }); }); + describe("position ownership transfer", () => { + let walletAddress: string; + + before(async () => { + walletAddress = ctx.payer.publicKey.toBase58(); + }); + + it("transfers position ownership to another wallet", async () => { + // #given a position owned by ctx.payer and a recipient keypair + const result = await createAndFundPosition(ctx, { + amount: "100000000", + lockupKind: "cliff", + lockupPeriodsInDays: 30, + }); + const recipient = Keypair.generate(); + await ensureFunds(recipient.publicKey, 0.01 * LAMPORTS_PER_SOL); + + // #when transferring position ownership + const { data, error } = + await ctx.safeClient.governance.transferPositionOwnership({ + from: walletAddress, + to: recipient.publicKey.toBase58(), + positionMint: result.positionMint, + }); + + // #then transaction builds with correct metadata + if (error) { + expect.fail(`Unexpected error: ${JSON.stringify(error)}`); + } + expect(data?.transactionData?.transactions).to.have.length(1); + expect(data?.transactionData?.transactions[0].metadata?.type).to.equal( + "position_transfer_ownership" + ); + expect(data?.transactionData?.actionMetadata).to.deep.include({ + type: "position_transfer_ownership", + positionMint: result.positionMint, + from: walletAddress, + to: recipient.publicKey.toBase58(), + }); + + // Sign with both from and to, then submit + const { blockhash, lastValidBlockHeight } = + await ctx.connection.getLatestBlockhash("confirmed"); + const tx = VersionedTransaction.deserialize( + Buffer.from( + data.transactionData.transactions[0].serializedTransaction, + "base64" + ) + ); + tx.message.recentBlockhash = blockhash; + tx.sign([ctx.payer, recipient]); + const sig = await ctx.connection.sendRawTransaction(tx.serialize(), { + skipPreflight: false, + }); + await ctx.connection.confirmTransaction( + { signature: sig, blockhash, lastValidBlockHeight }, + "confirmed" + ); + + // Verify ownership transferred on-chain: recipient now holds the position NFT + const recipientAta = getAssociatedTokenAddressSync( + new PublicKey(result.positionMint), + recipient.publicKey, + true + ); + const recipientTokenAccount = + await ctx.connection.getAccountInfo(recipientAta); + expect(recipientTokenAccount).to.not.be.null; + + // Original owner no longer holds it + const originalAta = getAssociatedTokenAddressSync( + new PublicKey(result.positionMint), + ctx.payer.publicKey, + true + ); + const originalTokenAccount = + await ctx.connection.getTokenAccountBalance(originalAta).catch(() => null); + const recipientBalance = + await ctx.connection.getTokenAccountBalance(recipientAta); + expect(recipientBalance.value.uiAmount).to.equal(1); + }); + }); + describe("delegation", () => { let walletAddress: string; diff --git a/packages/spl-utils/src/idl/voter_stake_registry.json b/packages/spl-utils/src/idl/voter_stake_registry.json index 9efce3aed..1b1c43320 100644 --- a/packages/spl-utils/src/idl/voter_stake_registry.json +++ b/packages/spl-utils/src/idl/voter_stake_registry.json @@ -2,7 +2,7 @@ "address": "hvsrNC3NKbcryqDs2DocYHZ9yPKEVzdSjQG6RVtK1s8", "metadata": { "name": "voter_stake_registry", - "version": "0.4.1", + "version": "0.4.6", "spec": "0.1.0", "description": "Heliums voter weight plugin for spl-governance" }, @@ -145,6 +145,7 @@ }, { "name": "registrar", + "writable": true, "relations": [ "position" ] @@ -1914,48 +1915,18 @@ "accounts": [ { "name": "rent_refund", - "writable": true, - "relations": [ - "marker" - ] + "writable": true }, { "name": "marker", - "writable": true, - "pda": { - "seeds": [ - { - "kind": "const", - "value": [ - 109, - 97, - 114, - 107, - 101, - 114 - ] - }, - { - "kind": "account", - "path": "marker.mint", - "account": "VoteMarkerV0" - }, - { - "kind": "account", - "path": "proposal" - } - ] - } + "writable": true }, { "name": "position", "writable": true }, { - "name": "proposal", - "relations": [ - "marker" - ] + "name": "proposal" }, { "name": "system_program", @@ -2270,16 +2241,41 @@ ] }, { - "name": "temp_backfill_proxy_marker", + "name": "temp_release_position_v0", "discriminator": [ 18, - 0, - 202, - 42, - 187, - 32, - 97, - 76 + 41, + 232, + 50, + 253, + 184, + 27, + 43 + ], + "accounts": [ + { + "name": "authority", + "signer": true, + "address": "hprdnjkbziK8NqhThmAn5Gu4XqrBbctX8du4PfJdgvW" + }, + { + "name": "position", + "writable": true + } + ], + "args": [] + }, + { + "name": "transfer_position_v0", + "discriminator": [ + 13, + 19, + 112, + 27, + 248, + 208, + 38, + 145 ], "accounts": [ { @@ -2288,131 +2284,235 @@ "signer": true }, { - "name": "marker", - "writable": true, + "name": "position", "pda": { "seeds": [ { "kind": "const", "value": [ 112, - 114, 111, - 120, - 121, - 95, - 109, - 97, - 114, - 107, - 101, - 114 + 115, + 105, + 116, + 105, + 111, + 110 ] }, { "kind": "account", - "path": "voter" - }, - { - "kind": "account", - "path": "proposal" + "path": "mint" } ] } }, { - "name": "voter" - }, - { - "name": "authority", - "signer": true, - "address": "hprdnjkbziK8NqhThmAn5Gu4XqrBbctX8du4PfJdgvW" + "name": "mint", + "writable": true, + "relations": [ + "position" + ] }, { - "name": "proposal" + "name": "from_token_account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "from" + }, + { + "kind": "const", + "value": [ + 6, + 221, + 246, + 225, + 215, + 101, + 161, + 147, + 217, + 203, + 225, + 70, + 206, + 235, + 121, + 172, + 28, + 180, + 133, + 237, + 95, + 91, + 55, + 145, + 58, + 140, + 245, + 133, + 126, + 255, + 0, + 169 + ] + }, + { + "kind": "account", + "path": "mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } }, { - "name": "system_program", - "address": "11111111111111111111111111111111" - } - ], - "args": [ - { - "name": "args", - "type": { - "defined": { - "name": "VoteArgsV0" + "name": "to_token_account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "to" + }, + { + "kind": "const", + "value": [ + 6, + 221, + 246, + 225, + 215, + 101, + 161, + 147, + 217, + 203, + 225, + 70, + 206, + 235, + 121, + 172, + 28, + 180, + 133, + 237, + 95, + 91, + 55, + 145, + 58, + 140, + 245, + 133, + 126, + 255, + 0, + 169 + ] + }, + { + "kind": "account", + "path": "mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] } } - } - ] - }, - { - "name": "temp_backfill_recent_proposals", - "discriminator": [ - 248, - 23, - 82, - 43, - 69, - 233, - 74, - 26 - ], - "accounts": [ - { - "name": "authority", - "signer": true, - "address": "hprdnjkbziK8NqhThmAn5Gu4XqrBbctX8du4PfJdgvW" }, { - "name": "registrar", - "writable": true, - "relations": [ - "position" - ] + "name": "from", + "signer": true }, { - "name": "position", - "writable": true + "name": "to", + "signer": true }, { "name": "system_program", "address": "11111111111111111111111111111111" - } - ], - "args": [ - { - "name": "args", - "type": { - "defined": { - "name": "TempBackfillRecentProposalsArgs" - } - } - } - ] - }, - { - "name": "temp_release_position_v0", - "discriminator": [ - 18, - 41, - 232, - 50, - 253, - 184, - 27, - 43 - ], - "accounts": [ + }, { - "name": "authority", - "signer": true, - "address": "hprdnjkbziK8NqhThmAn5Gu4XqrBbctX8du4PfJdgvW" + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" }, { - "name": "position", - "writable": true + "name": "associated_token_program", + "address": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" } ], "args": [] @@ -4330,24 +4430,6 @@ ] } }, - { - "name": "TempBackfillRecentProposalsArgs", - "type": { - "kind": "struct", - "fields": [ - { - "name": "recent_proposals", - "type": { - "vec": { - "defined": { - "name": "RecentProposal" - } - } - } - } - ] - } - }, { "name": "TransferArgsV0", "type": {