on master help sign in
Merge pull request #563 from urbit/m/release-transfer

release: support transferring lockups

commit0bd92f532bc2a0ca4f117fc5bb37f67e4fd98bf6 authorfang <github@fang.io> dateTue 30 Mar 2021, 17:44 (6 years ago) parent6b72a3bebac710
14 files changed+494−13
package.json+1−1 src/lib/constants.js+2 src/lib/flowCommand.js+64 src/lib/routeNames.js+1 src/lib/router.js+2 src/lib/starRelease.js+24−2 src/lib/useInviter.js+1 src/store/lib/useStarReleaseStore.js+8−1 src/views/AcceptLockup.js+156 src/views/Login.js+42−8 src/views/Release.js+4 src/views/Release/Locked.js−1 src/views/Release/Transfer.js+170 src/views/UrbitID/Transfer.js+19
package.json+1−1
@@ -22,7 +22,7 @@
2222 "@welldone-software/why-did-you-render": "^3.2.3",
2323 "async-retry": "^1.2.3",
2424 "azimuth-js": "^0.19.0",
25− "azimuth-solidity": "^1.2.1",
25+ "azimuth-solidity": "^1.2.2",
2626 "bip32": "^1.0.2",
2727 "bip39": "^2.5.0",
2828 "bn.js": "^4.11.8",
src/lib/constants.js+2
@@ -34,6 +34,8 @@ const GAS_LIMITS = {
3434 //
3535 GIFT_PLANET: 450000, //NOTE low sample size, //NOTE also update in gas tank
3636 //
37+ TRANSFER_LOCKUP: 200000, //NOTE no samples
38+ //
3739 SEND_ETH: 21000,
3840 //
3941 DEFAULT: 550000,
src/lib/flowCommand.jsnew file+64
@@ -0,0 +1,64 @@
1+import { useMemo } from 'react';
2+import { isValidAddress } from './wallet';
3+
4+// looks into the GET parameters and attempts to construct a "flow" object,
5+// which would indicate bridge is to be used for one specific flow.
6+// expects to find any of the following sets of parameters, varying by kind.
7+//
8+// ? kind = invite
9+// & as = ~ship // who to send the invite as
10+// & ship = ~ship // what planet to send as the invite
11+// & email = ex@amp.le // email to send the invite code to
12+//
13+// ? kind = takeLockup
14+// & lock = linear | conditional // which lockup contract, defaults to linear
15+// & from = 0x1234abcd // ethereum address to accept transfer from
16+//
17+
18+const COMMANDS = {
19+ TAKE_LOCKUP: 'takeLockup',
20+};
21+
22+const useFlowCommand = () => {
23+ const flow = useMemo(() => {
24+ let flow = {};
25+
26+ window.location.search
27+ .substr(1)
28+ .split('&')
29+ .forEach(arg => {
30+ if ('' === arg) return;
31+ const pam = arg.split('=');
32+ flow[pam[0]] = pam.length <= 1 ? true : decodeURIComponent(pam[1]);
33+ });
34+
35+ if (typeof flow === 'object') {
36+ switch (flow.kind) {
37+ case COMMANDS.TAKE_LOCKUP:
38+ flow.lock = flow.lock || 'linear';
39+ if (
40+ (flow.lock !== 'linear' && flow.lock !== 'conditional') ||
41+ !isValidAddress(flow.from)
42+ ) {
43+ flow = null;
44+ }
45+ break;
46+ //
47+ default:
48+ console.log('unrecognized kind of flow:', flow.kind);
49+ // eslint-disable-next-line no-fallthrough
50+ case undefined:
51+ flow = null;
52+ break;
53+ }
54+ } else {
55+ flow = null;
56+ }
57+
58+ return flow;
59+ }, []);
60+
61+ return flow;
62+};
63+
64+export { COMMANDS, useFlowCommand };
src/lib/routeNames.js+1
@@ -23,6 +23,7 @@ export const ROUTE_NAMES = {
2323 PARTY_SET_POOL_SIZE: Symbol('PARTY_SET_POOL_SIZE'),
2424 INVITE_COHORT: Symbol('INVITE_COHORT'),
2525 STAR_RELEASE: Symbol('STAR_RELEASE'),
26+ ACCEPT_LOCKUP: Symbol('ACCEPT_LOCKUP'),
2627 URBIT_OS: Symbol('URBIT_OS'),
2728 URBIT_ID: Symbol('URBIT_ID'),
2829 RESIDENTS: Symbol('RESIDENTS'),
src/lib/router.js+2
@@ -5,6 +5,7 @@ import Senate from 'views/Senate';
55import CreateGalaxy from 'views/CreateGalaxy';
66import IssueChild from 'views/IssueChild';
77import StarRelease from 'views/Release';
8+import AcceptLockup from 'views/AcceptLockup';
89
910import Point from 'views/Point';
1011import Points from 'views/Points';
@@ -38,6 +39,7 @@ export const ROUTES = {
3839 [ROUTE_NAMES.CANCEL_TRANSFER]: CancelTransfer,
3940 [ROUTE_NAMES.INVITE_COHORT]: InviteCohort,
4041 [ROUTE_NAMES.STAR_RELEASE]: StarRelease,
42+ [ROUTE_NAMES.ACCEPT_LOCKUP]: AcceptLockup,
4143 [ROUTE_NAMES.URBIT_OS]: UrbitOS,
4244 [ROUTE_NAMES.URBIT_ID]: UrbitID,
4345 [ROUTE_NAMES.RESIDENTS]: Residents,
src/lib/starRelease.js+24−2
@@ -64,6 +64,17 @@ function generateLinearWithdrawTxs(contracts, amount, to) {
6464 return mapRange(amount, () => linearSR.withdrawTo(contracts, to));
6565}
6666
67+export async function getLockupKind(contracts, address) {
68+ const conditional = await conditionalSR.getCommitment(contracts, address);
69+ const linear = await linearSR.getBatch(contracts, address);
70+ const haveConditional = conditional.total > 0;
71+ const haveLinear = linear.amount > 0;
72+ if (haveConditional && !haveLinear) return 'conditional';
73+ if (!haveConditional && haveLinear) return 'linear';
74+ if (haveConditional && haveLinear) return 'both';
75+ return 'none';
76+}
77+
6778export async function getConditional(contracts, address) {
6879 const [commitment, batches, remaining] = await Promise.all([
6980 conditionalSR.getCommitment(contracts, address),
@@ -110,7 +121,13 @@ export async function getConditional(contracts, address) {
110121 const withdrawnTotal = withdrawn.reduce((acc, val) => acc + val, 0);
111122 const available = batchLimits.reduce((acc, val) => acc + val, 0);
112123
113− return { total, available, withdrawn: withdrawnTotal, batchLimits };
124+ return {
125+ total,
126+ available,
127+ withdrawn: withdrawnTotal,
128+ batchLimits,
129+ approvedTransferTo: commitment.approvedTransferTo,
130+ };
114131}
115132
116133export async function getLinear(contracts, address) {
@@ -128,5 +145,10 @@ export async function getLinear(contracts, address) {
128145
129146 const total = Math.min(amount, remaining);
130147
131− return { available, withdrawn, total };
148+ return {
149+ available,
150+ withdrawn,
151+ total,
152+ approvedTransferTo: batch.approvedTransferTo,
153+ };
132154}
src/lib/useInviter.js+1
@@ -61,6 +61,7 @@ const useInviter = () => {
6161 setTxStatus(STATUS.INPUT);
6262 }, [setProgress, setInvites, setTxStatus]);
6363
64+ //TODO split this out into smaller callbacks within useInviter
6465 const generateInvites = useCallback(
6566 async numInvites => {
6667 const _contracts = contracts.getOrElse(null);
src/store/lib/useStarReleaseStore.js+8−1
@@ -7,6 +7,7 @@ import {
77 generateWithdrawTxs,
88 getLinear,
99 getConditional,
10+ getLockupKind,
1011} from 'lib/starRelease';
1112
1213export default function useStarRelease() {
@@ -28,9 +29,10 @@ export default function useStarRelease() {
2829 const address = _wallet.address;
2930
3031 try {
31− const [linear, conditional] = await Promise.all([
32+ const [linear, conditional, kind] = await Promise.all([
3233 getLinear(_contracts, address),
3334 getConditional(_contracts, address),
35+ getLockupKind(_contracts, address),
3436 ]);
3537
3638 const keys = Object.keys(linear);
@@ -38,6 +40,11 @@ export default function useStarRelease() {
3840 (acc, key) => ({ ...acc, [key]: linear[key] + conditional[key] }),
3941 {}
4042 );
43+ result.kind = kind;
44+ result.linear = { approvedTransferTo: linear.approvedTransferTo };
45+ result.conditional = {
46+ approvedTransferTo: conditional.approvedTransferTo,
47+ };
4148 setBatchLimits(conditional.batchLimits);
4249
4350 _setDetails(Just(result));
src/views/AcceptLockup.jsnew file+156
@@ -0,0 +1,156 @@
1+import React, { useCallback, useMemo } from 'react';
2+import cn from 'classnames';
3+import { Grid, Text } from 'indigo-react';
4+import * as azimuth from 'azimuth-js';
5+import { memoize } from 'lodash';
6+
7+import { useNetwork } from 'store/network';
8+import { useWallet } from 'store/wallet';
9+import { useHistory } from 'store/history';
10+
11+import * as need from 'lib/need';
12+import useEthereumTransaction from 'lib/useEthereumTransaction';
13+import { GAS_LIMITS } from 'lib/constants';
14+import { useLocalRouter } from 'lib/LocalRouter';
15+import { useFlowCommand } from 'lib/flowCommand';
16+import { isValidAddress, eqAddr } from 'lib/wallet';
17+
18+import ViewHeader from 'components/ViewHeader';
19+import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
20+import View from 'components/View';
21+
22+import BridgeForm from 'form/BridgeForm';
23+import { AddressInput } from 'form/Inputs';
24+import { composeValidator } from 'form/validators';
25+
26+function useAcceptLockup() {
27+ const { contracts } = useNetwork();
28+ const { push, names } = useHistory();
29+
30+ const flow = useFlowCommand();
31+
32+ const _contracts = need.contracts(contracts);
33+
34+ const transaction = useEthereumTransaction(
35+ useCallback(
36+ from => {
37+ if (flow.lock === 'conditional') {
38+ return azimuth.conditionalSR.transferCommitment(_contracts, from);
39+ } else {
40+ return azimuth.linearSR.transferBatch(_contracts, from);
41+ }
42+ },
43+ [_contracts, flow]
44+ ),
45+ useCallback(() => push(names.STAR_RELEASE), [push, names]),
46+ GAS_LIMITS.TRANSFER_LOCKUP
47+ );
48+
49+ return {
50+ ...transaction,
51+ };
52+}
53+
54+export default function AcceptLockup() {
55+ const { pop } = useLocalRouter();
56+ const { contracts } = useNetwork();
57+ const { wallet } = useWallet();
58+ const flow = useFlowCommand();
59+
60+ const _contracts = need.contracts(contracts);
61+ const _address = need.addressFromWallet(wallet);
62+
63+ const {
64+ completed,
65+ bind,
66+ inputsLocked,
67+ construct,
68+ unconstruct,
69+ } = useAcceptLockup();
70+
71+ const initialValues = useMemo(() => {
72+ if (!flow.from) return {};
73+ return { from: flow.from };
74+ }, [flow]);
75+
76+ const getApproved = useMemo(() => {
77+ return memoize(async from => {
78+ console.log('------ looking up');
79+ const lok = await (flow.lock === 'conditional'
80+ ? azimuth.conditionalSR.getCommitment(_contracts, from)
81+ : azimuth.linearSR.getBatch(_contracts, from));
82+ return lok.approvedTransferTo;
83+ });
84+ }, [flow, _contracts]);
85+
86+ const validate = useMemo(
87+ () =>
88+ composeValidator({
89+ from: async from => {
90+ if (!isValidAddress(from)) {
91+ return 'This is not a valid Ethereum address.';
92+ }
93+ const approved = await getApproved(from);
94+ if (!eqAddr(_address, approved)) {
95+ return 'Not permitted to take lockup from this address.';
96+ }
97+ },
98+ }),
99+ [_address, getApproved]
100+ );
101+
102+ const onValues = useCallback(
103+ ({ valid, values, form }) => {
104+ if (valid) {
105+ construct(values.from);
106+ } else {
107+ unconstruct();
108+ }
109+ },
110+ [construct, unconstruct]
111+ );
112+
113+ return (
114+ <View pop={pop} inset>
115+ <Grid>
116+ <Grid.Item full as={ViewHeader}>
117+ Accept Star Lockup
118+ </Grid.Item>
119+
120+ <Grid.Item
121+ full
122+ as={Text}
123+ className={cn('f5 wrap', {
124+ green3: completed,
125+ })}>
126+ {completed
127+ ? `You have accepted the star lockup.`
128+ : `Accept the incoming transfer of a star lockup.`}
129+ </Grid.Item>
130+
131+ <BridgeForm
132+ validate={validate}
133+ initialValues={initialValues}
134+ onValues={onValues}>
135+ {() => (
136+ <Grid.Item
137+ full
138+ as={AddressInput}
139+ name="from"
140+ label="Previous owner of the star lockup"
141+ disabled={inputsLocked}
142+ className="mv4"
143+ />
144+ )}
145+ </BridgeForm>
146+
147+ <Grid.Item
148+ full
149+ as={InlineEthereumTransaction}
150+ {...bind}
151+ onReturn={() => pop()}
152+ />
153+ </Grid>
154+ </View>
155+ );
156+}
src/views/Login.js+42−8
@@ -5,8 +5,10 @@ import { version } from '../../package.json';
55
66import { useHistory } from 'store/history';
77import { useWallet } from 'store/wallet';
8+import { usePointCursor } from 'store/pointCursor';
89
910import { WALLET_TYPES } from 'lib/wallet';
11+import { COMMANDS, useFlowCommand } from 'lib/flowCommand';
1012
1113import View from 'components/View';
1214import Tabs from 'components/Tabs';
@@ -43,6 +45,8 @@ export default function Login() {
4345 // globals
4446 const { pop, push, names } = useHistory();
4547 const { walletType } = useWallet();
48+ const { setPointCursor } = usePointCursor();
49+ const flow = useFlowCommand();
4650
4751 // inputs
4852 const [isOther, setisOther] = useState(false);
@@ -53,8 +57,29 @@ export default function Login() {
5357 ]);
5458
5559 const goHome = useCallback(() => {
56− push(names.POINTS);
57− }, [push, names]);
60+ if (!flow) {
61+ push(names.POINTS);
62+ } else {
63+ switch (flow.kind) {
64+ case COMMANDS.TAKE_LOCKUP:
65+ push(names.ACCEPT_LOCKUP);
66+ break;
67+ //
68+ default:
69+ throw new Error('unimplemented flow ' + flow.kind);
70+ }
71+ }
72+ }, [flow, push, names]);
73+
74+ const flowDescription = command => {
75+ switch (command.kind) {
76+ case COMMANDS.TAKE_LOCKUP:
77+ //TODO kind of want "sign in as 0xabc.." here...
78+ return <>To accept a star lockup batch, please sign in.</>;
79+ default:
80+ return <>Flow: {command.kind}</>;
81+ }
82+ };
5883
5984 return (
6085 <View inset>
@@ -65,8 +90,12 @@ export default function Login() {
6590 </Grid.Item>
6691 <Grid.Item as={Text}>Login</Grid.Item>
6792 </Grid.Item>
68− {isOther && <Grid.Item full as={Other} goHome={goHome} />}
69− {!isOther && <Grid.Item full as={Ticket} goHome={goHome} />}
93+ {flow && (
94+ <Grid.Item full as={Text} className="t-center mb4">
95+ {flowDescription(flow)}
96+ </Grid.Item>
97+ )}
98+ <Grid.Item full as={isOther ? Other : Ticket} goHome={goHome} />
7099 {!isOther && (
71100 <>
72101 <Grid.Item full className="t-center mv4 gray4">
@@ -80,10 +109,15 @@ export default function Login() {
80109 onClick={() => setisOther(true)}>
81110 Metamask, Mnemonic, Hardware Wallet...
82111 </Grid.Item>
83− <Grid.Item full onClick={goToActivate} className="mv10 t-center f6">
84− <span className="gray4">New Urbit ID? </span>
85− <LinkButton>Activate</LinkButton>
86− </Grid.Item>
112+ {!flow && (
113+ <Grid.Item
114+ full
115+ onClick={goToActivate}
116+ className="mv10 t-center f6">
117+ <span className="gray4">New Urbit ID? </span>
118+ <LinkButton>Activate</LinkButton>
119+ </Grid.Item>
120+ )}
87121 </>
88122 )}
89123 {isOther && (
src/views/Release.js+4
@@ -15,20 +15,24 @@ import CopiableAddress from 'components/CopiableAddress';
1515
1616import Active from 'views/Release/Active';
1717import Locked from 'views/Release/Locked';
18+import Transfer from 'views/Release/Transfer';
1819import NavHeader from 'components/NavHeader';
1920
2021const NAMES = {
2122 ACTIVE: 'ACTIVE',
2223 LOCKED: 'LOCKED',
24+ TRANSFER: 'TRANSFER',
2325};
2426
2527const VIEWS = {
2628 [NAMES.ACTIVE]: Active,
2729 [NAMES.LOCKED]: Locked,
30+ [NAMES.TRANSFER]: Transfer,
2831};
2932const OPTIONS = [
3033 { text: 'Active', value: NAMES.ACTIVE },
3134 { text: 'Locked', value: NAMES.LOCKED },
35+ { text: 'Transfer', value: NAMES.TRANSFER },
3236];
3337export default function ReleaseView() {
3438 const { pop, push, names } = useHistory();
src/views/Release/Locked.js−1
@@ -22,7 +22,6 @@ import { convertToNumber } from 'form/formatters';
2222
2323function MatchPluralizeStars({ quantity }) {
2424 const _quantity = quantity.getOrElse(0);
25− console.log(quantity);
2625 return (
2726 <>
2827 {' '}
src/views/Release/Transfer.jsnew file+170
@@ -0,0 +1,170 @@
1+import React, { useCallback, useEffect, useMemo } from 'react';
2+import cn from 'classnames';
3+import { Grid, Flex } from 'indigo-react';
4+import { conditionalSR, linearSR } from 'azimuth-js';
5+
6+import * as need from 'lib/need';
7+import useEthereumTransaction from 'lib/useEthereumTransaction';
8+import { getLockupKind } from 'lib/starRelease';
9+import { eqAddr, isZeroAddress } from 'lib/wallet';
10+
11+import { useNetwork } from 'store/network';
12+import { useWallet } from 'store/wallet';
13+import { useHistory } from 'store/history';
14+import { useStarReleaseCache } from 'store/starRelease';
15+
16+import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
17+import CopiableWithTooltip from 'components/CopiableWithTooltip';
18+import CopiableAddress from 'components/CopiableAddress';
19+import NoticeBox from 'components/NoticeBox';
20+
21+import { AddressInput } from 'form/Inputs';
22+import BridgeForm from 'form/BridgeForm';
23+import FormError from 'form/FormError';
24+import { composeValidator, buildAddressValidator } from 'form/validators';
25+import { isValidAddress } from 'ethereumjs-util';
26+
27+function useTransferLockup(kind) {
28+ const { contracts } = useNetwork();
29+
30+ return useEthereumTransaction(
31+ useCallback(
32+ to => {
33+ const _contracts = need.contracts(contracts);
34+ if (kind === 'conditional') {
35+ return conditionalSR.approveCommitmentTransfer(_contracts, to);
36+ } else {
37+ return linearSR.approveBatchTransfer(_contracts, to);
38+ }
39+ },
40+ [kind, contracts]
41+ ),
42+ () => {}
43+ );
44+}
45+
46+export default function Transfer({ className, goActive }) {
47+ const { wallet } = useWallet();
48+ const { contracts } = useNetwork();
49+ const { pop } = useHistory();
50+ const address = need.addressFromWallet(wallet);
51+
52+ const { syncStarReleaseDetails, starReleaseDetails } = useStarReleaseCache();
53+
54+ useEffect(() => {
55+ syncStarReleaseDetails();
56+ }, [syncStarReleaseDetails]);
57+
58+ const kind = useMemo(() => {
59+ return starReleaseDetails.map(a => a.kind).getOrElse('') === 'conditional'
60+ ? 'conditional'
61+ : 'linear';
62+ }, [starReleaseDetails]);
63+
64+ const approvedFor = useMemo(() => {
65+ let approved;
66+ if (kind === 'conditional') {
67+ approved = starReleaseDetails.map(a => a.conditional.approvedTransferTo);
68+ } else {
69+ approved = starReleaseDetails.map(a => a.linear.approvedTransferTo);
70+ }
71+ approved = approved.getOrElse('0x0');
72+ if (isZeroAddress(approved) || eqAddr(approved, address)) {
73+ return false;
74+ } else {
75+ return approved;
76+ }
77+ }, [starReleaseDetails, kind, address]);
78+
79+ const {
80+ bind,
81+ construct,
82+ unconstruct,
83+ inputsLocked,
84+ completed,
85+ } = useTransferLockup(kind);
86+
87+ const onValues = useCallback(
88+ async ({ valid, values }) => {
89+ if (valid) {
90+ construct(values.to);
91+ } else {
92+ unconstruct();
93+ }
94+ },
95+ [construct, unconstruct]
96+ );
97+
98+ const validate = useMemo(
99+ () =>
100+ composeValidator({
101+ to: async to => {
102+ if (!isValidAddress(to)) {
103+ return 'This is not a valid Ethereum address.';
104+ }
105+ const theirs = await getLockupKind(need.contracts(contracts), to);
106+ if (theirs === kind || theirs === 'both') {
107+ return `Target address already owns a ${kind} lockup.`;
108+ }
109+ },
110+ }),
111+ [kind, contracts]
112+ );
113+
114+ return (
115+ <Grid gap={completed ? 0 : 6} className={cn('mt4', className)} full>
116+ <BridgeForm onValues={onValues} validate={validate}>
117+ {({ values, form }) => (
118+ <>
119+ {!completed && (
120+ <>
121+ <Grid.Item full className="f5">
122+ Allow the specified address to take over your {kind} lockup.
123+ <br />
124+ After they accept the transfer, you will no longer be able to{' '}
125+ withdraw from this lockup.
126+ </Grid.Item>
127+ {approvedFor && (
128+ <Grid.Item full as={NoticeBox}>
129+ Transfer currently approved for{' '}
130+ <CopiableAddress>{approvedFor}</CopiableAddress>
131+ </Grid.Item>
132+ )}
133+ <Grid.Item full className="f5" cols={[1, 13]} as={Flex} col>
134+ <Flex.Item
135+ name="to"
136+ as={AddressInput}
137+ label="Approve transfer to"
138+ disabled={inputsLocked}
139+ />
140+ </Grid.Item>
141+ </>
142+ )}
143+
144+ <Grid.Item full as={FormError} />
145+ {completed && (
146+ <>
147+ <Grid.Item full className="pv4">
148+ <CopiableAddress>{values.to}</CopiableAddress> is now allowed{' '}
149+ to take ownership of this lockup. They can do so by visiting:
150+ </Grid.Item>
151+ <Grid.Item full as="code" className="f5 mb4 mono wrap">
152+ https://bridge.urbit.org/?kind=takeLockup&lock={kind}
153+ &from={address}
154+ </Grid.Item>
155+ <Grid.Divider />
156+ </>
157+ )}
158+ <Grid.Item
159+ full
160+ as={InlineEthereumTransaction}
161+ {...bind}
162+ label="Approve Transfer"
163+ onReturn={() => pop()}
164+ />
165+ </>
166+ )}
167+ </BridgeForm>
168+ </Grid>
169+ );
170+}
src/views/UrbitID/Transfer.js+19
@@ -6,6 +6,7 @@ import * as azimuth from 'azimuth-js';
66import { useNetwork } from 'store/network';
77import { usePointCursor } from 'store/pointCursor';
88import { usePointCache } from 'store/pointCache';
9+import { useStarReleaseCache } from 'store/starRelease';
910
1011import * as need from 'lib/need';
1112import { useLocalRouter } from 'lib/LocalRouter';
@@ -14,6 +15,7 @@ import useEthereumTransaction from 'lib/useEthereumTransaction';
1415import { GAS_LIMITS } from 'lib/constants';
1516
1617import ViewHeader from 'components/ViewHeader';
18+import NoticeBox from 'components/NoticeBox';
1719import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
1820import { AddressInput } from 'form/Inputs';
1921import { composeValidator, buildAddressValidator } from 'form/validators';
@@ -41,6 +43,8 @@ function useTransfer() {
4143export default function AdminTransfer() {
4244 const { pop } = useLocalRouter();
4345 const name = useCurrentPointName();
46+ const { pointCursor } = usePointCursor();
47+ const { starReleaseDetails } = useStarReleaseCache();
4448
4549 const {
4650 construct,
@@ -50,6 +54,15 @@ export default function AdminTransfer() {
5054 bind,
5155 } = useTransfer();
5256
57+ const needLockupWarning = useMemo(() => {
58+ const point = need.point(pointCursor);
59+ const az = azimuth.azimuth;
60+ return (
61+ az.getPointSize(point) === az.PointSize.Galaxy &&
62+ starReleaseDetails.map(a => a.kind).getOrElse('none') !== 'none'
63+ );
64+ }, [pointCursor, starReleaseDetails]);
65+
5366 const validate = useMemo(
5467 () => composeValidator({ address: buildAddressValidator() }),
5568 []
@@ -80,6 +93,12 @@ export default function AdminTransfer() {
8093 Transfer {name} to a new owner.
8194 </Grid.Item>
8295 )}
96+ {!completed && needLockupWarning && (
97+ <Grid.Item full as={NoticeBox}>
98+ You have stars in lockup. These will not transfer with the{' '}
99+ galaxy. If you wish, you must transfer the lockup separately.
100+ </Grid.Item>
101+ )}
83102 {completed && (
84103 <>
85104 <Grid.Item full as={Text} className="wrap">