Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/blockchain-api-client/src/contracts/governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
SplitPositionResponseSchema,
TransferPositionInputSchema,
TransferPositionResponseSchema,
TransferPositionOwnershipInputSchema,
TransferPositionOwnershipResponseSchema,
UnassignProxiesInputSchema,
UnassignProxiesResponseSchema,
UndelegateInputSchema,
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions packages/blockchain-api-client/src/schemas/governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -251,6 +261,7 @@ export const SplitPositionResponseSchema = createTypedTransactionResponse(
SplitPositionMetadataSchema,
);
export const TransferPositionResponseSchema = createTransactionResponse();
export const TransferPositionOwnershipResponseSchema = createTransactionResponse();
export const ExtendDelegationResponseSchema = createTransactionResponse();

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -285,6 +296,7 @@ export type FlipLockupKindInput = z.infer<typeof FlipLockupKindInputSchema>;
export type ResetLockupInput = z.infer<typeof ResetLockupInputSchema>;
export type SplitPositionInput = z.infer<typeof SplitPositionInputSchema>;
export type TransferPositionInput = z.infer<typeof TransferPositionInputSchema>;
export type TransferPositionOwnershipInput = z.infer<typeof TransferPositionOwnershipInputSchema>;
export type DelegatePositionInput = z.infer<typeof DelegatePositionInputSchema>;
export type ExtendDelegationInput = z.infer<typeof ExtendDelegationInputSchema>;
export type UndelegateInput = z.infer<typeof UndelegateInputSchema>;
Expand Down Expand Up @@ -314,6 +326,9 @@ export type SplitPositionResponse = z.infer<typeof SplitPositionResponseSchema>;
export type TransferPositionResponse = z.infer<
typeof TransferPositionResponseSchema
>;
export type TransferPositionOwnershipResponse = z.infer<
typeof TransferPositionOwnershipResponseSchema
>;
export type DelegatePositionsResponse = z.infer<
typeof DelegatePositionsResponseSchema
>;
Expand Down
8 changes: 8 additions & 0 deletions packages/blockchain-api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
1 change: 1 addition & 0 deletions packages/blockchain-api/src/lib/utils/transaction-tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions packages/blockchain-api/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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*"],
};
Original file line number Diff line number Diff line change
@@ -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(),
),
};
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,6 +26,7 @@ export const governanceRouter = implement(governanceContract).router({
resetLockup,
splitPosition,
transferPosition,
transferPositionOwnership,
delegatePositions: delegate,
claimDelegationRewards: claimRewards,
undelegatePosition: undelegate,
Expand Down
89 changes: 86 additions & 3 deletions packages/blockchain-api/tests/e2e/governance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ 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";
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,
Expand Down Expand Up @@ -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;

Expand Down
Loading
Loading