Skip to content

Commit

Permalink
Scripts and README
Browse files Browse the repository at this point in the history
  • Loading branch information
dankelleher committed Sep 15, 2023
1 parent 532858f commit ea89c31
Show file tree
Hide file tree
Showing 8 changed files with 301 additions and 8 deletions.
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Yield Controller

The Sunrise Stake Yield Controller is a suite of Solana programs that control
the distribution of staking yield from the Sunrise Stake program to climate projects.

The current yield distribution mechanism of Sunrise buys and retires
[Toucan NCT carbon tokens](https://blog.toucan.earth/announcing-nct-nature-carbon-tonne/),
however the target model is to diversify the yield distribution to a range of climate
projects, controlled by a DAO.

## History

### 1. Initial release - Toucan NCT Buy-Burn-Fixxed

The initial version of the Sunrise Yield Controller used the Buy-Burn-Fixed strategy to
distribute yield. This strategy was implemented in the `buy-burn-fixed` package.

The `buy_burn_fixed` strategy bought Toucan NCT tokens at a fixed price and burned them.
The Toucan NCT tokens were bridged from Polygon manually via [Wormhole](https://wormhole.com/) and
stored in a PDA owned by the program.

The price of NCT was manually updated periodically by a Sunrise administrator in order to ensure
a fair price was paid by Sunrise for the Toucan NCT tokens.

This had a number of downsides:
- The price of NCT was manually updated, which was a centralised process.
- NCT had to be manually bridged from Polygon to Solana, also a centralised process.
- Bridged NCT was burned, rather than retired via Toucan, which reduced the transparency of the process, and
meant that the off-chain registries could not be updated.
- Yield distribution was limited to Toucan NCT tokens.

### 2. Toucan NCT Buy-Burn-Switchboard

The second version used a price oracle set up on [Switchboard](https://switchboard.xyz/) to
determine the correct NCT price. This reduced the ability of administrators to control the price, but
did not affect the other downsides of release 1.

### 3. Offset Bridge

The third version of the yield controller replaced the buy-burn mechanism for a bridge-buy-retire mechanism
called the Offset Bridge.

Rather than manually bridging NCT to Solana, and having the yield controller buy and burn it, instead
a new system - the [offset bridge](https://github.com/sunrise-stake/offset-bridge) - was set up to
bridge funds to Polygon, store them in a smart contract wallet, and automatically retire them via Toucan.

This ensured that NCT was correctly converted to tCO2 tokens and retired against the Toucan registry,
while ensuring the funds remained in control of the protocol (not touching EOA wallets), but nonetheless
had a number of "crank" steps that, while permissionless, still needed manual involvement to perform.

Details of the Offset Bridge can be found in the [Offset Bridge README](https://github.com/sunrise-stake/offset-bridge/blob/main/README.md).

At present, this is the current mechanism for yield distribution in Sunrise.
It uses the "Yield Router" program to route yield from the Sunrise Stake program to the Offset Bridge.

### 4. (Planned) Diversified Yield Router

The current implementation of the yield controller sends all funds from the Sunrise program directly to
a PDA owned by the Offset Bridge using the Yield Router, however the Yield Router only has one destination,
the offset bridge.

The next step will be to adapt the router to support additional distribution of yield to a range of
climate projects, rather than just NCT.

The architecture is as follows:
- Sunrise Stake program sends yield to a PDA owned by the Yield Router program (as at present).
- Yield Router owns a [State account](https://solscan.io/account/6Uad9j9DpKE9Jhebb5T3vWNWuCYTP7XxG6LJBPaqJB31),
which contains a list of destinations and their allocation percentage
- The Yield Router `allocate_yield` instruction shares yield across the destinations according to their
allocation percentage.
- Each destination is expected to be a PDA that then distributes the yield to the climate project
according to their own rules. For example, the Offset Bridge bridges the funds to Polygon, buys
NCT etc.
27 changes: 19 additions & 8 deletions packages/yield-router/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export interface YieldRouterConfig {
spendThreshold: BN;
}

type InitialisedClient = YieldRouterClient & {
config: YieldRouterConfig;

Check failure on line 62 in packages/yield-router/client/index.ts

View workflow job for this annotation

GitHub Actions / lint-client

Delete `··`
}

Check failure on line 63 in packages/yield-router/client/index.ts

View workflow job for this annotation

GitHub Actions / lint-client

Insert `;`

export class YieldRouterClient {
config: YieldRouterConfig | undefined;
readonly program: Program<YieldRouter>;
Expand All @@ -80,7 +84,7 @@ export class YieldRouterClient {
};
}

public static getState(sunriseState: PublicKey): PublicKey {
public static getStateAddressFromSunriseAddress(sunriseState: PublicKey): PublicKey {

Check failure on line 87 in packages/yield-router/client/index.ts

View workflow job for this annotation

GitHub Actions / lint-client

Replace `sunriseState:·PublicKey` with `⏎····sunriseState:·PublicKey⏎··`
const [state] = PublicKey.findProgramAddressSync(
[Buffer.from("state"), sunriseState.toBuffer()],
PROGRAM_ID
Expand All @@ -96,13 +100,17 @@ export class YieldRouterClient {
public static async fetch(
stateAddress: PublicKey,
provider?: AnchorProvider
): Promise<YieldRouterClient> {
): Promise<InitialisedClient> {
const client = new YieldRouterClient(
provider ?? setUpAnchor(),
stateAddress
);
await client.init();
return client;

if (!client.config) {
throw new Error("Could not fetch client");

Check failure on line 111 in packages/yield-router/client/index.ts

View workflow job for this annotation

GitHub Actions / lint-client

Delete `··`
}
return client as InitialisedClient;
}

public static async register(
Expand All @@ -111,9 +119,9 @@ export class YieldRouterClient {
outputYieldAccounts: PublicKey[],
spendProportions: number[],
spendThreshold: BN
): Promise<YieldRouterClient> {
): Promise<InitialisedClient> {
// find state address
const stateAddress = await YieldRouterClient.getState(sunriseState);
const stateAddress = await YieldRouterClient.getStateAddressFromSunriseAddress(sunriseState);

Check failure on line 124 in packages/yield-router/client/index.ts

View workflow job for this annotation

GitHub Actions / lint-client

Insert `⏎·····`
const inputYieldAccount = getInputYieldAccountForState(stateAddress);

const client = new YieldRouterClient(setUpAnchor(), stateAddress);
Expand Down Expand Up @@ -142,24 +150,27 @@ export class YieldRouterClient {

await client.init();

return client;
return client as InitialisedClient;
}

public async updateOutputYieldAccounts(
outputYieldAccounts: PublicKey[],
spendProportions: number[]
): Promise<YieldRouterClient> {
if (!this.config) {
throw new Error("Client not initialized");

Check failure on line 161 in packages/yield-router/client/index.ts

View workflow job for this annotation

GitHub Actions / lint-client

Delete `··`
}
const accounts = {
payer: this.provider.wallet.publicKey,
state: this.stateAddress,
systemProgram: SystemProgram.programId,
};

const args = {
updateAuthority: this.config?.updateAuthority,
updateAuthority: this.config.updateAuthority,
outputYieldAccounts,
spendProportions: Buffer.from(spendProportions),
spendThreshold: this.config?.spendThreshold,
spendThreshold: this.config.spendThreshold,
};
await this.program.methods
.updateState(args)
Expand Down
44 changes: 44 additions & 0 deletions packages/yield-router/scripts/allocateYield.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { YieldRouterClient } from "../client";
import { logBalance } from "./lib/util";
import { PublicKey } from "@solana/web3.js";
import BN from "bn.js";

// mainnet Sunrise
const defaultSunriseStateAddress = "43m66crxGfXSJpmx5wXRoFuHubhHA1GCvtHgmHW6cM1P";

Check failure on line 7 in packages/yield-router/scripts/allocateYield.ts

View workflow job for this annotation

GitHub Actions / lint-client

Insert `⏎·`
const sunriseStateAddress = new PublicKey(
process.env.STATE_ADDRESS ?? defaultSunriseStateAddress

Check failure on line 9 in packages/yield-router/scripts/allocateYield.ts

View workflow job for this annotation

GitHub Actions / lint-client

Delete `··`
);

if (!process.env.AMOUNT) {

Check failure on line 12 in packages/yield-router/scripts/allocateYield.ts

View workflow job for this annotation

GitHub Actions / lint-client

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
throw new Error("AMOUNT env variable must be set");
}
const amount = parseInt(process.env.AMOUNT, 10);

(async () => {
const stateAddress = YieldRouterClient.getStateAddressFromSunriseAddress(sunriseStateAddress);
const client = await YieldRouterClient.fetch(stateAddress);

const log = logBalance(client)

console.log("state address", stateAddress.toBase58());
console.log("state account data", client.config);
console.log("input yield token address", client.getInputYieldAccount().toBase58());

await log("input yield token", client.getInputYieldAccount());

for (const outputYieldAccount of client.config.outputYieldAccounts) {
await log("output yield token", outputYieldAccount);
}

console.log("Allocating yield...");
await client.allocateYield(new BN(amount));

await log("input yield token", client.getInputYieldAccount());

for (const outputYieldAccount of client.config.outputYieldAccounts) {
await log("output yield token", outputYieldAccount);
}



})().catch(console.error);
26 changes: 26 additions & 0 deletions packages/yield-router/scripts/getState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import "./lib/util";
import {logBalance} from "./lib/util";
import { YieldRouterClient } from "../client";
import { PublicKey } from "@solana/web3.js";

// mainnet Sunrise
const defaultSunriseStateAddress = "43m66crxGfXSJpmx5wXRoFuHubhHA1GCvtHgmHW6cM1P";
const sunriseStateAddress = new PublicKey(
process.env.STATE_ADDRESS ?? defaultSunriseStateAddress
);

(async () => {
const stateAddress = YieldRouterClient.getStateAddressFromSunriseAddress(sunriseStateAddress);
const client = await YieldRouterClient.fetch(stateAddress);
const log = logBalance(client)

console.log("state address", stateAddress.toBase58());
console.log("state account data", client.config);
console.log("input yield token address", client.getInputYieldAccount().toBase58());

await log("input yield token", client.getInputYieldAccount());

for (const outputYieldAccount of client.config.outputYieldAccounts) {
await log("output yield token", outputYieldAccount);
}
})().catch(console.error);
14 changes: 14 additions & 0 deletions packages/yield-router/scripts/lib/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import os from "os";
import { PublicKey, Cluster, clusterApiUrl } from "@solana/web3.js";
import {YieldRouterClient} from "../../client";

export const idWallet = os.homedir() + "/.config/solana/id.json";

process.env.ANCHOR_PROVIDER_URL =
process.env.ANCHOR_PROVIDER_URL ?? clusterApiUrl((process.env.REACT_APP_SOLANA_NETWORK || 'devnet') as Cluster);
process.env.ANCHOR_WALLET = process.env.ANCHOR_WALLET ?? idWallet;

export const logBalance = (client: YieldRouterClient) => async (prefix: string, account: PublicKey) => {
const accountInfo = await client.provider.connection.getAccountInfo(account);
console.log(`${prefix} balance`, accountInfo?.lamports);
}
28 changes: 28 additions & 0 deletions packages/yield-router/scripts/registerState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { YieldRouterClient } from "../client";
import {Keypair, PublicKey} from "@solana/web3.js";
import BN from "bn.js";

// mainnet Sunrise
const defaultSunriseStateAddress = "43m66crxGfXSJpmx5wXRoFuHubhHA1GCvtHgmHW6cM1P";
const sunriseStateAddress = new PublicKey(
process.env.STATE_ADDRESS ?? defaultSunriseStateAddress
);

// mainnet offset bridge wrapped SOL ATA
const defaultOutputYieldAddress = "4XTLzYF3kteTbb3a9NYYjeDAYwNoEGSkjoqJYkiLCnmm";
const outputYieldAddress = new PublicKey(
process.env.OUTPUT_YIELD_ADDRESS ?? defaultOutputYieldAddress
);

const anchorWallet = Keypair.fromSecretKey(Buffer.from(require(process.env.ANCHOR_WALLET as string)));

(async () => {
const state = await YieldRouterClient.register(
sunriseStateAddress,
anchorWallet.publicKey,
[outputYieldAddress],
[100],
new BN(100)
);
console.log("state account data", state.config);
})().catch(console.error);
40 changes: 40 additions & 0 deletions packages/yield-router/scripts/setDelegate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
approveChecked,
getAssociatedTokenAddressSync,
} from "@solana/spl-token";
import { setUpAnchor } from "../client";
import { PublicKey } from "@solana/web3.js";
import NodeWallet from "@coral-xyz/anchor/dist/cjs/nodewallet";

const defaultMint = "tnct1RC5jg94CJLpiTZc2A2d98MP1Civjh7o6ShmTP6";
const mint = new PublicKey(process.env.MINT ?? defaultMint);

const defaultStateAddress = "CaFanGeqN6ykNTGTE7U2StJ8n1RJY6on6FoDFeLxabia";
const stateAddress = new PublicKey(
process.env.STATE_ADDRESS ?? defaultStateAddress
);

const defaultHoldingAccount = "A4c5nctuNSN7jTsjDahv6bAWthmUzmXi3yBocvLYM4Bz";
const holdingAccount = new PublicKey(
process.env.HOLDING_ACCOUNT ?? defaultHoldingAccount
);

(async () => {
const provider = setUpAnchor();
const holdingTokenAccount = getAssociatedTokenAddressSync(
mint,
holdingAccount,
true
);

await approveChecked(
provider.connection,
(provider.wallet as NodeWallet).payer,
mint,
holdingTokenAccount,
stateAddress,
holdingAccount,
10000 * 10 ** 9,
9
);
})().catch(console.error);
57 changes: 57 additions & 0 deletions packages/yield-router/scripts/updateState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { BuyBurnFixedClient } from "../client";
import * as anchor from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
import { getAssociatedTokenAddressSync } from "@solana/spl-token";

/** Adjust these values to whatever you want them to be */
const PRICE = 1;
const PURCHASE_THRESHOLD = 100;
const PURCHASE_PROPORTION = 0;

const defaultTreasuryKey = "ALhQPLkXvbLKsH5Bm9TC3CTabKFSmnXFmzjqpTXYBPpu";
const treasuryKey = new PublicKey(
process.env.TREASURY_KEY ?? defaultTreasuryKey
);

const defaultAuthority = "5HnwQGT79JypiAdjdjsXEn1EMD2AsRVVubqDyWfyWXRv";
const authorityKey = new PublicKey(
process.env.AUTHORITY_KEY ?? defaultAuthority
);

// used in devnet
const defaultMint = "tnct1RC5jg94CJLpiTZc2A2d98MP1Civjh7o6ShmTP6";
const mint = new PublicKey(process.env.MINT ?? defaultMint);

const defaultHoldingAccount = "A4c5nctuNSN7jTsjDahv6bAWthmUzmXi3yBocvLYM4Bz";
const holdingAccount = new PublicKey(
process.env.HOLDING_ACCOUNT ?? defaultHoldingAccount
);

// used for devnet testing
const defaultStateAddress = "9QxfwoxkgxE94uoHd3ZPFLmfNhewoFe3Xg5gwgtShYnn";
const stateAddress = new PublicKey(
process.env.STATE_ADDRESS ?? defaultStateAddress
);

(async () => {
// get token account account for holding account
const holdingAccountTokenAddress = getAssociatedTokenAddressSync(
mint,
holdingAccount,
true
);

const client = await BuyBurnFixedClient.updateController(
stateAddress,
authorityKey,
treasuryKey,
mint,
holdingAccount,
holdingAccountTokenAddress,
new anchor.BN(PRICE),
PURCHASE_PROPORTION,
new anchor.BN(PURCHASE_THRESHOLD)
);

console.log("updated state:", client.stateAddress);
})().catch(console.error);

0 comments on commit ea89c31

Please sign in to comment.